diff --git a/Cargo.lock b/Cargo.lock index 62d9d597c..afe33d97e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ dependencies = [ "lyon_tessellation", "meshopt", "naga 27.0.3", + "ocs_doc_api", "ocs_plugin_api", "open", "printpdf", @@ -880,7 +881,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=67f2427282d28922d9263e37b0136b6632e929b3#67f2427282d28922d9263e37b0136b6632e929b3" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6a5e6fc2822f6af6b2611cd605b44437b35158f0#6a5e6fc2822f6af6b2611cd605b44437b35158f0" dependencies = [ "acadrust", "cavalier_contours", @@ -3886,6 +3887,20 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "ocs_doc_api" +version = "0.1.0" +dependencies = [ + "acadrust", + "bincode", + "cadkernel", + "ocs_plugin_api", + "serde", + "serde_json", + "thiserror 1.0.69", + "toml 1.1.3+spec-1.1.0", +] + [[package]] name = "ocs_plugin_api" version = "0.2.1" @@ -3900,6 +3915,7 @@ dependencies = [ "rkyv", "serde", "serde-reflection", + "serde_bytes", "serde_json", "thiserror 1.0.69", ] @@ -5107,6 +5123,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index 5b2aa815c..5943733aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/ocs_web_worker", "crates/dwg-thumbnailer", "crates/dwg-thumbnailer-win", + "crates/ocs_doc_api", ] [target.'cfg(windows)'.build-dependencies] @@ -30,7 +31,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "67f2427282d28922d9263e37b0136b6632e929b3", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6a5e6fc2822f6af6b2611cd605b44437b35158f0", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -69,6 +70,8 @@ acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } +ocs_doc_api = { path = "crates/ocs_doc_api", features = ["host", "ipc"] } +bincode = "1.3" meshopt = "0.6.2" i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md new file mode 100644 index 000000000..708f1b77b --- /dev/null +++ b/crates/ocs_doc_api/ARCHITECTURE.md @@ -0,0 +1,27 @@ +# 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`. + +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. + +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 new file mode 100644 index 000000000..973bd0459 --- /dev/null +++ b/crates/ocs_doc_api/Cargo.toml @@ -0,0 +1,64 @@ +[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 = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6a5e6fc2822f6af6b2611cd605b44437b35158f0", 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"] + +[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 new file mode 100644 index 000000000..bd1f0289a --- /dev/null +++ b/crates/ocs_doc_api/README.md @@ -0,0 +1,41 @@ +# 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. + +| 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 | + +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 new file mode 100644 index 000000000..13f351c75 --- /dev/null +++ b/crates/ocs_doc_api/bindings/README.md @@ -0,0 +1,28 @@ +# 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. + +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. 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 new file mode 100644 index 000000000..53259fb15 --- /dev/null +++ b/crates/ocs_doc_api/bindings/python/generate.py @@ -0,0 +1,167 @@ +#!/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}}) +''' + +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) + +@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 new file mode 100644 index 000000000..b63ed1fe8 --- /dev/null +++ b/crates/ocs_doc_api/bindings/python/test_generate.py @@ -0,0 +1,75 @@ +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 new file mode 100644 index 000000000..74c522413 --- /dev/null +++ b/crates/ocs_doc_api/build.rs @@ -0,0 +1,429 @@ +//! 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). + +`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed +downcast when `kind == "Solid3D"`). + +## Collections & cross-cutting + +- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. +- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup, + `revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only + batch, one round-trip, **no revision bump**). +- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`, + `transform_many(&ids, placement)`, `delete_many(&ids)`. +- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` / + `compensate`). **Not** a transaction: no atomicity, no rollback. + +## Bulk ops — single op, all-or-nothing, item-capped + +`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo +step, one publish, and **all-or-nothing via upfront validation** (no rollback). +Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for +bulk data (N ≳ a few hundred); per-op autocommit is the right tool for +modeling-scale work. + +## Semantics + +- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host + call = one undo step. Multi-op write transactions were removed (security/ + integrity review). +- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a + failed op applies nothing and records no undo step. `erase_sources` booleans run + only on success. +- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported, + Transport}`; bulk validation names the failing index; a stale handle returns + `UnknownId` (never a panic). + +"# + .to_string() +} + +fn status_suffix(status: Option<&str>) -> &'static str { + match status { + Some("unsupported") => " — **not yet supported** (returns `ApiError::Unsupported`)", + Some("planned") => " — **planned** (no callable surface yet)", + _ => "", + } +} + +fn api_reference_md(spec: &Spec) -> String { + let mut s = String::new(); + s.push_str("# ocs_doc_api — API reference (auto-generated)\n\n"); + s.push_str(&format!( + "Envelope version: `{}`\n\n", + spec.meta.envelope_version + )); + s.push_str("Do NOT edit: regenerated by `build.rs` from `spec/entities.toml`.\n\n"); + + for fam in &spec.family { + let collection = fam.collection.as_deref().unwrap_or("-"); + let handle = fam.handle.as_deref().unwrap_or(&fam.name); + s.push_str(&format!( + "## `{}` (acadrust `{}`, collection `{}`)\n\n", + handle, + fam.acadrust_variant.as_deref().unwrap_or("-"), + collection + )); + if let Some(d) = &fam.description { + s.push_str(&format!("{d}\n\n")); + } + + if !fam.constructor.is_empty() { + s.push_str(&format!("### Constructors (`doc.{collection}()`)\n\n")); + for c in &fam.constructor { + let ret = c.returns.as_deref().unwrap_or(handle); + let bulk = if c.bulk { " *(bulk op)*" } else { "" }; + 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 new file mode 100644 index 000000000..363841d44 --- /dev/null +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -0,0 +1,440 @@ +//! Shared in-memory `DocApiBackend` for the runnable examples. +//! +//! Lets the examples run without the host binary: an in-process mock document +//! (HashMap + a kind table) behind the `InProcess` transport. +//! Not for production use — the real backend lives in the host (`src/app/doc_api.rs`). + +use std::collections::HashMap; + +use ocs_doc_api::backend::{DocApiBackend, KernelBody}; +use ocs_doc_api::{ + Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, + ObjectId, PlacementSpec, +}; + +#[derive(Default, 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, +} + +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> { + 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 id = self.alloc(kind); + self.curves.insert(id, spec.clone()); + Ok(id) + } + fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { + if spec.block_name.is_empty() { + 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 add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { + if self.entity_exists(id) { + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + fn remove_entity(&mut self, id: ObjectId) -> ApiResult { + self.bodies.remove(&id); + Ok(self.kinds.remove(&id).is_some()) + } + fn get_entity(&mut self, id: ObjectId) -> ApiResult { + let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); + let bounds = self.bodies.get(&id).and_then(|b| { + cadkernel::brep::body_bounds(b).map(|bb| Aabb { + min: bb.min, + max: bb.max, + }) + }); + Ok(EntityView { id, kind, bounds }) + } + fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + if let Some(body) = self.bodies.get(&id).cloned() { + let place = cadkernel::brep::Placement { + x_axis: placement.x_axis, + y_axis: placement.y_axis, + z_axis: placement.z_axis, + origin: placement.origin, + }; + let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| { + ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") + })?; + self.bodies.insert(id, moved); + } + Ok(()) + } + fn profile_curves(&self, _id: ObjectId) -> ApiResult> { + Err(ApiError::Unsupported("profiles not mocked".into())) + } + fn bounds(&mut self, id: ObjectId) -> ApiResult { + // Solids via the kernel body; 2D curves from their stored spec. + if let Some(body) = self.bodies.get(&id) { + let bb = cadkernel::brep::body_bounds(body) + .ok_or_else(|| ApiError::geometry(GeometryErrorKind::Empty, "no bounds"))?; + return Ok(Aabb { + min: bb.min, + max: bb.max, + }); + } + if let Some(spec) = self.curves.get(&id) { + return Ok(curve_bounds(spec)); + } + Err(ApiError::UnknownId(id)) + } + fn centroid(&mut self, id: ObjectId) -> ApiResult<[f64; 3]> { + let body = self.body(id)?; + Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).1) + } + fn volume(&mut self, id: ObjectId) -> ApiResult { + let body = self.body(id)?; + Ok(mesh_vc(&cadkernel::brep::mesh_body(body, 0.5, 1e-3)).0) + } + fn entity_exists(&self, id: ObjectId) -> bool { + self.kinds.contains_key(&id) + } + fn revision(&self) -> GeometryRevision { + GeometryRevision(self.revision) + } + fn push_undo(&mut self, _label: &str) {} + fn finalize_op(&mut self) { + self.undo_steps += 1; + self.revision += 1; + } +} + +/// Signed volume + centroid of a closed mesh — 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 new file mode 100644 index 000000000..3d3312f11 --- /dev/null +++ b/crates/ocs_doc_api/examples/e1_primitives_and_queries.rs @@ -0,0 +1,45 @@ +//! Example 1 — Create solid primitives and query their geometry. +//! +//! Run: `cargo run --example e1_primitives_and_queries --features host` +//! +//! Shows: `doc.solids().create_*` for the supported primitives, and the read +//! queries `bounds()` / `volume()` / `centroid()` on a `Solid`. + +mod common; + +use ocs_doc_api::DocApi; + +fn main() -> ocs_doc_api::ApiResult<()> { + // In-process document over the mock backend (no host binary needed). + let api = DocApi::in_process(common::MockBackend::default(), 0); + let doc = api.document(api.active_tab()); + + // The supported solid primitives. + let block = doc + .solids() + .create_cuboid([0.0, 0.0, 0.0], [10.0, 10.0, 10.0])?; + let ball = doc.solids().create_sphere([5.0, 5.0, 5.0], 6.0)?; + let cyl = doc.solids().create_cylinder([0.0, 0.0, 0.0], 4.0, 12.0)?; + + // Geometric queries are entity methods — one host call each. + let bb = block.bounds()?; + let vol = ball.volume()?; + let cg = cyl.centroid()?; + + println!("cuboid bounds : {bb:?}"); + println!("sphere volume : {vol:.2} (analytic ~904.78)"); + println!("cylinder centroid: {cg:?}"); + + // Read-only batching: same queries, ONE round-trip. + let res = doc.query_batch(|q| { + q.bounds(&block); + q.volume(&ball); + q.centroid(&cyl); + })?; + let (bb2, vol2, cg2) = (res.bounds(0)?, res.volume(1)?, res.centroid(2)?); + assert_eq!(bb, bb2); + assert!((vol - vol2).abs() < 1e-9); + assert_eq!(cg, cg2); + println!("query_batch matches the per-call results"); + Ok(()) +} diff --git a/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs b/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs new file mode 100644 index 000000000..dd117c6fd --- /dev/null +++ b/crates/ocs_doc_api/examples/e2_intersect_two_solids.rs @@ -0,0 +1,53 @@ +//! Example 2 — Construct two solids and intersect them (the canonical boolean). +//! +//! Run: `cargo run --example e2_intersect_two_solids --features host` +//! +//! Shows: `intersect` / `union` / `subtract` on `Solid`, `erase_sources` +//! semantics (the result lives at the first input's id, the second is erased), +//! and `OpGroup` cleanup on failure. + +mod common; + +use ocs_doc_api::{DocApi, HasId, OpGroup}; + +fn main() -> ocs_doc_api::ApiResult<()> { + let api = DocApi::in_process(common::MockBackend::default(), 0); + let doc = api.document(api.active_tab()); + let mut grp = OpGroup::new(); + + // Two overlapping boxes: [0,10]^3 and [5,15]^3. + let a = grp.track(doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3]))?; + let b = grp.track(doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3]))?; + + // intersect -> SolidBoolean{Intersection, erase_sources:true}. The result is + // returned AT `a`'s id; `b` is erased. [5,10]^3 = volume 125. + let lens = match a.intersect(&b) { + Ok(l) => { + grp.commit(); // keep everything + l + } + Err(e) => { + grp.compensate(&doc)?; // best-effort: delete the two boxes + return Err(e); + } + }; + + assert_eq!(lens.id(), a.id(), "result lives at the first input's id"); + let vol = lens.volume()?; + let bb = lens.bounds()?; + println!("intersection volume = {vol:.2} (expected 125)"); + println!("intersection bounds = {bb:?}"); + + // union / subtract follow the same shape. Fresh inputs (b was erased above). + let c = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; + let d = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; + let uni = c.union(&d)?; + // union volume = 1000 + 1000 - 125 (overlap) = 1875. + println!("union volume = {:.2} (expected 1875)", uni.volume()?); + + let e = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; + let f = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; + let diff = e.subtract(&f)?; + println!("difference volume = {:.2} (expected 875)", diff.volume()?); + Ok(()) +} diff --git a/crates/ocs_doc_api/examples/e3_transform_and_chain.rs b/crates/ocs_doc_api/examples/e3_transform_and_chain.rs new file mode 100644 index 000000000..1229f2997 --- /dev/null +++ b/crates/ocs_doc_api/examples/e3_transform_and_chain.rs @@ -0,0 +1,38 @@ +//! Example 3 — Transform a solid and chain geometric operations. +//! +//! Run: `cargo run --example e3_transform_and_chain --features host` +//! +//! Shows: `transform(PlacementSpec)` (in-place, same ObjectId), how bounds shift +//! exactly by the translation, and chaining intersect -> transform. + +mod common; + +use ocs_doc_api::{DocApi, PlacementSpec}; + +fn main() -> ocs_doc_api::ApiResult<()> { + let api = DocApi::in_process(common::MockBackend::default(), 0); + let doc = api.document(api.active_tab()); + + let a = doc.solids().create_cuboid([0.0, 0.0, 0.0], [10.0; 3])?; + let b = doc.solids().create_cuboid([5.0, 5.0, 5.0], [10.0; 3])?; + + // Boolean first: lens = [5,10]^3. + let lens = a.intersect(&b)?; + let before = lens.bounds()?; + println!("lens bounds before transform: {before:?}"); + + // Transform is in-place: the SAME ObjectId is preserved; bounds shift by the + // translation. [5,10]^3 moved +100 in X -> [105,110] x [5,10] x [5,10]. + lens.transform(PlacementSpec::at([100.0, 0.0, 0.0]))?; + let after = lens.bounds()?; + println!("lens bounds after +100 X : {after:?}"); + assert!((after.min[0] - 105.0).abs() < 1e-4); + assert!((after.max[0] - 110.0).abs() < 1e-4); + assert_eq!(after.min[1], before.min[1], "Y unchanged"); + + // Volume is invariant under a rigid transform. + let vol = lens.volume()?; + println!("lens volume after transform = {vol:.2} (still ~125)"); + assert!((vol - 125.0).abs() < 1.0); + Ok(()) +} diff --git a/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs b/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs new file mode 100644 index 000000000..eb1ff0b5b --- /dev/null +++ b/crates/ocs_doc_api/examples/e4_2d_entities_and_bulk.rs @@ -0,0 +1,49 @@ +//! Example 4 — 2D entities and bulk creation. +//! +//! Run: `cargo run --example e4_2d_entities_and_bulk --features host` +//! +//! Shows: the minimal-2D constructors (`create_line`/`create_circle`/ +//! `create_polyline`/`create_point`), `bounds()` on 2D entities, and the bulk op +//! `create_points` (one op, all-or-nothing, one undo step). + +mod common; + +use ocs_doc_api::{DocApi, HasId}; + +fn main() -> ocs_doc_api::ApiResult<()> { + let api = DocApi::in_process(common::MockBackend::default(), 0); + let doc = api.document(api.active_tab()); + + // Individual 2D constructors. + let line = doc + .curves() + .create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?; + let circle = doc.curves().create_circle([5.0, 5.0, 0.0], 3.0)?; + let poly = doc.curves().create_polyline( + &[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [0.0, 1.0, 0.0], + ], + true, + )?; + + println!("line bounds: {:?}", line.bounds()?); // [0,10] in X + println!("circle bounds: {:?}", circle.bounds()?); // centre ± radius + println!("poly bounds: {:?}", poly.bounds()?); // unit square + + // Bulk: 1000 points in ONE op (all-or-nothing, one undo step — not 1000). + let coords: Vec<[f64; 3]> = (0..1000) + .map(|i| [i as f64, (i % 10) as f64, 0.0]) + .collect(); + let pts = doc.curves().create_points(&coords)?; + println!("created {} points in one bulk op", pts.len()); + assert_eq!(pts.len(), 1000); + + // Generic lookup: any id -> Entity -> kind. + let e = doc.entities().get(line.id())?; + println!("line entity kind: {}", e.view()?.kind); + + Ok(()) +} diff --git a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs new file mode 100644 index 000000000..f86db5132 --- /dev/null +++ b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs @@ -0,0 +1,63 @@ +//! 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!(matches!( + doc.entities().get(ObjectId::from_u64(0xDEAD)), + Err(_) + )); + println!("failure semantics verified"); + Ok(()) +} diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml new file mode 100644 index 000000000..b885693dd --- /dev/null +++ b/crates/ocs_doc_api/spec/entities.toml @@ -0,0 +1,501 @@ +# 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" + +# ============================================================================ +# Cross-cutting ops (generic handles + bulk) — documented in build.rs +# ============================================================================ +# - Generic handle methods on EVERY handle: bounds() -> Aabb, transform(placement), +# delete(), id() -> ObjectId. (transform is solids-only in v1.) +# - EntityCollection: get(id) -> Entity, delete(id), transform_many(&ids, pl), +# delete_many(&ids). +# - Bulk ops (CreateMany / TransformMany / DeleteMany): single op, all-or-nothing +# via upfront validation, item-capped (BULK_ITEM_CAP). +# - Read-only batching: query_batch(|q| ...) — one round-trip, no revision bump. +# - OpGroup: best-effort client-side failure cleanup (NOT a transaction). + +# ---- Kernel/op mapping (reference for the executor + docs) ---- +[[kernel_map]] +op = "CreateSolid.Cuboid" +kernel = "cadkernel::brep::make::cuboid" +returns = "Option" + +[[kernel_map]] +op = "CreateSolid.Sphere" +kernel = "cadkernel::brep::make::sphere" +returns = "Option" + +[[kernel_map]] +op = "SolidBoolean" +kernel = "cadkernel::brep::combine" +returns = "Result" + +[[kernel_map]] +op = "Transform" +kernel = "cadkernel::brep::transform" +returns = "Option" + +[[kernel_map]] +op = "GetBounds" +kernel = "cadkernel::brep::body_bounds" +returns = "Option" + +[[kernel_map]] +op = "GetVolume" +kernel = "cadkernel::brep::mesh_body + divergence" +returns = "computed" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs new file mode 100644 index 000000000..5a857db8a --- /dev/null +++ b/crates/ocs_doc_api/src/backend.rs @@ -0,0 +1,230 @@ +//! 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; +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; + + /// 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)) + } + } + + /// 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 new file mode 100644 index 000000000..4f91cb4c4 --- /dev/null +++ b/crates/ocs_doc_api/src/convert.rs @@ -0,0 +1,268 @@ +//! 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 new file mode 100644 index 000000000..0f847740f --- /dev/null +++ b/crates/ocs_doc_api/src/envelope.rs @@ -0,0 +1,93 @@ +//! 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 new file mode 100644 index 000000000..783ec1434 --- /dev/null +++ b/crates/ocs_doc_api/src/error.rs @@ -0,0 +1,68 @@ +//! 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 new file mode 100644 index 000000000..b65443279 --- /dev/null +++ b/crates/ocs_doc_api/src/executor.rs @@ -0,0 +1,490 @@ +//! 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)?; + if let Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) = &op { + 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", + )); + } + } + 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) + } + }; + 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 } + } + }; + 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 a `Curve2Spec`'s fallible conditions BEFORE mutation (the same rules +/// the host's `curve_spec_to_entity` enforces), so `CreateMany` stays all-or-nothing. +/// Currently: ellipse minor/major ratio must be in (0, 1]. + +fn require_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { + if b.entity_exists(id) { + Ok(()) + } else { + Err(ApiError::validation(op, format!("unknown ObjectId {id:?}"))) + } +} + +fn over_cap(op: &'static str, n: usize) -> ApiError { + ApiError::validation(op, format!("bulk op over cap: {n} > {BULK_ITEM_CAP}")) +} + +fn stale_index(op: &'static str, i: usize, id: ObjectId) -> ApiError { + ApiError::validation(op, format!("stale ObjectId at index {i}: {id:?}")) +} + +fn at_index(op: &'static str, i: usize, e: ApiError) -> ApiError { + match e { + ApiError::Validation { reason, .. } => { + ApiError::validation(op, format!("index {i}: {reason}")) + } + other => other, + } +} + +/// Boolean op kind → kernel `Operation` (host feature only). +#[cfg(feature = "host")] +pub(crate) fn kernel_bool_op(op: BoolOp) -> cadkernel::brep::Operation { + match op { + BoolOp::Union => cadkernel::brep::Operation::Union, + BoolOp::Intersection => cadkernel::brep::Operation::Intersection, + BoolOp::Difference => cadkernel::brep::Operation::Difference, + } +} diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs new file mode 100644 index 000000000..97cc4d0d6 --- /dev/null +++ b/crates/ocs_doc_api/src/facade.rs @@ -0,0 +1,1066 @@ +//! 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}; +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(), + )), + } + } + + /// 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 + } + } + impl $name { + pub fn bounds(&self) -> ApiResult { + match self.session.one_query(Query::GetBounds { id: self.id })? { + QueryResult::Bounds(b) => Ok(b), + _ => Err(ApiError::Transport("unexpected bounds result".into())), + } + } + pub fn delete(&self) -> ApiResult<()> { + self.session.apply_op(Operation::Delete { id: self.id })?; + Ok(()) + } + pub fn transform(&self, placement: PlacementSpec) -> ApiResult<()> { + self.session.apply_op(Operation::Transform { + id: self.id, + placement, + })?; + Ok(()) + } + } + }; +} + +handle!(Entity); +handle!(Solid); +handle!(Line); +handle!(Circle); +handle!(Polyline); +handle!(Point); +handle!(ArcCurve); +handle!(Ellipse); +handle!(Spline); +handle!(Ray); +handle!(XLine); +handle!(Text); +handle!(MText); +handle!(Dimension); + +impl Dimension { + /// The measured value of this dimension (distance for linear/radius, degrees + /// for angular). + pub fn measurement(&self) -> ApiResult { + match self + .session + .one_query(Query::GetDimensionMeasurement { id: self.id })? + { + QueryResult::DimensionMeasurement(v) => Ok(v), + _ => Err(ApiError::Transport("unexpected measurement result".into())), + } + } +} + +impl Entity { + pub fn view(&self) -> ApiResult { + 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(()) + } + 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 })?; + Ok(Line::new(s, id)) + } + pub fn create_circle(&self, centre: [f64; 3], radius: f64) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Circle { centre, radius })?; + Ok(Circle::new(s, id)) + } + pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Polyline { + points: points.to_vec(), + closed, + })?; + Ok(Polyline::new(s, id)) + } + pub fn create_point(&self, position: [f64; 3]) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Point { position })?; + Ok(Point::new(s, id)) + } + pub fn create_arc( + &self, + centre: [f64; 3], + radius: f64, + start_angle: f64, + end_angle: f64, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Arc { + centre, + radius, + start_angle, + end_angle, + })?; + Ok(ArcCurve::new(s, id)) + } + pub fn create_ellipse( + &self, + centre: [f64; 3], + major_axis: [f64; 3], + ratio: f64, + start: f64, + end: f64, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Ellipse { + centre, + major_axis, + ratio, + start, + end, + })?; + Ok(Ellipse::new(s, id)) + } + pub fn create_spline( + &self, + degree: i32, + control_points: &[[f64; 3]], + knots: &[f64], + weights: &[f64], + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Spline { + degree, + control_points: control_points.to_vec(), + knots: knots.to_vec(), + weights: weights.to_vec(), + })?; + Ok(Spline::new(s, id)) + } + pub fn create_ray(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Ray { origin, direction })?; + Ok(Ray::new(s, id)) + } + pub fn create_xline(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?; + Ok(XLine::new(s, id)) + } + /// 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. + pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { + let specs: Vec = positions + .iter() + .map(|&p| crate::ops::EntitySpec::Curve(Curve2Spec::Point { position: p })) + .collect(); + let receipt = self.session.apply_op(Operation::CreateMany(specs))?; + let outcome = receipt + .outcome + .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; + Ok(outcome + .new_ids() + .iter() + .map(|&id| Point::new(self.session.clone(), id)) + .collect()) + } +} + +#[derive(Clone)] +pub struct EntityCollection { + session: Session, +} + +impl EntityCollection { + /// 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(()) + } + /// 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 new file mode 100644 index 000000000..20cb2a0d6 --- /dev/null +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -0,0 +1,323 @@ +# 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` + +## 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). + +`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed +downcast when `kind == "Solid3D"`). + +## Collections & cross-cutting + +- **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. +- **`Document`** — `solids()` / `curves()` / `entities()` factories + lookup, + `revision()`, `assert_revision(rev)` (read-guard), `query_batch(|q| …)` (read-only + batch, one round-trip, **no revision bump**). +- **`EntityCollection`** — `get(id) -> Entity`, `delete(id)`, + `transform_many(&ids, placement)`, `delete_many(&ids)`. +- **`OpGroup`** — best-effort client-side failure cleanup (`track` / `commit` / + `compensate`). **Not** a transaction: no atomicity, no rollback. + +## Bulk ops — single op, all-or-nothing, item-capped + +`CreateMany` / `TransformMany` / `DeleteMany` are **single** write ops: one undo +step, one publish, and **all-or-nothing via upfront validation** (no rollback). +Item-capped at `BULK_ITEM_CAP = 100_000` to bound host execution. Use them for +bulk data (N ≳ a few hundred); per-op autocommit is the right tool for +modeling-scale work. + +## Semantics + +- **Per-op autocommit, no write transactions.** Every write op is ONE atomic host + call = one undo step. Multi-op write transactions were removed (security/ + integrity review). +- **Per-op atomicity.** Validate + compute geometry **before** any mutation; a + failed op applies nothing and records no undo step. `erase_sources` booleans run + only on success. +- **Structured errors.** `ApiError::{Validation, Geometry, UnknownId, Unsupported, + Transport}`; bulk validation names the failing index; a stale handle returns + `UnknownId` (never a panic). + +## Kernel mapping + +| op | kernel call | returns | +|---|---|---| +| CreateSolid.Cuboid | `cadkernel::brep::make::cuboid` | Option | +| CreateSolid.Sphere | `cadkernel::brep::make::sphere` | Option | +| SolidBoolean | `cadkernel::brep::combine` | Result | +| Transform | `cadkernel::brep::transform` | Option | +| GetBounds | `cadkernel::brep::body_bounds` | Option | +| GetVolume | `cadkernel::brep::mesh_body + divergence` | computed | + +## 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)` + +### `Query` + +- `GetEntity { id: ObjectId }` +- `GetBounds { id: ObjectId }` +- `GetCentroid { id: ObjectId }` +- `GetVolume { id: ObjectId }` +- `GetIntersects { a: ObjectId, b: ObjectId }` +- `GetGeometryRevision` +- `GetTextContent { id: ObjectId }` +- `GetHatchBoundary { id: ObjectId }` +- `GetDimensionMeasurement { id: ObjectId }` +- `GetAttributes { id: ObjectId }` +- `GetBlockEntities { block_name: String }` +- `GetViewportView { id: ObjectId }` + +## Errors (`ApiError`) + +| Variant | Meaning | +|---|---| +| `Validation { op, reason }` | Input rejected before any 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 new file mode 100644 index 000000000..90f422758 --- /dev/null +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -0,0 +1,1213 @@ +{ + "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" + } + ], + "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)" + ] + }, + "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 }" + ] + } + }, + "object_model": { + "collections": [ + "solids", + "curves", + "entities" + ], + "document": "Document", + "handles": [ + "Entity", + "Solid", + "Line", + "Circle", + "Polyline", + "Point", + "ArcCurve", + "Ellipse", + "Spline", + "Ray", + "XLine", + "Text", + "MText", + "Dimension" + ], + "root": "DocApi" + }, + "schema": "ocs_doc_api/binding_schema" +} \ No newline at end of file diff --git a/crates/ocs_doc_api/src/gen/layouts.json b/crates/ocs_doc_api/src/gen/layouts.json new file mode 100644 index 000000000..c743ba25d --- /dev/null +++ b/crates/ocs_doc_api/src/gen/layouts.json @@ -0,0 +1,51 @@ +{ + "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)" + ] + }, + "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 }" + ] + } +} diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs new file mode 100644 index 000000000..22ca691a3 --- /dev/null +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -0,0 +1,102 @@ +// 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, +}; + +/// 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), +} diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs new file mode 100644 index 000000000..efefde74a --- /dev/null +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -0,0 +1,58 @@ +// 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, + }, +} diff --git a/crates/ocs_doc_api/src/geom.rs b/crates/ocs_doc_api/src/geom.rs new file mode 100644 index 000000000..e77086d51 --- /dev/null +++ b/crates/ocs_doc_api/src/geom.rs @@ -0,0 +1,100 @@ +//! 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 new file mode 100644 index 000000000..87f4de167 --- /dev/null +++ b/crates/ocs_doc_api/src/id.rs @@ -0,0 +1,36 @@ +//! 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/lib.rs b/crates/ocs_doc_api/src/lib.rs new file mode 100644 index 000000000..4949b82d6 --- /dev/null +++ b/crates/ocs_doc_api/src/lib.rs @@ -0,0 +1,66 @@ +//! 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 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, +}; +pub use ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive}; +pub use query::{Aabb, EntityView, Query, QueryResult}; + +#[cfg(feature = "host")] +pub use convert::{ + bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name, + entity_to_profile_curves, transform_entity_geometry, +}; +pub use transport::Transport; + +/// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`). +/// 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 new file mode 100644 index 000000000..c41d7cb1c --- /dev/null +++ b/crates/ocs_doc_api/src/ops.rs @@ -0,0 +1,296 @@ +//! 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; + +/// Boolean operation kind for [`Operation::SolidBoolean`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BoolOp { + Union, + Intersection, + Difference, +} + +/// Construction spec for a B-rep solid primitive (plain-data mirror of the +/// non-serde `cadkernel::brep::make::*` arguments). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum SolidPrimitive { + Cuboid { + origin: [f64; 3], + size: [f64; 3], + }, + Sphere { + centre: [f64; 3], + radius: f64, + }, + Cylinder { + base: [f64; 3], + radius: f64, + height: f64, + }, + Cone { + base: [f64; 3], + radius: f64, + height: f64, + }, + Torus { + centre: [f64; 3], + major_radius: f64, + minor_radius: f64, + }, + Wedge { + origin: [f64; 3], + size: [f64; 3], + }, +} + +/// Construction spec for a minimal 2D curve entity (phase-1 set; profiles/inputs). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Curve2Spec { + Line { + start: [f64; 3], + end: [f64; 3], + }, + Circle { + centre: [f64; 3], + radius: f64, + }, + Polyline { + points: Vec<[f64; 3]>, + closed: bool, + }, + Point { + position: [f64; 3], + }, + /// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians). + Arc { + centre: [f64; 3], + radius: f64, + start_angle: f64, + end_angle: f64, + }, + /// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`; + /// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π). + Ellipse { + centre: [f64; 3], + major_axis: [f64; 3], + ratio: f64, + start: f64, + end: f64, + }, + /// NURBS spline (control points + knots + weights; `degree` typically 3). + Spline { + degree: i32, + control_points: Vec<[f64; 3]>, + knots: Vec, + weights: Vec, + }, + /// A ray from `origin` along `direction` (bounded at origin only). + Ray { + origin: [f64; 3], + direction: [f64; 3], + }, + /// An infinite construction line through `origin` along `direction`. + XLine { + origin: [f64; 3], + direction: [f64; 3], + }, +} + +/// A generic entity-construction payload used by the bulk op +/// [`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), +} + +/// 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", + } + } +} diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs new file mode 100644 index 000000000..4ce1b1ba5 --- /dev/null +++ b/crates/ocs_doc_api/src/query.rs @@ -0,0 +1,92 @@ +//! 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 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, + }, +} + +/// 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", + } + } +} diff --git a/crates/ocs_doc_api/src/revision.rs b/crates/ocs_doc_api/src/revision.rs new file mode 100644 index 000000000..f0759afe9 --- /dev/null +++ b/crates/ocs_doc_api/src/revision.rs @@ -0,0 +1,16 @@ +//! Geometry revision counter (mirrors the host scene's `geometry_epoch`). + +use serde::{Deserialize, Serialize}; + +/// Monotonic revision of a document's geometry. Bumped exactly once per applied +/// write op (`push_undo` + `finalize_op`). Pure queries return the current +/// revision without bumping it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GeometryRevision(pub u64); + +impl GeometryRevision { + pub const fn as_u64(self) -> u64 { + self.0 + } +} diff --git a/crates/ocs_doc_api/src/transport/inproc.rs b/crates/ocs_doc_api/src/transport/inproc.rs new file mode 100644 index 000000000..8d9353024 --- /dev/null +++ b/crates/ocs_doc_api/src/transport/inproc.rs @@ -0,0 +1,42 @@ +//! 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 new file mode 100644 index 000000000..74bd4f06f --- /dev/null +++ b/crates/ocs_doc_api/src/transport/ipc.rs @@ -0,0 +1,58 @@ +//! 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::{PluginRequestError, PluginRequestSender}; +use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; + +use crate::envelope::{DocApiEnvelope, Receipt}; +use crate::error::{ApiError, ApiResult}; +use crate::transport::Transport; + +/// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`; +/// the underlying `PluginRequestSender` already serializes + correlates (V4). +pub struct OcsPluginApiIpc { + sender: Arc, + tab_id: u64, +} + +impl OcsPluginApiIpc { + pub fn new(sender: Arc, tab_id: u64) -> Self { + Self { sender, tab_id } + } + + fn transport_err(e: PluginRequestError) -> ApiError { + ApiError::Transport(e.0) + } +} + +impl Transport for OcsPluginApiIpc { + fn apply(&self, req: DocApiEnvelope) -> ApiResult { + let bytes = bincode::serialize(&req) + .map_err(|e| ApiError::Transport(format!("envelope serialize: {e}")))?; + let resp = self + .sender + .request(PluginRequest::DocApiRequest { + tab_id: self.tab_id, + bytes, + }) + .map_err(Self::transport_err)?; + match resp { + PluginResponse::DocApiResponse { bytes } => { + // The host serializes `ApiResult` so op/query errors + // (validation, geometry, unknown id) surface as the SAME + // structured `ApiError` the in-process executor produced. + let result: ApiResult = bincode::deserialize(&bytes) + .map_err(|e| ApiError::Transport(format!("receipt deserialize: {e}")))?; + result + } + PluginResponse::Error(msg) => Err(ApiError::Transport(msg)), + other => Err(ApiError::Transport(format!( + "unexpected DocApi response variant: {other:?}" + ))), + } + } +} diff --git a/crates/ocs_doc_api/src/transport/mod.rs b/crates/ocs_doc_api/src/transport/mod.rs new file mode 100644 index 000000000..3a17f92e4 --- /dev/null +++ b/crates/ocs_doc_api/src/transport/mod.rs @@ -0,0 +1,36 @@ +//! 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 new file mode 100644 index 000000000..8a96a3710 --- /dev/null +++ b/crates/ocs_doc_api/src/validation.rs @@ -0,0 +1,182 @@ +//! Reject invalid numeric inputs before the backend starts an undo operation. +use crate::{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", + )) + } +} + +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), + 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 new file mode 100644 index 000000000..7983d7ad8 --- /dev/null +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -0,0 +1,611 @@ +//! 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::{ + Aabb, ApiError, ApiResult, Curve2Spec, DocApi, EntityView, GeometryErrorKind, GeometryRevision, + HasId, ObjectId, PlacementSpec, +}; + +/// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds and +/// the example mock — all three must answer curve bounds identically). +fn curve_bounds(spec: &Curve2Spec) -> Aabb { + match spec { + Curve2Spec::Line { start, end } => Aabb { + min: [ + start[0].min(end[0]), + start[1].min(end[1]), + start[2].min(end[2]), + ], + max: [ + start[0].max(end[0]), + start[1].max(end[1]), + start[2].max(end[2]), + ], + }, + Curve2Spec::Circle { centre, radius } | Curve2Spec::Arc { centre, radius, .. } => Aabb { + min: [centre[0] - radius, centre[1] - radius, centre[2]], + max: [centre[0] + radius, centre[1] + radius, centre[2]], + }, + Curve2Spec::Point { position } => Aabb { + min: *position, + max: *position, + }, + Curve2Spec::Polyline { points, .. } + | Curve2Spec::Spline { + control_points: points, + .. + } => { + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for p in points { + for i in 0..3 { + min[i] = min[i].min(p[i]); + max[i] = max[i].max(p[i]); + } + } + Aabb { min, max } + } + Curve2Spec::Ellipse { + centre, major_axis, .. + } => { + let r = (major_axis[0] * major_axis[0] + + major_axis[1] * major_axis[1] + + major_axis[2] * major_axis[2]) + .sqrt(); + Aabb { + min: [centre[0] - r, centre[1] - r, centre[2] - r], + max: [centre[0] + r, centre[1] + r, centre[2] + r], + } + } + Curve2Spec::Ray { origin, .. } | Curve2Spec::XLine { origin, .. } => Aabb { + min: *origin, + max: *origin, + }, + } +} + +/// In-memory mock backend: HashMap + a tiny entity table. +/// Tracks undo steps + revision bumps to assert per-op semantics. +#[derive(Default, Clone)] +struct MockBackend { + next_id: u64, + revision: u64, + undo_steps: u32, + bodies: HashMap, + kinds: 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, +} + +impl MockBackend { + fn alloc(&mut self, kind: &str) -> ObjectId { + self.next_id += 1; + let id = ObjectId::from_u64(self.next_id); + self.kinds.insert(id, kind.to_string()); + id + } + fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { + self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) + } +} + +impl DocApiBackend for MockBackend { + fn resolve_body(&mut self, id: ObjectId) -> ApiResult { + self.bodies.get(&id).cloned().ok_or(ApiError::UnknownId(id)) + } + fn store_solid(&mut self, body: &KernelBody) -> ApiResult { + let id = self.alloc("Solid3D"); + self.bodies.insert(id, body.clone()); + Ok(id) + } + fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { + if self.bodies.insert(id, body.clone()).is_none() { + return Err(ApiError::UnknownId(id)); + } + Ok(()) + } + fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { + 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 id = self.alloc(kind); + self.curves.insert(id, spec.clone()); + Ok(id) + } + fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { + if spec.block_name.is_empty() { + 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); // mock: 90 degrees + 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", + )); + } + 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")); + } + // 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 add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { + if self.entity_exists(id) { + Ok(()) + } else { + Err(ApiError::UnknownId(id)) + } + } + fn remove_entity(&mut self, id: ObjectId) -> ApiResult { + self.bodies.remove(&id); + Ok(self.kinds.remove(&id).is_some()) + } + fn get_entity(&mut self, id: ObjectId) -> ApiResult { + let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); + let bounds = self.bodies.get(&id).and_then(|b| { + cadkernel::brep::body_bounds(b).map(|bb| Aabb { + min: bb.min, + max: bb.max, + }) + }); + Ok(EntityView { id, kind, bounds }) + } + fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + if let Some(body) = self.bodies.get(&id).cloned() { + let place = cadkernel::brep::Placement { + x_axis: placement.x_axis, + y_axis: placement.y_axis, + z_axis: placement.z_axis, + origin: placement.origin, + }; + let moved = cadkernel::brep::transform(&body, &place).ok_or_else(|| { + ApiError::geometry(GeometryErrorKind::InvalidInput, "transform failed") + })?; + self.bodies.insert(id, moved); + } + Ok(()) + } + fn profile_curves(&self, _id: ObjectId) -> ApiResult> { + Err(ApiError::Unsupported("profiles not mocked".into())) + } + fn bounds(&mut self, id: ObjectId) -> ApiResult { + // Solids via the kernel body; 2D curves from their stored spec (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 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()); +} diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs new file mode 100644 index 000000000..f65c3a63f --- /dev/null +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -0,0 +1,358 @@ +//! Spec↔wire consistency tests (enforces the codegen contract the headers +//! promise): every `op`/`query` name in `spec/entities.toml` maps to an existing +//! `Operation`/`Query` variant, and the wire enums are append-only relative to a +//! recorded baseline (bincode discriminant stability). + +use std::collections::BTreeSet; + +use serde::Deserialize; + +#[derive(Deserialize)] +struct Spec { + #[serde(default)] + family: Vec, +} +#[derive(Deserialize)] +struct Family { + #[serde(default)] + handle: Option, + #[serde(default)] + constructor: Vec, + #[serde(default)] + method: Vec, +} +#[derive(Deserialize)] +struct MethodRef { + #[serde(default)] + op: Option, + #[serde(default)] + query: Option, +} + +fn spec() -> Spec { + let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/spec/entities.toml")) + .expect("read spec/entities.toml"); + toml::from_str(&src).expect("parse spec/entities.toml") +} + +/// The variant names of the `Operation` enum, in declaration order. +fn operation_variants() -> Vec { + use ocs_doc_api::ops::{ + BoolOp, Curve2Spec, InsertSpec, Operation, PlacementSpec, SolidPrimitive, ViewportSpec, + }; + use ocs_doc_api::ObjectId; + let _marker: Vec = vec![ + Operation::CreateSolid(SolidPrimitive::Sphere { + centre: [0.0; 3], + radius: 1.0, + }), + Operation::CreateCurve(Curve2Spec::Point { position: [0.0; 3] }), + Operation::Extrude { + profile: ObjectId::from_u64(0), + direction: [0.0; 3], + }, + Operation::Revolve { + profile: ObjectId::from_u64(0), + axis: ([0.0; 3], [0.0; 3]), + angle: 0.0, + }, + Operation::Transform { + id: ObjectId::from_u64(0), + placement: PlacementSpec::IDENTITY, + }, + Operation::SolidBoolean { + op: BoolOp::Union, + a: ObjectId::from_u64(0), + b: ObjectId::from_u64(0), + erase_sources: true, + }, + Operation::AddVertex { + id: ObjectId::from_u64(0), + at: 0, + point: [0.0; 3], + }, + Operation::Delete { + id: ObjectId::from_u64(0), + }, + Operation::CreateMany(vec![]), + Operation::TransformMany { + ids: vec![], + placement: PlacementSpec::IDENTITY, + }, + Operation::DeleteMany(vec![]), + Operation::CreateInsert(InsertSpec { + block_name: String::new(), + insert_point: [0.0; 3], + scale: 1.0, + rotation: 0.0, + }), + Operation::CreateViewport(ViewportSpec { + center: [0.0; 3], + width: 0.0, + height: 0.0, + view_target: [0.0; 3], + view_height: 0.0, + }), + 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], + }), + ]; + _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(|c: char| c == '(' || c == ' ' || c == '{') + .next() + .unwrap_or("") + .to_string() + }) + .collect() +} + +/// The variant names of the `Query` enum, in declaration order. +fn query_variants() -> Vec { + use ocs_doc_api::query::Query; + use ocs_doc_api::ObjectId; + let marker: Vec = vec![ + Query::GetEntity { + id: ObjectId::from_u64(0), + }, + Query::GetBounds { + id: ObjectId::from_u64(0), + }, + Query::GetCentroid { + id: ObjectId::from_u64(0), + }, + Query::GetVolume { + id: ObjectId::from_u64(0), + }, + Query::GetIntersects { + a: ObjectId::from_u64(0), + b: ObjectId::from_u64(0), + }, + Query::GetGeometryRevision, + 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), + }, + ]; + 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(|c: char| c == '(' || c == ' ' || c == '{') + .next() + .unwrap_or("") + .to_string() + }) + .collect() +} + +#[test] +fn every_spec_op_and_query_maps_to_an_enum_variant() { + let spec = spec(); + let op_names: BTreeSet = operation_variants().into_iter().collect(); + let query_names: BTreeSet = query_variants().into_iter().collect(); + + let mut missing = Vec::new(); + for fam in &spec.family { + for m in fam.constructor.iter().chain(fam.method.iter()) { + if let Some(op) = &m.op { + if !op_names.contains(op) { + missing.push(format!( + "Operation::{op} (family {})", + fam.handle.as_deref().unwrap_or("?") + )); + } + } + if let Some(q) = &m.query { + if !query_names.contains(q) { + missing.push(format!( + "Query::{q} (family {})", + fam.handle.as_deref().unwrap_or("?") + )); + } + } + } + } + assert!( + missing.is_empty(), + "spec references ops/queries with no enum variant: {missing:?}" + ); +} + +/// Recorded baseline of the `Operation` variant order (append-only contract). +/// New variants must be APPENDED after these; none may be removed or reordered. +const OPERATION_BASELINE: &[&str] = &[ + "CreateSolid", + "CreateCurve", + "Extrude", + "Revolve", + "Transform", + "SolidBoolean", + "AddVertex", + "Delete", + "CreateMany", + "TransformMany", + "DeleteMany", + "CreateInsert", + "CreateViewport", + "CreateText", + "CreateMText", + "SetTextContent", + "CreateHatch", + "CreateDimensionLinear", + "SetAttribute", + "SetViewportView", + "CreateRasterImage", + "Loft", + "CreateDimensionRadius", + "CreateDimensionDiameter", + "CreateDimensionAngular", + "CreateAttributeDefinition", + "CreateTable", + "CreateDimensionAngular2Ln", +]; + +/// Recorded baseline of the `Query` variant order. +const QUERY_BASELINE: &[&str] = &[ + "GetEntity", + "GetBounds", + "GetCentroid", + "GetVolume", + "GetIntersects", + "GetGeometryRevision", + "GetTextContent", + "GetHatchBoundary", + "GetDimensionMeasurement", + "GetAttributes", + "GetBlockEntities", + "GetViewportView", +]; + +#[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/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 49e520059..ac88fe336 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -19,6 +19,7 @@ acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "c91a1c # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } bincode = { version = "1", optional = true } +serde_bytes = { version = "0.11", optional = true } thiserror = { version = "1", optional = true } libloading = { version = "0.8", optional = true } memmap2 = { version = "0.9", optional = true } @@ -29,7 +30,7 @@ getrandom = { version = "0.2", optional = true } # Enables the runtime host surface (`HostApi` trait) and the out-of-process # plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers # leave it off. -host = ["dep:acadrust", "dep:interprocess", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"] +host = ["dep:acadrust", "dep:interprocess", "dep:bincode", "dep:serde_bytes", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"] [build-dependencies] serde-reflection = "0.6" diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 87b9bafb9..fd1dc38b9 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -509,7 +509,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": 5, + "api_version": 6, "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 4046b22b9..b43bacd8a 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -552,6 +552,18 @@ pub trait HostApi { let _ = tab_id; None } + + /// DocApi v2 (`ocs_doc_api`): dispatch one bincode-serialized `DocApiEnvelope` + /// (one write op OR a read-only query batch) and return the bincode-serialized + /// `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 c20cad1b2..585f2cc85 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -138,6 +138,12 @@ pub enum PluginRequest { GetTabId, /// V5: ask the host for the filesystem path of the document in `tab_id`. DocumentPath { tab_id: u64 }, + /// DocApi v2 (`ocs_doc_api`): carry one bincode-serialized `DocApiEnvelope` + /// (one write op OR a read-only query batch). Appended at the end to keep + /// existing discriminants stable. The host routes this to the DocApi executor. + /// `serde_bytes` keeps the payload a single length-prefixed bulk copy (the + /// outer frame would otherwise walk it byte-by-byte). + DocApiRequest { tab_id: u64, #[serde(with = "serde_bytes")] bytes: Vec }, } /// Responses the host sends back for `PluginRequest`. @@ -164,6 +170,10 @@ pub enum PluginResponse { TabId(u64), /// V5: filesystem path of the document in the requested tab, if any. DocumentPath(Option), + /// DocApi v2 (`ocs_doc_api`): the bincode-serialized `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 055880ef0..8394d456e 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -77,5 +77,9 @@ pub fn handle_plugin_request( DocumentPath { tab_id } => PluginResponse::DocumentPath( host.document_path(tab_id).map(|path| path.into_os_string()), ), + DocApiRequest { tab_id, bytes } => match host.doc_api_dispatch(tab_id, &bytes) { + Ok(bytes) => PluginResponse::DocApiResponse { bytes }, + Err(msg) => PluginResponse::Error(msg), + }, } } diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index c0113e126..b72648a72 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -19,7 +19,14 @@ /// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on /// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5 /// adds the `BuiltinPlugin::on_load` lifecycle callback. -pub const API_VERSION: u32 = 5; +/// Host ABI major version. Bumped to 6 when the DocApi v2 surface +/// (`PluginRequest::DocApiRequest` / `PluginResponse::DocApiResponse` + +/// `HostApi::doc_api_dispatch`) was added: plugins that call DocApi require an +/// API-6 host and are rejected at handshake by older (API ≤ 5) hosts, instead of +/// failing at runtime with a bincode-decode transport error. The DocApi wire +/// changes are append-only and `doc_api_dispatch` is a defaulted method, so +/// older V2–V5 plugins still load unchanged on an API-6 host. +pub const API_VERSION: u32 = 6; /// Oldest plugin API major the current host still loads. This keeps previously /// compiled cdylibs usable as long as their vtable layout is a prefix of the diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index aa74a81ab..8d25e0ede 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -147,7 +147,7 @@ mod tests { value["ocs_plugin_api_version"].as_str(), Some(env!("CARGO_PKG_VERSION")) ); - assert_eq!(value["api_version"].as_i64(), Some(5)); + assert_eq!(value["api_version"].as_i64(), Some(crate::API_VERSION as i64)); assert_eq!(value["api_version_min_supported"].as_i64(), Some(2)); // acadrust_version should have a patch component (e.g. "0.4.0"). diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs new file mode 100644 index 000000000..360daa23a --- /dev/null +++ b/src/app/doc_api.rs @@ -0,0 +1,3484 @@ +//! Native document API adapter over the existing scene, history and kernel paths. + +use acadrust::entities::Solid3D; +use acadrust::{EntityType, Handle}; +use ocs_doc_api::backend::{DocApiBackend, KernelBody}; +use ocs_doc_api::{ + Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, + ObjectId, PlacementSpec, +}; + +use crate::scene::convert::acis_export; +use crate::scene::model::solid_model; +use ocs_doc_api::convert; + +use super::plugin_host::HostSession; + +/// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by +// the in-process path. Deserializes the envelope, runs the crate executor, +// serializes the `Receipt` (or `ApiError`) back to bytes. +pub fn execute_doc_api( + host: &mut HostSession<'_>, + tab_id: u64, + bytes: &[u8], +) -> Result, String> { + use 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) +} + +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> { + 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) => PreparedDocEntity { + entity: convert::curve_spec_to_entity(spec)?, + 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 entity = convert::curve_spec_to_entity(spec)?; + let handle = self.scene_mut().add_entity(entity); + Ok(handle_to_obj(handle)) + } + + fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { + // Validate the referenced block exists before committing. + if self + .document() + .block_records + .get(&spec.block_name) + .is_none() + { + return Err(ApiError::validation( + "CreateInsert", + format!("unknown block {:?}", spec.block_name), + )); + } + let mut ins = acadrust::entities::Insert::new( + spec.block_name.clone(), + acadrust::types::Vector3::new( + spec.insert_point[0], + spec.insert_point[1], + spec.insert_point[2], + ), + ); + ins.rotation = spec.rotation; + ins.set_x_scale(spec.scale); + ins.set_y_scale(spec.scale); + ins.set_z_scale(spec.scale); + let handle = self.scene_mut().add_entity(EntityType::Insert(ins)); + Ok(handle_to_obj(handle)) + } + + fn add_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 mut att = AttributeDefinition::default(); + att.tag = spec.tag.clone(); + att.prompt = spec.prompt.clone(); + att.default_value = spec.default_value.clone(); + att.insertion_point = Vector3::new( + spec.insertion_point[0], + spec.insertion_point[1], + spec.insertion_point[2], + ); + att.height = spec.height; + att.rotation = spec.rotation; + let handle = self + .scene_mut() + .add_entity(EntityType::AttributeDefinition(att)); + Ok(handle_to_obj(handle)) + } + + fn 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 mut attr = acadrust::entities::AttributeEntity::default(); + attr.tag = tag.to_string(); + attr.value = value.to_string(); + attr.insertion_point = ins.insert_point; + ins.attributes.push(attr); + } + if !self.scene_mut().update_entity(EntityType::Insert(ins)) { + return Err(ApiError::Unsupported(format!( + "entity {id:?} is on a locked layer" + ))); + } + Ok(()) + } + + fn attributes(&self, id: ObjectId) -> ApiResult> { + let entity = self + .document() + .get_entity(obj_to_handle(id)) + .ok_or(ApiError::UnknownId(id))?; + let EntityType::Insert(ins) = entity else { + return Err(ApiError::Unsupported( + "GetAttributes is only for Insert entities".into(), + )); + }; + Ok(ins + .attributes + .iter() + .map(|a| (a.tag.clone(), a.value.clone())) + .collect()) + } + + fn block_entities(&self, block_name: &str) -> ApiResult> { + let br = self + .document() + .block_records + .get(block_name) + .ok_or_else(|| { + ApiError::validation("GetBlockEntities", format!("unknown block {block_name:?}")) + })?; + // Read-only traversal of the block definition's entities (handle lookups). + let mut out = Vec::with_capacity(br.entity_handles.len()); + for h in &br.entity_handles { + let id = handle_to_obj(*h); + let (kind, bounds) = match self.document().get_entity(*h) { + Some(e) => ( + convert::entity_kind_name(e).to_string(), + convert::entity_bounds(Some(e), id).ok(), + ), + None => ("Missing".to_string(), None), + }; + out.push(EntityView { id, kind, bounds }); + } + 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 mut img = RasterImage::default(); + img.file_path = spec.file_path.clone(); + img.insertion_point = Vector3::new( + spec.insertion_point[0], + spec.insertion_point[1], + spec.insertion_point[2], + ); + img.u_vector = Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]); + img.v_vector = Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]); + img.size = Vector2::new(spec.size[0], spec.size[1]); + // add_entity auto-registers the ImageDefinition (scene/entity.rs). + let handle = self.scene_mut().add_entity(EntityType::RasterImage(img)); + Ok(handle_to_obj(handle)) + } + + fn 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 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(), + 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 ────────────────────────────────────────────────────────────────── + +struct PreparedDocEntity { + entity: EntityType, + solid: Option<( + KernelBody, + ( + crate::scene::model::mesh_model::MeshLodSet, + Vec, + [f64; 3], + ), + )>, +} + +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, + } +} + +/// Signed volume and centroid of a closed triangle mesh via the divergence +/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source +/// of truth for both (used by the cold-cache `volume`/`centroid` fallback). +fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { + // Delegate to the crate's single source of truth (divergence theorem). + ocs_doc_api::geom::mesh_volume_centroid(mesh) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::plugin_host::HostSession; + use crate::app::OpenCADStudio; + use ocs_doc_api::ops::{BoolOp, SolidPrimitive}; + use ocs_doc_api::{DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt}; + + fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult { + let bytes = bincode::serialize(&env).unwrap(); + let 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 { + points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], + closed: false, + }); + let poly = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_poly)).unwrap()); + // Insert a vertex at index 1. + dispatch( + &mut host, + DocApiEnvelope::op(Operation::AddVertex { + id: poly, + at: 1, + point: [5.0, 0.0, 0.0], + }), + ) + .unwrap(); + // The polyline now has 4 vertices with the inserted one at index 1. + let handle = obj_to_handle(poly); + let Some(EntityType::LwPolyline(pl)) = host.document().get_entity(handle) else { + panic!("polyline not found"); + }; + assert_eq!(pl.vertices.len(), 4); + assert!((pl.vertices[1].location.x - 5.0).abs() < 1e-9); + // Out-of-range index is a validation error, not a panic. + let err = dispatch( + &mut host, + DocApiEnvelope::op(Operation::AddVertex { + id: poly, + at: 99, + point: [0.0, 0.0, 0.0], + }), + ) + .unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); + } + + #[test] + fn phase0_extrude_rectangular_profile_to_solid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A closed rectangular 2x3 profile in XY. + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { + points: vec![ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.0, 3.0, 0.0], + [0.0, 3.0, 0.0], + ], + closed: true, + }); + let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap()); + // Extrude +Z by 5 -> a 2x3x5 box = volume 30. + let solid = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::Extrude { + profile, + direction: [0.0, 0.0, 5.0], + }), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetVolume { id: solid }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Volume(v) => assert!((*v - 30.0).abs() < 1.0, "extrude volume {v}"), + other => panic!("expected volume, got {other:?}"), + } + } + + #[test] + fn phase0_revolve_profile_to_solid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // A 1-wide, 2-tall rectangle offset 1 from the Y axis; revolve about the Y + // axis by 2*pi -> a cylinder-ish annulus (outer r=2, inner r=1, h=2): pi*(4-1)*2 = 6pi ≈ 18.85. + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { + points: vec![ + [1.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.0, 2.0, 0.0], + [1.0, 2.0, 0.0], + ], + closed: true, + }); + let profile = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_profile)).unwrap()); + let result = dispatch( + &mut host, + DocApiEnvelope::op(Operation::Revolve { + profile, + axis: ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]), + angle: std::f64::consts::TAU, + }), + ); + // Revolve may be geometry-sensitive; assert it either produces a positive + // volume or surfaces a structured geometry error (no panic). + match result { + Ok(receipt) => { + let id = receipt.outcome.and_then(|o| o.new_id()).unwrap(); + let v = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetVolume { id }]), + ) + .unwrap(); + match &v.query_results[0] { + QueryResult::Volume(vol) => assert!(*vol > 0.0, "revolve volume {vol}"), + other => panic!("expected volume, got {other:?}"), + } + } + Err(ApiError::Geometry { .. } | ApiError::Validation { .. }) => {} + Err(e) => panic!("unexpected error: {e}"), + } + } + + #[test] + fn phase0_non_solid_transform_line_and_circle() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let mk_line = Operation::CreateCurve(Curve2Spec::Line { + start: [0.0; 3], + end: [10.0; 3], + }); + let line = new_id(&dispatch(&mut host, DocApiEnvelope::op(mk_line)).unwrap()); + // Translate the line by +5 in X: bounds shift by +5. + dispatch( + &mut host, + DocApiEnvelope::op(Operation::Transform { + id: line, + placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), + }), + ) + .unwrap(); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetBounds { id: line }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => { + assert!( + (b.min[0] - 5.0).abs() < 1e-6 && (b.max[0] - 15.0).abs() < 1e-6, + "{b:?}" + ); + } + other => panic!("expected bounds, got {other:?}"), + } + // TransformMany over a mix of line + circle is now all-or-nothing (both transformable). + let circle = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + centre: [0.0; 3], + radius: 2.0, + })), + ) + .unwrap(), + ); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::TransformMany { + ids: vec![line, circle], + placement: ocs_doc_api::PlacementSpec::at([0.0, 1.0, 0.0]), + }), + ) + .unwrap(); + } + + // ── Phase 2: full 2D curve families ──────────────────────────────────── + + #[test] + fn phase2_arc_ellipse_spline_create_bounds_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Arc: create -> kind Arc; bounds are the coarse full-circle bounds. + let arc = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { + centre: [5.0, 5.0, 0.0], + radius: 4.0, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + })), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![ + Query::GetEntity { id: arc }, + Query::GetBounds { id: arc }, + ]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Arc"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => assert!( + (b.min[0] - 1.0).abs() < 1e-6 && (b.max[0] - 9.0).abs() < 1e-6, + "{b:?}" + ), + other => panic!("expected bounds, got {other:?}"), + } + + // Ellipse: create -> kind Ellipse; bounds = centre ± major-axis length. + let ell = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + centre: [0.0, 0.0, 0.0], + major_axis: [6.0, 0.0, 0.0], + ratio: 0.5, + start: 0.0, + end: std::f64::consts::TAU, + })), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetEntity { id: ell }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Ellipse"), + other => panic!("expected entity, got {other:?}"), + } + + // Spline (degree-3 cubic through 4 control points): create -> kind Spline; + // bounds = control-point bounds. Transform by +10 X shifts bounds. + let spline = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Spline { + degree: 3, + control_points: vec![ + [0.0, 0.0, 0.0], + [1.0, 2.0, 0.0], + [2.0, -2.0, 0.0], + [3.0, 0.0, 0.0], + ], + knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], + weights: vec![1.0; 4], + })), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => assert!( + (b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, + "{b:?}" + ), + other => panic!("expected bounds, got {other:?}"), + } + dispatch( + &mut host, + DocApiEnvelope::op(Operation::Transform { + id: spline, + placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]), + }), + ) + .unwrap(); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetBounds { id: spline }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => assert!((b.min[0] - 10.0).abs() < 1e-6, "{b:?}"), + other => panic!("expected bounds, got {other:?}"), + } + } + + #[test] + fn phase2_ray_xline_create_and_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let ray = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { + origin: [1.0, 2.0, 0.0], + direction: [1.0, 0.0, 0.0], + })), + ) + .unwrap(), + ); + let xline = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::XLine { + origin: [0.0, 0.0, 0.0], + direction: [0.0, 1.0, 0.0], + })), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![ + Query::GetEntity { id: ray }, + Query::GetEntity { id: xline }, + ]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Ray"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Entity(v) => assert_eq!(v.kind, "XLine"), + other => panic!("expected entity, got {other:?}"), + } + // Ray/XLine are unbounded -> GetBounds is Unsupported (not a panic). + assert!(dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetBounds { id: ray }]) + ) + .is_err()); + // Transform moves the ray's base point (no error). + dispatch( + &mut host, + DocApiEnvelope::op(Operation::Transform { + id: ray, + placement: ocs_doc_api::PlacementSpec::at([0.0, 0.0, 5.0]), + }), + ) + .unwrap(); + let handle = obj_to_handle(ray); + let Some(EntityType::Ray(r)) = host.document().get_entity(handle) else { + panic!("ray not found") + }; + assert!((r.base_point.z - 5.0).abs() < 1e-6); + } + + // ── 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 { + centre: [0.0, 0.0, 0.0], + radius: 1.0, + })), + ) + .unwrap(), + ); + // Uniform scale by 2 (x_axis=(2,0,0) etc). Radius must become 2 (s), not 4 (s^2). + let placement = ocs_doc_api::PlacementSpec { + x_axis: [2.0, 0.0, 0.0], + y_axis: [0.0, 2.0, 0.0], + z_axis: [0.0, 0.0, 2.0], + origin: [0.0; 3], + }; + dispatch( + &mut host, + DocApiEnvelope::op(Operation::Transform { + id: circle, + placement, + }), + ) + .unwrap(); + let handle = obj_to_handle(circle); + let Some(EntityType::Circle(c)) = host.document().get_entity(handle) else { + panic!("circle not found") + }; + assert!( + (c.radius - 2.0).abs() < 1e-9, + "radius {} (must be 2.0, not 4.0)", + c.radius + ); + } + + #[test] + fn fix_rotation_rotates_ellipse_ray_and_insert() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // 90-degree Z-rotation: x_axis=(0,1,0), y_axis=(-1,0,0). + let rot90 = ocs_doc_api::PlacementSpec { + x_axis: [0.0, 1.0, 0.0], + y_axis: [-1.0, 0.0, 0.0], + z_axis: [0.0, 0.0, 1.0], + origin: [0.0; 3], + }; + // Ray along +X must become along +Y after a 90° rotation. + let ray = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { + origin: [0.0, 0.0, 0.0], + direction: [1.0, 0.0, 0.0], + })), + ) + .unwrap(), + ); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::Transform { + id: ray, + placement: rot90, + }), + ) + .unwrap(); + let Some(EntityType::Ray(r)) = host.document().get_entity(obj_to_handle(ray)) else { + panic!("ray not found") + }; + assert!( + (r.direction.y - 1.0).abs() < 1e-6 && r.direction.x.abs() < 1e-6, + "ray dir {:?}", + r.direction + ); + + // Ellipse major_axis along +X must rotate to +Y (length preserved). + let ell = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + centre: [0.0; 3], + major_axis: [4.0, 0.0, 0.0], + ratio: 0.5, + start: 0.0, + end: std::f64::consts::TAU, + })), + ) + .unwrap(), + ); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::Transform { + id: ell, + placement: rot90, + }), + ) + .unwrap(); + let Some(EntityType::Ellipse(e)) = host.document().get_entity(obj_to_handle(ell)) else { + panic!("ellipse not found") + }; + assert!( + (e.major_axis.y - 4.0).abs() < 1e-6 && e.major_axis.x.abs() < 1e-6, + "ellipse axis {:?}", + e.major_axis + ); + + // Arc angles offset by +90° (π/2). + let arc = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { + centre: [0.0; 3], + radius: 2.0, + start_angle: 0.0, + end_angle: 1.0, + })), + ) + .unwrap(), + ); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::Transform { + id: arc, + placement: rot90, + }), + ) + .unwrap(); + let Some(EntityType::Arc(a)) = host.document().get_entity(obj_to_handle(arc)) else { + panic!("arc not found") + }; + let half_pi = std::f64::consts::FRAC_PI_2; + assert!( + (a.start_angle - half_pi).abs() < 1e-6, + "arc start {} (expected {})", + a.start_angle, + half_pi + ); + } + + #[test] + fn fix_transform_many_locked_layer_fails_all_or_nothing() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let a = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + position: [0.0; 3], + })), + ) + .unwrap(), + ); + let b = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + position: [1.0; 3], + })), + ) + .unwrap(), + ); + // Lock the layer holding `b`. + let bh = obj_to_handle(b); + let layer = host + .document() + .get_entity(bh) + .unwrap() + .common() + .layer + .clone(); + host.document_mut().layers.get_mut(&layer).unwrap().lock(); + let rev_before = host.scene().geometry_epoch; + + // TransformMany [a, b] must fail all-or-nothing (b is locked) BEFORE mutating a. + let err = dispatch( + &mut host, + DocApiEnvelope::op(Operation::TransformMany { + ids: vec![a, b], + placement: ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]), + }), + ) + .unwrap_err(); + assert!( + matches!( + err, + ApiError::Validation { .. } | ApiError::Unsupported(_) | ApiError::UnknownId(_) + ), + "{err:?}" + ); + // `a` was NOT transformed (all-or-nothing): its location is unchanged. + let Some(EntityType::Point(pa)) = host.document().get_entity(obj_to_handle(a)) else { + panic!("point a not found") + }; + assert!( + (pa.location.x - 0.0).abs() < 1e-9, + "a moved despite locked batch member" + ); + assert_eq!( + host.scene().geometry_epoch, + rev_before, + "no mutation on rejected TransformMany" + ); + } + + // ── Phase 2b-a: annotations (Text/MText) ───────────────────────────────── + + #[test] + fn phase2b_text_mtext_create_content_transform() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Create a TEXT, read its content, set it, verify. + let text = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateText(ocs_doc_api::ops::TextSpec { + value: "hello".into(), + insertion_point: [1.0, 2.0, 0.0], + height: 2.5, + rotation: 0.0, + })), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetTextContent { id: text }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::TextContent(s) => assert_eq!(s, "hello"), + other => panic!("expected content, got {other:?}"), + } + dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetTextContent { + id: text, + value: "world".into(), + }), + ) + .unwrap(); + let Some(EntityType::Text(t)) = host.document().get_entity(obj_to_handle(text)) else { + panic!("text not found") + }; + assert_eq!(t.value, "world"); + + // SetTextContent on a non-text entity is Unsupported. + let line = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + start: [0.0; 3], + end: [1.0; 3], + })), + ) + .unwrap(), + ); + assert!(dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetTextContent { + id: line, + value: "x".into() + }) + ) + .is_err()); + + // MTEXT create + content + transform (insertion point moves). + let mtext = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateMText(ocs_doc_api::ops::MTextSpec { + value: "multi\nline".into(), + insertion_point: [5.0, 5.0, 0.0], + height: 3.0, + })), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetTextContent { id: mtext }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::TextContent(s) => assert_eq!(s, "multi\nline"), + other => panic!("expected content, got {other:?}"), + } + dispatch( + &mut host, + DocApiEnvelope::op(Operation::Transform { + id: mtext, + placement: ocs_doc_api::PlacementSpec::at([10.0, 0.0, 0.0]), + }), + ) + .unwrap(); + let Some(EntityType::MText(t)) = host.document().get_entity(obj_to_handle(mtext)) else { + panic!("mtext not found") + }; + assert!((t.insertion_point.x - 15.0).abs() < 1e-6); + } + + // ── Outstanding methods: loft + bulge-arc profiles ─────────────────────── + + #[test] + fn loft_two_profiles_produces_solid() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + // Two circles at different Z (profiles for loft). + let c1 = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + centre: [0.0, 0.0, 0.0], + radius: 5.0, + })), + ) + .unwrap(), + ); + let c2 = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + centre: [0.0, 0.0, 10.0], + radius: 2.0, + })), + ) + .unwrap(), + ); + let result = dispatch( + &mut host, + DocApiEnvelope::op(Operation::Loft { + profiles: vec![c1, c2], + }), + ); + 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 { + 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 - 0.7071).abs() < 0.01, + "cw arc radius {}", + arc.radius + ); + assert!( + arc.centre[1] < 0.0, + "CW arc center y must be below the chord, got {:?}", + arc.centre + ); + } else { + panic!("expected an arc for negative bulge"); + } + // And the CCW mirror: center above. + let pos = + 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 { position: [0.0; 3] }), + ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { + centre: [0.0; 3], + major_axis: [1.0; 3], + ratio: 2.0, + start: 0.0, + end: std::f64::consts::TAU, + }), + ]; + let err = + dispatch(&mut host, DocApiEnvelope::op(Operation::CreateMany(specs))).unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. }), "{err:?}"); + // All-or-nothing: NO entity was created (the point would have been added first). + assert_eq!( + host.scene().geometry_epoch, + rev_before, + "no mutation on rejected CreateMany" + ); + } + + #[test] + fn 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 { + 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 mut leader = acadrust::entities::Leader::default(); + leader.vertices = vec![ + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(5.0, 5.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ]; + let leader_h = host + .document_mut() + .add_entity(EntityType::Leader(leader)) + .unwrap(); + // Mesh with vertices in a unit cube at (2..3, 2..3, 0). + let mut mesh = acadrust::entities::Mesh::default(); + mesh.vertices = vec![ + acadrust::types::Vector3::new(2.0, 2.0, 0.0), + acadrust::types::Vector3::new(3.0, 2.0, 0.0), + acadrust::types::Vector3::new(3.0, 3.0, 0.0), + acadrust::types::Vector3::new(2.0, 3.0, 0.0), + ]; + let mesh_h = host + .document_mut() + .add_entity(EntityType::Mesh(mesh)) + .unwrap(); + + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![ + Query::GetBounds { + id: handle_to_obj(leader_h), + }, + Query::GetBounds { + id: handle_to_obj(mesh_h), + }, + Query::GetEntity { + id: handle_to_obj(leader_h), + }, + ]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => { + assert!( + (b.min[0] - 0.0).abs() < 1e-6 && (b.max[0] - 10.0).abs() < 1e-6, + "{b:?}" + ); + assert!( + (b.min[1] - 0.0).abs() < 1e-6 && (b.max[1] - 5.0).abs() < 1e-6, + "{b:?}" + ); + } + other => panic!("expected bounds, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => { + assert!( + (b.min[0] - 2.0).abs() < 1e-6 && (b.max[0] - 3.0).abs() < 1e-6, + "{b:?}" + ); + } + other => panic!("expected bounds, got {other:?}"), + } + match &receipt.query_results[2] { + QueryResult::Entity(e) => assert_eq!(e.kind, "Leader"), + other => panic!("expected entity, got {other:?}"), + } + } + + // ── Phase 5 remaining: typed raster image create ───────────────────────── + + #[test] + 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 { + 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 { + 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 mut img = RasterImage::default(); + img.insertion_point = Vector3::new(10.0, 20.0, 0.0); + img.u_vector = Vector3::new(0.5, 0.0, 0.0); // 0.5 world-units/pixel in X + img.v_vector = Vector3::new(0.0, 0.5, 0.0); + img.size = Vector2::new(100.0, 50.0); + host.document_mut() + .add_entity(EntityType::RasterImage(img)) + .unwrap() + }; + let img_id = handle_to_obj(img); + // GetEntity reports kind "RasterImage"; bounds = insertion + u*100 + v*50 = (60, 45). + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![ + Query::GetEntity { id: img_id }, + Query::GetBounds { id: img_id }, + ]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(e) => assert_eq!(e.kind, "RasterImage"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => { + assert!( + (b.min[0] - 10.0).abs() < 1e-6 && (b.max[0] - 60.0).abs() < 1e-6, + "{b:?}" + ); + assert!( + (b.min[1] - 20.0).abs() < 1e-6 && (b.max[1] - 45.0).abs() < 1e-6, + "{b:?}" + ); + } + other => panic!("expected bounds, got {other:?}"), + } + // Generic delete works on media entities (read-mostly, but deletable). + dispatch( + &mut host, + DocApiEnvelope::op(Operation::Delete { id: img_id }), + ) + .unwrap(); + assert!(host.document().get_entity(img).is_none()); + } + + // ── Phase 3: containers (block references) ─────────────────────────────── + + #[test] + 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 { + 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 { + start: [0.0, 0.0, 0.0], + end: [10.0, 0.0, 0.0], + })), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![ + Query::GetEntity { id: line }, + Query::GetBounds { id: line }, + ]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Entity(v) => assert_eq!(v.kind, "Line"), + other => panic!("expected entity, got {other:?}"), + } + match &receipt.query_results[1] { + QueryResult::Bounds(b) => { + assert!( + (b.min[0] - 0.0).abs() < 1e-9 && (b.max[0] - 10.0).abs() < 1e-9, + "{b:?}" + ); + } + other => panic!("expected bounds, got {other:?}"), + } + + // Circle centre (5,5,0) r=3: bounds = centre ± radius in XY. + let circle = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + centre: [5.0, 5.0, 0.0], + radius: 3.0, + })), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetBounds { id: circle }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => { + assert!( + (b.min[0] - 2.0).abs() < 1e-9 && (b.max[0] - 8.0).abs() < 1e-9, + "{b:?}" + ); + } + other => panic!("expected bounds, got {other:?}"), + } + + // Point at (1,2,3): degenerate bounds. + let point = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + position: [1.0, 2.0, 3.0], + })), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetBounds { id: point }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => assert_eq!(b.min, [1.0, 2.0, 3.0]), + other => panic!("expected bounds, got {other:?}"), + } + + // Closed polyline (unit square in XY): bounds = [0,1]^2 at z=0. + let poly = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { + points: vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [0.0, 1.0, 0.0], + ], + closed: true, + })), + ) + .unwrap(), + ); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetBounds { id: poly }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(b) => { + assert!( + (b.min[0] - 0.0).abs() < 1e-9 && (b.max[1] - 1.0).abs() < 1e-9, + "{b:?}" + ); + } + other => panic!("expected bounds, got {other:?}"), + } + } + + /// Geometric methods round-trip: transform moves a solid (bounds shift); + /// union/subtract produce the expected mass/volume relationships. + #[test] + fn roundtrip_geometric_methods_transform_and_booleans() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Two overlapping boxes [0,10]^3 and [5,15]^3. + let a = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { + origin: [0.0; 3], + size: [10.0; 3], + })), + ) + .unwrap(), + ); + let b = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { + origin: [5.0; 3], + size: [10.0; 3], + })), + ) + .unwrap(), + ); + + // Intersection FIRST: [0,10]^3 ∩ [5,15]^3 = [5,10]^3 = 125. + let intersect_op = Operation::SolidBoolean { + op: BoolOp::Intersection, + a, + b, + erase_sources: true, + }; + let inter = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap()); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetVolume { id: inter }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Volume(v) => assert!((*v - 125.0).abs() < 1.0, "intersection vol {v}"), + other => panic!("expected volume, got {other:?}"), + } + + // THEN transform the result by +100 in X (a real, non-identity move): its + // bounds must shift by exactly +100 in X while Y/Z stay at [5,10]. + let move_op = Operation::Transform { + id: inter, + placement: PlacementSpec::at([100.0, 0.0, 0.0]), + }; + dispatch(&mut host, DocApiEnvelope::op(move_op)).unwrap(); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetBounds { id: inter }]), + ) + .unwrap(); + match &receipt.query_results[0] { + QueryResult::Bounds(bb) => { + assert!( + (bb.min[0] - 105.0).abs() < 1e-4 && (bb.max[0] - 110.0).abs() < 1e-4, + "{bb:?}" + ); + assert!( + (bb.min[1] - 5.0).abs() < 1e-4 && (bb.max[1] - 10.0).abs() < 1e-4, + "{bb:?}" + ); + } + other => panic!("expected bounds, got {other:?}"), + } + } + + /// DWG roundtrip: create two intersected solids, write the document to a DWG + /// file, read it back, and assert the intersected solid survives with valid + /// ACIS data (the exact path `restore_solid_models` re-lifts on load). + #[test] + fn roundtrip_intersected_solids_through_dwg_file() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Two overlapping boxes -> intersect -> the result solid (125 vol). + let a = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { + origin: [0.0; 3], + size: [10.0; 3], + })), + ) + .unwrap(), + ); + let b = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateSolid(SolidPrimitive::Cuboid { + origin: [5.0; 3], + size: [10.0; 3], + })), + ) + .unwrap(), + ); + let intersect_op = Operation::SolidBoolean { + op: BoolOp::Intersection, + a, + b, + erase_sources: true, + }; + let lens = new_id(&dispatch(&mut host, DocApiEnvelope::op(intersect_op)).unwrap()); + + // Write the live document to a STABLE, inspectable DWG in the workspace + // target dir so it can be opened in a CAD viewer after the test. The file + // is kept (not deleted). If the stable path is momentarily locked (e.g. a + // concurrent test run or a viewer holding it), fall back to a process- + // unique path for the roundtrip so the test never flakes. + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"); + std::fs::create_dir_all(&dir).expect("create target dir"); + let doc = host.document().clone(); + let stable = dir.join("doc_api_roundtrip_intersected.dwg"); + // Release any stale lock/handle on the stable file before rewriting. + let _ = std::fs::remove_file(&stable); + let path = match acadrust::io::dwg::DwgWriter::write_to_file(&stable, &doc) { + Ok(()) => stable, + Err(_) => { + // Locked by another live process (e.g. a viewer) — write a unique + // file for the roundtrip instead of failing. + let alt = dir.join(format!( + "doc_api_roundtrip_intersected_{}.dwg", + std::process::id() + )); + acadrust::io::dwg::DwgWriter::write_to_file(&alt, &doc).expect("write DWG failed"); + alt + } + }; + assert!(path.exists(), "DWG was written"); + + // Read it back. + let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).expect("open DWG"); + let reloaded = reader.read().expect("read DWG failed"); + + // The intersected solid must be present as a Solid3D with valid ACIS data, + // and re-lifting it must reproduce a body with the expected volume. + let handle = obj_to_handle(lens); + let Some(EntityType::Solid3D(solid)) = reloaded.get_entity(handle) else { + panic!("intersected solid {handle:?} not found as Solid3D in reloaded DWG"); + }; + assert!(solid.has_acis_data(), "reloaded solid carries ACIS data"); + + // Re-lift through the same SAT->kernel path used on load and check volume. + let body = crate::scene::convert::solid3d_tess::kernel_body(solid) + .expect("re-lift reloaded solid failed"); + let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); + let vol = mesh_volume_centroid(&mesh).0; + assert!( + (vol - 125.0).abs() < 1.0, + "reloaded intersection volume {vol}" + ); + + // The DWG is intentionally kept for inspection (see `path` above). + } + #[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 { position: [0.0; 3] }), + EntitySpec::Solid(SolidPrimitive::Sphere { + centre: [0.0; 3], + radius: f64::NAN, + }), + ]), + Operation::CreateCurve(Curve2Spec::Circle { + centre: [0.0; 3], + radius: -1.0, + }), + Operation::CreateCurve(Curve2Spec::Polyline { + 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 { 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 { + 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); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 8e75d1bcc..ea54b4be1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,6 +14,8 @@ pub(crate) mod expr_eval; mod find_replace; pub(crate) mod helpers; mod history; +#[cfg(not(target_arch = "wasm32"))] +mod doc_api; mod layers; mod model_ops; mod mtext_editor; diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index d60992822..c39129315 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -16,6 +16,8 @@ pub(crate) struct HostSession<'a> { app: &'a mut OpenCADStudio, tab: usize, doc_store: Option>, + /// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`). + doc_api_pending: Option, } impl<'a> HostSession<'a> { @@ -24,6 +26,7 @@ impl<'a> HostSession<'a> { app, tab, doc_store: None, + doc_api_pending: None, } } @@ -242,6 +245,39 @@ 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; } @@ -360,6 +396,10 @@ impl HostApi for HostSession<'_> { fn close_document_view_v4(&mut self, tab_id: u64) { self.close_document_view_v4(tab_id) } + #[cfg(not(target_arch = "wasm32"))] + fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result, String> { + super::doc_api::execute_doc_api(self, tab_id, bytes) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d817ff041..fb48c0cef 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1807,6 +1807,13 @@ pub struct Scene { pub block_meshes: HashMap, /// Kernel B-reps used by solid operations and exact-geometry saves. pub solid_models: HashMap, + /// Memoized DocApi volume/centroid per (handle, geometry_epoch) so repeated + /// mass queries on the same solid are O(1). Persists across DocApi dispatches. + #[cfg(not(target_arch = "wasm32"))] + pub doc_api_mass_cache: std::collections::HashMap, + /// Bounds uncached mass computations in one API request. + #[cfg(not(target_arch = "wasm32"))] + pub doc_api_cold_tess_used: usize, /// GPU render data for raster images (RasterImage entities), keyed by handle. pub images: HashMap, /// The viewport that is currently "entered" (MSPACE mode). @@ -2068,6 +2075,10 @@ impl Scene { material_base_dir: None, block_meshes: HashMap::default(), solid_models: HashMap::default(), + #[cfg(not(target_arch = "wasm32"))] + doc_api_mass_cache: std::collections::HashMap::default(), + #[cfg(not(target_arch = "wasm32"))] + doc_api_cold_tess_used: 0, images: HashMap::default(), active_viewport: None, bg_color: [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0],