diff --git a/Cargo.lock b/Cargo.lock index 9acbdfdf0..03db6242c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,6 @@ dependencies = [ "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", - "ocs_doc_api", "ocs_plugin_api", "open", "printpdf", @@ -3891,20 +3890,6 @@ 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 1.1.3+spec-1.1.0", -] - [[package]] name = "ocs_plugin_api" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 0a526489a..1d03b1311 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ members = [ "crates/ocs_web_worker", "crates/dwg-thumbnailer", "crates/dwg-thumbnailer-win", - "crates/ocs_doc_api", ] [target.'cfg(windows)'.build-dependencies] @@ -76,7 +75,6 @@ iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0 [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"] } diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md deleted file mode 100644 index 8603f8a10..000000000 --- a/crates/ocs_doc_api/ARCHITECTURE.md +++ /dev/null @@ -1,45 +0,0 @@ -# Document API architecture - -`DocApi → Document → collections → typed handles` builds DTO requests. -`Transport::apply` carries one operation or a read-only query batch to the -executor. The native adapter binds each request to its existing `HostSession`. - -For out-of-process / worker-thread plugins, `doc_api_for_host(host)` constructs a -`DocApi` backed by `OcsPluginApiIpc`, which sends `PluginRequest::DocApiRequest` -through the host's `PluginRequestSender` and deserializes -`PluginResponse::DocApiResponse`. This keeps plugin code free of manual envelope -serialization while reusing the same executor that the in-process transport uses. - -The executor validates inputs and dispatches through `DocApiBackend`. Backends -must prepare fallible geometry and serialization before changing the document. -Bulk writes prepare all entities, begin one undo record, apply the prepared -changes, then finalize once. Cancellation releases a pending undo capture; -it is not a rollback mechanism for partially applied writes. - -The native adapter reuses scene changes, document history, entity transforms -and profile conversion. Solid construction, sweeps, booleans, meshing and mass -properties belong to cadkernel. Profile planes preserve their source elevation -and orientation. Geometry revisions advance when the scene changes; a compound -operation may advance the revision more than once while recording one undo step. - -`Operation` and `Query` are manually maintained wire enums. Append variants; -do not reorder them. The consistency tests verify their bincode discriminants. -`spec/entities.toml` supplies family, constructor and method mappings. `build.rs` -generates the API reference and binding schema. The layout snapshot is curated -vocabulary, not a reflected binary codec schema. - -Layer CRUD is a document-table family, not an entity family. The API carries -layer properties in the plain-data `LayerInfo` DTO and the host translates them -to/from `acadrust::tables::Layer`. Layer names are case-insensitive. The host -rejects duplicate names, deletion of layer "0" or the current layer, and removal -of a layer that still has entities assigned. - -XDATA and XRECORD are serialization roundtrip families: the API carries their -payloads as plain-data DTOs and the host stores/retrieves them without semantic -interpretation. XDATA records are keyed by registered application name on any -entity; XRECORD objects are standalone named dictionaries of group-code/value -entries. Both preserve exact bytes/values across save and reload. - -To extend the API, update the DTO, backend, executor, facade and spec together, -add a native behavior test, and rebuild the generated reference/schema. New -backend methods must reject unsupported behavior explicitly. diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml deleted file mode 100644 index 744d3559e..000000000 --- a/crates/ocs_doc_api/Cargo.toml +++ /dev/null @@ -1,66 +0,0 @@ -[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: 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" - -# Host adapters convert ObjectId to acadrust handles; kernel handles stay local. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", 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 = "1" - -[dev-dependencies] -serde_json = "1" -toml = "1" - -[features] -# Default keeps the facade and DTOs independent of geometry dependencies. -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"] -# doc_api_host: convenience helper that builds a DocApi from a HostApi (IPC-only). -doc_api_host = ["ipc"] - -[lib] -name = "ocs_doc_api" -path = "src/lib.rs" - -[[test]] -name = "mock_backend" -required-features = ["host"] - -[[example]] -name = "e1_primitives_and_queries" -required-features = ["host"] - -[[example]] -name = "e2_intersect_two_solids" -required-features = ["host"] - -[[example]] -name = "e3_transform_and_chain" -required-features = ["host"] - -[[example]] -name = "e4_2d_entities_and_bulk" -required-features = ["host"] - -[[example]] -name = "e5_failure_and_guards" -required-features = ["host"] diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md deleted file mode 100644 index cc38a56ca..000000000 --- a/crates/ocs_doc_api/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# ocs_doc_api - -Typed document operations and queries for OpenCADStudio plugins and hosts. -The same facade runs over an in-process backend or the plugin IPC channel. - -```rust -use ocs_doc_api::{ApiResult, DocApi}; - -fn intersect_solids(api: &DocApi) -> ApiResult { - let doc = api.document(api.active_tab()); - let a = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?; - let b = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?; - a.intersect(&b)?.volume() -} -``` - -Each successful write marks the document changed and records one undo step. -A failed operation leaves the document unchanged. Bulk creation and transforms -prepare every entity before applying changes. Separate calls commit separately; -`OpGroup` offers best-effort cleanup of creations, not rollback. - -Boolean `intersect`, `union` and `subtract` replace the first solid and erase the -second. `intersects` only tests bounding-box overlap. Volume and centroid are -computed by the kernel from a tessellated solid (chord tolerance 0.1 drawing units, -angular tolerance 0.0001 radians), then cached per geometry revision. A request -may compute at most 32 uncached mass properties; excess work returns an error. -Bulk operations and query batches are capped at 100,000 items. - -Transports bind to one document tab. Typed handles from another transport and -requests for another tab are rejected. Raw `ObjectId` values are relative to the -receiving document. Errors are serialized as `ApiError` variants over IPC. - -Layer CRUD is exposed through `Document::layers()`, `create_layer(info)`, -`update_layer(name, info)`, `delete_layer(name)` and through the `layer()` / -`set_layer(...)` methods on every entity handle (`Line`, `Circle`, `Solid`, -`Entity`, etc.). Layer properties are carried as the plain-data `LayerInfo` DTO -with `LayerFlags`, `Color` and `LineWeight`. The host enforces CAD-style rules: -layer "0" and the current layer cannot be deleted, duplicate names are rejected, -and a layer still referenced by entities cannot be removed. - -XDATA and XRECORD payloads are carried as plain-data, serde-compatible DTOs -(`XDataRecord`, `XRecordSpec`, and their value/entry types) and roundtripped -verbatim by the host. Every `Entity` handle can read/write one XDATA record per -registered application name via `xdata(application_name)` and `set_xdata(...)`, -and standalone XRECORD objects can be created with `doc.entities().create_xrecord(...)` -and updated through the `XRecord` handle. - -| Feature | Use | -|---|---| -| Default | DTOs, facade, transport trait and binding schema | -| `host` | Kernel and entity adapters, executor, `DocApiBackend`, `InProcess` | -| `ipc` | `OcsPluginApiIpc` adapter for a plugin connected to the host | -| `doc_api_host` | Convenience `doc_api_for_host(&dyn HostApi) -> Option` for out-of-process / worker-thread plugins | - -### Connecting from a plugin - -For out-of-process or worker-thread plugins that receive `&dyn HostApi` (or any -`ocs_plugin_api::host::HostApi` implementor), enable the `doc_api_host` feature -and use the helper instead of manually serializing `DocApiEnvelope`s: - -```rust -use ocs_doc_api::doc_api_for_host; -use ocs_plugin_api::host::HostApi; - -fn on_dispatch(host: &mut dyn HostApi) -> Option<()> { - let doc_api = doc_api_for_host(host)?; - let doc = doc_api.document(host.tab_id()); - let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?; - Some(()) -} -``` - -`doc_api_for_host` returns `None` for hosts that do not expose a -`PluginRequestSender` (for example the in-process `HostSession`). In-process code -should use `DocApi::in_process(backend, tab_id)` with a concrete -`DocApiBackend`. - -### Plugin example: create, read, update and delete a line - -```rust -use ocs_doc_api::{doc_api_for_host, ApiResult, Color, LayerInfo, LineWeight}; -use ocs_plugin_api::host::HostApi; - -fn draw_and_modify_line(host: &mut dyn HostApi) -> ApiResult<()> { - // Build a DocApi from the host's plugin request channel. - let api = doc_api_for_host(host).ok_or_else(|| { - ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into()) - })?; - let doc = api.document(api.active_tab()); - - // Ensure a target layer exists. - let mut info = LayerInfo::new("MyLayer"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - let _ = doc.create_layer(&info); // ok if it already exists - - // Create a line on that layer. - let line = doc.curves().create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?; - line.set_layer("MyLayer")?; - - // Read it back. - assert_eq!(line.layer()?, "MYLAYER"); - - // Move the endpoint in place (same ObjectId). - line.transform(ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]))?; - - // Delete it; the document is left unchanged if this failed. - line.delete()?; - Ok(()) -} -``` - -### Plugin example: create a box and read its volume - -```rust -use ocs_doc_api::{doc_api_for_host, ApiResult}; -use ocs_plugin_api::host::HostApi; - -fn create_box_and_measure(host: &mut dyn HostApi) -> ApiResult { - let api = doc_api_for_host(host).ok_or_else(|| { - ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into()) - })?; - let doc = api.document(api.active_tab()); - - let box_ = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?; - let volume = box_.volume()?; - - // Boolean example: intersect with a second box. - let other = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?; - let intersection = box_.intersect(&other)?; - let _intersection_volume = intersection.volume()?; - - Ok(volume) -} -``` - -See the [API reference](src/gen/api_reference.md) for constructors and methods, -[architecture](ARCHITECTURE.md) for backend rules, and -[binding guide](bindings/README.md) for the Python facade and transport contract. diff --git a/crates/ocs_doc_api/bindings/README.md b/crates/ocs_doc_api/bindings/README.md deleted file mode 100644 index deb13c55d..000000000 --- a/crates/ocs_doc_api/bindings/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Language bindings - -`src/gen/binding_schema.json` describes handle families, constructors, methods, -arguments and operation/query mappings. `build.rs` derives it from -`spec/entities.toml`. Its `layouts` section is manually maintained vocabulary; -it does not contain everything needed to generate a bincode codec. - -Generate and test the Python facade from the repository root: - -```sh -python3 crates/ocs_doc_api/bindings/python/generate.py > /tmp/ocs_doc.py -python3 -m unittest discover -s crates/ocs_doc_api/bindings/python -``` - -The generated `DocApi(transport, active_tab)` expects a caller-supplied -`transport.apply(envelope) -> Receipt`. It does not open a plugin connection. -An envelope has `version` and a Python tuple `("Op", operation_dict)` or -`("Queries", [query_dict])`. Enum payloads use externally tagged dictionaries, -for example `{"CreateSolid": {"Sphere": {"centre": [0, 0, 0], "radius": 2}}}`. -The bridge converts these objects to the Rust DTOs and bincode 1.3 wire format, -sends `DocApiRequest { tab_id, bytes }`, and decodes `Result`. -Host plugin API version 6 and document envelope version 1 are required. - -For Rust plugins compiled against `ocs_doc_api` with the `doc_api_host` feature, -use the `doc_api_for_host` helper to build a typed `DocApi` from any -`ocs_plugin_api::host::HostApi` that exposes a `PluginRequestSender` -(out-of-process / worker-thread hosts): - -```rust -use ocs_doc_api::doc_api_for_host; -use ocs_plugin_api::host::HostApi; - -fn on_dispatch(host: &mut dyn HostApi) -> Option<()> { - let doc_api = doc_api_for_host(host)?; - let doc = doc_api.document(host.tab_id()); - let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?; - // ... - Some(()) -} -``` - -In-process plugins should use `DocApi::in_process(backend, tab_id)` with a -concrete `DocApiBackend`; `doc_api_for_host` returns `None` for hosts that do not -expose a `PluginRequestSender`. - -Decode receipt outcomes as `{"NewId": 42}` or `{"NewIds": [42, 43]}` and query -results as externally tagged dictionaries such as `{"Volume": 12.5}`. The facade -unwraps these into typed handles or values. Layer results use `{"Layers": [...]}` -and `{"EntityLayer": "WALLS"}`. Layer properties (`LayerInfo`, `LayerFlags`, -`Color`, `LineWeight`) appear as plain dictionaries in operation payloads, e.g. -`{"CreateLayer": {"name": "Walls", "flags": {"frozen": false, "locked": false, -"frozen_in_new_viewport": false, "off": false}, "color": {"Index": 7}, -"line_type": "Continuous", "line_weight": {"Default": null}, -"plot_style": "", "is_plottable": true}}`. -XDATA and XRECORD payloads appear as nested externally tagged dictionaries -(e.g. `{"XData": {"application_name": ..., "values": [...]}}` or -`{"XRecord": {"name": ..., "entries": [...]}}`). The bridge must raise an -exception for an error response. Transport binding determines the tab; requesting -another tab or combining typed handles from different documents raises an error. diff --git a/crates/ocs_doc_api/bindings/python/generate.py b/crates/ocs_doc_api/bindings/python/generate.py deleted file mode 100644 index d361493ba..000000000 --- a/crates/ocs_doc_api/bindings/python/generate.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a Python facade over a caller-supplied document transport. - -Usage: python generate.py ../../src/gen/binding_schema.json > ocs_doc.py -The transport converts these Python DTOs to/from the Rust wire representation. -""" -import json -import sys -from pathlib import Path - -HEADER = '''"""Generated document facade. See bindings/README.md for the transport contract.""" -from dataclasses import dataclass -from typing import Any - -@dataclass -class DocApiEnvelope: - version: int - body: Any - -@dataclass -class Receipt: - outcome: Any - query_results: list - new_revision: int - -@dataclass -class Entity: - id: int - _doc: "Document" - - def bounds(self): - return self._doc._apply_query({"GetBounds": {"id": self.id}}) - - def transform(self, placement): - self._doc._apply_op({"Transform": {"id": self.id, "placement": placement}}) - - def delete(self): - self._doc._apply_op({"Delete": {"id": self.id}}) - - def layer(self): - return self._doc._apply_query({"GetEntityLayer": {"id": self.id}}) - - def set_layer(self, layer): - self._doc._apply_op({"SetEntityLayer": {"id": self.id, "layer": layer}}) -''' - -DOCUMENT = ''' -@dataclass -class Document: - _transport: Any - _tab: int - - def _id(self, value): - if isinstance(value, Entity): - if value._doc._transport is not self._transport or value._doc._tab != self._tab: - raise ValueError("handle belongs to another document") - return value.id - return int(value) - - def _apply_op(self, op, want=None): - receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Op", op))) - if want is None: - return None - if want.startswith("Vec<"): - handle = globals()[want[4:-1]] - return [handle(i, self) for i in receipt.outcome["NewIds"]] - return globals()[want](receipt.outcome["NewId"], self) - - def _apply_query(self, query): - receipt = self._transport.apply(DocApiEnvelope(ENVELOPE_VERSION, ("Queries", [query]))) - if len(receipt.query_results) != 1: - raise ValueError("expected one query result") - result = receipt.query_results[0] - if not isinstance(result, dict) or len(result) != 1: - raise ValueError("invalid query result") - return next(iter(result.values())) - - def revision(self): - return self._apply_query("GetGeometryRevision") - - def block_entities(self, block_name): - return self._apply_query({"GetBlockEntities": {"block_name": block_name}}) - - def solids(self): return Solids(self) - def curves(self): return Curves(self) - def entities(self): return Entities(self) - - def layers(self): - return self._apply_query({"ListLayers": {}}) - - def create_layer(self, info): - self._apply_op({"CreateLayer": info}) - - def update_layer(self, name, info): - self._apply_op({"UpdateLayer": {"name": name, "info": info}}) - - def delete_layer(self, name): - self._apply_op({"DeleteLayer": {"name": name}}) - -@dataclass -class DocApi: - _transport: Any - active_tab: int = 0 - - def document(self, tab): - if tab != self.active_tab: - raise ValueError("transport is bound to another tab") - return Document(self._transport, tab) -''' - - -def render_method(method, constructor=False): - args = method.get("args", []) - signature = "".join(", " + a["name"] for a in args) - variant = method.get("op") or method["query"] - fields = {} - for arg in args: - name, ty = arg["name"], arg["ty"] - if ty in ("EntityRef", "Solid", "ObjectId"): - fields[name] = f"self._doc._id({name})" - elif ty == "&[EntityRef]": - fields[name] = f"[self._doc._id(p) for p in {name}]" - else: - fields[name] = name - fixed = dict(method.get("fixed") or {}) - if variant in ("SolidBoolean", "GetIntersects"): - fields = {"a": "self.id", "b": fields.pop("other"), **fields} - fields.pop("other", None) - elif not constructor: - fields = {"id": "self.id", **fields} - if variant == "Revolve": - fields["axis"] = "(pivot, axis)" - fields.pop("pivot") - nested = fixed.pop("primitive", fixed.pop("curve", None)) - fields.update({key: repr(value) for key, value in fixed.items()}) - payload = "{" + ", ".join(repr(k) + ": " + v for k, v in fields.items()) + "}" - if variant == "CreateMany": - payload = "[{'Curve': {'Point': {'position': p}}} for p in positions]" - elif nested: - payload = "{" + repr(nested) + ": " + payload + "}" - message = "{" + repr(variant) + ": " + payload + "}" - if method.get("kind") == "query": - call = f"self._doc._apply_query({message})" - else: - want = method.get("returns") - if want == "()": - want = None - call = f"self._doc._apply_op({message}, {want!r})" - return f" def {method['name']}(self{signature}):\n return {call}\n" - - -def render(schema): - out = [HEADER, f"ENVELOPE_VERSION = {schema['envelope_version']}\n"] - handles = {name: {} for name in schema["object_model"]["handles"]} - collections = {"solids": {}, "curves": {}, "entities": {}} - for family in schema["families"]: - handle = family.get("handle") or family["name"] - for method in family.get("methods", []): - existing = handles[handle].get(method["name"]) - if existing is not None and existing != method: - raise ValueError(f"conflicting method: {handle}.{method['name']}") - handles[handle][method["name"]] = method - for constructor in family.get("constructors", []): - collections[family["collection"]][constructor["name"]] = constructor - # Families sharing Entity contribute to one class, preserving all methods. - entity_methods = "\n".join(render_method(m) for m in handles.pop("Entity").values()) - out[0] += entity_methods - for handle, methods in handles.items(): - out.append(f"\nclass {handle}(Entity):\n" + ( - "\n".join(render_method(m) for m in methods.values()) or " pass\n")) - for collection, constructors in collections.items(): - out.append(f"\nclass {collection.title()}:\n def __init__(self, doc):\n self._doc = doc\n") - out.extend(render_method(c, constructor=True) for c in constructors.values()) - out.append(DOCUMENT) - return "\n".join(out) - - -def main(): - default = Path(__file__).resolve().parents[2] / "src/gen/binding_schema.json" - path = Path(sys.argv[1]) if len(sys.argv) > 1 else default - sys.stdout.write(render(json.loads(path.read_text(encoding="utf-8")))) - - -if __name__ == "__main__": - main() diff --git a/crates/ocs_doc_api/bindings/python/test_generate.py b/crates/ocs_doc_api/bindings/python/test_generate.py deleted file mode 100644 index b63ed1fe8..000000000 --- a/crates/ocs_doc_api/bindings/python/test_generate.py +++ /dev/null @@ -1,75 +0,0 @@ -import inspect -import json -from pathlib import Path -import types -import sys -import unittest - -from generate import render - - -class BindingTest(unittest.TestCase): - def setUp(self): - self.schema = json.loads((Path(__file__).resolve().parents[2] / 'src/gen/binding_schema.json').read_text()) - self.module = types.ModuleType('generated') - sys.modules['generated'] = self.module - self.addCleanup(sys.modules.pop, 'generated') - exec(render(self.schema), self.module.__dict__) - module = self.module - - class Transport: - def __init__(self): - self.calls = [] - - def apply(self, envelope): - self.calls.append(envelope) - kind, payload = envelope.body - if kind == 'Queries': - return module.Receipt(None, [{'Volume': 125.0}], 2) - if 'CreateMany' in payload: - return module.Receipt({'NewIds': [10, 11]}, [], 2) - return module.Receipt({'NewId': 9}, [], 2) - - self.transport = Transport() - self.api = module.DocApi(self.transport, active_tab=4) - self.doc = self.api.document(4) - - def test_constructors_returns_and_wire_payloads(self): - solid = self.doc.solids().create_cuboid([0, 0, 0], [5, 5, 5]) - self.assertIsInstance(solid, self.module.Solid) - self.assertEqual(self.transport.calls[-1].body, ('Op', {'CreateSolid': {'Cuboid': {'origin': [0, 0, 0], 'size': [5, 5, 5]}}})) - self.assertEqual(solid.volume(), 125.0) - self.assertIsInstance(solid.intersect(solid), self.module.Solid) - self.assertEqual(self.transport.calls[-1].body[1], {'SolidBoolean': {'a': 9, 'b': 9, 'op': 'Intersection', 'erase_sources': True}}) - points = self.doc.curves().create_points([[0, 0, 0], [1, 1, 1]]) - self.assertEqual([p.id for p in points], [10, 11]) - self.assertTrue(all(isinstance(p, self.module.Point) for p in points)) - self.doc.solids().revolve(points[0], [0, 0, 0], [0, 0, 1], 3.14) - self.assertEqual(self.transport.calls[-1].body[1]['Revolve']['axis'], ([0, 0, 0], [0, 0, 1])) - self.doc.solids().loft(points) - self.assertEqual(self.transport.calls[-1].body[1], {'Loft': {'profiles': [10, 11]}}) - self.assertIsNone(points[0].delete()) - - def test_every_schema_method_and_constructor_survives_generation(self): - for family in self.schema['families']: - handle = getattr(self.module, family['handle']) - collection = getattr(self.doc, family['collection'])() - for method in family['methods']: - self.assertTrue(callable(getattr(handle, method['name']))) - for constructor in family['constructors']: - method = getattr(collection, constructor['name']) - self.assertEqual(list(inspect.signature(method).parameters), [a['name'] for a in constructor['args']]) - - def test_foreign_handles_and_tab_mismatch_do_not_send(self): - with self.assertRaises(ValueError): - self.api.document(5) - foreign = self.module.Solid(9, self.module.DocApi(object(), 4).document(4)) - local = self.module.Solid(9, self.doc) - for operation in [lambda: local.union(foreign), lambda: self.doc.solids().extrude(foreign, [0, 0, 1]), lambda: self.doc.solids().loft([foreign])]: - with self.assertRaises(ValueError): - operation() - self.assertEqual(self.transport.calls, []) - - -if __name__ == '__main__': - unittest.main() diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs deleted file mode 100644 index c951f0d66..000000000 --- a/crates/ocs_doc_api/build.rs +++ /dev/null @@ -1,452 +0,0 @@ -//! Regenerate the API reference and binding schema from the curated spec. - -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()); - - println!("cargo:rerun-if-changed=src/gen/layouts.json"); - let spec_src = fs::read_to_string(&spec_path).expect("read spec/entities.toml"); - let spec: Spec = toml::from_str(&spec_src).expect("parse spec/entities.toml"); - - let gen_dir = manifest_dir.join("src/gen"); - fs::create_dir_all(&gen_dir).unwrap(); - - write_if_changed(&gen_dir.join("api_reference.md"), &api_reference_md(&spec)); - - write_if_changed( - &gen_dir.join("binding_schema.json"), - &binding_schema_json(&spec), - ); -} - -/// 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())); - } -} - -/// 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 { - r#"## Generic methods (every handle) - -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 - computed; later families may be `Unsupported`). -- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: - solids only.** -- **`delete()`** — remove the entity (one undo step). -- **`layer()` -> `String`** — the name of the layer the entity is on. -- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; - blocked if the entity or target layer is locked). - -`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed -downcast when `kind == "Solid3D"`), and XDATA access through -`xdata(application_name)` / `set_xdata(application_name, record)`. - -## Layer table - -Layers are a named document table, not an entity family. `Document::layers()` -lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, -`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` -returns the layer name and `Entity::set_layer(layer)` moves the entity to an -existing layer. The host rejects duplicate names, deletion of layer "0" or the -current layer, and removing a layer that still has entities assigned. - -## Transports - -- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. -- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the - `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes - `DocApiRequest { tab_id, bytes }` to the same executor. -- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that - builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a - `PluginRequestSender` (out-of-process / worker-thread plugins). - -## 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 { "" }; - let op = c.op.as_deref().unwrap_or("-"); - let fixed = fmt_fixed(c.fixed.as_ref()); - s.push_str(&format!( - "- **{}**{} -> `{ret}`{bulk} — `{op}`{fixed}{}\n", - c.name, - fmt_call_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("-"); - let fixed = fmt_fixed(m.fixed.as_ref()); - s.push_str(&format!( - "- **{}**{} -> `{}` — {} `{}`{fixed}{}\n", - m.name, - fmt_call_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.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 = read_layouts(); - 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 document mutation. | -| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). | -| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). | -| `Unsupported(String)` | Capability not supported for this entity or backend. | -| `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 + curated wire vocabulary 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 { - let registry = read_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, - "constructors": fam.constructor.iter().map(|c| serde_json::json!({ - "name": c.name, "op": c.op, "fixed": c.fixed, "bulk": c.bulk, - "returns": c.returns.as_deref().unwrap_or(fam.handle.as_deref().unwrap_or(&fam.name)), - "args": c.args.iter().map(|a| serde_json::json!({"name": a.name, "ty": a.ty})).collect::>() - })).collect::>(), - }) - }) - .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"], - // 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, - // Curated wire vocabulary (not a complete binary codec schema). - "layouts": registry, - }); - - serde_json::to_string_pretty(&schema).expect("serialize binding schema") -} - -/// Read the manually maintained wire vocabulary; malformed snapshots fail the build. -fn read_layouts() -> serde_json::Value { - let src = fs::read_to_string(layouts_snapshot_path()).expect("read layouts.json"); - serde_json::from_str(&src).expect("parse layouts.json") -} - -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 deleted file mode 100644 index 8b7b7454b..000000000 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ /dev/null @@ -1,710 +0,0 @@ -//! 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::{ - normalize_layer_name, Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, - GeometryRevision, LayerInfo, ObjectId, PlacementSpec, -}; - - -#[derive(Clone)] -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, - /// Stored text content for Text/MText annotations. - text_values: HashMap, - /// Stored hatch boundary loops. - hatch_boundaries: HashMap>>, - /// Stored dimension measurements. - dimension_measurements: HashMap, - /// Stored insert attributes. - attributes_store: HashMap>, - /// Stored viewport views (target, height). - viewport_views: HashMap, - /// Stored XDATA records keyed by (entity id, application name). - xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>, - /// Stored XRECORD payloads keyed by entity id. - xrecord_store: HashMap, - /// Stored layer table. - layers: HashMap, - /// Layer name assigned to each entity. - entity_layers: HashMap, -} - -impl Default for MockBackend { - fn default() -> Self { - let mut backend = Self { - next_id: 0, - revision: 0, - undo_steps: 0, - bodies: HashMap::new(), - kinds: HashMap::new(), - curves: HashMap::new(), - text_values: HashMap::new(), - hatch_boundaries: HashMap::new(), - dimension_measurements: HashMap::new(), - attributes_store: HashMap::new(), - viewport_views: HashMap::new(), - xdata_store: HashMap::new(), - xrecord_store: HashMap::new(), - layers: HashMap::new(), - entity_layers: HashMap::new(), - }; - backend.layers.insert("0".to_string(), LayerInfo::new("0")); - backend - } -} - -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 } - } - 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, - }, - } -} - -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 create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut next = self.clone(); - let ids = specs - .iter() - .map(|spec| match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - next.store_solid(&ocs_doc_api::geom::make_solid(spec)?) - } - ocs_doc_api::EntitySpec::Curve(spec) => { - ocs_doc_api::convert::curve_spec_to_entity(spec)?; - next.add_curve(spec) - } - }) - .collect::>>()?; - next.push_undo("CreateMany"); - next.finalize_op(); - *self = next; - Ok(ids) - } - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let mut next = self.clone(); - for &id in ids { - next.transform_entity(id, placement)?; - } - next.push_undo("TransformMany"); - next.finalize_op(); - *self = next; - Ok(()) - } - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let kind = match spec { - Curve2Spec::Line { .. } => "Line", - 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 layer = spec - .layer() - .map(|name| { - self.lookup_layer(name).map(|info| info.normalized_name()) - }) - .transpose()?; - let id = self.alloc(kind); - self.curves.insert(id, spec.clone()); - self.entity_layers - .insert(id, layer.unwrap_or_else(|| "0".to_string())); - Ok(id) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers - .get(&norm) - .cloned() - .ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist"))) - } - 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_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"); - 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_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_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 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)) - } - 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 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) { - 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)); - } - 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_raster_image( - &mut self, - _spec: &ocs_doc_api::ops::RasterImageSpec, - ) -> ApiResult { - Ok(self.alloc("RasterImage")) - } - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, 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)); - } - 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 set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let key = (id, application_name.to_string()); - match record { - Some(r) => { - self.xdata_store.insert(key, r.clone()); - } - None => { - self.xdata_store.remove(&key); - } - } - Ok(()) - } - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - Ok(self - .xdata_store - .get(&(id, application_name.to_string())) - .cloned()) - } - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - let id = self.alloc("XRecord"); - self.xrecord_store.insert(id, spec.clone()); - Ok(id) - } - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - self.xrecord_store.insert(id, spec.clone()); - Ok(()) - } - fn xrecord(&self, id: ObjectId) -> ApiResult> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "GetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - Ok(self.xrecord_store.get(&id).cloned()) - } - fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> { - let name = normalize_layer_name(&info.name); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.layers.contains_key(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut stored = info.clone(); - stored.name = name.clone(); - self.layers.insert(name, stored); - Ok(()) - } - fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if !self.layers.contains_key(&name_upper) { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - } - let mut stored = info.clone(); - stored.name = name_upper.clone(); - self.layers.insert(name_upper, stored); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if self.entity_layers.values().any(|l| normalize_layer_name(l) == name_upper) { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name_upper}' still in use"), - )); - } - if self.layers.remove(&name_upper).is_none() { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - Ok(()) - } - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let layer_upper = normalize_layer_name(layer); - let target = self - .layers - .get(&layer_upper) - .ok_or_else(|| { - ApiError::validation( - "SetEntityLayer", - format!("layer '{layer_upper}' does not exist"), - ) - })?; - if target.flags.locked { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer_upper}'" - ))); - } - self.entity_layers.insert(id, layer_upper); - Ok(()) - } - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self.layers.values().cloned().collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - fn entity_layer(&self, id: ObjectId) -> ApiResult { - self.entity_layers - .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(()) - } else { - Err(ApiError::UnknownId(id)) - } - } - fn remove_entity(&mut self, id: ObjectId) -> ApiResult { - self.bodies.remove(&id); - self.xdata_store.retain(|(k, _), _| *k != id); - self.xrecord_store.remove(&id); - self.entity_layers.remove(&id); - Ok(self.kinds.remove(&id).is_some()) - } - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let mut out = Vec::new(); - for (id, kind_name) in &self.kinds { - if let Some(k) = kind { - if !k.eq_ignore_ascii_case(kind_name) { - continue; - } - } - let entity_layer = self - .entity_layers - .get(id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - if let Some(l) = layer { - if !l.eq_ignore_ascii_case(&entity_layer) { - continue; - } - } - let bounds = if include_bounds { - self.curves.get(id).map(curve_bounds) - } else { - None - }; - out.push(EntityView { - id: *id, - kind: kind_name.clone(), - layer: entity_layer, - bounds, - }); - } - Ok(out) - } - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Point { position, .. } => Ok(*position), - _ => Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )), - } - } - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Line { start, end, .. } => Ok((*start, *end)), - _ => Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )), - } - } - fn get_entity(&mut self, id: ObjectId) -> ApiResult { - let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); - let layer = self - .entity_layers - .get(&id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - 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, - layer, - 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 object_exists(&self, id: ObjectId) -> bool { - self.xrecord_store.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 — 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]) { - ocs_doc_api::geom::mesh_volume_centroid(mesh) -} diff --git a/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs b/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs deleted file mode 100644 index 3d3312f11..000000000 --- a/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! 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 deleted file mode 100644 index dd117c6fd..000000000 --- a/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! 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 deleted file mode 100644 index 1229f2997..000000000 --- a/crates/ocs_doc_api/examples/e3_transform_and_chain.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! 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 deleted file mode 100644 index eb1ff0b5b..000000000 --- a/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! 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 deleted file mode 100644 index 2cc82dbe5..000000000 --- a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! 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, ObjectId, OpGroup}; - -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!(doc.entities().get(ObjectId::from_u64(0xDEAD)).is_err()); - println!("failure semantics verified"); - Ok(()) -} diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml deleted file mode 100644 index 4de4cf6eb..000000000 --- a/crates/ocs_doc_api/spec/entities.toml +++ /dev/null @@ -1,566 +0,0 @@ -# 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" - -[[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" - -[[family.constructor]] -name = "loft" -args = [{ name = "profiles", ty = "&[EntityRef]" }] -op = "Loft" - -[[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 = "()" - -[[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" - -[[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" } - -# ============================================================================ -# 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 = "()" - -# ============================================================================ -# 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>" - -# ============================================================================ -# 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.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.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" -query = "GetDimensionMeasurement" -returns = "f64" - -# ============================================================================ -# 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" - -[[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 = "()" - -# ============================================================================ -# 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 -# ============================================================================ -[[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" - -[[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 -# computable; they have no typed construction/geometric ops in v1. -# ============================================================================ -[[family]] -name = "RasterImage" -acadrust_variant = "RasterImage" -handle = "Entity" -collection = "entities" -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" -acadrust_variant = "Table" -handle = "Entity" -collection = "entities" -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" - -# ============================================================================ -# XDATA / XRECORD FAMILY — application-specific extended data roundtrip -# ============================================================================ -[[family]] -name = "XRecord" -acadrust_variant = "XRecord" -handle = "XRecord" -collection = "entities" -description = "Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload." - -[[family.constructor]] -name = "create_xrecord" -args = [{ name = "spec", ty = "XRecordSpec" }] -op = "CreateXRecord" -returns = "XRecord" - -[[family.method]] -name = "payload" -kind = "query" -query = "GetXRecord" -returns = "XRecordSpec" - -[[family.method]] -name = "set_payload" -kind = "op" -op = "SetXRecord" -args = [{ name = "spec", ty = "XRecordSpec" }] -returns = "()" - -# ============================================================================ -# 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). - -# ============================================================================ -# LAYER family (named drawing layers) -# ============================================================================ -[[family]] -name = "Layer" -handle = "Layer" -collection = "layers" -description = "Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers()." - -[[family.method]] -name = "list" -kind = "query" -query = "ListLayers" -returns = "Vec" - -[[family.method]] -name = "create" -kind = "op" -op = "CreateLayer" -args = [{ name = "info", ty = "LayerInfo" }] -returns = "()" - -[[family.method]] -name = "update" -kind = "op" -op = "UpdateLayer" -args = [{ name = "name", ty = "String" }, { name = "info", ty = "LayerInfo" }] -returns = "()" - -[[family.method]] -name = "delete" -kind = "op" -op = "DeleteLayer" -args = [{ name = "name", ty = "String" }] -returns = "()" - -# ---- 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 deleted file mode 100644 index 51e4cfafe..000000000 --- a/crates/ocs_doc_api/src/backend.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! The thin host hook. 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, LayerInfo}; -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. 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`). - 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; - - /// Prepare every entity before recording one undo step and committing the batch. - fn create_many(&mut self, specs: &[crate::ops::EntitySpec]) -> ApiResult>; - - /// Prepare all transformed geometry before committing any entity. - fn transform_many( - &mut self, - ids: &[ObjectId], - placement: &crate::PlacementSpec, - ) -> ApiResult<()>; - - /// Add a block reference (`INSERT`). Validates `block_name` exists; returns - /// the fresh `ObjectId`. `Validation` if the block is unknown. - fn add_insert(&mut self, spec: &crate::ops::InsertSpec) -> ApiResult; - - /// 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; - - /// 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>>; - - /// 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; - - /// 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; - - /// 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; - - /// 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>; - - /// 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)>; - - /// 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: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult; - - /// Attach or replace an XDATA record for `application_name` on `id`. - /// `None` removes any existing record for that application. - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&crate::ops::XDataRecord>, - ) -> ApiResult<()>; - - /// Read the XDATA record for `application_name` on `id` (`None` if absent). - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult>; - - /// Create a standalone `XRECORD` object from a spec; returns the fresh `ObjectId`. - fn add_xrecord(&mut self, spec: &crate::ops::XRecordSpec) -> ApiResult; - - /// Replace an existing `XRECORD` object's payload in place. - fn set_xrecord(&mut self, id: ObjectId, spec: &crate::ops::XRecordSpec) -> ApiResult<()>; - - /// Read an existing `XRECORD` object's payload (`None` if `id` is not an XRecord). - fn xrecord(&self, id: ObjectId) -> ApiResult>; - - // ── layer CRUD ───────────────────────────────────────────────────────── - /// Create a new layer. Fails with `Validation` if the name already exists or is reserved. - fn create_layer(&mut self, info: &crate::ops::LayerInfo) -> ApiResult<()>; - - /// Update an existing layer's properties by name. Fails with `Validation` if absent. - fn update_layer(&mut self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()>; - - /// Delete a layer by name. Fails with `Validation` if the layer is "0", - /// the current layer, does not exist, or has entities. - fn delete_layer(&mut self, name: &str) -> ApiResult<()>; - - /// Look up a layer by raw name, normalizing it. Default implementation scans - /// `layers()`; backends with O(1) lookup should override. - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = crate::layer::normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers()? - .into_iter() - .find(|l| l.normalized_name() == norm) - .ok_or_else(|| { - ApiError::validation( - "LookupLayer", - format!("layer '{name}' does not exist"), - ) - }) - } - - /// Move an entity to a different layer. The target layer must exist. - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()>; - - /// List all layers in the document. - fn layers(&self) -> ApiResult>; - - /// The layer name of an entity. - fn entity_layer(&self, id: ObjectId) -> ApiResult; - - /// Enumerate first-class entities, optionally filtered by kind and/or layer. - /// `include_bounds` controls whether `EntityView::bounds` is populated; skip it - /// for lightweight list views. - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult>; - - /// The WCS location of a Point entity. - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]>; - - /// The start and end WCS points of a Line entity. - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])>; - - /// 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; - - /// 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)) - } - } - - /// Whether an object (named-object / XRecord / dictionary) exists. Distinct - /// from `entity_exists`, which is for first-class entities only. - fn object_exists(&self, id: ObjectId) -> bool; - - /// 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>; - - fn profile_plane(&self, _id: ObjectId) -> ApiResult { - Ok(cadkernel::space::Plane::XY) - } - - /// Apply a rigid similarity to any entity in place (same `ObjectId`). - fn transform_entity( - &mut self, - id: ObjectId, - placement: &crate::ops::PlacementSpec, - ) -> ApiResult<()>; - - /// Validate that `id` exists and can be transformed, WITHOUT mutating. - /// Used by `TransformMany` to pre-validate all ids before `push_undo` so the - /// apply loop cannot fail part-way (all-or-nothing, the bulk contract). Default: - /// 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); - /// Discard an undo capture after preparation or validation failed. - fn cancel_op(&mut self) {} -} diff --git a/crates/ocs_doc_api/src/convert.rs b/crates/ocs_doc_api/src/convert.rs deleted file mode 100644 index db1f0a22f..000000000 --- a/crates/ocs_doc_api/src/convert.rs +++ /dev/null @@ -1,271 +0,0 @@ -//! Entity conversions: 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 at least two vertices", - )); - } - KCurve::Polyline(cadkernel::geom2d::Polyline { - vertices: pl - .vertices - .iter() - .map(|v| cadkernel::geom2d::PolylineVertex { - position: [v.location.x, v.location.y], - bulge: v.bulge, - }) - .collect(), - closed: pl.is_closed, - }) - .segments() - } - _ => { - 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 { - crate::geom::validate_placement(p)?; - let matrix = acadrust::types::Matrix4 { - m: [ - [p.x_axis[0], p.y_axis[0], p.z_axis[0], p.origin[0]], - [p.x_axis[1], p.y_axis[1], p.z_axis[1], p.origin[1]], - [p.x_axis[2], p.y_axis[2], p.z_axis[2], p.origin[2]], - [0.0, 0.0, 0.0, 1.0], - ], - }; - let mut moved = entity.clone(); - moved.apply_transform(&acadrust::types::Transform::from_matrix(matrix)); - Ok(moved) -} - -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 arc = cadkernel::geom2d::BulgeArc::from_bulge(start, end, bulge) - .ok_or_else(|| ApiError::validation("profile", "invalid bulge segment"))?; - // The kernel's Arc is counter-clockwise; reverse clockwise endpoints. - let (start_angle, end_angle) = if arc.sweep < 0.0 { - (arc.end_angle, arc.start_angle) - } else { - (arc.start_angle, arc.end_angle) - }; - Ok(KCurve::Arc(KArc { - centre: arc.center, - radius: arc.radius, - start_angle, - end_angle, - })) -} - -/// Build an acadrust entity for a 2D curve construction spec. -pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { - crate::validation::curve(spec)?; - 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.elevation = points.first().map_or(0.0, |p| p[2]); - if points.iter().any(|p| p[2] != e.elevation) { - return Err(ApiError::validation( - "CreateCurve", - "polyline points must share an elevation", - )); - } - e.is_closed = *closed; - EntityType::LwPolyline(e) - } - 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", - } -} - -/// Existing entity bounds; unbounded or empty geometry returns an error. -pub fn entity_bounds(entity: Option<&EntityType>, id: ObjectId) -> ApiResult { - let entity = entity.ok_or(ApiError::UnknownId(id))?; - if matches!(entity, EntityType::Ray(_) | EntityType::XLine(_)) { - return Err(ApiError::Unsupported("unbounded entity".into())); - } - let bounds = entity.as_entity().bounding_box(); - let min = [bounds.min.x, bounds.min.y, bounds.min.z]; - let max = [bounds.max.x, bounds.max.y, bounds.max.z]; - if min.iter().chain(&max).any(|v| !v.is_finite()) { - return Err(ApiError::geometry( - GeometryErrorKind::Empty, - "entity has no finite bounds", - )); - } - Ok(Aabb { min, max }) -} diff --git a/crates/ocs_doc_api/src/envelope.rs b/crates/ocs_doc_api/src/envelope.rs deleted file mode 100644 index 0f847740f..000000000 --- a/crates/ocs_doc_api/src/envelope.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! The wire envelope. 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 validate_version(&self) -> crate::ApiResult<()> { - if self.version != ENVELOPE_VERSION { - return Err(crate::ApiError::Unsupported(format!( - "envelope version {}", - self.version - ))); - } - Ok(()) - } - - pub fn op(operation: Operation) -> Self { - Self { - version: ENVELOPE_VERSION, - body: EnvelopeBody::Op(operation), - } - } - 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 deleted file mode 100644 index 783ec1434..000000000 --- a/crates/ocs_doc_api/src/error.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Structured error model. 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 deleted file mode 100644 index 423fc0658..000000000 --- a/crates/ocs_doc_api/src/executor.rs +++ /dev/null @@ -1,616 +0,0 @@ -//! The op executor: 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 and an advanced revision. -/// On failure: nothing applied, no undo, no bump (the op validates before mutating). -pub fn apply_op(b: &mut B, op: Operation) -> ApiResult { - let result = apply_op_inner(b, op); - if result.is_err() { - b.cancel_op(); - } - result -} - -fn apply_op_inner(b: &mut B, op: Operation) -> ApiResult { - let name = op.op_name(); - crate::validation::operation(&op)?; - match &op { - Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) => { - let unique: std::collections::HashSet<_> = ids.iter().collect(); - if ids.is_empty() || unique.len() != ids.len() { - return Err(ApiError::validation( - name, - "batch ids must be nonempty and unique", - )); - } - } - Operation::SetXDataMany(records) => { - if records.is_empty() { - return Err(ApiError::validation( - name, - "SetXDataMany records must be nonempty", - )); - } - if records.len() > BULK_ITEM_CAP { - return Err(over_cap(name, records.len())); - } - let unique: std::collections::HashSet<_> = records.iter().map(|(id, _, _)| id).collect(); - if unique.len() != records.len() { - return Err(ApiError::validation( - name, - "SetXDataMany records must have unique entity ids", - )); - } - } - _ => {} - } - if let Operation::Transform { placement, .. } | Operation::TransformMany { placement, .. } = &op - { - crate::geom::validate_placement(placement)?; - } - let outcome = match &op { - Operation::CreateSolid(prim) => { - let body = crate::geom::make_solid(prim)?; - 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(b.profile_plane(*profile)?, &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(b.profile_plane(*profile)?, &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. - if *erase_sources { - if a == bid { - return Err(ApiError::validation(name, "boolean source ids must differ")); - } - b.can_modify(*a)?; - b.can_remove(*bid)?; - } - b.push_undo(name); - // `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. - 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())); - } - if specs.is_empty() { - return Err(ApiError::validation(name, "batch must not be empty")); - } - for spec in specs { - if let EntitySpec::Curve(curve) = spec { - crate::validation::curve(curve)?; - } - } - let ids = b.create_many(specs)?; - 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, - // the bulk contract): 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.transform_many(ids, placement)?; - 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()) - } - Operation::CreateInsert(spec) => { - // 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) - } - Operation::CreateViewport(spec) => { - b.push_undo(name); - let id = b.add_viewport(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateText(spec) => { - 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) - } - Operation::CreateHatch(spec) => { - // Validate the boundary (>= 3 points) before committing. - if spec.boundary.len() < 3 { - return Err(ApiError::validation( - name, - "hatch boundary needs >= 3 points", - )); - } - b.push_undo(name); - let id = b.add_hatch(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionLinear(spec) => { - b.push_undo(name); - let id = b.add_dimension_linear(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetAttribute { id, tag, 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_attribute(*id, tag, value)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetViewportView { - id, - view_target, - view_height, - } => { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => { - ApiError::validation(name, format!("unknown ObjectId {id:?}")) - } - other => other, - })?; - b.push_undo(name); - b.set_viewport_view(*id, *view_target, *view_height)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::CreateRasterImage(spec) => { - b.push_undo(name); - let id = b.add_raster_image(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::Loft { profiles } => { - // 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")); - } - let mut sections = Vec::with_capacity(profiles.len()); - for (i, p) in profiles.iter().enumerate() { - let curves = profile_curves(b, *p, name).map_err(|e| at_index(name, i, e))?; - sections.push((b.profile_plane(*p)?, curves)); - } - b.push_undo(name); - let id = b.loft(§ions)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateDimensionRadius(spec) => { - 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) - } - Operation::CreateAttributeDefinition(spec) => { - b.push_undo(name); - let id = b.add_attribute_definition(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateTable(spec) => { - // 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) - } - Operation::CreateDimensionAngular2Ln(spec) => { - b.push_undo(name); - let id = b.add_dimension_angular2ln(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::CreateLayer(info) => { - b.push_undo(name); - b.create_layer(info)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::UpdateLayer { name, info } => { - b.push_undo(name); - b.update_layer(name, info)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::DeleteLayer { name } => { - b.push_undo(name); - b.delete_layer(name)?; - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::SetEntityLayer { id, layer } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.set_entity_layer(*id, layer)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetXData { - id, - application_name, - record, - } => { - require_exists(b, *id, name)?; - b.push_undo(name); - b.set_xdata(*id, application_name, record.as_ref())?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - Operation::SetXDataMany(records) => { - // A later locked entity must not leave earlier records changed. - for (i, (id, _, _)) in records.iter().enumerate() { - b.can_modify(*id).map_err(|e| match e { - ApiError::UnknownId(_) => stale_index(name, i, *id), - ApiError::Unsupported(reason) => { - ApiError::validation(name, format!("index {i}: {reason}")) - } - other => other, - })?; - } - b.push_undo(name); - for (id, application_name, record) in records { - b.set_xdata(*id, application_name, record.as_ref())?; - } - b.finalize_op(); - OpOutcome::Updated(ObjectId::NULL) - } - Operation::CreateXRecord(spec) => { - b.push_undo(name); - let id = b.add_xrecord(spec)?; - b.finalize_op(); - OpOutcome::NewId(id) - } - Operation::SetXRecord { id, spec } => { - require_object_exists(b, *id, name)?; - b.push_undo(name); - b.set_xrecord(*id, spec)?; - b.finalize_op(); - OpOutcome::Updated(*id) - } - }; - Ok(Receipt { - outcome: Some(outcome), - query_results: Vec::new(), - new_revision: b.revision(), - }) -} - -/// 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 qname = q.query_name(); - let r = match q { - Query::GetEntity { id } => { - QueryResult::Entity(b.get_entity(*id).map_err(|e| label(qname, e))?) - } - Query::GetBounds { id } => { - QueryResult::Bounds(b.bounds(*id).map_err(|e| label(qname, e))?) - } - Query::GetCentroid { id } => { - QueryResult::Centroid(b.centroid(*id).map_err(|e| label(qname, e))?) - } - Query::GetVolume { id } => { - QueryResult::Volume(b.volume(*id).map_err(|e| label(qname, e))?) - } - Query::GetIntersects { a, b: bid } => { - let ba = b.bounds(*a).map_err(|e| label(qname, e))?; - let bb = b.bounds(*bid).map_err(|e| label(qname, e))?; - QueryResult::Intersects(ba.overlaps(&bb)) - } - Query::GetGeometryRevision => QueryResult::Revision(b.revision()), - Query::GetTextContent { id } => { - QueryResult::TextContent(b.text_content(*id).map_err(|e| label(qname, e))?) - } - Query::GetHatchBoundary { id } => { - QueryResult::HatchBoundary(b.hatch_boundary(*id).map_err(|e| label(qname, e))?) - } - Query::GetDimensionMeasurement { id } => QueryResult::DimensionMeasurement( - b.dimension_measurement(*id).map_err(|e| label(qname, e))?, - ), - Query::GetAttributes { id } => { - QueryResult::Attributes(b.attributes(*id).map_err(|e| label(qname, e))?) - } - Query::GetBlockEntities { block_name } => QueryResult::BlockEntities( - b.block_entities(block_name).map_err(|e| label(qname, e))?, - ), - Query::GetViewportView { id } => { - let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?; - QueryResult::ViewportView { target, height } - } - Query::GetXData { - id, - application_name, - } => QueryResult::XData( - b.xdata(*id, application_name) - .map_err(|e| label(qname, e))? - .clone(), - ), - Query::GetXRecord { id } => { - QueryResult::XRecord(b.xrecord(*id).map_err(|e| label(qname, e))?.ok_or_else( - || ApiError::validation(qname, format!("ObjectId {id:?} is not an XRecord")), - )?) - } - Query::ListLayers => QueryResult::Layers(b.layers().map_err(|e| label(qname, e))?), - Query::GetEntityLayer { id } => { - QueryResult::EntityLayer(b.entity_layer(*id).map_err(|e| label(qname, e))?) - } - Query::EnumerateEntities { - kind, - layer, - include_bounds, - } => QueryResult::Entities( - b.enumerate_entities(kind.as_deref(), layer.as_deref(), *include_bounds) - .map_err(|e| label(qname, e))?, - ), - Query::GetPointPosition { id } => QueryResult::PointPosition( - b.point_position(*id).map_err(|e| label(qname, e))?, - ), - Query::GetLineGeometry { id } => { - let (start, end) = b.line_geometry(*id).map_err(|e| label(qname, e))?; - QueryResult::LineGeometry((start, end)) - } - }; - results.push(r); - } - Ok(Receipt { - outcome: None, - query_results: results, - new_revision: b.revision(), - }) -} - -/// 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, - op: &'static str, -) -> ApiResult> { - if !b.entity_exists(profile) { - return Err(ApiError::validation( - op, - format!("unknown ObjectId {profile:?}"), - )); - } - b.profile_curves(profile) -} - -/// Validate that an entity exists before performing an operation. -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:?}"))) - } -} - -/// Validate that a named object exists before performing an operation. -fn require_object_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { - if b.object_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 deleted file mode 100644 index ef6f2b13d..000000000 --- a/crates/ocs_doc_api/src/facade.rs +++ /dev/null @@ -1,1413 +0,0 @@ -//! The object-centric facade. -//! -//! 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, XDataRecord, XRecordSpec}; -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, - valid_tab: bool, -} - -impl Session { - fn validate_tab(&self) -> ApiResult<()> { - if !self.valid_tab { - return Err(ApiError::validation( - "document", - "transport is bound to another tab", - )); - } - Ok(()) - } - fn same_transport(&self, other: &Session) -> ApiResult<()> { - self.validate_tab()?; - other.validate_tab()?; - if !Arc::ptr_eq(&self.transport, &other.transport) { - return Err(ApiError::validation( - "document", - "handles belong to different sessions", - )); - } - Ok(()) - } - fn apply_op(&self, op: Operation) -> ApiResult { - self.validate_tab()?; - self.transport.apply(DocApiEnvelope::op(op)) - } - fn apply_queries(&self, queries: Vec) -> ApiResult { - self.validate_tab()?; - 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 the transport's tab. Requests for another tab fail. - pub fn document(&self, tab: u64) -> Document { - Document { - session: Session { - transport: Arc::clone(&self.transport), - valid_tab: tab == self.active_tab, - }, - } - } -} - -/// 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" - ), - }) - } - } - - /// 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(), - )), - } - } - - /// List all layers in the document. - pub fn layers(&self) -> ApiResult> { - match self.session.one_query(Query::ListLayers)? { - QueryResult::Layers(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected layers result".into())), - } - } - - /// Create a new layer. One undo step; fails if the normalized name already - /// exists or is empty. - pub fn create_layer(&self, info: &crate::ops::LayerInfo) -> ApiResult<()> { - if !crate::layer::is_valid_layer_name(&info.name) { - return Err(ApiError::validation( - "CreateLayer", - "empty layer name", - )); - } - self.session - .apply_op(Operation::CreateLayer(info.clone()))?; - Ok(()) - } - - /// Update an existing layer's properties by name. One undo step; `info.name` - /// becomes the new display name (case-insensitive key unchanged). - pub fn update_layer(&self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()> { - if !crate::layer::is_valid_layer_name(&info.name) { - return Err(ApiError::validation( - "UpdateLayer", - "empty layer name", - )); - } - self.session.apply_op(Operation::UpdateLayer { - name: name.to_string(), - info: info.clone(), - })?; - Ok(()) - } - - /// Delete a layer by name. One undo step; fails for layer "0", the current - /// layer, or any layer still referenced by entities. - pub fn delete_layer(&self, name: &str) -> ApiResult<()> { - self.session.apply_op(Operation::DeleteLayer { - name: name.to_string(), - })?; - Ok(()) - } - - /// Set XDATA on many entities in a single atomic operation. - /// - /// Each tuple is `(entity_id, application_name, record)`. Pass `None` for - /// `record` to delete the application's data. Fails up-front if any id is - /// invalid; the whole batch is one undo step. - pub fn set_xdata_many(&self, items: I) -> ApiResult<()> - where - I: IntoIterator)>, - S: Into, - { - let updates: Vec<_> = items - .into_iter() - .map(|(id, app, record)| (id, app.into(), record)) - .collect(); - if updates.is_empty() { - return Ok(()); - } - self.session.apply_op(Operation::SetXDataMany(updates))?; - Ok(()) - } - - /// 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(), - session: self.session.clone(), - error: None, - }; - f(&mut qb); - if let Some(error) = qb.error { - return Err(error); - } - let receipt = self.session.apply_queries(qb.queries)?; - Ok(QueryResults { - results: receipt.query_results, - }) - } -} - -/// Records queries for [`Document::query_batch`]. Each method appends one query. -pub struct QueryBatch { - queries: Vec, - session: Session, - error: Option, -} - -impl QueryBatch { - fn check(&mut self, e: &impl HasId) { - if let Err(error) = e.validate_session(&self.session) { - self.error = Some(error); - } - } - pub fn bounds(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetBounds { id: e.id() }); - } - pub fn volume(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetVolume { id: e.id() }); - } - pub fn centroid(&mut self, e: &impl HasId) { - self.check(e); - self.queries.push(Query::GetCentroid { id: e.id() }); - } - pub fn intersects(&mut self, a: &impl HasId, b: &impl HasId) { - self.check(a); - self.check(b); - self.queries.push(Query::GetIntersects { - a: a.id(), - b: b.id(), - }); - } - 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; - /// Typed handles retain their session; raw ids are relative to the receiver. - fn session(&self) -> Option<&Session> { - None - } - fn validate_session(&self, target: &Session) -> ApiResult<()> { - if let Some(session) = self.session() { - session.same_transport(target)?; - } - Ok(()) - } -} -impl HasId for ObjectId { - fn id(&self) -> ObjectId { - *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 session(&self) -> Option<&Session> { - Some(&self.session) - } - fn id(&self) -> ObjectId { - self.id - } - } - }; -} - -macro_rules! entity_handle { - ($name:ident) => { - handle!($name); - 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(()) - } - /// Return the entity's current layer name (normalized uppercase). - pub fn layer(&self) -> ApiResult { - match self - .session - .one_query(Query::GetEntityLayer { id: self.id })? - { - QueryResult::EntityLayer(name) => Ok(name), - _ => Err(ApiError::Transport("unexpected entity layer result".into())), - } - } - /// Move the entity to a different layer. One undo step; fails if the - /// target layer does not exist or is locked. - pub fn set_layer(&self, layer: &str) -> ApiResult<()> { - self.session.apply_op(Operation::SetEntityLayer { - id: self.id, - layer: layer.to_string(), - })?; - Ok(()) - } - } - }; -} - -entity_handle!(Entity); -entity_handle!(Solid); -entity_handle!(Line); -entity_handle!(Circle); -entity_handle!(Polyline); -entity_handle!(Point); -entity_handle!(ArcCurve); -entity_handle!(Ellipse); -entity_handle!(Spline); -entity_handle!(Ray); -entity_handle!(XLine); -entity_handle!(Text); -entity_handle!(MText); -entity_handle!(Dimension); -handle!(XRecord); - -impl XRecord { - /// Read the XRECORD payload. - pub fn payload(&self) -> ApiResult { - match self.session.one_query(Query::GetXRecord { id: self.id })? { - QueryResult::XRecord(spec) => Ok(spec), - _ => Err(ApiError::Transport("unexpected xrecord result".into())), - } - } - /// Replace the XRECORD payload in place (one undo step). - pub fn set_payload(&self, spec: &XRecordSpec) -> ApiResult<()> { - self.session.apply_op(Operation::SetXRecord { - id: self.id, - spec: spec.clone(), - })?; - Ok(()) - } -} - -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 { - match self.session.one_query(Query::GetEntity { id: self.id })? { - QueryResult::Entity(v) => Ok(v), - _ => 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(()) - } - /// 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(()) - } - /// The XDATA record for `application_name` on this entity (`None` if absent). - pub fn xdata(&self, application_name: &str) -> ApiResult> { - match self.session.one_query(Query::GetXData { - id: self.id, - application_name: application_name.to_string(), - })? { - QueryResult::XData(v) => Ok(v), - _ => Err(ApiError::Transport("unexpected xdata result".into())), - } - } - /// Attach or replace an XDATA record for `application_name` on this entity. - /// Pass `None` to remove the record (one undo step). - pub fn set_xdata(&self, application_name: &str, record: Option) -> ApiResult<()> { - self.session.apply_op(Operation::SetXData { - id: self.id, - application_name: application_name.to_string(), - record, - })?; - 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)) - } -} - -impl Solid { - fn boolean(&self, op: BoolOp, other: &Solid) -> ApiResult { - self.session.same_transport(&other.session)?; - 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 { - self.session.same_transport(&other.session)?; - 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(()) - } -} - -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)] -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 { - profile.validate_session(&self.session)?; - 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 { - profile.validate_session(&self.session)?; - 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)) - } - /// Loft a solid through >= 2 profile entities (polylines/circles/arcs). - pub fn loft(&self, profiles: &[impl HasId]) -> ApiResult { - for profile in profiles { - profile.validate_session(&self.session)?; - } - 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)] -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, - layer: None, - })?; - Ok(Line::new(s, id)) - } - pub fn create_line_on_layer( - &self, - start: [f64; 3], - end: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Line { - start, - end, - layer: Some(layer.to_string()), - })?; - 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, - layer: None, - })?; - Ok(Circle::new(s, id)) - } - pub fn create_circle_on_layer( - &self, - centre: [f64; 3], - radius: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Circle { - centre, - radius, - layer: Some(layer.to_string()), - })?; - 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, - layer: None, - })?; - Ok(Polyline::new(s, id)) - } - pub fn create_polyline_on_layer( - &self, - points: &[[f64; 3]], - closed: bool, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Polyline { - points: points.to_vec(), - closed, - layer: Some(layer.to_string()), - })?; - Ok(Polyline::new(s, id)) - } - pub fn create_point(&self, position: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Point { - position, - layer: None, - })?; - Ok(Point::new(s, id)) - } - pub fn create_point_on_layer(&self, position: [f64; 3], layer: &str) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Point { - position, - layer: Some(layer.to_string()), - })?; - 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, - layer: None, - })?; - Ok(ArcCurve::new(s, id)) - } - pub fn create_arc_on_layer( - &self, - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Arc { - centre, - radius, - start_angle, - end_angle, - layer: Some(layer.to_string()), - })?; - 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, - layer: None, - })?; - Ok(Ellipse::new(s, id)) - } - pub fn create_ellipse_on_layer( - &self, - centre: [f64; 3], - major_axis: [f64; 3], - ratio: f64, - start: f64, - end: f64, - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ellipse { - centre, - major_axis, - ratio, - start, - end, - layer: Some(layer.to_string()), - })?; - 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(), - layer: None, - })?; - Ok(Spline::new(s, id)) - } - pub fn create_spline_on_layer( - &self, - degree: i32, - control_points: &[[f64; 3]], - knots: &[f64], - weights: &[f64], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Spline { - degree, - control_points: control_points.to_vec(), - knots: knots.to_vec(), - weights: weights.to_vec(), - layer: Some(layer.to_string()), - })?; - 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, - layer: None, - })?; - Ok(Ray::new(s, id)) - } - pub fn create_ray_on_layer( - &self, - origin: [f64; 3], - direction: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ray { - origin, - direction, - layer: Some(layer.to_string()), - })?; - 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, - layer: None, - })?; - Ok(XLine::new(s, id)) - } - pub fn create_xline_on_layer( - &self, - origin: [f64; 3], - direction: [f64; 3], - layer: &str, - ) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::XLine { - origin, - direction, - layer: Some(layer.to_string()), - })?; - 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 { - boundary: boundary.to_vec(), - solid, - }))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_hatch returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) - } - - /// A 2-line angular DIMENSION: angle between lines (vertex→first) and (vertex→second). - pub fn create_dimension_angular2ln( - &self, - vertex: [f64; 3], - first_point: [f64; 3], - second_point: [f64; 3], - arc_location: [f64; 3], - ) -> ApiResult { - 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) - } - /// 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 { - 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 { - 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: all-or-nothing, one undo step. - /// Places points on the current layer (`layer: None`). - pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { - let specs: Vec = positions - .iter() - .map(|&p| { - crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { - position: p, - layer: None, - }) - }) - .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()) - } - - /// Bulk-create many points on a specific layer in ONE op. - pub fn create_points_on_layer( - &self, - positions: &[[f64; 3]], - layer: &str, - ) -> ApiResult> { - let specs: Vec = positions - .iter() - .map(|&p| { - crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { - position: p, - layer: Some(layer.to_string()), - }) - }) - .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 { - /// 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)) - } - - /// 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 { - 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 { - 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`. - 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(()) - } - - /// Create a standalone `XRECORD` object. - pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult { - let receipt = self - .session - .apply_op(Operation::CreateXRecord(spec.clone()))?; - let id = receipt - .outcome - .and_then(|o| o.new_id()) - .ok_or_else(|| ApiError::Transport("create_xrecord returned no id".into()))?; - Ok(XRecord::new(self.session.clone(), id)) - } - - /// Lookup an `XRECORD` object by id and return a typed accessor handle. - pub fn get_xrecord(&self, id: ObjectId) -> ApiResult { - // Validate existence + that the payload is readable as an XRecord. - match self.session.one_query(Query::GetXRecord { id })? { - QueryResult::XRecord(_) => Ok(XRecord::new(self.session.clone(), id)), - _ => Err(ApiError::Transport("unexpected xrecord result".into())), - } - } - - /// Bulk-create many entities in ONE op (all-or-nothing, one undo step). - pub fn create_many(&self, specs: &[crate::ops::EntitySpec]) -> ApiResult> { - let receipt = self.session.apply_op(Operation::CreateMany(specs.to_vec()))?; - let outcome = receipt - .outcome - .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; - Ok(outcome - .new_ids() - .iter() - .map(|&id| Entity::new(self.session.clone(), id)) - .collect()) - } - - /// 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 with a bulk delete. Separate writes remain committed; -/// no host involvement, no rollback. -#[derive(Default)] -pub struct OpGroup { - created: Vec, - session: Option, -} - -impl OpGroup { - pub fn new() -> Self { - Self { - created: Vec::new(), - session: None, - } - } - /// 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?; - if let Some(session) = handle.session() { - if let Some(previous) = &self.session { - previous.same_transport(session)?; - } else { - self.session = Some(session.clone()); - } - } - if !self.created.contains(&handle.id()) { - self.created.push(handle.id()); - } - 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. - pub fn compensate(self, doc: &Document) -> ApiResult<()> { - if let Some(session) = &self.session { - session.same_transport(&doc.session)?; - } - if self.created.is_empty() { - return Ok(()); - } - 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 deleted file mode 100644 index a0396caa1..000000000 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ /dev/null @@ -1,382 +0,0 @@ -# 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` — `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`; 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` — `CreateCurve`; fixed curve="Line" - -## `Circle` (acadrust `Circle`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **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` — `CreateCurve`; fixed curve="Polyline" - -### Methods - -- **add_vertex**(at: usize, point: [f64; 3]) -> `()` — op `AddVertex` - -## `Point` (acadrust `Point`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **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` — `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` — `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` — `CreateCurve`; fixed curve="Spline" - -## `Ray` (acadrust `Ray`, collection `curves`) - -### Constructors (`doc.curves()`) - -- **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` — `CreateCurve`; fixed curve="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` — `CreateText` - -### 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` — `CreateMText` - -### Methods - -- **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` — `CreateHatch` - -### Methods - -- **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` — `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` -- **create_dimension_angular2ln**(vertex: [f64; 3], first_point: [f64; 3], second_point: [f64; 3], arc_location: [f64; 3]) -> `Dimension` — `CreateDimensionAngular2Ln` - -### Methods - -- **measurement**() -> `f64` — query `GetDimensionMeasurement` - -## `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` — `CreateInsert` - -### Methods - -- **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. - -### Constructors (`doc.entities()`) - -- **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` - -## `Entity` (acadrust `RasterImage`, collection `entities`) - -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` — `CreateRasterImage` - -## `Entity` (acadrust `Table`, collection `entities`) - -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` - -## `XRecord` (acadrust `XRecord`, collection `entities`) - -Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload. - -### Constructors (`doc.entities()`) - -- **create_xrecord**(spec: XRecordSpec) -> `XRecord` — `CreateXRecord` - -### Methods - -- **payload**() -> `XRecordSpec` — query `GetXRecord` -- **set_payload**(spec: XRecordSpec) -> `()` — op `SetXRecord` - -## `Layer` (acadrust `-`, collection `layers`) - -Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers(). - -### Methods - -- **list**() -> `Vec` — query `ListLayers` -- **create**(info: LayerInfo) -> `()` — op `CreateLayer` -- **update**(name: String, info: LayerInfo) -> `()` — op `UpdateLayer` -- **delete**(name: String) -> `()` — op `DeleteLayer` - -## Generic methods (every handle) - -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 - computed; later families may be `Unsupported`). -- **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: - solids only.** -- **`delete()`** — remove the entity (one undo step). -- **`layer()` -> `String`** — the name of the layer the entity is on. -- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; - blocked if the entity or target layer is locked). - -`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed -downcast when `kind == "Solid3D"`), and XDATA access through -`xdata(application_name)` / `set_xdata(application_name, record)`. - -## Layer table - -Layers are a named document table, not an entity family. `Document::layers()` -lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, -`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` -returns the layer name and `Entity::set_layer(layer)` moves the entity to an -existing layer. The host rejects duplicate names, deletion of layer "0" or the -current layer, and removing a layer that still has entities assigned. - -## Transports - -- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. -- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the - `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes - `DocApiRequest { tab_id, bytes }` to the same executor. -- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that - builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a - `PluginRequestSender` (out-of-process / worker-thread plugins). - -## 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 | - -## 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 }` -- `CreateDimensionRadius(DimensionRadialSpec)` -- `CreateDimensionDiameter(DimensionRadialSpec)` -- `CreateDimensionAngular(DimensionAngularSpec)` -- `CreateAttributeDefinition(AttributeDefinitionSpec)` -- `CreateTable(TableSpec)` -- `CreateDimensionAngular2Ln(DimensionAngularSpec)` -- `SetXData { id: ObjectId, application_name: String, record: Option }` -- `CreateXRecord(XRecordSpec)` -- `SetXRecord { id: ObjectId, spec: XRecordSpec }` -- `CreateLayer(LayerInfo)` -- `UpdateLayer { name: String, info: LayerInfo }` -- `DeleteLayer { name: String }` -- `SetEntityLayer { id: ObjectId, layer: String }` -- `SetXDataMany(Vec<(ObjectId, String, Option)` - -### `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 }` -- `GetXData { id: ObjectId, application_name: String }` -- `GetXRecord { id: ObjectId }` -- `ListLayers` -- `GetEntityLayer { id: ObjectId }` - -## Errors (`ApiError`) - -| Variant | Meaning | -|---|---| -| `Validation { op, reason }` | Input rejected before any document mutation. | -| `Geometry { kind, msg }` | The kernel failed (boolean refused, invalid input, ACIS lift/lower). | -| `UnknownId(ObjectId)` | The handle is stale / deleted / never existed (never a panic). | -| `Unsupported(String)` | Capability not supported for this entity or backend. | -| `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`. diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json deleted file mode 100644 index 04697d696..000000000 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ /dev/null @@ -1,1349 +0,0 @@ -{ - "envelope_version": 1, - "families": [ - { - "acadrust_variant": "Solid3D", - "collection": "solids", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cuboid" - }, - "name": "create_cuboid", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Sphere" - }, - "name": "create_sphere", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "base", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cylinder" - }, - "name": "create_cylinder", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "base", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Cone" - }, - "name": "create_cone", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "major_radius", - "ty": "f64" - }, - { - "name": "minor_radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "primitive": "Torus" - }, - "name": "create_torus", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "primitive": "Wedge" - }, - "name": "create_wedge", - "op": "CreateSolid", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profile", - "ty": "EntityRef" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "extrude", - "op": "Extrude", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profile", - "ty": "EntityRef" - }, - { - "name": "pivot", - "ty": "[f64; 3]" - }, - { - "name": "axis", - "ty": "[f64; 3]" - }, - { - "name": "angle", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "revolve", - "op": "Revolve", - "returns": "Solid" - }, - { - "args": [ - { - "name": "profiles", - "ty": "&[EntityRef]" - } - ], - "bulk": false, - "fixed": null, - "name": "loft", - "op": "Loft", - "returns": "Solid" - } - ], - "handle": "Solid", - "methods": [ - { - "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", - "constructors": [ - { - "args": [ - { - "name": "start", - "ty": "[f64; 3]" - }, - { - "name": "end", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Line" - }, - "name": "create_line", - "op": "CreateCurve", - "returns": "Line" - } - ], - "handle": "Line", - "methods": [], - "name": "Line" - }, - { - "acadrust_variant": "Circle", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Circle" - }, - "name": "create_circle", - "op": "CreateCurve", - "returns": "Circle" - } - ], - "handle": "Circle", - "methods": [], - "name": "Circle" - }, - { - "acadrust_variant": "LwPolyline", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "points", - "ty": "&[[f64; 3]]" - }, - { - "name": "closed", - "ty": "bool" - } - ], - "bulk": false, - "fixed": { - "curve": "Polyline" - }, - "name": "create_polyline", - "op": "CreateCurve", - "returns": "Polyline" - } - ], - "handle": "Polyline", - "methods": [ - { - "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", - "constructors": [ - { - "args": [ - { - "name": "position", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Point" - }, - "name": "create_point", - "op": "CreateCurve", - "returns": "Point" - }, - { - "args": [ - { - "name": "positions", - "ty": "&[[f64; 3]]" - } - ], - "bulk": true, - "fixed": { - "curve": "Point" - }, - "name": "create_points", - "op": "CreateMany", - "returns": "Vec" - } - ], - "handle": "Point", - "methods": [], - "name": "Point" - }, - { - "acadrust_variant": "Arc", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "radius", - "ty": "f64" - }, - { - "name": "start_angle", - "ty": "f64" - }, - { - "name": "end_angle", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Arc" - }, - "name": "create_arc", - "op": "CreateCurve", - "returns": "ArcCurve" - } - ], - "handle": "ArcCurve", - "methods": [], - "name": "Arc" - }, - { - "acadrust_variant": "Ellipse", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "centre", - "ty": "[f64; 3]" - }, - { - "name": "major_axis", - "ty": "[f64; 3]" - }, - { - "name": "ratio", - "ty": "f64" - }, - { - "name": "start", - "ty": "f64" - }, - { - "name": "end", - "ty": "f64" - } - ], - "bulk": false, - "fixed": { - "curve": "Ellipse" - }, - "name": "create_ellipse", - "op": "CreateCurve", - "returns": "Ellipse" - } - ], - "handle": "Ellipse", - "methods": [], - "name": "Ellipse" - }, - { - "acadrust_variant": "Spline", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "degree", - "ty": "i32" - }, - { - "name": "control_points", - "ty": "&[[f64; 3]]" - }, - { - "name": "knots", - "ty": "&[f64]" - }, - { - "name": "weights", - "ty": "&[f64]" - } - ], - "bulk": false, - "fixed": { - "curve": "Spline" - }, - "name": "create_spline", - "op": "CreateCurve", - "returns": "Spline" - } - ], - "handle": "Spline", - "methods": [], - "name": "Spline" - }, - { - "acadrust_variant": "Ray", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "Ray" - }, - "name": "create_ray", - "op": "CreateCurve", - "returns": "Ray" - } - ], - "handle": "Ray", - "methods": [], - "name": "Ray" - }, - { - "acadrust_variant": "XLine", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "origin", - "ty": "[f64; 3]" - }, - { - "name": "direction", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": { - "curve": "XLine" - }, - "name": "create_xline", - "op": "CreateCurve", - "returns": "XLine" - } - ], - "handle": "XLine", - "methods": [], - "name": "XLine" - }, - { - "acadrust_variant": "Text", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_text", - "op": "CreateText", - "returns": "Text" - } - ], - "handle": "Text", - "methods": [ - { - "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", - "constructors": [ - { - "args": [ - { - "name": "value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_mtext", - "op": "CreateMText", - "returns": "MText" - } - ], - "handle": "MText", - "methods": [ - { - "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": "Hatch", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "boundary", - "ty": "&[[f64; 2]]" - }, - { - "name": "solid", - "ty": "bool" - } - ], - "bulk": false, - "fixed": null, - "name": "create_hatch", - "op": "CreateHatch", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "boundary", - "op": null, - "query": "GetHatchBoundary", - "read_only": true, - "returns": "Vec>" - } - ], - "name": "Hatch" - }, - { - "acadrust_variant": "Dimension", - "collection": "curves", - "constructors": [ - { - "args": [ - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "definition_point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_linear", - "op": "CreateDimensionLinear", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_radius", - "op": "CreateDimensionRadius", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "point", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_diameter", - "op": "CreateDimensionDiameter", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "vertex", - "ty": "[f64; 3]" - }, - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "arc_location", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_angular", - "op": "CreateDimensionAngular", - "returns": "Dimension" - }, - { - "args": [ - { - "name": "vertex", - "ty": "[f64; 3]" - }, - { - "name": "first_point", - "ty": "[f64; 3]" - }, - { - "name": "second_point", - "ty": "[f64; 3]" - }, - { - "name": "arc_location", - "ty": "[f64; 3]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_dimension_angular2ln", - "op": "CreateDimensionAngular2Ln", - "returns": "Dimension" - } - ], - "handle": "Dimension", - "methods": [ - { - "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", - "constructors": [ - { - "args": [ - { - "name": "block_name", - "ty": "&str" - }, - { - "name": "insert_point", - "ty": "[f64; 3]" - }, - { - "name": "scale", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_insert", - "op": "CreateInsert", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "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" - }, - { - "acadrust_variant": "AttributeDefinition", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "tag", - "ty": "&str" - }, - { - "name": "prompt", - "ty": "&str" - }, - { - "name": "default_value", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "rotation", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_attribute_definition", - "op": "CreateAttributeDefinition", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "AttributeDefinition" - }, - { - "acadrust_variant": "Viewport", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "center", - "ty": "[f64; 3]" - }, - { - "name": "width", - "ty": "f64" - }, - { - "name": "height", - "ty": "f64" - }, - { - "name": "view_target", - "ty": "[f64; 3]" - }, - { - "name": "view_height", - "ty": "f64" - } - ], - "bulk": false, - "fixed": null, - "name": "create_viewport", - "op": "CreateViewport", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [ - { - "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" - }, - { - "acadrust_variant": "RasterImage", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "file_path", - "ty": "&str" - }, - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "u_vector", - "ty": "[f64; 3]" - }, - { - "name": "v_vector", - "ty": "[f64; 3]" - }, - { - "name": "size", - "ty": "[f64; 2]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_raster_image", - "op": "CreateRasterImage", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "RasterImage" - }, - { - "acadrust_variant": "Table", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "insertion_point", - "ty": "[f64; 3]" - }, - { - "name": "data", - "ty": "&[Vec]" - } - ], - "bulk": false, - "fixed": null, - "name": "create_table", - "op": "CreateTable", - "returns": "Entity" - } - ], - "handle": "Entity", - "methods": [], - "name": "Table" - }, - { - "acadrust_variant": "XRecord", - "collection": "entities", - "constructors": [ - { - "args": [ - { - "name": "spec", - "ty": "XRecordSpec" - } - ], - "bulk": false, - "fixed": null, - "name": "create_xrecord", - "op": "CreateXRecord", - "returns": "XRecord" - } - ], - "handle": "XRecord", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "payload", - "op": null, - "query": "GetXRecord", - "read_only": true, - "returns": "XRecordSpec" - }, - { - "args": [ - { - "name": "spec", - "ty": "XRecordSpec" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_payload", - "op": "SetXRecord", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "XRecord" - }, - { - "acadrust_variant": null, - "collection": "layers", - "constructors": [], - "handle": "Layer", - "methods": [ - { - "args": [], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "list", - "op": null, - "query": "ListLayers", - "read_only": true, - "returns": "Vec" - }, - { - "args": [ - { - "name": "info", - "ty": "LayerInfo" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "create", - "op": "CreateLayer", - "query": null, - "read_only": false, - "returns": "()" - }, - { - "args": [ - { - "name": "name", - "ty": "String" - }, - { - "name": "info", - "ty": "LayerInfo" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "update", - "op": "UpdateLayer", - "query": null, - "read_only": false, - "returns": "()" - }, - { - "args": [ - { - "name": "name", - "ty": "String" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "delete", - "op": "DeleteLayer", - "query": null, - "read_only": false, - "returns": "()" - } - ], - "name": "Layer" - } - ], - "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)", - "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 }", - "CreateDimensionRadius(DimensionRadialSpec)", - "CreateDimensionDiameter(DimensionRadialSpec)", - "CreateDimensionAngular(DimensionAngularSpec)", - "CreateAttributeDefinition(AttributeDefinitionSpec)", - "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)", - "SetXData { id: ObjectId, application_name: String, record: Option }", - "CreateXRecord(XRecordSpec)", - "SetXRecord { id: ObjectId, spec: XRecordSpec }", - "CreateLayer(LayerInfo)", - "UpdateLayer { name: String, info: LayerInfo }", - "DeleteLayer { name: String }", - "SetEntityLayer { id: ObjectId, layer: String }", - "SetXDataMany(Vec<(ObjectId, String, Option)" - ] - }, - "Query": { - "enum": [ - "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 }", - "GetXData { id: ObjectId, application_name: String }", - "GetXRecord { id: ObjectId }", - "ListLayers", - "GetEntityLayer { id: ObjectId }" - ] - } - }, - "object_model": { - "collections": [ - "solids", - "curves", - "entities" - ], - "document": "Document", - "handles": [ - "Entity", - "Solid", - "Line", - "Circle", - "Polyline", - "Point", - "ArcCurve", - "Ellipse", - "Spline", - "Ray", - "XLine", - "Text", - "MText", - "Dimension", - "XRecord", - "Layer" - ], - "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 deleted file mode 100644 index b43755336..000000000 --- a/crates/ocs_doc_api/src/gen/layouts.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "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)", - "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 }", - "CreateDimensionRadius(DimensionRadialSpec)", - "CreateDimensionDiameter(DimensionRadialSpec)", - "CreateDimensionAngular(DimensionAngularSpec)", - "CreateAttributeDefinition(AttributeDefinitionSpec)", - "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)", - "SetXData { id: ObjectId, application_name: String, record: Option }", - "CreateXRecord(XRecordSpec)", - "SetXRecord { id: ObjectId, spec: XRecordSpec }", - "CreateLayer(LayerInfo)", - "UpdateLayer { name: String, info: LayerInfo }", - "DeleteLayer { name: String }", - "SetEntityLayer { id: ObjectId, layer: String }", - "SetXDataMany(Vec<(ObjectId, String, Option)" - ] - }, - "Query": { - "enum": [ - "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 }", - "GetXData { id: ObjectId, application_name: String }", - "GetXRecord { id: ObjectId }", - "ListLayers", - "GetEntityLayer { id: ObjectId }" - ] - } -} diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs deleted file mode 100644 index 7d8e91d22..000000000 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ /dev/null @@ -1,127 +0,0 @@ -// 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). Do NOT reorder or remove variants. - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; -use crate::ops::{ - AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, - DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, - SolidPrimitive, TableSpec, TextSpec, ViewportSpec, XDataRecord, XRecordSpec, -}; - -/// A typed write operation. 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), - // containers - CreateInsert(InsertSpec), - // paper-space - CreateViewport(ViewportSpec), - // annotations - CreateText(TextSpec), - CreateMText(MTextSpec), - SetTextContent { - id: ObjectId, - value: String, - }, - // hatch - CreateHatch(HatchSpec), - // dimensions - CreateDimensionLinear(DimensionSpec), - // attributes - SetAttribute { - id: ObjectId, - tag: String, - value: String, - }, - // viewport view - SetViewportView { - id: ObjectId, - view_target: [f64; 3], - view_height: f64, - }, - // media - CreateRasterImage(RasterImageSpec), - // multi-profile solid - Loft { - profiles: Vec, - }, - // dimension sub-types - CreateDimensionRadius(DimensionRadialSpec), - CreateDimensionDiameter(DimensionRadialSpec), - CreateDimensionAngular(DimensionAngularSpec), - // attribute definitions - CreateAttributeDefinition(AttributeDefinitionSpec), - // tables - CreateTable(TableSpec), - // dimension sub-types (2-line angular) - CreateDimensionAngular2Ln(DimensionAngularSpec), - // xdata / xrecord roundtrip - SetXData { - id: ObjectId, - application_name: String, - record: Option, - }, - SetXDataMany(Vec<(ObjectId, String, Option)>), - CreateXRecord(XRecordSpec), - SetXRecord { - id: ObjectId, - spec: XRecordSpec, - }, - // layer CRUD - CreateLayer(crate::ops::LayerInfo), - UpdateLayer { - name: String, - info: crate::ops::LayerInfo, - }, - DeleteLayer { - name: String, - }, - SetEntityLayer { - id: ObjectId, - layer: String, - }, -} diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs deleted file mode 100644 index f58c6a0c8..000000000 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ /dev/null @@ -1,89 +0,0 @@ -// 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). - -use serde::{Deserialize, Serialize}; - -use crate::id::ObjectId; - -/// A typed read query. Read-only: no mutation, no undo, no revision -/// bump. Safe to batch (`EnvelopeBody::Queries`). Append new variants at the END only. -/// (Not `Copy`: some variants carry owned data like a block name.) -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[non_exhaustive] -pub enum Query { - GetEntity { - id: ObjectId, - }, - GetBounds { - id: ObjectId, - }, - GetCentroid { - id: ObjectId, - }, - GetVolume { - id: ObjectId, - }, - /// 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, - }, - /// 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, - }, - /// 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, - }, - /// A viewport's view (target + zoom height). - GetViewportView { - id: ObjectId, - }, - /// The XDATA record for `application_name` attached to an entity (`None` if absent). - GetXData { - id: ObjectId, - application_name: String, - }, - /// The XRECORD object payload by id. - GetXRecord { - id: ObjectId, - }, - /// List all layers in the document. - ListLayers, - /// The layer name of an entity. - GetEntityLayer { - id: ObjectId, - }, - /// Enumerate first-class entities, optionally filtered by kind and/or layer. - /// `include_bounds` populates `EntityView::bounds`; leave it false for a - /// lightweight list view. - EnumerateEntities { - kind: Option, - layer: Option, - include_bounds: bool, - }, - /// The WCS location of a Point entity. - GetPointPosition { - id: ObjectId, - }, - /// The start and end WCS points of a Line entity. - GetLineGeometry { - id: ObjectId, - }, -} diff --git a/crates/ocs_doc_api/src/geom.rs b/crates/ocs_doc_api/src/geom.rs deleted file mode 100644 index e77086d51..000000000 --- a/crates/ocs_doc_api/src/geom.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! 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) -} - -/// 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]) { - mesh.mass_properties().unwrap_or((0.0, [0.0; 3])) -} - -pub fn validate_placement(p: &crate::PlacementSpec) -> ApiResult<()> { - let placement = cadkernel::brep::Placement { - origin: p.origin, - x_axis: p.x_axis, - y_axis: p.y_axis, - z_axis: p.z_axis, - }; - if placement.scale().is_none() || p.origin.iter().any(|v| !v.is_finite()) { - return Err(ApiError::validation( - "Transform", - "placement must be a finite, nonzero similarity", - )); - } - Ok(()) -} - -/// Construct a solid primitive. -pub fn make_solid(p: &SolidPrimitive) -> ApiResult { - crate::validation::solid(p)?; - 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( - plane: Plane, - profile: &[Curve2], - direction: [f64; 3], -) -> ApiResult { - cadkernel::brep::extrude(plane, profile, direction) - .ok_or_else(|| geom_err(GeometryErrorKind::InvalidInput, "extrude failed")) -} - -/// Revolve a closed 2D profile about an axis into a solid (`brep::revolve`). -pub(crate) fn revolve( - plane: Plane, - profile: &[Curve2], - pivot: [f64; 3], - axis: [f64; 3], - angle: f64, -) -> ApiResult { - cadkernel::brep::revolve(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 deleted file mode 100644 index 87f4de167..000000000 --- a/crates/ocs_doc_api/src/id.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Stable entity identity. - -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 - } - - /// 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"))] - 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"))] - pub fn to_handle(self) -> acadrust::Handle { - acadrust::Handle::new(self.0) - } -} diff --git a/crates/ocs_doc_api/src/layer.rs b/crates/ocs_doc_api/src/layer.rs deleted file mode 100644 index 48987f24f..000000000 --- a/crates/ocs_doc_api/src/layer.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Shared layer-name normalization rules. -//! -//! Layer names in OpenCADStudio/acadrust are case-insensitive, leading/trailing -//! whitespace is ignored, and empty names are invalid. The host (`normalize_name` -//! in `acadrust::tables`) implements the canonical rule set; this module re-exports -//! a compatible, dependency-free function for the wire side and for backends that -//! do not link against `acadrust` directly. - -/// Normalize a raw layer name into its case-insensitive lookup key. -/// -/// This mirrors `acadrust::tables::normalize_name` used by the host: -/// - trims leading and trailing ASCII whitespace -/// - converts the result to uppercase -/// - returns `""` if the name is empty after trimming -pub fn normalize_layer_name(name: &str) -> String { - name.trim().to_ascii_uppercase() -} - -/// A normalized layer name is non-empty and matches the host lookup key. -pub fn is_valid_layer_name(name: &str) -> bool { - !normalize_layer_name(name).is_empty() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalization_trims_and_uppercases() { - assert_eq!(normalize_layer_name(" Walls "), "WALLS"); - assert_eq!(normalize_layer_name("walls"), "WALLS"); - assert_eq!(normalize_layer_name("WALLS"), "WALLS"); - } - - #[test] - fn empty_and_whitespace_names_are_invalid() { - assert_eq!(normalize_layer_name(""), ""); - assert_eq!(normalize_layer_name(" "), ""); - assert!(!is_valid_layer_name("")); - assert!(!is_valid_layer_name(" ")); - } -} diff --git a/crates/ocs_doc_api/src/lib.rs b/crates/ocs_doc_api/src/lib.rs deleted file mode 100644 index 649f4bc84..000000000 --- a/crates/ocs_doc_api/src/lib.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Typed document operations, queries and facades over in-process or IPC transports. - -pub mod error; -pub mod id; -pub mod revision; - -#[cfg(feature = "host")] -pub mod backend; -#[cfg(feature = "host")] -pub mod convert; -#[cfg(feature = "host")] -pub mod executor; -#[cfg(feature = "host")] -pub mod geom; -#[cfg(feature = "host")] -mod validation; - -pub mod envelope; -pub mod facade; -pub mod layer; -pub mod ops; -pub mod query; -pub mod transport; - -// Append-only wire enums, maintained alongside the DTOs and spec. -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::{ - ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity, - EntityCollection, HasId, Line, MText, OpGroup, Point, Polyline, QueryBatch, QueryResults, Ray, - Solid, SolidCollection, Spline, Text, XLine, XRecord, -}; -pub use layer::{is_valid_layer_name, normalize_layer_name}; -pub use ops::{ - BoolOp, Color, Curve2Spec, EntitySpec, LayerFlags, LayerInfo, LineWeight, Operation, - PlacementSpec, SolidPrimitive, XDataRecord, XDataValue, XRecordCloningFlags, XRecordEntry, - XRecordSpec, XRecordValue, -}; -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, -}; -#[cfg(feature = "doc_api_host")] -pub use transport::ipc::doc_api_for_host; - -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: object model + method -/// signatures + op/query mapping with curated wire vocabulary. 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 deleted file mode 100644 index 7941b4112..000000000 --- a/crates/ocs_doc_api/src/ops.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! Write operations — the typed, transport-agnostic vocabulary for changing the -//! document. 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}; - -pub use crate::gen::Operation; - -/// An XDATA record payload: one registered application name plus its typed -/// value list. This is a plain-data, serde-compatible mirror of -/// `acadrust::xdata::ExtendedDataRecord` so the wire DTO stays independent of -/// the host dependency. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XDataRecord { - pub application_name: String, - pub values: Vec, -} - -/// A single XDATA value. Mirrors `acadrust::xdata::XDataValue`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum XDataValue { - String(String), - ControlString(String), - LayerName(String), - BinaryData(Vec), - Handle(u64), - Point3D([f64; 3]), - Position3D([f64; 3]), - Displacement3D([f64; 3]), - Direction3D([f64; 3]), - Real(f64), - Distance(f64), - ScaleFactor(f64), - Integer16(i16), - Integer32(i32), -} - -/// Construction spec for an `XRECORD` object. Mirrors the acadrust -/// `XRecord` payload (name + cloning flags + group-code entries) in a -/// serde-compatible, host-independent form. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XRecordSpec { - pub name: String, - pub cloning_flags: XRecordCloningFlags, - pub entries: Vec, -} - -/// XRecord cloning behavior flags. Mirror of -/// `acadrust::objects::DictionaryCloningFlags`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum XRecordCloningFlags { - #[default] - NotApplicable, - KeepExisting, - UseClone, - XrefName, - Name, - UnmangleName, -} - -/// One XRECORD group-code entry. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct XRecordEntry { - pub code: i32, - pub value: XRecordValue, -} - -/// A single XRECORD value. Mirrors `acadrust::objects::XRecordValue`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum XRecordValue { - String(String), - Double(f64), - Int16(i16), - Int32(i32), - Int64(i64), - Byte(u8), - Bool(bool), - Handle(u64), - Point3D([f64; 3]), - Chunk(Vec), -} - -/// Boolean operation kind for [`Operation::SolidBoolean`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum BoolOp { - Union, - Intersection, - Difference, -} - -/// A plain-data color value. Mirrors `acadrust::types::Color` for wire -/// independence from the host dependency. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Color { - ByLayer, - None, - ByBlock, - Index(u8), - Rgb { r: u8, g: u8, b: u8 }, -} - -impl Color { - pub const WHITE: Color = Color::Index(7); -} - -/// A plain-data line weight. Mirrors `acadrust::types::LineWeight`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum LineWeight { - #[default] - ByLayer, - ByBlock, - Default, - /// Specific line weight in 1/100 mm. - Value(i16), -} - -/// Layer visibility/lock flags. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct LayerFlags { - pub frozen: bool, - pub locked: bool, - pub frozen_in_new_viewport: bool, - pub off: bool, -} - -/// A plain-data layer table entry. Mirrors `acadrust::tables::Layer`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct LayerInfo { - pub name: String, - pub flags: LayerFlags, - pub color: Color, - pub line_type: String, - pub line_weight: LineWeight, - pub plot_style: String, - pub is_plottable: bool, -} - -impl LayerInfo { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - flags: LayerFlags::default(), - color: Color::WHITE, - line_type: "Continuous".to_string(), - line_weight: LineWeight::Default, - plot_style: String::new(), - is_plottable: true, - } - } - - /// The normalized (trimmed + uppercased) lookup key for this layer name. - pub fn normalized_name(&self) -> String { - crate::layer::normalize_layer_name(&self.name) - } -} - -/// 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], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Circle { - centre: [f64; 3], - radius: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Polyline { - points: Vec<[f64; 3]>, - closed: bool, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - Point { - position: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians). - Arc { - centre: [f64; 3], - radius: f64, - start_angle: f64, - end_angle: f64, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// 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, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// NURBS spline (control points + knots + weights; `degree` typically 3). - Spline { - degree: i32, - control_points: Vec<[f64; 3]>, - knots: Vec, - weights: Vec, - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// A ray from `origin` along `direction` (bounded at origin only). - Ray { - origin: [f64; 3], - direction: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, - /// An infinite construction line through `origin` along `direction`. - XLine { - origin: [f64; 3], - direction: [f64; 3], - /// Target layer name. `None` places the entity on the current layer. - layer: Option, - }, -} - -impl Curve2Spec { - /// The target layer name, if any. - pub fn layer(&self) -> Option<&str> { - match self { - Curve2Spec::Line { layer, .. } - | Curve2Spec::Circle { layer, .. } - | Curve2Spec::Polyline { layer, .. } - | Curve2Spec::Point { layer, .. } - | Curve2Spec::Arc { layer, .. } - | Curve2Spec::Ellipse { layer, .. } - | Curve2Spec::Spline { layer, .. } - | Curve2Spec::Ray { layer, .. } - | Curve2Spec::XLine { layer, .. } => layer.as_deref(), - } - } -} - -/// 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), -} - -impl EntitySpec { - /// The target layer name for this spec, if explicitly set. - pub fn layer(&self) -> Option<&str> { - match self { - EntitySpec::Curve(c) => c.layer(), - EntitySpec::Solid(_) => None, - } - } -} - -/// 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, -} - -/// 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, -} - -/// 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, -} - -/// 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, -} - -/// 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], -} - -/// 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 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 `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. -#[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. -#[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: 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", - CreateInsert(_) => "CreateInsert", - CreateViewport(_) => "CreateViewport", - CreateText(_) => "CreateText", - CreateMText(_) => "CreateMText", - SetTextContent { .. } => "SetTextContent", - CreateHatch(_) => "CreateHatch", - CreateDimensionLinear(_) => "CreateDimensionLinear", - SetAttribute { .. } => "SetAttribute", - SetViewportView { .. } => "SetViewportView", - CreateRasterImage(_) => "CreateRasterImage", - Loft { .. } => "Loft", - CreateDimensionRadius(_) => "CreateDimensionRadius", - CreateDimensionDiameter(_) => "CreateDimensionDiameter", - CreateDimensionAngular(_) => "CreateDimensionAngular", - CreateAttributeDefinition(_) => "CreateAttributeDefinition", - CreateTable(_) => "CreateTable", - CreateDimensionAngular2Ln(_) => "CreateDimensionAngular2Ln", - SetXData { .. } => "SetXData", - SetXDataMany(_) => "SetXDataMany", - CreateXRecord(_) => "CreateXRecord", - SetXRecord { .. } => "SetXRecord", - CreateLayer(_) => "CreateLayer", - UpdateLayer { .. } => "UpdateLayer", - DeleteLayer { .. } => "DeleteLayer", - SetEntityLayer { .. } => "SetEntityLayer", - } - } -} diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs deleted file mode 100644 index e53d2b650..000000000 --- a/crates/ocs_doc_api/src/query.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Read queries — typed, transport-agnostic reads of entity state. -//! 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}; - -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 { - #[cfg(feature = "host")] - pub fn overlaps(&self, other: &Aabb) -> bool { - cadkernel::brep::Aabb { - min: self.min, - max: self.max, - } - .overlaps(&cadkernel::brep::Aabb { - min: other.min, - max: other.max, - }) - } -} - -/// 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 normalized (uppercase) layer name the entity is on. - pub layer: String, -} - -/// 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), - /// The text content of a Text/MText annotation. - 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), - /// An insert's attributes as (tag, value) pairs. - 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, - }, - /// An XDATA record for a single application (`None` if no such record). - XData(Option), - /// An XRECORD object payload. - XRecord(crate::ops::XRecordSpec), - /// All layers in the document. - Layers(Vec), - /// The layer name of an entity. - EntityLayer(String), - /// A filtered list of entity views. - Entities(Vec), - /// The WCS location of a Point entity. - PointPosition([f64; 3]), - /// The start and end WCS points of a Line entity. - LineGeometry(([f64; 3], [f64; 3])), -} - -/// 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", - GetTextContent { .. } => "GetTextContent", - GetHatchBoundary { .. } => "GetHatchBoundary", - GetDimensionMeasurement { .. } => "GetDimensionMeasurement", - GetAttributes { .. } => "GetAttributes", - GetBlockEntities { .. } => "GetBlockEntities", - GetViewportView { .. } => "GetViewportView", - GetXData { .. } => "GetXData", - GetXRecord { .. } => "GetXRecord", - ListLayers => "ListLayers", - GetEntityLayer { .. } => "GetEntityLayer", - EnumerateEntities { .. } => "EnumerateEntities", - GetPointPosition { .. } => "GetPointPosition", - GetLineGeometry { .. } => "GetLineGeometry", - } - } -} diff --git a/crates/ocs_doc_api/src/revision.rs b/crates/ocs_doc_api/src/revision.rs deleted file mode 100644 index f0759afe9..000000000 --- a/crates/ocs_doc_api/src/revision.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! 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 deleted file mode 100644 index 8d9353024..000000000 --- a/crates/ocs_doc_api/src/transport/inproc.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! In-process transport: 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 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 { - req.validate_version()?; - 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 deleted file mode 100644 index f9b4b1d3e..000000000 --- a/crates/ocs_doc_api/src/transport/ipc.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! IPC transport: 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::{HostApi, PluginRequestError, PluginRequestSender}; -use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; - -use crate::envelope::{DocApiEnvelope, Receipt}; -use crate::error::{ApiError, ApiResult}; -use crate::facade::DocApi; -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) - } -} - -/// Build a typed [`DocApi`] from any host that can provide a -/// [`PluginRequestSender`] (out-of-process / V4 worker plugins). -/// -/// Returns `None` for hosts that do not expose a request sender (e.g. in-process -/// hosts). In-process code should use [`crate::DocApi::in_process`] with a -/// concrete [`crate::backend::DocApiBackend`]. -pub fn doc_api_for_host(host: &dyn HostApi) -> Option { - let sender = host.plugin_request_sender()?; - let tab_id = host.tab_id(); - Some(DocApi::connect( - Arc::new(OcsPluginApiIpc::new(sender.into(), tab_id)), - tab_id, - )) -} - -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:?}" - ))), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::facade::HasId; - use crate::ObjectId; - use ocs_plugin_api::host::{ - CadDocument, EntityType, ExtendedDataRecord, Handle, HostApi, - }; - use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; - use std::any::Any; - use std::sync::{Arc, Mutex}; - - #[derive(Clone)] - struct MockSender { - captured: Arc>>, - } - - impl PluginRequestSender for MockSender { - fn request(&self, req: PluginRequest) -> Result { - let tab_id = match &req { - PluginRequest::DocApiRequest { tab_id, .. } => *tab_id, - _ => 0, - }; - self.captured.lock().unwrap().push((tab_id, req)); - Ok(PluginResponse::DocApiResponse { - bytes: bincode::serialize(&crate::ApiResult::Ok(Receipt { - outcome: Some(crate::envelope::OpOutcome::NewId(ObjectId::from_u64(7))), - query_results: vec![], - new_revision: crate::revision::GeometryRevision(1), - })) - .unwrap(), - }) - } - } - - struct MockHost { - sender: Option, - } - - impl HostApi for MockHost { - fn tab_index(&self) -> usize { - 0 - } - fn document(&self) -> &CadDocument { - unimplemented!() - } - fn document_mut(&mut self) -> &mut CadDocument { - unimplemented!() - } - fn add_entity(&mut self, _entity: EntityType) -> Handle { - unimplemented!() - } - fn bump_geometry(&mut self) {} - fn push_undo(&mut self, _label: &str) {} - fn set_dirty(&mut self) {} - fn push_info(&mut self, _msg: &str) {} - fn push_output(&mut self, _msg: &str) {} - fn push_error(&mut self, _msg: &str) {} - fn start_interactive(&mut self, _command: Box) {} - fn read_record(&self, _handle: Handle, _app_name: &str) -> Option<&ExtendedDataRecord> { - None - } - fn write_record(&mut self, _handle: Handle, _record: ExtendedDataRecord) -> bool { - false - } - fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool { - false - } - fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> { - None - } - fn plugin_state_any_mut(&mut self, _plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> { - None - } - fn ensure_plugin_state_any( - &mut self, - _plugin_id: &'static str, - _init: &mut dyn FnMut() -> Box, - ) -> &mut (dyn Any + Send + Sync) { - unimplemented!() - } - fn document_reader(&self) -> Box { - unimplemented!() - } - fn plugin_request_sender(&self) -> Option> { - self.sender.as_ref().map(|s| Box::new(s.clone()) as Box) - } - } - - #[test] - fn doc_api_for_host_builds_doc_api_and_routes_doc_api_request() { - let captured = Arc::new(Mutex::new(Vec::new())); - let host = MockHost { - sender: Some(MockSender { - captured: Arc::clone(&captured), - }), - }; - let doc_api = doc_api_for_host(&host).expect("host exposes a sender"); - let doc = doc_api.document(host.tab_id()); - let id = doc - .solids() - .create_cuboid([0.0; 3], [1.0; 3]) - .expect("mock returns a NewId receipt"); - assert_eq!(id.id(), ObjectId::from_u64(7)); - - let calls = captured.lock().unwrap(); - assert_eq!(calls.len(), 1); - let (tab_id, ref req) = &calls[0]; - assert_eq!(*tab_id, host.tab_id()); - match req { - PluginRequest::DocApiRequest { tab_id, bytes } => { - assert_eq!(*tab_id, host.tab_id()); - assert!(!bytes.is_empty()); - } - other => panic!("expected DocApiRequest, got {other:?}"), - } - } - - #[test] - fn doc_api_for_host_returns_none_without_sender() { - let host = MockHost { sender: None }; - assert!(doc_api_for_host(&host).is_none()); - } -} diff --git a/crates/ocs_doc_api/src/transport/mod.rs b/crates/ocs_doc_api/src/transport/mod.rs deleted file mode 100644 index 3a17f92e4..000000000 --- a/crates/ocs_doc_api/src/transport/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! The transport boundary — 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/src/validation.rs b/crates/ocs_doc_api/src/validation.rs deleted file mode 100644 index dc9ca9909..000000000 --- a/crates/ocs_doc_api/src/validation.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Reject invalid numeric inputs before the backend starts an undo operation. -use crate::{id::ObjectId, ops::*, ApiError, ApiResult}; - -fn finite(values: &[f64]) -> bool { - values.iter().all(|v| v.is_finite()) -} -fn positive(value: f64) -> bool { - value.is_finite() && value > 0.0 -} -fn direction(value: &[f64; 3]) -> bool { - finite(value) && value.iter().any(|v| *v != 0.0) -} -fn check(valid: bool, op: &'static str) -> ApiResult<()> { - if valid { - Ok(()) - } else { - Err(ApiError::validation( - op, - "invalid or nonfinite geometry input", - )) - } -} - -fn check_payload(valid: bool, op: &'static str, reason: &'static str) -> ApiResult<()> { - if valid { - Ok(()) - } else { - Err(ApiError::validation(op, reason)) - } -} - -fn valid_application_name(name: &str) -> bool { - !name.trim().is_empty() && !name.contains('\0') -} - -fn valid_xdata_record(app: &str, record: Option<&XDataRecord>) -> bool { - valid_application_name(app) - && record.is_none_or(|record| { - record.application_name == app - && record.values.iter().all(|value| match value { - XDataValue::String(value) - | XDataValue::ControlString(value) - | XDataValue::LayerName(value) => !value.contains('\0'), - XDataValue::Point3D(value) - | XDataValue::Position3D(value) - | XDataValue::Displacement3D(value) - | XDataValue::Direction3D(value) => finite(value), - XDataValue::Real(value) - | XDataValue::Distance(value) - | XDataValue::ScaleFactor(value) => value.is_finite(), - XDataValue::BinaryData(_) - | XDataValue::Handle(_) - | XDataValue::Integer16(_) - | XDataValue::Integer32(_) => true, - }) - }) -} - -fn valid_xrecord(spec: &XRecordSpec) -> bool { - !spec.name.trim().is_empty() - && !spec.name.contains('\0') - && spec.entries.len() <= BULK_ITEM_CAP - && spec.entries.iter().all(|entry| { - let code = entry.code; - match &entry.value { - XRecordValue::String(value) => { - matches!( - code, - 0..=4 - | 6..=9 - | 100..=102 - | 300..=309 - | 410..=419 - | 430..=439 - | 470..=479 - | 999 - | 1000..=1003 - ) && !value.contains('\0') - && value.encode_utf16().count() <= u16::MAX as usize - } - XRecordValue::Point3D(value) => { - matches!(code, 10..=37 | 110..=139 | 210..=269 | 1010..=1039 | 1043..=1069) - && finite(value) - } - XRecordValue::Double(value) => { - matches!(code, 38..=59 | 140..=149 | 460..=469 | 1040..=1042) - && value.is_finite() - } - XRecordValue::Int16(_) => { - matches!(code, 60..=79 | 170..=179 | 270..=279 | 370..=389 | 400..=409 | 1070) - } - XRecordValue::Int32(_) => matches!(code, 80..=99 | 420..=429 | 440..=459 | 1071), - XRecordValue::Int64(_) => matches!(code, 150..=169), - XRecordValue::Byte(_) => matches!(code, 280..=289), - XRecordValue::Bool(_) => matches!(code, 290..=299), - XRecordValue::Handle(_) => { - code < 0 || matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480..=481 | 1005) - } - XRecordValue::Chunk(value) => { - matches!(code, 310..=319 | 1004) && value.len() <= u8::MAX as usize - } - } - }) -} - -pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { - use Curve2Spec::*; - let valid = match spec { - Line { start, end, .. } => finite(start) && finite(end) && start != end, - Circle { centre, radius, .. } => finite(centre) && positive(*radius), - Point { position, .. } => finite(position), - Arc { - centre, - radius, - start_angle, - end_angle, - .. - } => finite(centre) && positive(*radius) && finite(&[*start_angle, *end_angle]), - Polyline { points, closed, .. } => { - points.len() >= if *closed { 3 } else { 2 } - && points.len() <= BULK_ITEM_CAP - && points.iter().all(|p| finite(p) && p[2] == points[0][2]) - } - Ellipse { - centre, - major_axis, - ratio, - start, - end, - .. - } => { - finite(centre) - && direction(major_axis) - && positive(*ratio) - && *ratio <= 1.0 - && finite(&[*start, *end]) - } - Spline { - degree, - control_points, - knots, - weights, - .. - } => { - *degree > 0 - && control_points.len() <= BULK_ITEM_CAP - && knots.len() <= BULK_ITEM_CAP - && weights.len() <= BULK_ITEM_CAP - && cadkernel::space::NurbsCurve3::new_strict( - *degree as usize, - control_points.clone(), - knots.clone(), - if weights.is_empty() { - vec![1.0; control_points.len()] - } else { - weights.clone() - }, - ) - .is_some() - } - Ray { - origin, - direction: d, - .. - } - | XLine { - origin, - direction: d, - .. - } => finite(origin) && direction(d), - }; - check(valid, "CreateCurve") -} - -pub(crate) fn operation(op: &Operation) -> ApiResult<()> { - use Operation::*; - let valid = match op { - CreateCurve(spec) => return curve(spec), - Extrude { direction: d, .. } => direction(d), - Revolve { - axis: (pivot, d), - angle, - .. - } => finite(pivot) && direction(d) && angle.is_finite() && *angle != 0.0, - AddVertex { point, .. } => finite(point), - CreateInsert(s) => { - finite(&s.insert_point) - && s.scale.is_finite() - && s.scale != 0.0 - && s.rotation.is_finite() - } - CreateViewport(s) => { - finite(&s.center) - && finite(&s.view_target) - && positive(s.width) - && positive(s.height) - && positive(s.view_height) - } - CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(), - CreateMText(s) => finite(&s.insertion_point) && positive(s.height), - SetXData { - id, - application_name, - record, - } => { - return check_payload( - *id != ObjectId::NULL && valid_xdata_record(application_name, record.as_ref()), - "SetXData", - "invalid XDATA record", - ) - } - SetXDataMany(records) => { - return check_payload( - records.iter().all(|(id, app, record)| { - *id != ObjectId::NULL && valid_xdata_record(app, record.as_ref()) - }), - "SetXDataMany", - "invalid XDATA record", - ) - } - CreateXRecord(spec) => { - return check_payload(valid_xrecord(spec), "CreateXRecord", "invalid XRecord payload") - } - SetXRecord { id, spec } => { - return check_payload( - *id != ObjectId::NULL && valid_xrecord(spec), - "SetXRecord", - "invalid XRecord payload", - ) - } - CreateHatch(s) => { - (3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p)) - } - CreateDimensionLinear(s) => { - finite(&s.first_point) && finite(&s.second_point) && finite(&s.definition_point) - } - CreateDimensionRadius(s) | CreateDimensionDiameter(s) => { - finite(&s.center) && finite(&s.point) - } - CreateDimensionAngular(s) | CreateDimensionAngular2Ln(s) => { - finite(&s.vertex) - && finite(&s.first_point) - && finite(&s.second_point) - && finite(&s.arc_location) - } - CreateAttributeDefinition(s) => { - finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite() - } - SetViewportView { - view_target, - view_height, - .. - } => finite(view_target) && positive(*view_height), - CreateRasterImage(s) => { - finite(&s.insertion_point) - && direction(&s.u_vector) - && direction(&s.v_vector) - && s.size.iter().all(|v| positive(*v)) - } - CreateTable(s) => { - finite(&s.insertion_point) - && !s.data.is_empty() - && !s.data[0].is_empty() - && s.data - .len() - .checked_mul(s.data[0].len()) - .is_some_and(|n| n <= BULK_ITEM_CAP) - && s.data.iter().all(|r| r.len() == s.data[0].len()) - } - _ => true, - }; - check(valid, op.op_name()) -} - -pub(crate) fn solid(spec: &SolidPrimitive) -> ApiResult<()> { - use SolidPrimitive::*; - let valid = match spec { - Cuboid { origin, size } | Wedge { origin, size } => { - finite(origin) && size.iter().all(|v| positive(*v)) - } - Sphere { centre, radius } => finite(centre) && positive(*radius), - Cylinder { - base, - radius, - height, - } - | Cone { - base, - radius, - height, - } => finite(base) && positive(*radius) && positive(*height), - Torus { - centre, - major_radius, - minor_radius, - } => finite(centre) && positive(*major_radius) && positive(*minor_radius), - }; - check(valid, "CreateSolid") -} diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs deleted file mode 100644 index d9059bbde..000000000 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ /dev/null @@ -1,1082 +0,0 @@ -//! Unit tests over an in-memory mock `DocApiBackend`: 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::{ - normalize_layer_name, Aabb, ApiError, ApiResult, Color, Curve2Spec, DocApi, EntityView, - GeometryErrorKind, GeometryRevision, HasId, LayerInfo, LineWeight, ObjectId, PlacementSpec, - XDataRecord, XDataValue, -}; - -/// 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(Clone)] -struct MockBackend { - next_id: u64, - revision: u64, - undo_steps: u32, - bodies: HashMap, - kinds: HashMap, - /// Layer name assigned to each entity. - entity_layers: HashMap, - /// Stored layer table. - layers: HashMap, - /// Stored text content for Text/MText annotations. - text_values: HashMap, - /// Stored hatch boundary loops. - hatch_boundaries: HashMap>>, - /// Stored dimension measurements. - 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, - /// Stored XDATA records keyed by (entity id, application name). - xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>, - /// Stored XRECORD payloads keyed by entity id. - xrecord_store: HashMap, -} - -impl Default for MockBackend { - fn default() -> Self { - let mut backend = Self { - next_id: 0, - revision: 0, - undo_steps: 0, - bodies: HashMap::new(), - kinds: HashMap::new(), - entity_layers: HashMap::new(), - layers: HashMap::new(), - text_values: HashMap::new(), - hatch_boundaries: HashMap::new(), - dimension_measurements: HashMap::new(), - attributes_store: HashMap::new(), - curves: HashMap::new(), - viewport_views: HashMap::new(), - xdata_store: HashMap::new(), - xrecord_store: HashMap::new(), - }; - backend - .layers - .insert("0".to_string(), ocs_doc_api::LayerInfo::new("0")); - backend - } -} - -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)) - } - fn add_curve_only(&mut self, spec: &Curve2Spec) -> ApiResult { - let kind = match spec { - Curve2Spec::Line { .. } => "Line", - 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()); - Ok(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()); - self.entity_layers.insert(id, "0".to_string()); - 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 create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut next = self.clone(); - let ids = specs - .iter() - .map(|spec| match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - next.store_solid(&ocs_doc_api::geom::make_solid(spec)?) - } - ocs_doc_api::EntitySpec::Curve(spec) => { - ocs_doc_api::convert::curve_spec_to_entity(spec)?; - next.add_curve(spec) - } - }) - .collect::>>()?; - next.push_undo("CreateMany"); - next.finalize_op(); - *self = next; - Ok(ids) - } - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let mut next = self.clone(); - for &id in ids { - next.transform_entity(id, placement)?; - } - next.push_undo("TransformMany"); - next.finalize_op(); - *self = next; - Ok(()) - } - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let layer = spec - .layer() - .map(|name| { - self.lookup_layer(name).map(|info| info.normalized_name()) - }) - .transpose()?; - let id = self.add_curve_only(spec)?; - self.entity_layers - .insert(id, layer.unwrap_or_else(|| "0".to_string())); - Ok(id) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = normalize_layer_name(name); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.layers - .get(&norm) - .cloned() - .ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist"))) - } - 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")); - } - let id = self.alloc("Insert"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - 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"); - self.text_values.insert(id, spec.value.clone()); - self.entity_layers.insert(id, "0".to_string()); - 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()); - self.entity_layers.insert(id, "0".to_string()); - 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_hatch(&mut self, spec: &ocs_doc_api::ops::HatchSpec) -> ApiResult { - let id = self.alloc("Hatch"); - self.hatch_boundaries - .insert(id, vec![spec.boundary.clone()]); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - 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); - self.entity_layers.insert(id, "0".to_string()); - 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); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn add_dimension_diameter( - &mut self, - spec: &ocs_doc_api::ops::DimensionRadialSpec, - ) -> ApiResult { - let id = self.add_dimension_radius(spec)?; - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - 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 - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - 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 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) { - return Err(ApiError::validation( - "CreateTable", - "table needs a non-empty rectangular grid", - )); - } - let id = self.alloc("Table"); - self.entity_layers.insert(id, "0".to_string()); - Ok(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_raster_image( - &mut self, - _spec: &ocs_doc_api::ops::RasterImageSpec, - ) -> ApiResult { - let id = self.alloc("RasterImage"); - self.entity_layers.insert(id, "0".to_string()); - Ok(id) - } - fn loft( - &mut self, - sections: &[(cadkernel::space::Plane, 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)); - } - 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 set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let key = (id, application_name.to_string()); - match record { - Some(r) => { - self.xdata_store.insert(key, r.clone()); - } - None => { - self.xdata_store.remove(&key); - } - } - Ok(()) - } - fn xdata( - &self, - id: ObjectId, - application_name: &str, - ) -> ApiResult> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - Ok(self - .xdata_store - .get(&(id, application_name.to_string())) - .cloned()) - } - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - let id = self.alloc("XRecord"); - self.xrecord_store.insert(id, spec.clone()); - Ok(id) - } - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - self.xrecord_store.insert(id, spec.clone()); - Ok(()) - } - fn xrecord(&self, id: ObjectId) -> ApiResult> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "GetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - Ok(self.xrecord_store.get(&id).cloned()) - } - fn create_layer(&mut self, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> { - let name = normalize_layer_name(&info.name); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.layers.contains_key(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut stored = info.clone(); - stored.name = name.clone(); - self.layers.insert(name, stored); - Ok(()) - } - fn update_layer(&mut self, name: &str, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if !self.layers.contains_key(&name_upper) { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - } - let mut stored = info.clone(); - stored.name = name_upper.clone(); - self.layers.insert(name_upper, stored); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = normalize_layer_name(name); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if self - .entity_layers - .values() - .any(|l| normalize_layer_name(l) == name_upper) - { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name_upper}' still in use"), - )); - } - if self.layers.remove(&name_upper).is_none() { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - Ok(()) - } - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - if !self.entity_exists(id) { - return Err(ApiError::UnknownId(id)); - } - let layer_upper = normalize_layer_name(layer); - let target = self - .layers - .get(&layer_upper) - .ok_or_else(|| { - ApiError::validation( - "SetEntityLayer", - format!("layer '{layer_upper}' does not exist"), - ) - })?; - if target.flags.locked { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer_upper}'" - ))); - } - self.entity_layers.insert(id, layer_upper); - Ok(()) - } - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self.layers.values().cloned().collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - fn entity_layer(&self, id: ObjectId) -> ApiResult { - self.entity_layers - .get(&id) - .cloned() - .ok_or(ApiError::UnknownId(id)) - } - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let mut out = Vec::new(); - for (id, kind_name) in &self.kinds { - if let Some(k) = kind { - if !k.eq_ignore_ascii_case(kind_name) { - continue; - } - } - let entity_layer = self - .entity_layers - .get(id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - if let Some(l) = layer { - if !l.eq_ignore_ascii_case(&entity_layer) { - continue; - } - } - let bounds = if include_bounds { - self.curves.get(id).map(curve_bounds) - } else { - None - }; - out.push(EntityView { - id: *id, - kind: kind_name.clone(), - layer: entity_layer, - bounds, - }); - } - Ok(out) - } - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Point { position, .. } => Ok(*position), - _ => Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )), - } - } - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; - match spec { - Curve2Spec::Line { start, end, .. } => Ok((*start, *end)), - _ => Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )), - } - } - 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); - self.xdata_store.retain(|(k, _), _| *k != id); - self.xrecord_store.remove(&id); - self.entity_layers.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, - }) - }); - let layer = self - .entity_layers - .get(&id) - .cloned() - .unwrap_or_else(|| "0".to_string()); - Ok(EntityView { - id, - kind, - layer, - 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 (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"))?; - Ok(Aabb { - min: bb.min, - max: bb.max, - }) - } - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - let body = self.body(id)?; - 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(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) - } - fn object_exists(&self, id: ObjectId) -> bool { - self.xrecord_store.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; - } -} - -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); -} - -#[test] -fn typed_handles_reject_foreign_sessions_and_tabs() { - let first = DocApi::in_process(MockBackend::default(), 10); - let second = DocApi::in_process(MockBackend::default(), 10); - let doc = first.document(10); - let foreign = second - .document(10) - .curves() - .create_circle([0.0; 3], 2.0) - .unwrap(); - assert!(doc.solids().extrude(&foreign, [0.0, 0.0, 2.0]).is_err()); - assert!(doc - .solids() - .loft(&[foreign.clone(), foreign.clone()]) - .is_err()); - assert!(doc.query_batch(|q| q.bounds(&foreign)).is_err()); - let mut group = ocs_doc_api::OpGroup::new(); - group.track(Ok(foreign)).unwrap(); - assert!(group.compensate(&doc).is_err()); - assert!(first.document(11).curves().create_point([0.0; 3]).is_err()); -} - -#[test] -fn doc_api_layer_crud_via_facade_and_entity_assignment() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - - let mut info = LayerInfo::new("Walls"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - doc.create_layer(&info).unwrap(); - - let layers = doc.layers().unwrap(); - assert!(layers.iter().any(|l| l.name == "WALLS")); - - let mut updated = info.clone(); - updated.color = Color::Index(2); - doc.update_layer("walls", &updated).unwrap(); - - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - line.set_layer("Walls").unwrap(); - assert_eq!(line.layer().unwrap(), "WALLS"); - - let err = doc.delete_layer("Walls").unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - let other = LayerInfo::new("Other"); - doc.create_layer(&other).unwrap(); - line.set_layer("Other").unwrap(); - doc.delete_layer("Walls").unwrap(); - assert!(!doc.layers().unwrap().iter().any(|l| l.name == "WALLS")); -} - -#[test] -fn set_xdata_many_is_atomic_and_assigns_records() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let circle = doc.curves().create_circle([0.0; 3], 1.0).unwrap(); - - let record1 = XDataRecord { - application_name: "APP1".to_string(), - values: vec![XDataValue::Integer32(42)], - }; - let record2 = XDataRecord { - application_name: "APP2".to_string(), - values: vec![XDataValue::Real(3.14)], - }; - doc.set_xdata_many([ - (line.id(), "APP1", Some(record1.clone())), - (circle.id(), "APP2", Some(record2.clone())), - ]) - .unwrap(); - - let r1 = doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().unwrap(); - assert_eq!(r1.values, vec![XDataValue::Integer32(42)]); - let r2 = doc.entities().get(circle.id()).unwrap().xdata("APP2").unwrap().unwrap(); - assert_eq!(r2.values, vec![XDataValue::Real(3.14)]); -} - -#[test] -fn set_xdata_many_with_unknown_id_fails_atomically() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let stale = ObjectId::from_u64(999_999); - - let record = XDataRecord { - application_name: "APP1".to_string(), - values: vec![XDataValue::Integer32(42)], - }; - let err = doc - .set_xdata_many([ - (line.id(), "APP1", Some(record.clone())), - (stale, "APP2", Some(record)), - ]) - .unwrap_err(); - assert!(matches!(err, ApiError::UnknownId(..) | ApiError::Validation { .. })); - assert!(doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().is_none()); -} - -#[test] -fn xdata_rejects_a_mismatched_record_name() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); - let entity = doc.entities().get(line.id()).unwrap(); - let result = entity.set_xdata( - "EXPECTED", - Some(XDataRecord { - application_name: "OTHER".into(), - values: vec![], - }), - ); - assert!(matches!(result, Err(ApiError::Validation { .. }))); - assert!(entity.xdata("EXPECTED").unwrap().is_none()); -} - -#[test] -fn create_xrecord_returns_the_typed_handle() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let spec = ocs_doc_api::XRecordSpec { - name: "PLUGIN_DATA".into(), - cloning_flags: ocs_doc_api::XRecordCloningFlags::NotApplicable, - entries: vec![ocs_doc_api::XRecordEntry { - code: 1, - value: ocs_doc_api::XRecordValue::String("value".into()), - }], - }; - let record = doc.entities().create_xrecord(&spec).unwrap(); - assert_eq!(record.payload().unwrap(), spec); -} - -#[test] -fn create_many_applies_per_curve_layer() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - doc.create_layer(&LayerInfo::new("Walls")).unwrap(); - - let ids = doc - .entities() - .create_many(&[ - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: Some("Walls".to_string()), - }), - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point { - position: [2.0; 3], - layer: None, - }), - ]) - .unwrap(); - - assert_eq!(ids[0].layer().unwrap(), "WALLS"); - assert_eq!(ids[1].layer().unwrap(), "0"); -} - -#[test] -fn create_many_rejects_missing_layer_before_mutating() { - let (api, tp) = api(); - let doc = api.document(api.active_tab()); - let rev0 = doc.revision().unwrap().as_u64(); - - let err = doc - .entities() - .create_many(&[ - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: Some("Missing".to_string()), - }), - ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point { - position: [2.0; 3], - layer: None, - }), - ]) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - let backend = tp.backend(); - assert_eq!(backend.revision, rev0); - assert_eq!(backend.next_id, 0); -} - -#[test] -fn create_line_on_layer_requires_existing_layer() { - let (api, _tp) = api(); - let doc = api.document(api.active_tab()); - let err = doc - .curves() - .create_line_on_layer([0.0; 3], [1.0; 3], "Missing") - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); -} diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs deleted file mode 100644 index d7fc6c3dd..000000000 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! 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, LayerInfo, 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], - layer: None, - }), - 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, - }), - Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - rotation: 0.0, - }), - Operation::CreateMText(ocs_doc_api::ops::MTextSpec { - value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - }), - Operation::SetTextContent { - id: ObjectId::from_u64(0), - value: String::new(), - }, - Operation::CreateHatch(ocs_doc_api::ops::HatchSpec { - boundary: vec![], - solid: true, - }), - Operation::CreateDimensionLinear(ocs_doc_api::ops::DimensionSpec { - first_point: [0.0; 3], - second_point: [0.0; 3], - definition_point: [0.0; 3], - }), - Operation::SetAttribute { - id: ObjectId::from_u64(0), - tag: String::new(), - value: String::new(), - }, - Operation::SetViewportView { - id: ObjectId::from_u64(0), - view_target: [0.0; 3], - view_height: 0.0, - }, - Operation::CreateRasterImage(ocs_doc_api::ops::RasterImageSpec { - file_path: String::new(), - insertion_point: [0.0; 3], - u_vector: [0.0; 3], - v_vector: [0.0; 3], - size: [0.0; 2], - }), - Operation::Loft { profiles: vec![] }, - Operation::CreateDimensionRadius(ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [0.0; 3], - }), - Operation::CreateDimensionDiameter(ocs_doc_api::ops::DimensionRadialSpec { - center: [0.0; 3], - point: [0.0; 3], - }), - Operation::CreateDimensionAngular(ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [0.0; 3], - second_point: [0.0; 3], - arc_location: [0.0; 3], - }), - Operation::CreateAttributeDefinition(ocs_doc_api::ops::AttributeDefinitionSpec { - tag: String::new(), - prompt: String::new(), - default_value: String::new(), - insertion_point: [0.0; 3], - height: 0.0, - rotation: 0.0, - }), - Operation::CreateTable(ocs_doc_api::ops::TableSpec { - insertion_point: [0.0; 3], - data: vec![], - }), - Operation::CreateDimensionAngular2Ln(ocs_doc_api::ops::DimensionAngularSpec { - vertex: [0.0; 3], - first_point: [0.0; 3], - second_point: [0.0; 3], - arc_location: [0.0; 3], - }), - Operation::SetXData { - id: ObjectId::from_u64(0), - application_name: String::new(), - record: None, - }, - Operation::SetXDataMany(vec![]), - Operation::CreateXRecord(ocs_doc_api::ops::XRecordSpec { - name: String::new(), - cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable, - entries: vec![], - }), - Operation::SetXRecord { - id: ObjectId::from_u64(0), - spec: ocs_doc_api::ops::XRecordSpec { - name: String::new(), - cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable, - entries: vec![], - }, - }, - Operation::CreateLayer(LayerInfo::new("LAYER")), - Operation::UpdateLayer { - name: String::new(), - info: LayerInfo::new("LAYER"), - }, - Operation::DeleteLayer { - name: String::new(), - }, - Operation::SetEntityLayer { - id: ObjectId::from_u64(0), - layer: String::new(), - }, - ]; - _marker - .iter() - .enumerate() - .map(|(index, op)| { - let wire = bincode::serialize(op).unwrap(); - assert_eq!( - u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize, - index - ); - let dbg = format!("{:?}", op); - dbg.split(['(', ' ', '{']) - .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, - Query::GetTextContent { - id: ObjectId::from_u64(0), - }, - Query::GetHatchBoundary { - id: ObjectId::from_u64(0), - }, - Query::GetDimensionMeasurement { - id: ObjectId::from_u64(0), - }, - Query::GetAttributes { - id: ObjectId::from_u64(0), - }, - Query::GetBlockEntities { - block_name: String::new(), - }, - Query::GetViewportView { - id: ObjectId::from_u64(0), - }, - Query::GetXData { - id: ObjectId::from_u64(0), - application_name: String::new(), - }, - Query::GetXRecord { - id: ObjectId::from_u64(0), - }, - Query::ListLayers, - Query::GetEntityLayer { - id: ObjectId::from_u64(0), - }, - ]; - marker - .iter() - .enumerate() - .map(|(index, q)| { - let wire = bincode::serialize(q).unwrap(); - assert_eq!( - u32::from_le_bytes(wire[..4].try_into().unwrap()) as usize, - index - ); - let dbg = format!("{:?}", q); - dbg.split(['(', ' ', '{']) - .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", - "CreateText", - "CreateMText", - "SetTextContent", - "CreateHatch", - "CreateDimensionLinear", - "SetAttribute", - "SetViewportView", - "CreateRasterImage", - "Loft", - "CreateDimensionRadius", - "CreateDimensionDiameter", - "CreateDimensionAngular", - "CreateAttributeDefinition", - "CreateTable", - "CreateDimensionAngular2Ln", - "SetXData", - "SetXDataMany", - "CreateXRecord", - "SetXRecord", - "CreateLayer", - "UpdateLayer", - "DeleteLayer", - "SetEntityLayer", -]; - -/// Recorded baseline of the `Query` variant order. -const QUERY_BASELINE: &[&str] = &[ - "GetEntity", - "GetBounds", - "GetCentroid", - "GetVolume", - "GetIntersects", - "GetGeometryRevision", - "GetTextContent", - "GetHatchBoundary", - "GetDimensionMeasurement", - "GetAttributes", - "GetBlockEntities", - "GetViewportView", - "GetXData", - "GetXRecord", - "ListLayers", - "GetEntityLayer", -]; - -#[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:?}" - ); - } -} diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index d8ed9a7b0..7a284bc60 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -908,7 +908,7 @@ fn generate_version_info(out_dir: &Path) { "acadrust_version": acadrust.version.to_string(), "acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()), "rustc_version": rustc_version, - "api_version": 6, + "api_version": 5, "api_version_min_supported": 2, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index dfc2acbf4..19130c596 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -552,18 +552,6 @@ 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 - /// `Result`. 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 585f2cc85..c20cad1b2 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -138,12 +138,6 @@ 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`. @@ -170,10 +164,6 @@ 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 `Result` 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 8394d456e..055880ef0 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -77,9 +77,5 @@ 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/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index b72648a72..b0e4b3dcd 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -18,15 +18,9 @@ /// plugins and appends `document_reader` / `document_view` at the end of the /// 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. -/// 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; +/// adds the `BuiltinPlugin::on_load` lifecycle callback and +/// `HostApi::document_path`. +pub const API_VERSION: u32 = 5; /// 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 deleted file mode 100644 index 4c27a8fa1..000000000 --- a/src/app/doc_api.rs +++ /dev/null @@ -1,4501 +0,0 @@ -//! Native document API adapter over the existing scene, history and kernel paths. - -use acadrust::entities::Solid3D; -use acadrust::objects::{Dictionary, ObjectType}; -use acadrust::{EntityType, Handle}; -use ocs_doc_api::backend::{DocApiBackend, KernelBody}; -use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Color, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, - LayerFlags, LayerInfo, LineWeight, ObjectId, PlacementSpec, -}; - -use crate::scene::annotative::root_named_dict_handle; -use crate::scene::convert::acis_export; -use crate::scene::model::solid_model; -use ocs_doc_api::convert; - -use super::plugin_host::HostSession; - -const XRECORD_DICT_NAME: &str = "OCS_XRECORD_DICT"; - -/// Return the stable named-object dictionary used to hold `XRecord` objects. -/// Created on first use under the root named-objects dictionary and reused -/// thereafter. -fn xrecord_dictionary_handle(doc: &mut acadrust::CadDocument) -> Handle { - let root_h = root_named_dict_handle(doc); - let existing = match doc.objects.get(&root_h) { - Some(ObjectType::Dictionary(root)) => root - .entries - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case(XRECORD_DICT_NAME)) - .map(|(_, handle)| *handle), - _ => None, - }; - match existing.filter(|handle| matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_)))) { - Some(handle) => handle, - None => { - let handle = doc.allocate_handle(); - let mut dict = Dictionary::new(); - dict.handle = handle; - dict.owner = root_h; - doc.objects.insert(handle, ObjectType::Dictionary(dict)); - if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_h) { - root.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(XRECORD_DICT_NAME)); - root.add_entry(XRECORD_DICT_NAME, handle); - } - handle - } - } -} - -fn xrecord_owner_dictionary( - doc: &acadrust::CadDocument, - record_handle: Handle, - owner: Handle, -) -> Option { - let owns_record = |handle: Handle| { - matches!(doc.objects.get(&handle), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(_, child)| *child == record_handle)) - }; - if owns_record(owner) { - return Some(owner); - } - doc.objects.iter().find_map(|(handle, object)| match object { - ObjectType::Dictionary(dict) - if dict.entries.iter().any(|(_, child)| *child == record_handle) => - { - Some(*handle) - } - _ => None, - }) -} - -/// 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 bincode::Options; - 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() - )); - } - host.scene_mut().doc_api_cold_tess_used = 0; - const FRAME_LIMIT: u64 = 64 * 1024 * 1024; - if bytes.len() as u64 > FRAME_LIMIT { - return Err("DocApi frame exceeds 64 MiB".into()); - } - let envelope: DocApiEnvelope = bincode::DefaultOptions::new() - .with_fixint_encoding() - .with_limit(FRAME_LIMIT) - .reject_trailing_bytes() - .deserialize(bytes) - .map_err(|e| format!("DocApiEnvelope deserialize: {e}"))?; - let result: ApiResult = - envelope - .validate_version() - .and_then(|_| 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}")) -} - -// ObjectId <-> acadrust::Handle converters delegate to the crate's helpers -// (single source of truth; no local duplicates). -fn obj_to_handle(id: ObjectId) -> Handle { - id.to_handle() -} -fn handle_to_obj(h: Handle) -> ObjectId { - ObjectId::from_handle(h) -} - -fn v3_to_array(v: acadrust::types::Vector3) -> [f64; 3] { - [v.x, v.y, v.z] -} - -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 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 { - let prepared = self.prepare_doc_solid(body.clone(), None)?; - Ok(self.apply_doc_entity(prepared, None)) - } - - fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { - self.can_modify(id)?; - let prepared = self.prepare_doc_solid(body.clone(), Some(id))?; - self.apply_doc_entity(prepared, Some(id)); - Ok(()) - } - - fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { - // Validate per-spec layers up front so the op is atomic. - for spec in specs { - if let Some(name) = spec.layer() { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateMany", - e.to_string(), - ))?; - } - } - let mut prepared = Vec::with_capacity(specs.len()); - for spec in specs { - prepared.push(match spec { - ocs_doc_api::EntitySpec::Solid(spec) => { - self.prepare_doc_solid(ocs_doc_api::geom::make_solid(spec)?, None)? - } - ocs_doc_api::EntitySpec::Curve(spec) => { - let mut entity = convert::curve_spec_to_entity(spec)?; - if let Some(name) = spec.layer() { - entity.common_mut().layer = - acadrust::tables::normalize_name(name.trim()); - } - PreparedDocEntity { - entity, - solid: None, - } - } - }); - } - DocApiBackend::push_undo(self, "CreateMany"); - let ids = prepared - .into_iter() - .map(|entity| self.apply_doc_entity(entity, None)) - .collect(); - self.finalize_op(); - Ok(ids) - } - - fn transform_many(&mut self, ids: &[ObjectId], placement: &PlacementSpec) -> ApiResult<()> { - let prepared = ids - .iter() - .map(|&id| self.prepare_doc_transform(id, placement)) - .collect::>>()?; - DocApiBackend::push_undo(self, "TransformMany"); - for (&id, entity) in ids.iter().zip(prepared) { - self.apply_doc_entity(entity, Some(id)); - } - self.finalize_op(); - Ok(()) - } - - fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let layer_name = spec.layer(); - if let Some(name) = layer_name { - self.lookup_layer(name).map_err(|e| ApiError::validation( - "CreateCurve", - e.to_string(), - ))?; - } - let mut entity = convert::curve_spec_to_entity(spec)?; - if let Some(name) = layer_name { - entity.common_mut().layer = acadrust::tables::normalize_name(name.trim()); - } - let handle = self.scene_mut().add_entity(entity); - Ok(handle_to_obj(handle)) - } - - fn lookup_layer(&self, name: &str) -> ApiResult { - let norm = acadrust::tables::normalize_name(name.trim()); - if norm.is_empty() { - return Err(ApiError::validation("LookupLayer", "empty layer name")); - } - self.document() - .layers - .get(&norm) - .map(|l| layer_info_from_acadrust(l)) - .ok_or_else(|| { - ApiError::validation( - "LookupLayer", - format!("layer '{name}' does not exist"), - ) - }) - } - - 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_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_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_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_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 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 { - 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(), - 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(), - )) - } - }) - } - - fn add_attribute_definition( - &mut self, - spec: &ocs_doc_api::ops::AttributeDefinitionSpec, - ) -> ApiResult { - use acadrust::entities::AttributeDefinition; - use acadrust::types::Vector3; - let att = AttributeDefinition { - tag: spec.tag.clone(), - prompt: spec.prompt.clone(), - default_value: spec.default_value.clone(), - insertion_point: Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ), - height: spec.height, - rotation: spec.rotation, - ..Default::default() - }; - let handle = self - .scene_mut() - .add_entity(EntityType::AttributeDefinition(att)); - 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; - 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))?; - 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 attr = acadrust::entities::AttributeEntity { - tag: tag.to_string(), - value: value.to_string(), - insertion_point: ins.insert_point, - ..Default::default() - }; - 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) => ( - convert::entity_kind_name(e).to_string(), - convert::entity_bounds(Some(e), id).ok(), - ), - None => ("Missing".to_string(), None), - }; - let layer = match self.document().get_entity(*h) { - Some(e) => e.common().layer.clone(), - None => String::new(), - }; - out.push(EntityView { - id, - kind, - layer, - bounds, - }); - } - 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_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 img = RasterImage { - file_path: spec.file_path.clone(), - insertion_point: Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ), - u_vector: Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]), - v_vector: Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]), - size: Vector2::new(spec.size[0], spec.size[1]), - ..Default::default() - }; - // 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 loft( - &mut self, - sections: &[(cadkernel::space::Plane, Vec)], - ) -> ApiResult { - let body = cadkernel::brep::loft(sections) - .ok_or_else(|| ApiError::geometry(GeometryErrorKind::InvalidInput, "loft failed"))?; - self.store_solid(&body) - } - - fn set_xdata( - &mut self, - id: ObjectId, - application_name: &str, - record: Option<&ocs_doc_api::XDataRecord>, - ) -> ApiResult<()> { - self.can_modify(id)?; - let handle = obj_to_handle(id); - let values = record.map(|r| { - r.values - .iter() - .map(|v| xdata_value_to_acadrust(v, self.document())) - .collect::>() - }); - crate::scene::view::dispatch::set_entity_xdata( - self.document_mut(), - handle, - application_name, - values, - ); - Ok(()) - } - - fn xdata(&self, id: ObjectId, application_name: &str) -> ApiResult> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let Some(rec) = entity.common().extended_data.get_record(application_name) else { - return Ok(None); - }; - Ok(Some(ocs_doc_api::XDataRecord { - application_name: rec.application_name.clone(), - values: rec - .values - .iter() - .map(xdata_value_from_acadrust) - .collect(), - })) - } - - fn object_exists(&self, id: ObjectId) -> bool { - let handle = obj_to_handle(id); - self.document().objects.contains_key(&handle) - } - - fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { - use acadrust::objects::{ObjectType, XRecord}; - let dict_h = xrecord_dictionary_handle(self.document_mut()); - if matches!(self.document().objects.get(&dict_h), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(name, _)| name.eq_ignore_ascii_case(&spec.name))) - { - return Err(ApiError::validation( - "CreateXRecord", - format!("XRecord '{}' already exists", spec.name), - )); - } - let mut record = XRecord::named(spec.name.clone()); - record.owner = dict_h; - record.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); - record.entries = spec - .entries - .iter() - .map(xrecord_entry_to_acadrust) - .collect(); - record.synchronize_object_references(); - let record_handle = { - let doc = self.document_mut(); - let handle = doc.allocate_handle(); - record.handle = handle; - doc.objects - .insert(handle, ObjectType::XRecord(record)); - handle - }; - // Attach the new XRecord to the stable named-object dictionary so it is - // reachable as a standalone named object. - if let Some(ObjectType::Dictionary(dict)) = self.document_mut().objects.get_mut(&dict_h) { - dict.add_entry(spec.name.clone(), record_handle); - } - Ok(handle_to_obj(record_handle)) - } - - fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { - if !self.object_exists(id) { - return Err(ApiError::validation( - "SetXRecord", - format!("unknown ObjectId {id:?}"), - )); - } - let handle = obj_to_handle(id); - let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { - return Err(ApiError::Unsupported(format!("ObjectId {id:?} is not an XRecord"))); - }; - let mut updated = record.clone(); - let dict_h = xrecord_owner_dictionary(self.document(), handle, record.owner) - .unwrap_or_else(|| xrecord_dictionary_handle(self.document_mut())); - if matches!(self.document().objects.get(&dict_h), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(name, child)| { - *child != handle && name.eq_ignore_ascii_case(&spec.name) - })) - { - return Err(ApiError::validation( - "SetXRecord", - format!("XRecord '{}' already exists", spec.name), - )); - } - updated.name = spec.name.clone(); - updated.owner = dict_h; - updated.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); - updated.entries = spec - .entries - .iter() - .map(xrecord_entry_to_acadrust) - .collect(); - updated.synchronize_object_references(); - self.document_mut() - .objects - .insert(handle, acadrust::objects::ObjectType::XRecord(updated)); - if let Some(ObjectType::Dictionary(dict)) = self.document_mut().objects.get_mut(&dict_h) { - dict.entries.retain(|(_, child)| *child != handle); - dict.add_entry(spec.name.clone(), handle); - } - Ok(()) - } - - fn xrecord(&self, id: ObjectId) -> ApiResult> { - let handle = obj_to_handle(id); - let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { - return Ok(None); - }; - Ok(Some(ocs_doc_api::XRecordSpec { - name: record.name.clone(), - cloning_flags: cloning_flags_from_acadrust(record.cloning_flags), - entries: record - .entries - .iter() - .map(xrecord_entry_from_acadrust) - .collect(), - })) - } - - fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> { - let name = acadrust::tables::normalize_name(info.name.trim()); - if name.is_empty() { - return Err(ApiError::validation("CreateLayer", "empty layer name")); - } - if self.document().layers.contains(&name) { - return Err(ApiError::validation( - "CreateLayer", - format!("layer '{name}' already exists"), - )); - } - let mut layer = acadrust::tables::Layer::new(&name); - layer.handle = self.document_mut().allocate_handle(); - apply_layer_info(&mut layer, info); - self.document_mut() - .layers - .add(layer) - .map_err(|e| ApiError::validation("CreateLayer", e))?; - Ok(()) - } - - fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> { - let deps = { - let doc = self.document_mut(); - let name_upper = acadrust::tables::normalize_name(name.trim()); - let Some(existing) = doc.layers.get(&name_upper).cloned() else { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - }; - let new_name = acadrust::tables::normalize_name(info.name.trim()); - if new_name.is_empty() { - return Err(ApiError::validation("UpdateLayer", "empty layer name")); - } - // Rename if the key changed (preserves handle and table position). - if name_upper != new_name { - doc.layers - .rename(&name_upper, new_name.clone()) - .map_err(|e| ApiError::validation("UpdateLayer", e))?; - } - let Some(layer) = doc.layers.get_mut(&new_name) else { - return Err(ApiError::validation( - "UpdateLayer", - format!("layer '{name}' does not exist"), - )); - }; - apply_layer_info(layer, info); - [existing.name.clone(), layer.name.clone()] - }; - // By-layer appearance changed: rebuild derived geometry that resolved it. - self.scene_mut().invalidate_layer_dependencies(&deps); - Ok(()) - } - fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = acadrust::tables::normalize_name(name.trim()); - if name_upper == "0" { - return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); - } - if !self.document().layers.contains(&name_upper) { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' does not exist"), - )); - } - if acadrust::tables::normalize_name(&self.document().header.current_layer_name) == name_upper - { - return Err(ApiError::validation( - "DeleteLayer", - "cannot delete the current layer", - )); - } - if self - .document() - .entities() - .any(|e| acadrust::tables::normalize_name(&e.common().layer) == name_upper) - { - return Err(ApiError::validation( - "DeleteLayer", - format!("layer '{name}' is still in use"), - )); - } - // Remove by the normalized name so a differently-cased request behaves - // consistently with the rest of the layer table API. - self.document_mut().layers.remove(&name_upper); - Ok(()) - } - - - fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { - self.can_modify(id)?; - let handle = obj_to_handle(id); - let layer_upper = acadrust::tables::normalize_name(layer.trim()); - let target = self.document().layers.get(&layer_upper).cloned(); - let Some(target) = target else { - return Err(ApiError::validation( - "SetEntityLayer", - format!("layer '{layer}' does not exist"), - )); - }; - if target.is_locked() { - return Err(ApiError::Unsupported(format!( - "entity {id:?} cannot be moved to locked layer '{layer}'" - ))); - } - let mut entity = self - .document() - .get_entity(handle) - .cloned() - .ok_or(ApiError::UnknownId(id))?; - entity.common_mut().layer = target.name.clone(); - if !self.scene_mut().update_entity(entity) { - return Err(ApiError::Unsupported(format!( - "entity {id:?} is on a locked layer" - ))); - } - Ok(()) - } - - fn layers(&self) -> ApiResult> { - let mut v: Vec<_> = self - .document() - .layers - .iter() - .map(layer_info_from_acadrust) - .collect(); - v.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(v) - } - - fn entity_layer(&self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - Ok(entity.common().layer.clone()) - } - - fn enumerate_entities( - &self, - kind: Option<&str>, - layer: Option<&str>, - include_bounds: bool, - ) -> ApiResult> { - let doc = self.document(); - let mut out = Vec::new(); - for entity in doc.entities() { - let handle = entity.common().handle; - let id = handle_to_obj(handle); - let entity_kind = convert::entity_kind_name(entity); - if let Some(k) = kind { - if !entity_kind.eq_ignore_ascii_case(k) { - continue; - } - } - if let Some(l) = layer { - if acadrust::tables::normalize_name(&entity.common().layer) - != acadrust::tables::normalize_name(l.trim()) - { - continue; - } - } - out.push(EntityView { - id, - kind: entity_kind.to_string(), - layer: entity.common().layer.clone(), - bounds: if include_bounds { - convert::entity_bounds(Some(entity), id).ok() - } else { - None - }, - }); - } - Ok(out) - } - - fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Point(pt) = entity else { - return Err(ApiError::Unsupported( - "GetPointPosition is only for Point entities".into(), - )); - }; - Ok(v3_to_array(pt.location)) - } - - fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { - let handle = obj_to_handle(id); - let entity = self - .document() - .get_entity(handle) - .ok_or(ApiError::UnknownId(id))?; - let EntityType::Line(line) = entity else { - return Err(ApiError::Unsupported( - "GetLineGeometry is only for Line entities".into(), - )); - }; - Ok((v3_to_array(line.start), v3_to_array(line.end))) - } - - 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 point[2] != pl.elevation || pl.vertices.len() >= ocs_doc_api::ops::BULK_ITEM_CAP { - return Err(ApiError::validation( - "AddVertex", - "vertex must share the polyline elevation and fit the item cap", - )); - } - 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 { - 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. - 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<()> { - // 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(handle) - .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::Insert(_) - | EntityType::Viewport(_) - | EntityType::Text(_) - | EntityType::MText(_) - | EntityType::Point(_) - | EntityType::LwPolyline(_) - ); - 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<()> { - 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: convert::entity_kind_name(entity).to_string(), - layer: entity.common().layer.clone(), - bounds: self.bounds(id).ok(), - }) - } - - fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { - let prepared = self.prepare_doc_transform(id, placement)?; - self.apply_doc_entity(prepared, Some(id)); - Ok(()) - } - - fn profile_curves(&self, id: ObjectId) -> ApiResult> { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - crate::scene::model::sweep_model::extrusion_profile_of(entity) - .map(|(profile, _)| profile.pieces) - .ok_or_else(|| ApiError::Unsupported("entity is not a planar profile".into())) - } - - fn profile_plane(&self, id: ObjectId) -> ApiResult { - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - crate::scene::model::sweep_model::extrusion_profile_of(entity) - .map(|(profile, _)| profile.plane) - .ok_or_else(|| ApiError::Unsupported("entity is not a planar profile".into())) - } - - fn bounds(&mut self, id: ObjectId) -> ApiResult { - let handle = obj_to_handle(id); - // 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 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); - } - convert::entity_bounds(self.document().get_entity(handle), id) - } - - fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { - Ok(self.mass_properties(id)?.1) - } - - fn volume(&mut self, id: ObjectId) -> ApiResult { - Ok(self.mass_properties(id)?.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 cancel_op(&mut self) { - self.cancel_doc_api_undo(); - } - - fn finalize_op(&mut self) { - // Close the delta entry + bump geometry + republish the document view. - self.commit_doc_api_undo(); - } -} - -// ── helpers ────────────────────────────────────────────────────────────────── - -type SolidDisplay = ( - crate::scene::model::mesh_model::MeshLodSet, - Vec, - [f64; 3], -); - -struct PreparedDocEntity { - entity: EntityType, - solid: Option<(KernelBody, SolidDisplay)>, -} - -impl HostSession<'_> { - fn prepare_doc_solid( - &self, - body: KernelBody, - id: Option, - ) -> ApiResult { - let mut inner = match id { - Some(id) => match self.document().get_entity(obj_to_handle(id)) { - Some(EntityType::Solid3D(solid)) => solid.clone(), - _ => return Err(ApiError::UnknownId(id)), - }, - None => Solid3D::new(), - }; - let handle = id.map_or(Handle::NULL, obj_to_handle); - let display = self - .scene() - .prepare_solid_model_display(handle, &body) - .ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Other, "solid display preparation failed") - })?; - let sat = acis_export::solid_to_sat(&body).ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Acis, "solid serialization failed") - })?; - inner.wires = solid_model::edge_wires(&body); - inner.set_sat_document(&sat); - Ok(PreparedDocEntity { - entity: EntityType::Solid3D(inner), - solid: Some((body, display)), - }) - } - - fn prepare_doc_transform( - &mut self, - id: ObjectId, - placement: &PlacementSpec, - ) -> ApiResult { - self.ensure_transformable(id)?; - let entity = self - .document() - .get_entity(obj_to_handle(id)) - .ok_or(ApiError::UnknownId(id))?; - if matches!(entity, EntityType::Solid3D(_)) { - let body = self.resolve_body(id)?; - let moved = cadkernel::brep::transform(&body, &kernel_placement(placement)) - .ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") - })?; - self.prepare_doc_solid(moved, Some(id)) - } else { - Ok(PreparedDocEntity { - entity: convert::transform_entity_geometry(entity, placement)?, - solid: None, - }) - } - } - - fn apply_doc_entity(&mut self, prepared: PreparedDocEntity, id: Option) -> ObjectId { - let handle = match id { - Some(id) => { - assert!( - self.scene_mut().update_entity(prepared.entity), - "prevalidated entity update" - ); - obj_to_handle(id) - } - None => self.scene_mut().add_entity(prepared.entity), - }; - if let Some((body, display)) = prepared.solid { - self.scene_mut() - .register_prepared_solid_model(handle, body, display); - } - handle_to_obj(handle) - } - - /// Kernel mesh mass properties, cached per entity and geometry epoch. - 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.scene().doc_api_mass_cache.get(&handle) { - if cached_epoch == epoch { - return Ok((v, c)); - } - } - if self.scene().doc_api_cold_tess_used >= 32 { - return Err(ApiError::validation( - "mass properties", - "request exceeds 32 uncached solid queries", - )); - } - let body = self.resolve_body(id)?; - self.scene_mut().doc_api_cold_tess_used += 1; - let mesh = cadkernel::brep::mesh_body(&body, 0.1, 1e-4); - let (v, c) = mesh.mass_properties().ok_or_else(|| { - ApiError::geometry(GeometryErrorKind::Empty, "solid has no measurable volume") - })?; - if self.scene().doc_api_mass_cache.len() >= ocs_doc_api::ops::BULK_ITEM_CAP { - self.scene_mut().doc_api_mass_cache.clear(); - } - 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, - y_axis: p.y_axis, - z_axis: p.z_axis, - origin: p.origin, - } -} - -// Conversions between ocs_doc_api plain-data DTOs and acadrust types. -// Kept in this file because they are host-side implementation details. - -fn cloning_flags_to_acadrust( - flags: ocs_doc_api::XRecordCloningFlags, -) -> acadrust::objects::DictionaryCloningFlags { - use acadrust::objects::DictionaryCloningFlags as F; - match flags { - ocs_doc_api::XRecordCloningFlags::NotApplicable => F::NotApplicable, - ocs_doc_api::XRecordCloningFlags::KeepExisting => F::KeepExisting, - ocs_doc_api::XRecordCloningFlags::UseClone => F::UseClone, - ocs_doc_api::XRecordCloningFlags::XrefName => F::XrefName, - ocs_doc_api::XRecordCloningFlags::Name => F::Name, - ocs_doc_api::XRecordCloningFlags::UnmangleName => F::UnmangleName, - } -} - -fn cloning_flags_from_acadrust( - flags: acadrust::objects::DictionaryCloningFlags, -) -> ocs_doc_api::XRecordCloningFlags { - use acadrust::objects::DictionaryCloningFlags as F; - match flags { - F::NotApplicable => ocs_doc_api::XRecordCloningFlags::NotApplicable, - F::KeepExisting => ocs_doc_api::XRecordCloningFlags::KeepExisting, - F::UseClone => ocs_doc_api::XRecordCloningFlags::UseClone, - F::XrefName => ocs_doc_api::XRecordCloningFlags::XrefName, - F::Name => ocs_doc_api::XRecordCloningFlags::Name, - F::UnmangleName => ocs_doc_api::XRecordCloningFlags::UnmangleName, - } -} - -fn xdata_value_to_acadrust( - value: &ocs_doc_api::XDataValue, - _doc: &acadrust::CadDocument, -) -> acadrust::xdata::XDataValue { - use acadrust::types::Vector3; - use ocs_doc_api::XDataValue as V; - match value { - V::String(s) => acadrust::xdata::XDataValue::String(s.clone()), - V::ControlString(s) => acadrust::xdata::XDataValue::ControlString(s.clone()), - V::LayerName(s) => acadrust::xdata::XDataValue::LayerName(s.clone()), - V::BinaryData(b) => acadrust::xdata::XDataValue::BinaryData(b.clone()), - V::Handle(h) => acadrust::xdata::XDataValue::Handle(acadrust::Handle::new(*h)), - V::Point3D(p) => acadrust::xdata::XDataValue::Point3D(Vector3::new(p[0], p[1], p[2])), - V::Position3D(p) => acadrust::xdata::XDataValue::Position3D(Vector3::new(p[0], p[1], p[2])), - V::Displacement3D(p) => { - acadrust::xdata::XDataValue::Displacement3D(Vector3::new(p[0], p[1], p[2])) - } - V::Direction3D(p) => acadrust::xdata::XDataValue::Direction3D(Vector3::new(p[0], p[1], p[2])), - V::Real(r) => acadrust::xdata::XDataValue::Real(*r), - V::Distance(d) => acadrust::xdata::XDataValue::Distance(*d), - V::ScaleFactor(s) => acadrust::xdata::XDataValue::ScaleFactor(*s), - V::Integer16(i) => acadrust::xdata::XDataValue::Integer16(*i), - V::Integer32(i) => acadrust::xdata::XDataValue::Integer32(*i), - } -} - -fn xdata_value_from_acadrust(value: &acadrust::xdata::XDataValue) -> ocs_doc_api::XDataValue { - use acadrust::xdata::XDataValue as V; - match value { - V::String(s) => ocs_doc_api::XDataValue::String(s.clone()), - V::ControlString(s) => ocs_doc_api::XDataValue::ControlString(s.clone()), - V::LayerName(s) => ocs_doc_api::XDataValue::LayerName(s.clone()), - V::BinaryData(b) => ocs_doc_api::XDataValue::BinaryData(b.clone()), - V::Handle(h) => ocs_doc_api::XDataValue::Handle(h.value()), - V::Point3D(v) => ocs_doc_api::XDataValue::Point3D([v.x, v.y, v.z]), - V::Position3D(v) => ocs_doc_api::XDataValue::Position3D([v.x, v.y, v.z]), - V::Displacement3D(v) => ocs_doc_api::XDataValue::Displacement3D([v.x, v.y, v.z]), - V::Direction3D(v) => ocs_doc_api::XDataValue::Direction3D([v.x, v.y, v.z]), - V::Real(r) => ocs_doc_api::XDataValue::Real(*r), - V::Distance(d) => ocs_doc_api::XDataValue::Distance(*d), - V::ScaleFactor(s) => ocs_doc_api::XDataValue::ScaleFactor(*s), - V::Integer16(i) => ocs_doc_api::XDataValue::Integer16(*i), - V::Integer32(i) => ocs_doc_api::XDataValue::Integer32(*i), - } -} - -fn xrecord_value_to_acadrust(value: &ocs_doc_api::XRecordValue) -> acadrust::objects::XRecordValue { - use ocs_doc_api::XRecordValue as V; - match value { - V::String(s) => acadrust::objects::XRecordValue::String(s.clone()), - V::Double(d) => acadrust::objects::XRecordValue::Double(*d), - V::Int16(i) => acadrust::objects::XRecordValue::Int16(*i), - V::Int32(i) => acadrust::objects::XRecordValue::Int32(*i), - V::Int64(i) => acadrust::objects::XRecordValue::Int64(*i), - V::Byte(b) => acadrust::objects::XRecordValue::Byte(*b), - V::Bool(b) => acadrust::objects::XRecordValue::Bool(*b), - V::Handle(h) => acadrust::objects::XRecordValue::Handle(acadrust::Handle::new(*h)), - V::Point3D(p) => acadrust::objects::XRecordValue::Point3D(p[0], p[1], p[2]), - V::Chunk(c) => acadrust::objects::XRecordValue::Chunk(c.clone()), - } -} - -fn xrecord_value_from_acadrust( - value: &acadrust::objects::XRecordValue, -) -> ocs_doc_api::XRecordValue { - use acadrust::objects::XRecordValue as V; - match value { - V::String(s) => ocs_doc_api::XRecordValue::String(s.clone()), - V::Double(d) => ocs_doc_api::XRecordValue::Double(*d), - V::Int16(i) => ocs_doc_api::XRecordValue::Int16(*i), - V::Int32(i) => ocs_doc_api::XRecordValue::Int32(*i), - V::Int64(i) => ocs_doc_api::XRecordValue::Int64(*i), - V::Byte(b) => ocs_doc_api::XRecordValue::Byte(*b), - V::Bool(b) => ocs_doc_api::XRecordValue::Bool(*b), - V::Handle(h) => ocs_doc_api::XRecordValue::Handle(h.value()), - V::Point3D(x, y, z) => ocs_doc_api::XRecordValue::Point3D([*x, *y, *z]), - V::Chunk(c) => ocs_doc_api::XRecordValue::Chunk(c.clone()), - } -} - -fn xrecord_entry_to_acadrust( - entry: &ocs_doc_api::XRecordEntry, -) -> acadrust::objects::XRecordEntry { - acadrust::objects::XRecordEntry { - code: entry.code, - value: xrecord_value_to_acadrust(&entry.value), - } -} - -fn xrecord_entry_from_acadrust( - entry: &acadrust::objects::XRecordEntry, -) -> ocs_doc_api::XRecordEntry { - ocs_doc_api::XRecordEntry { - code: entry.code, - value: xrecord_value_from_acadrust(&entry.value), - } -} - -// ── layer conversions ───────────────────────────────────────────────────── - -fn color_to_acadrust(c: Color) -> acadrust::types::Color { - use acadrust::types::Color as A; - match c { - Color::ByLayer => A::ByLayer, - Color::None => A::None, - Color::ByBlock => A::ByBlock, - Color::Index(i) => A::Index(i), - Color::Rgb { r, g, b } => A::Rgb { r, g, b }, - } -} - -fn color_from_acadrust(c: acadrust::types::Color) -> Color { - use acadrust::types::Color as A; - match c { - A::ByLayer => Color::ByLayer, - A::None => Color::None, - A::ByBlock => Color::ByBlock, - A::Index(i) => Color::Index(i), - A::Rgb { r, g, b } => Color::Rgb { r, g, b }, - } -} - -fn line_weight_to_acadrust(lw: LineWeight) -> acadrust::types::LineWeight { - use acadrust::types::LineWeight as A; - match lw { - LineWeight::ByLayer => A::ByLayer, - LineWeight::ByBlock => A::ByBlock, - LineWeight::Default => A::Default, - LineWeight::Value(v) => A::Value(v), - } -} - -fn line_weight_from_acadrust(lw: acadrust::types::LineWeight) -> LineWeight { - use acadrust::types::LineWeight as A; - match lw { - A::ByLayer => LineWeight::ByLayer, - A::ByBlock => LineWeight::ByBlock, - A::Default => LineWeight::Default, - A::Value(v) => LineWeight::Value(v), - } -} - -fn layer_flags_to_acadrust(f: LayerFlags) -> acadrust::tables::LayerFlags { - acadrust::tables::LayerFlags { - frozen: f.frozen, - locked: f.locked, - frozen_in_new_viewport: f.frozen_in_new_viewport, - off: f.off, - xref_dependent: false, - } -} - -fn layer_flags_from_acadrust(f: acadrust::tables::LayerFlags) -> LayerFlags { - LayerFlags { - frozen: f.frozen, - locked: f.locked, - frozen_in_new_viewport: f.frozen_in_new_viewport, - off: f.off, - } -} - -fn apply_layer_info(layer: &mut acadrust::tables::Layer, info: &LayerInfo) { - layer.name = info.name.trim().to_string(); - layer.flags = layer_flags_to_acadrust(info.flags); - layer.color = color_to_acadrust(info.color); - layer.color_name = None; - layer.book_name = None; - layer.line_type = info.line_type.clone(); - layer.line_weight = line_weight_to_acadrust(info.line_weight); - layer.plot_style = info.plot_style.clone(); - layer.is_plottable = info.is_plottable; -} - -fn layer_info_from_acadrust(layer: &acadrust::tables::Layer) -> LayerInfo { - LayerInfo { - name: layer.name.clone(), - flags: layer_flags_from_acadrust(layer.flags), - color: color_from_acadrust(layer.color), - line_type: layer.line_type.clone(), - line_weight: line_weight_from_acadrust(layer.line_weight), - plot_style: layer.plot_style.clone(), - is_plottable: layer.is_plottable, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::plugin_host::HostSession; - use crate::app::OpenCADStudio; - use ocs_doc_api::ops::{ - BoolOp, Color, LayerFlags, LayerInfo, LineWeight, SolidPrimitive, XDataRecord, - XDataValue, XRecordEntry, XRecordSpec, XRecordValue, - }; - use ocs_doc_api::{ - DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt, - XRecordCloningFlags, - }; - - fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult { - let bytes = bincode::serialize(&env).unwrap(); - let tab_id = host.tab_id(); - let out = execute_doc_api(host, tab_id, &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_stale_id_fails_all_or_nothing() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // 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 solid = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_solid)).unwrap()); - let stale = ObjectId::from_u64(0xFFFF_FFFF); - let rev_before = host.scene().geometry_epoch; - - // 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, 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::UnknownId(_)), - "{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:?}"); - } - - // ── 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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); - } - - // ── 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); - } - - // ── 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - position: [0.0; 3], - })), - ) - .unwrap(), - ); - let b = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, - 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 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 { layer: None, - 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); - } - - // ── 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 { layer: None, - centre: [0.0, 0.0, 0.0], - radius: 5.0, - })), - ) - .unwrap(), - ); - let c2 = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0, 0.0, 10.0], - radius: 2.0, - })), - ) - .unwrap(), - ); - let result = dispatch( - &mut host, - DocApiEnvelope::op(Operation::Loft { - profiles: vec![c1, c2], - }), - ); - let id = new_id(&result.expect("loft must preserve the sections' elevations")); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetVolume { id }, Query::GetBounds { id }]), - ) - .unwrap(); - let QueryResult::Volume(volume) = receipt.query_results[0] else { - panic!("volume") - }; - let expected = std::f64::consts::PI * 10.0 * 39.0 / 3.0; - assert!( - (volume - expected).abs() < expected * 0.05, - "volume {volume}" - ); - let QueryResult::Bounds(bounds) = receipt.query_results[1] else { - panic!("bounds") - }; - assert!((bounds.min[2]).abs() < 1e-4 && (bounds.max[2] - 10.0).abs() < 1e-4); - } - - #[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 { layer: None, - 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 - ); - } - - // 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 = 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 - std::f64::consts::FRAC_1_SQRT_2).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 = - 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) ────────── - - #[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 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-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:?}"), - } - } - - // ── 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] - 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:?}"), - } - } - - // ── 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 { layer: None, position: [0.0; 3] }), - ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { layer: None, - 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 partial_ellipse_rotation_preserves_parameters() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - // Rotating the major axis already rotates the ellipse; its parameters stay fixed. - let ell = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, - 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.abs() < 1e-6); - assert!((e.end_parameter - half_pi).abs() < 1e-6); - // 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] - 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 leader = acadrust::entities::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), - ], - ..Default::default() - }; - 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 mesh = acadrust::entities::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), - ], - ..Default::default() - }; - 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] - 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] - 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 { layer: None, - 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] - 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 { layer: None, - 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] - 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] - 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] - 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 img = RasterImage { - insertion_point: Vector3::new(10.0, 20.0, 0.0), - u_vector: Vector3::new(0.5, 0.0, 0.0), // 0.5 world-units/pixel in X - v_vector: Vector3::new(0.0, 0.5, 0.0), - size: Vector2::new(100.0, 50.0), - ..Default::default() - }; - 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] - 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_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 { layer: None, - 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(); - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 { layer: None, - 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 = ocs_doc_api::geom::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). - } - #[test] - fn doc_api_failed_inputs_leave_document_and_history_unchanged() { - use ocs_doc_api::EntitySpec; - let mut app = OpenCADStudio::new_for_test(); - let count = app.tabs[0].scene.document.entity_count(); - let dirty = app.tabs[0].dirty; - let undo = app.tabs[0].history.undo_stack.len(); - let epoch = app.tabs[0].scene.geometry_epoch; - { - let mut host = HostSession::new(&mut app, 0); - let invalid_ops = vec![ - Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [0.0; 3] }), - EntitySpec::Solid(SolidPrimitive::Sphere { - centre: [0.0; 3], - radius: f64::NAN, - }), - ]), - Operation::CreateCurve(Curve2Spec::Circle { layer: None, - centre: [0.0; 3], - radius: -1.0, - }), - Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![[0.0; 3], [1.0; 3]], - closed: false, - }), - Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: "test".into(), - insertion_point: [0.0; 3], - height: f64::INFINITY, - rotation: 0.0, - }), - ]; - for op in invalid_ops { - assert!(dispatch(&mut host, DocApiEnvelope::op(op)).is_err()); - assert!(!host.scene().is_recording_undo()); - } - } - assert_eq!(app.tabs[0].scene.document.entity_count(), count); - assert_eq!(app.tabs[0].scene.geometry_epoch, epoch); - assert_eq!(app.tabs[0].history.undo_stack.len(), undo); - assert_eq!(app.tabs[0].dirty, dirty); - } - - #[test] - fn doc_api_bulk_creation_is_dirty_and_undoes_in_one_step() { - use ocs_doc_api::EntitySpec; - let mut app = OpenCADStudio::new_for_test(); - let undo = app.tabs[0].history.undo_stack.len(); - let count = app.tabs[0].scene.document.entity_count(); - let ids = { - let mut host = HostSession::new(&mut app, 0); - let receipt = dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [1.0; 3] }), - EntitySpec::Solid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [2.0; 3], - }), - ])), - ) - .unwrap(); - receipt.outcome.unwrap().new_ids().to_vec() - }; - assert!(app.tabs[0].dirty); - assert_eq!(app.tabs[0].history.undo_stack.len(), undo + 1); - assert_eq!(app.tabs[0].scene.document.entity_count(), count + 2); - app.undo_active_tab(); - assert_eq!(app.tabs[0].scene.document.entity_count(), count); - for id in &ids { - assert!(!app.tabs[0] - .scene - .solid_models - .contains_key(&obj_to_handle(*id))); - } - app.redo_active_tab(); - for id in ids { - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_some()); - } - } - - #[test] - fn doc_api_text_scale_and_elevated_extrusion() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let text = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateText(ocs_doc_api::ops::TextSpec { - value: "label".into(), - insertion_point: [1.0, 2.0, 0.0], - height: 2.0, - rotation: 0.0, - })), - ) - .unwrap(), - ); - let placement = PlacementSpec { - origin: [0.0, 0.0, 7.0], - x_axis: [2.0, 0.0, 0.0], - y_axis: [0.0, 2.0, 0.0], - z_axis: [0.0, 0.0, 2.0], - }; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::Transform { - id: text, - placement, - }), - ) - .unwrap(); - let EntityType::Text(text) = host.document().get_entity(obj_to_handle(text)).unwrap() - else { - panic!("text") - }; - assert!((text.height - 4.0).abs() < 1e-9); - assert!((text.insertion_point.z - 7.0).abs() < 1e-9); - let profile = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, - points: vec![ - [0.0, 0.0, 7.0], - [2.0, 0.0, 7.0], - [2.0, 3.0, 7.0], - [0.0, 3.0, 7.0], - ], - closed: true, - })), - ) - .unwrap(), - ); - let solid = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::Extrude { - profile, - direction: [0.0, 0.0, 5.0], - }), - ) - .unwrap(), - ); - let bounds = host.bounds(solid).unwrap(); - assert!( - (bounds.min[2] - 7.0).abs() < 1e-6 && (bounds.max[2] - 12.0).abs() < 1e-6, - "{bounds:?}" - ); - assert!((host.volume(solid).unwrap() - 30.0).abs() < 1e-6); - } - - #[test] - fn doc_api_envelope_guards_and_query_budget_reset() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let id = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { - origin: [0.0; 3], - size: [2.0; 3], - })), - ) - .unwrap(), - ); - let mut env = DocApiEnvelope::queries(vec![Query::GetVolume { id }]); - env.version += 1; - assert!(matches!( - dispatch(&mut host, env), - Err(ApiError::Unsupported(_)) - )); - let valid = DocApiEnvelope::queries(vec![Query::GetVolume { id }]); - let mut bytes = bincode::serialize(&valid).unwrap(); - bytes.push(0); - let tab = host.tab_id(); - assert!(execute_doc_api(&mut host, tab, &bytes).is_err()); - let bytes = bincode::serialize(&valid).unwrap(); - assert!(execute_doc_api(&mut host, tab + 1, &bytes).is_err()); - host.scene_mut().doc_api_cold_tess_used = 32; - let receipt = dispatch(&mut host, valid).unwrap(); - assert!( - matches!(receipt.query_results[0], QueryResult::Volume(v) if (v - 8.0).abs() < 1e-9) - ); - assert_eq!(host.scene().doc_api_cold_tess_used, 1); - } - #[test] - fn doc_api_plugin_wire_dispatch_and_image_undo() { - use ocs_plugin_api::ipc::{ - protocol::{PluginRequest, PluginResponse}, - server::handle_plugin_request, - }; - let mut app = OpenCADStudio::new_for_test(); - let objects = app.tabs[0].scene.document.objects.len(); - let id = { - let mut host = HostSession::new(&mut app, 0); - let env = DocApiEnvelope::op(Operation::CreateRasterImage( - ocs_doc_api::ops::RasterImageSpec { - file_path: "image.png".into(), - insertion_point: [0.0; 3], - u_vector: [1.0, 0.0, 0.0], - v_vector: [0.0, 1.0, 0.0], - size: [2.0; 2], - }, - )); - let request = PluginRequest::DocApiRequest { - tab_id: host.tab_id(), - bytes: bincode::serialize(&env).unwrap(), - }; - let request = bincode::deserialize(&bincode::serialize(&request).unwrap()).unwrap(); - let response = handle_plugin_request(&mut host, request, &mut |_| {}); - let response: PluginResponse = - bincode::deserialize(&bincode::serialize(&response).unwrap()).unwrap(); - let PluginResponse::DocApiResponse { bytes } = response else { - panic!("unexpected response") - }; - let result: ApiResult = bincode::deserialize(&bytes).unwrap(); - new_id(&result.unwrap()) - }; - assert!(app.tabs[0].scene.document.objects.len() > objects); - app.undo_active_tab(); - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_none()); - assert_eq!(app.tabs[0].scene.document.objects.len(), objects); - app.redo_active_tab(); - assert!(app.tabs[0] - .scene - .document - .get_entity(obj_to_handle(id)) - .is_some()); - assert!(app.tabs[0].scene.document.objects.len() > objects); - } - - #[test] - fn doc_api_layer_crud_roundtrip_and_entity_assignment() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Create a line to assign to a layer later. - let line = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { layer: None, - start: [0.0; 3], - end: [1.0; 3], - })), - ) - .unwrap(), - ); - - // List default layers — must include "0". - let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::ListLayers])).unwrap(); - let QueryResult::Layers(list) = &receipt.query_results[0] else { - panic!("expected Layers result"); - }; - assert!(list.iter().any(|l| l.name == "0")); - - // Create a new layer. - let mut info = LayerInfo::new("Walls"); - info.color = Color::Index(1); - info.line_weight = LineWeight::Value(25); - info.flags = LayerFlags { - frozen: false, - locked: false, - frozen_in_new_viewport: false, - off: false, - }; - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(info.clone()))).unwrap(); - - // Update the layer color. - let mut updated = info.clone(); - updated.color = Color::Index(2); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::UpdateLayer { - name: " Walls ".into(), - info: updated.clone(), - }), - ) - .unwrap(); - - // Assign the line to the new layer. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: line, - layer: "Walls".into(), - }), - ) - .unwrap(); - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetEntityLayer { id: line }]), - ) - .unwrap(); - assert!(matches!(&receipt.query_results[0], QueryResult::EntityLayer(name) if name == "Walls")); - - // Deleting a layer still in use fails. - let err = dispatch( - &mut host, - DocApiEnvelope::op(Operation::DeleteLayer { - name: "Walls".into(), - }), - ) - .unwrap_err(); - assert!(matches!(err, ApiError::Validation { .. })); - - // Move the line back to "0", then delete the layer. - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: line, - layer: "0".into(), - }), - ) - .unwrap(); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::DeleteLayer { - name: " Walls ".into(), - }), - ) - .unwrap(); - let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::ListLayers])).unwrap(); - let QueryResult::Layers(list) = &receipt.query_results[0] else { - panic!("expected Layers result"); - }; - assert!(!list.iter().any(|l| l.name == "Walls")); - } - - #[test] - fn doc_api_xdata_batch_rejects_a_locked_entity_before_mutating() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let line = |host: &mut HostSession<'_>| { - new_id(&dispatch( - host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { - start: [0.0; 3], - end: [1.0; 3], - layer: None, - })), - ).unwrap()) - }; - let first = line(&mut host); - let locked = line(&mut host); - - let mut layer = LayerInfo::new("Locked"); - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(layer.clone()))).unwrap(); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: locked, - layer: layer.name.clone(), - }), - ) - .unwrap(); - layer.flags.locked = true; - dispatch( - &mut host, - DocApiEnvelope::op(Operation::UpdateLayer { - name: layer.name.clone(), - info: layer, - }), - ) - .unwrap(); - - let record = |application_name: &str| XDataRecord { - application_name: application_name.into(), - values: vec![XDataValue::Integer32(42)], - }; - let result = dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetXDataMany(vec![ - (first, "FIRST".into(), Some(record("FIRST"))), - (locked, "LOCKED".into(), Some(record("LOCKED"))), - ])), - ); - assert!(matches!(result, Err(ApiError::Validation { .. }))); - assert!(host.xdata(first, "FIRST").unwrap().is_none()); - } - - #[test] - fn doc_api_xrecords_keep_one_named_owner() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - let spec = |name: &str| XRecordSpec { - name: name.into(), - cloning_flags: XRecordCloningFlags::NotApplicable, - entries: vec![XRecordEntry { - code: 1, - value: XRecordValue::String("value".into()), - }], - }; - - let first = new_id(&dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(spec("FIRST"))), - ).unwrap()); - let first_handle = obj_to_handle(first); - let ObjectType::XRecord(first_record) = host.document().objects.get(&first_handle).unwrap() - else { - panic!("expected XRecord") - }; - let owner = first_record.owner; - assert!(matches!(host.document().objects.get(&owner), Some(ObjectType::Dictionary(dict)) if - dict.entries.iter().any(|(name, child)| name == "FIRST" && *child == first_handle))); - - let object_count = host.document().objects.len(); - assert!(matches!( - dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(spec("first"))), - ), - Err(ApiError::Validation { .. }) - )); - assert_eq!(host.document().objects.len(), object_count); - - let second = new_id(&dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(spec("SECOND"))), - ).unwrap()); - assert!(matches!( - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetXRecord { - id: second, - spec: spec("FIRST"), - }), - ), - Err(ApiError::Validation { .. }) - )); - assert_eq!(host.xrecord(second).unwrap().unwrap().name, "SECOND"); - - let mut invalid = spec("INVALID"); - invalid.entries[0] = XRecordEntry { - code: 1, - value: XRecordValue::Int32(7), - }; - assert!(matches!( - dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateXRecord(invalid)), - ), - Err(ApiError::Validation { .. }) - )); - } - - #[test] - fn doc_api_enumerate_entities_and_geometry_queries() { - let mut app = OpenCADStudio::new_for_test(); - let mut host = HostSession::new(&mut app, 0); - - // Create entities on different layers. - let line_id = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { layer: None, - start: [1.0, 2.0, 3.0], - end: [4.0, 5.0, 6.0], - })), - ) - .unwrap(), - ); - let point_id = new_id( - &dispatch( - &mut host, - DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Point { layer: None, - position: [7.0, 8.0, 9.0], - })), - ) - .unwrap(), - ); - - // Create a layer and move the point to it. - let mut info = LayerInfo::new("Markers"); - info.color = Color::Index(3); - dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(info))).unwrap(); - dispatch( - &mut host, - DocApiEnvelope::op(Operation::SetEntityLayer { - id: point_id, - layer: "Markers".into(), - }), - ) - .unwrap(); - - // Enumerate all entities. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::EnumerateEntities { - kind: None, - layer: None, - include_bounds: true, - }]), - ) - .unwrap(); - let QueryResult::Entities(all) = &receipt.query_results[0] else { - panic!("expected Entities result"); - }; - assert!(all.iter().any(|e| e.id == line_id && e.kind == "Line" && e.layer == "0")); - assert!(all.iter().any(|e| e.id == point_id && e.kind == "Point" && e.layer == "Markers")); - - // Filter by kind. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::EnumerateEntities { - kind: Some("Line".into()), - layer: None, - include_bounds: true, - }]), - ) - .unwrap(); - let QueryResult::Entities(lines) = &receipt.query_results[0] else { - panic!("expected Entities result"); - }; - assert_eq!(lines.len(), 1); - assert_eq!(lines[0].id, line_id); - - // Filter by layer. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::EnumerateEntities { - kind: None, - layer: Some(" Markers ".into()), - include_bounds: true, - }]), - ) - .unwrap(); - let QueryResult::Entities(markers) = &receipt.query_results[0] else { - panic!("expected Entities result"); - }; - assert_eq!(markers.len(), 1); - assert_eq!(markers[0].id, point_id); - - // Geometry queries. - let receipt = dispatch( - &mut host, - DocApiEnvelope::queries(vec![ - Query::GetPointPosition { id: point_id }, - Query::GetLineGeometry { id: line_id }, - ]), - ) - .unwrap(); - let QueryResult::PointPosition(pos) = &receipt.query_results[0] else { - panic!("expected PointPosition result"); - }; - assert!((pos[0] - 7.0).abs() < 1e-9); - assert!((pos[1] - 8.0).abs() < 1e-9); - assert!((pos[2] - 9.0).abs() < 1e-9); - let QueryResult::LineGeometry((start, end)) = &receipt.query_results[1] else { - panic!("expected LineGeometry result"); - }; - assert!((start[0] - 1.0).abs() < 1e-9); - assert!((end[0] - 4.0).abs() < 1e-9); - - // Wrong entity family is rejected. - let err = dispatch( - &mut host, - DocApiEnvelope::queries(vec![Query::GetPointPosition { id: line_id }]), - ) - .unwrap_err(); - assert!(matches!(err, ApiError::Unsupported { .. })); - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs index 0c2cd3e73..5582a774d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,8 +9,6 @@ pub(crate) mod config; pub use automation::{export_headless, serve}; mod command_driver; pub(crate) mod commands; -#[cfg(not(target_arch = "wasm32"))] -mod doc_api; mod document; pub(crate) mod expr_eval; mod find_replace; diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index c39129315..d60992822 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -16,8 +16,6 @@ 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> { @@ -26,7 +24,6 @@ impl<'a> HostSession<'a> { app, tab, doc_store: None, - doc_api_pending: None, } } @@ -245,39 +242,6 @@ 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 - } - - /// DocApi `push_undo`: capture entity and document-structure changes 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, false); - } - - pub(crate) fn cancel_doc_api_undo(&mut self) { - if self.doc_api_pending.take().is_some() { - self.scene_mut().take_undo_recording(); - } - } - - /// 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) { - self.set_dirty(); - 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; } @@ -396,10 +360,6 @@ 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)