forked from 1b-agent/opencadstudio-upstream
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:
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()
|
||||
Reference in new issue
Block a user