Merge PR #1028: add document API with atomic edits

Add the typed document facade and plugin IPC adapter. Prepare bulk edits
before mutation, preserve undo and dirty state, keep profile elevations,
validate numeric inputs and session bindings, and generate Python constructors
with typed results.

Reuse existing entity transforms and profile conversion. Compute mass
properties and closed-surface bounds in cadkernel; pin its main revision
6a5e6fc2822f6af6b2611cd605b44437b35158f0. Keep plugin API metadata at version 6.

Validation: 646 native application tests, 597 kernel tests, 12 document API
tests, 91 plugin API tests, 3 Python tests, default/IPC feature builds and
wasm32 check passed.
This commit is contained in:
Hakan Seven committed 2026-09-06 21:43:02 +03:00
commit 3385160493
50 files changed
+11481 -6

No files matched your search

@@ -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()
@@ -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()