modulor 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modulor/__init__.py +43 -0
- modulor/__main__.py +5 -0
- modulor/cli.py +264 -0
- modulor/document.py +248 -0
- modulor/document.schema.json +330 -0
- modulor/engine.py +76 -0
- modulor/errors.py +60 -0
- modulor/exporters/__init__.py +0 -0
- modulor/exporters/dxf.py +113 -0
- modulor/exporters/mesh3d.py +169 -0
- modulor/exporters/svg.py +115 -0
- modulor/expr.py +147 -0
- modulor/geometry.py +549 -0
- modulor/importers/__init__.py +0 -0
- modulor/importers/dxf.py +307 -0
- modulor/mcp_server.py +201 -0
- modulor/ops/__init__.py +273 -0
- modulor/ops/arch.py +429 -0
- modulor/ops/doc_ops.py +314 -0
- modulor/ops/draw2d.py +512 -0
- modulor/ops/export_ops.py +151 -0
- modulor/ops/model3d.py +747 -0
- modulor/ops/param_ops.py +267 -0
- modulor/ops/query.py +188 -0
- modulor/ops/transform.py +345 -0
- modulor/render/__init__.py +0 -0
- modulor/render/flatten.py +260 -0
- modulor/render/font.py +148 -0
- modulor/render/raster.py +109 -0
- modulor/render/render2d.py +87 -0
- modulor/render/render3d.py +247 -0
- modulor/shapes.py +378 -0
- modulor/viewer/__init__.py +0 -0
- modulor/viewer/index.html +482 -0
- modulor/viewer/server.py +254 -0
- modulor-0.5.0.dist-info/METADATA +199 -0
- modulor-0.5.0.dist-info/RECORD +41 -0
- modulor-0.5.0.dist-info/WHEEL +5 -0
- modulor-0.5.0.dist-info/entry_points.txt +2 -0
- modulor-0.5.0.dist-info/licenses/LICENSE +21 -0
- modulor-0.5.0.dist-info/top_level.txt +1 -0
modulor/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Modulor — agent-native 2D drafting + 3D modeling.
|
|
2
|
+
|
|
3
|
+
No GUI. The entire tool is a set of JSON commands ("ops") applied to a
|
|
4
|
+
JSON document, reachable four ways:
|
|
5
|
+
|
|
6
|
+
CLI batch modulor run model.json script.json
|
|
7
|
+
CLI pipe modulor repl model.json (JSON Lines in/out)
|
|
8
|
+
MCP server modulor mcp (for MCP-speaking agents)
|
|
9
|
+
Python from modulor import Cad
|
|
10
|
+
|
|
11
|
+
>>> cad = Cad("house.json", units="mm")
|
|
12
|
+
>>> cad("add_wall", path=[[0, 0], [6000, 0]], thickness=200)
|
|
13
|
+
{'ok': True, 'op': 'add_wall', 'created': ['e1'], 'length': 6000.0}
|
|
14
|
+
>>> cad("render", path="plan.png")
|
|
15
|
+
>>> cad.save()
|
|
16
|
+
"""
|
|
17
|
+
from .document import Document
|
|
18
|
+
from .engine import BatchError, execute, run_batch
|
|
19
|
+
from .errors import CadError
|
|
20
|
+
|
|
21
|
+
__version__ = "0.5.0"
|
|
22
|
+
__all__ = ["Cad", "Document", "CadError", "BatchError",
|
|
23
|
+
"execute", "run_batch", "__version__"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Cad:
|
|
27
|
+
"""Convenience wrapper for Python-side agents and scripts."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path: str | None = None, units: str = "mm"):
|
|
30
|
+
if path:
|
|
31
|
+
self.doc = Document.open_or_create(path, units=units)
|
|
32
|
+
else:
|
|
33
|
+
self.doc = Document(units=units)
|
|
34
|
+
|
|
35
|
+
def __call__(self, op: str, **params) -> dict:
|
|
36
|
+
return execute(self.doc, {"op": op, **params})
|
|
37
|
+
|
|
38
|
+
def run(self, commands) -> list[dict]:
|
|
39
|
+
return run_batch(self.doc, commands)
|
|
40
|
+
|
|
41
|
+
def save(self, path: str | None = None):
|
|
42
|
+
self.doc.save(path)
|
|
43
|
+
return self.doc.path
|
modulor/__main__.py
ADDED
modulor/cli.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Command-line front-end.
|
|
2
|
+
|
|
3
|
+
Everything reads/writes JSON on stdout so output is machine-parseable;
|
|
4
|
+
exit code 0 = success, 1 = structured error, 2 = usage error.
|
|
5
|
+
|
|
6
|
+
modulor ops [name] discover the API
|
|
7
|
+
modulor new DOC [--units mm] create an empty document
|
|
8
|
+
modulor info DOC document summary
|
|
9
|
+
modulor run DOC SCRIPT run a JSON command batch (SCRIPT='-' = stdin)
|
|
10
|
+
modulor op DOC NAME [JSON] run a single op
|
|
11
|
+
modulor repl DOC JSON-Lines session over stdin/stdout
|
|
12
|
+
modulor mcp MCP stdio server
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from .document import Document
|
|
22
|
+
from .engine import BatchError, run_batch
|
|
23
|
+
from .errors import CadError
|
|
24
|
+
from .ops import describe_op, list_ops
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv=None) -> int:
|
|
28
|
+
# Windows consoles default to legacy encodings; force UTF-8 JSON I/O
|
|
29
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
30
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
31
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
ap = argparse.ArgumentParser(prog="modulor",
|
|
34
|
+
description="Agent-native 2D/3D CAD")
|
|
35
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
36
|
+
|
|
37
|
+
p_ops = sub.add_parser("ops", help="list ops / describe one op")
|
|
38
|
+
p_ops.add_argument("name", nargs="?", help="op name")
|
|
39
|
+
|
|
40
|
+
p_new = sub.add_parser("new", help="create an empty document")
|
|
41
|
+
p_new.add_argument("doc")
|
|
42
|
+
p_new.add_argument("--units", default="mm")
|
|
43
|
+
|
|
44
|
+
p_info = sub.add_parser("info", help="document summary")
|
|
45
|
+
p_info.add_argument("doc")
|
|
46
|
+
|
|
47
|
+
p_chk = sub.add_parser("check",
|
|
48
|
+
help="validate a document file against the "
|
|
49
|
+
"modulor/1 format (structure + geometry; "
|
|
50
|
+
"full JSON Schema if jsonschema is installed)")
|
|
51
|
+
p_chk.add_argument("doc")
|
|
52
|
+
p_chk.add_argument("--strict", action="store_true",
|
|
53
|
+
help="conformance mode: fail (instead of skipping) "
|
|
54
|
+
"when the jsonschema package is unavailable")
|
|
55
|
+
|
|
56
|
+
p_run = sub.add_parser("run", help="run a JSON command batch against a doc")
|
|
57
|
+
p_run.add_argument("doc")
|
|
58
|
+
p_run.add_argument("script", help="JSON file with a command array, or '-'")
|
|
59
|
+
p_run.add_argument("--units", default="mm", help="units if doc is created")
|
|
60
|
+
p_run.add_argument("--as-recipe", action="store_true",
|
|
61
|
+
help="store the script as the document's recipe and "
|
|
62
|
+
"regenerate from it (parametric workflow)")
|
|
63
|
+
p_run.add_argument("--pretty", action="store_true")
|
|
64
|
+
|
|
65
|
+
p_op = sub.add_parser("op", help="run a single op")
|
|
66
|
+
p_op.add_argument("doc")
|
|
67
|
+
p_op.add_argument("name")
|
|
68
|
+
p_op.add_argument("params", nargs="?", default="{}",
|
|
69
|
+
help='op params as JSON, e.g. {"start":[0,0],"end":[1,0]}')
|
|
70
|
+
p_op.add_argument("--units", default="mm")
|
|
71
|
+
p_op.add_argument("--pretty", action="store_true")
|
|
72
|
+
|
|
73
|
+
p_repl = sub.add_parser("repl", help="JSON-Lines session (one batch per line)")
|
|
74
|
+
p_repl.add_argument("doc")
|
|
75
|
+
p_repl.add_argument("--units", default="mm")
|
|
76
|
+
|
|
77
|
+
p_srv = sub.add_parser("serve",
|
|
78
|
+
help="read-only browser viewer that live-follows "
|
|
79
|
+
"the document as agents edit it")
|
|
80
|
+
p_srv.add_argument("doc")
|
|
81
|
+
p_srv.add_argument("--port", type=int, default=8400)
|
|
82
|
+
p_srv.add_argument("--host", default="127.0.0.1")
|
|
83
|
+
p_srv.add_argument("--no-open", action="store_true",
|
|
84
|
+
help="don't launch a browser")
|
|
85
|
+
|
|
86
|
+
sub.add_parser("mcp", help="serve the Model Context Protocol over stdio")
|
|
87
|
+
|
|
88
|
+
args = ap.parse_args(argv)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
if args.cmd == "ops":
|
|
92
|
+
if args.name:
|
|
93
|
+
_emit(describe_op(args.name))
|
|
94
|
+
else:
|
|
95
|
+
_emit({"ops": list_ops(),
|
|
96
|
+
"usage": "modulor ops <name> for parameter details"})
|
|
97
|
+
return 0
|
|
98
|
+
if args.cmd == "new":
|
|
99
|
+
doc = Document(units=args.units)
|
|
100
|
+
doc.save(args.doc)
|
|
101
|
+
_emit({"ok": True, "doc": args.doc, "units": doc.units})
|
|
102
|
+
return 0
|
|
103
|
+
if args.cmd == "info":
|
|
104
|
+
doc = Document.load(args.doc)
|
|
105
|
+
results = run_batch(doc, [{"op": "doc_info"}])
|
|
106
|
+
_emit(results[0])
|
|
107
|
+
return 0
|
|
108
|
+
if args.cmd == "check":
|
|
109
|
+
return _cmd_check(args)
|
|
110
|
+
if args.cmd == "run":
|
|
111
|
+
return _cmd_run(args)
|
|
112
|
+
if args.cmd == "op":
|
|
113
|
+
return _cmd_op(args)
|
|
114
|
+
if args.cmd == "repl":
|
|
115
|
+
return _cmd_repl(args)
|
|
116
|
+
if args.cmd == "serve":
|
|
117
|
+
from .viewer.server import serve as serve_viewer
|
|
118
|
+
serve_viewer(args.doc, host=args.host, port=args.port,
|
|
119
|
+
open_browser=not args.no_open)
|
|
120
|
+
return 0
|
|
121
|
+
if args.cmd == "mcp":
|
|
122
|
+
from .mcp_server import serve
|
|
123
|
+
serve()
|
|
124
|
+
return 0
|
|
125
|
+
except CadError as e:
|
|
126
|
+
_emit({"ok": False, "error": e.to_dict()})
|
|
127
|
+
return 1
|
|
128
|
+
except FileNotFoundError as e:
|
|
129
|
+
_emit({"ok": False, "error": {"code": "file_not_found", "message": str(e)}})
|
|
130
|
+
return 1
|
|
131
|
+
except json.JSONDecodeError as e:
|
|
132
|
+
_emit({"ok": False, "error": {"code": "bad_json",
|
|
133
|
+
"message": f"invalid JSON: {e}"}})
|
|
134
|
+
return 1
|
|
135
|
+
return 2
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _cmd_check(args) -> int:
|
|
139
|
+
"""Conformance check for modulor/1 documents — usable against files
|
|
140
|
+
written by ANY implementation, not just this one.
|
|
141
|
+
|
|
142
|
+
Three layers: structural load, geometric validation (the 'validate'
|
|
143
|
+
op), and — when the optional jsonschema package is available — strict
|
|
144
|
+
validation against the packaged document.schema.json.
|
|
145
|
+
"""
|
|
146
|
+
with open(args.doc, "r", encoding="utf-8") as f:
|
|
147
|
+
raw = json.load(f)
|
|
148
|
+
doc = Document.from_dict(raw) # structural: raises bad_format
|
|
149
|
+
|
|
150
|
+
problems = run_batch(doc, [{"op": "validate"}])[0]["problems"]
|
|
151
|
+
|
|
152
|
+
schema_status = "skipped (pip install modulor[check] to enable)"
|
|
153
|
+
schema_errors: list[str] = []
|
|
154
|
+
try:
|
|
155
|
+
import jsonschema
|
|
156
|
+
except ImportError:
|
|
157
|
+
jsonschema = None
|
|
158
|
+
if jsonschema is None and args.strict:
|
|
159
|
+
_emit({"ok": False, "doc": args.doc,
|
|
160
|
+
"error": {"code": "bad_format",
|
|
161
|
+
"message": "--strict requires the jsonschema package "
|
|
162
|
+
"for full conformance validation",
|
|
163
|
+
"hint": "pip install modulor[check]"}})
|
|
164
|
+
return 1
|
|
165
|
+
if jsonschema is not None:
|
|
166
|
+
import os as _os
|
|
167
|
+
schema_path = _os.path.join(_os.path.dirname(__file__),
|
|
168
|
+
"document.schema.json")
|
|
169
|
+
with open(schema_path, "r", encoding="utf-8") as f:
|
|
170
|
+
schema = json.load(f)
|
|
171
|
+
validator = jsonschema.Draft202012Validator(schema)
|
|
172
|
+
schema_errors = [f"{e.json_path}: {e.message[:160]}"
|
|
173
|
+
for e in validator.iter_errors(raw)][:20]
|
|
174
|
+
schema_status = "ok" if not schema_errors else "failed"
|
|
175
|
+
|
|
176
|
+
ok = not problems and not schema_errors
|
|
177
|
+
_emit({"ok": ok, "doc": args.doc,
|
|
178
|
+
"format": raw.get("format"), "units": doc.units,
|
|
179
|
+
"entities": len(doc.entities),
|
|
180
|
+
"geometry_problems": problems,
|
|
181
|
+
"schema": schema_status,
|
|
182
|
+
"schema_errors": schema_errors})
|
|
183
|
+
return 0 if ok else 1
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _cmd_run(args) -> int:
|
|
187
|
+
if args.script == "-":
|
|
188
|
+
commands = json.loads(sys.stdin.buffer.read().decode("utf-8-sig"))
|
|
189
|
+
else:
|
|
190
|
+
with open(args.script, "r", encoding="utf-8-sig") as f:
|
|
191
|
+
commands = json.load(f)
|
|
192
|
+
doc = Document.open_or_create(args.doc, units=args.units)
|
|
193
|
+
if getattr(args, "as_recipe", False):
|
|
194
|
+
if isinstance(commands, dict):
|
|
195
|
+
commands = [commands]
|
|
196
|
+
commands = [{"op": "recipe_set", "commands": commands, "run": True}]
|
|
197
|
+
try:
|
|
198
|
+
results = run_batch(doc, commands)
|
|
199
|
+
except BatchError as e:
|
|
200
|
+
_emit({"ok": False, "error": e.to_dict(), "results": e.results,
|
|
201
|
+
"doc": args.doc, "saved": False}, args.pretty)
|
|
202
|
+
return 1
|
|
203
|
+
doc.save()
|
|
204
|
+
_emit({"ok": True, "results": results, "doc": args.doc, "saved": True},
|
|
205
|
+
args.pretty)
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _cmd_op(args) -> int:
|
|
210
|
+
params = json.loads(args.params)
|
|
211
|
+
if not isinstance(params, dict):
|
|
212
|
+
raise CadError("bad_json", "params must be a JSON object")
|
|
213
|
+
doc = Document.open_or_create(args.doc, units=args.units)
|
|
214
|
+
try:
|
|
215
|
+
results = run_batch(doc, [{"op": args.name, **params}])
|
|
216
|
+
except BatchError as e:
|
|
217
|
+
_emit({"ok": False, "error": e.to_dict(), "doc": args.doc,
|
|
218
|
+
"saved": False}, args.pretty)
|
|
219
|
+
return 1
|
|
220
|
+
doc.save()
|
|
221
|
+
_emit({**results[0], "doc": args.doc, "saved": True}, args.pretty)
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _cmd_repl(args) -> int:
|
|
226
|
+
"""One JSON command (or array) per input line -> one JSON result line.
|
|
227
|
+
|
|
228
|
+
The document is saved after every successful line; a failed line is
|
|
229
|
+
rolled back by reloading the last saved state, so each line is atomic.
|
|
230
|
+
"""
|
|
231
|
+
doc = Document.open_or_create(args.doc, units=args.units)
|
|
232
|
+
doc.save()
|
|
233
|
+
stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8-sig")
|
|
234
|
+
_emit({"ok": True, "ready": True, "doc": args.doc,
|
|
235
|
+
"hint": 'one JSON command or array per line; {"op":"help"} lists ops'})
|
|
236
|
+
for line in stdin:
|
|
237
|
+
line = line.strip()
|
|
238
|
+
if not line:
|
|
239
|
+
continue
|
|
240
|
+
if line in ("exit", "quit"):
|
|
241
|
+
break
|
|
242
|
+
try:
|
|
243
|
+
commands = json.loads(line)
|
|
244
|
+
results = run_batch(doc, commands)
|
|
245
|
+
doc.save()
|
|
246
|
+
_emit({"ok": True, "results": results, "saved": True})
|
|
247
|
+
except json.JSONDecodeError as e:
|
|
248
|
+
_emit({"ok": False, "error": {"code": "bad_json",
|
|
249
|
+
"message": f"invalid JSON: {e}"}})
|
|
250
|
+
except BatchError as e:
|
|
251
|
+
doc = Document.load(args.doc) # roll back this line
|
|
252
|
+
_emit({"ok": False, "error": e.to_dict(), "results": e.results,
|
|
253
|
+
"saved": False})
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _emit(obj, pretty: bool = False):
|
|
258
|
+
print(json.dumps(obj, ensure_ascii=False,
|
|
259
|
+
indent=2 if pretty else None))
|
|
260
|
+
sys.stdout.flush()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
sys.exit(main())
|
modulor/document.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Document model: a JSON-serializable scene of layers, materials and entities.
|
|
2
|
+
|
|
3
|
+
The whole document is plain data (dicts/lists) so it round-trips losslessly
|
|
4
|
+
through JSON and is trivially inspectable by agents. Entity ids are "e1",
|
|
5
|
+
"e2", ... and never reused within a document.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from .errors import CadError
|
|
14
|
+
|
|
15
|
+
FORMAT = "modulor/1"
|
|
16
|
+
# documents written before the rename remain readable forever
|
|
17
|
+
LEGACY_FORMATS = ("nativecad/1",)
|
|
18
|
+
UNITS = ("mm", "cm", "m", "in", "ft")
|
|
19
|
+
|
|
20
|
+
DEFAULT_LAYER = {"color": "#222222", "visible": True, "line_width": 1.0}
|
|
21
|
+
DEFAULT_MATERIAL = {"color": "#9aa0a6", "metallic": 0.0, "roughness": 0.8}
|
|
22
|
+
|
|
23
|
+
ENTITY_TYPES_2D = ("line", "polyline", "spline", "circle", "arc", "region",
|
|
24
|
+
"text", "dim", "dim_angular", "dim_radial", "wall",
|
|
25
|
+
"grid", "room")
|
|
26
|
+
ENTITY_TYPES_3D = ("solid",)
|
|
27
|
+
ENTITY_TYPES = ENTITY_TYPES_2D + ENTITY_TYPES_3D
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Document:
|
|
31
|
+
def __init__(self, units: str = "mm", name: str = "untitled"):
|
|
32
|
+
if units not in UNITS:
|
|
33
|
+
raise CadError("bad_param", f"unknown units {units!r}",
|
|
34
|
+
hint=f"use one of {UNITS}")
|
|
35
|
+
self.units = units
|
|
36
|
+
self.meta = {"name": name, "created": _now(), "modified": _now()}
|
|
37
|
+
self.layers: dict[str, dict] = {"0": dict(DEFAULT_LAYER)}
|
|
38
|
+
self.materials: dict[str, dict] = {"default": dict(DEFAULT_MATERIAL)}
|
|
39
|
+
self.entities: dict[str, dict] = {}
|
|
40
|
+
self.params: dict[str, float] = {} # named design parameters
|
|
41
|
+
self.levels: dict[str, dict] = {} # name -> {elevation, height}
|
|
42
|
+
self.recipe: list[dict] = [] # the program that built this doc
|
|
43
|
+
self._counter = 0
|
|
44
|
+
self.path: str | None = None # where this doc lives on disk, if anywhere
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------- expressions
|
|
47
|
+
|
|
48
|
+
def resolve(self, value):
|
|
49
|
+
"""Number params accept expression strings: 'bay*3+200',
|
|
50
|
+
'level("L2")', 'grid_x("B")'. Returns a float."""
|
|
51
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
52
|
+
return float(value)
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
raise CadError("bad_param", f"expected number or expression, "
|
|
55
|
+
f"got {value!r}")
|
|
56
|
+
from .expr import eval_expr
|
|
57
|
+
|
|
58
|
+
def level(name, _key="elevation"):
|
|
59
|
+
if name not in self.levels:
|
|
60
|
+
raise CadError("not_found", f"no level named {name!r}",
|
|
61
|
+
hint=f"levels: {sorted(self.levels)}")
|
|
62
|
+
return float(self.levels[name]["elevation"])
|
|
63
|
+
|
|
64
|
+
def level_top(name):
|
|
65
|
+
lv = self.levels.get(name)
|
|
66
|
+
if lv is None:
|
|
67
|
+
raise CadError("not_found", f"no level named {name!r}")
|
|
68
|
+
return float(lv["elevation"]) + float(lv.get("height", 0.0))
|
|
69
|
+
|
|
70
|
+
def _grid_lookup(axis, label):
|
|
71
|
+
label = str(label)
|
|
72
|
+
for ent in self.entities.values():
|
|
73
|
+
if ent.get("type") != "grid":
|
|
74
|
+
continue
|
|
75
|
+
labels = ent[f"{axis}_labels"]
|
|
76
|
+
coords = ent[f"{axis}s"]
|
|
77
|
+
if label in labels:
|
|
78
|
+
return float(coords[labels.index(label)])
|
|
79
|
+
raise CadError("not_found", f"no grid line labeled {label!r} "
|
|
80
|
+
f"on the {axis} axis")
|
|
81
|
+
|
|
82
|
+
out = eval_expr(value, extra_names=dict(self.params),
|
|
83
|
+
extra_funcs={"level": level, "level_top": level_top,
|
|
84
|
+
"grid_x": lambda s: _grid_lookup("x", s),
|
|
85
|
+
"grid_y": lambda s: _grid_lookup("y", s)})
|
|
86
|
+
import math
|
|
87
|
+
if not math.isfinite(out):
|
|
88
|
+
raise CadError("bad_expr",
|
|
89
|
+
f"expression {value!r} produced a non-finite number")
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------- ids
|
|
93
|
+
|
|
94
|
+
def new_id(self) -> str:
|
|
95
|
+
self._counter += 1
|
|
96
|
+
return f"e{self._counter}"
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------- entities
|
|
99
|
+
|
|
100
|
+
def add_entity(self, etype: str, data: dict, layer: str = "0",
|
|
101
|
+
tag: str | None = None) -> str:
|
|
102
|
+
if etype not in ENTITY_TYPES:
|
|
103
|
+
raise CadError("bad_type", f"unknown entity type {etype!r}")
|
|
104
|
+
if layer not in self.layers:
|
|
105
|
+
# auto-create layers on first use: agents shouldn't have to
|
|
106
|
+
# pre-declare every layer
|
|
107
|
+
self.layers[layer] = dict(DEFAULT_LAYER)
|
|
108
|
+
eid = self.new_id()
|
|
109
|
+
ent = {"type": etype, "layer": layer, **data}
|
|
110
|
+
if tag:
|
|
111
|
+
ent["tag"] = tag
|
|
112
|
+
self.entities[eid] = ent
|
|
113
|
+
return eid
|
|
114
|
+
|
|
115
|
+
def get_entity(self, eid: str) -> dict:
|
|
116
|
+
if not isinstance(eid, str):
|
|
117
|
+
raise CadError("bad_selector",
|
|
118
|
+
f"entity ids are strings, got {eid!r}")
|
|
119
|
+
if eid not in self.entities:
|
|
120
|
+
# ids are generated lowercase ("e7") but renders display them in a
|
|
121
|
+
# caps-only stroke font; accept "E7" so reading an image works
|
|
122
|
+
low = eid.lower() if isinstance(eid, str) else eid
|
|
123
|
+
if low in self.entities:
|
|
124
|
+
return self.entities[low]
|
|
125
|
+
raise CadError("not_found", f"entity {eid!r} does not exist",
|
|
126
|
+
hint="use the 'list' op to see existing ids")
|
|
127
|
+
return self.entities[eid]
|
|
128
|
+
|
|
129
|
+
def delete_entities(self, ids) -> int:
|
|
130
|
+
n = 0
|
|
131
|
+
for eid in ids:
|
|
132
|
+
if eid in self.entities:
|
|
133
|
+
del self.entities[eid]
|
|
134
|
+
n += 1
|
|
135
|
+
return n
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------- selectors
|
|
138
|
+
|
|
139
|
+
def select(self, sel) -> list[str]:
|
|
140
|
+
"""Resolve a selector to entity ids (in creation order).
|
|
141
|
+
|
|
142
|
+
Selector forms:
|
|
143
|
+
"all" -> every entity
|
|
144
|
+
"e12" -> single id
|
|
145
|
+
["e1", "e2"] -> list of ids (errors on unknown id)
|
|
146
|
+
{"ids": [...], "tags": [...], "layers": [...], "types": [...]}
|
|
147
|
+
-> AND of the given filters
|
|
148
|
+
"""
|
|
149
|
+
if sel is None or sel == "all":
|
|
150
|
+
return list(self.entities.keys())
|
|
151
|
+
norm = lambda e: e if e in self.entities else \
|
|
152
|
+
(e.lower() if isinstance(e, str) else e) # noqa: E731
|
|
153
|
+
if isinstance(sel, str):
|
|
154
|
+
self.get_entity(sel)
|
|
155
|
+
return [norm(sel)]
|
|
156
|
+
if isinstance(sel, list):
|
|
157
|
+
for eid in sel:
|
|
158
|
+
self.get_entity(eid)
|
|
159
|
+
return [norm(e) for e in sel]
|
|
160
|
+
if isinstance(sel, dict):
|
|
161
|
+
ids = sel.get("ids")
|
|
162
|
+
if ids:
|
|
163
|
+
ids = [norm(e) for e in ids]
|
|
164
|
+
tags = sel.get("tags") or ([sel["tag"]] if sel.get("tag") else None)
|
|
165
|
+
layers = sel.get("layers") or ([sel["layer"]] if sel.get("layer") else None)
|
|
166
|
+
types = sel.get("types") or ([sel["type"]] if sel.get("type") else None)
|
|
167
|
+
if ids:
|
|
168
|
+
for eid in ids:
|
|
169
|
+
self.get_entity(eid)
|
|
170
|
+
out = []
|
|
171
|
+
for eid, ent in self.entities.items():
|
|
172
|
+
if ids and eid not in ids:
|
|
173
|
+
continue
|
|
174
|
+
if tags and ent.get("tag") not in tags:
|
|
175
|
+
continue
|
|
176
|
+
if layers and ent.get("layer") not in layers:
|
|
177
|
+
continue
|
|
178
|
+
if types and ent.get("type") not in types:
|
|
179
|
+
continue
|
|
180
|
+
out.append(eid)
|
|
181
|
+
return out
|
|
182
|
+
raise CadError("bad_selector", f"cannot interpret selector {sel!r}",
|
|
183
|
+
hint='use "all", an id, a list of ids, or '
|
|
184
|
+
'{"layers": [...], "types": [...], "tags": [...]}')
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------- io
|
|
187
|
+
|
|
188
|
+
def to_dict(self) -> dict:
|
|
189
|
+
return {
|
|
190
|
+
"format": FORMAT,
|
|
191
|
+
"units": self.units,
|
|
192
|
+
"meta": self.meta,
|
|
193
|
+
"counter": self._counter,
|
|
194
|
+
"layers": self.layers,
|
|
195
|
+
"materials": self.materials,
|
|
196
|
+
"params": self.params,
|
|
197
|
+
"levels": self.levels,
|
|
198
|
+
"recipe": self.recipe,
|
|
199
|
+
"entities": self.entities,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def from_dict(cls, d: dict) -> "Document":
|
|
204
|
+
if d.get("format") not in (FORMAT, *LEGACY_FORMATS):
|
|
205
|
+
raise CadError("bad_format", f"not a {FORMAT} document")
|
|
206
|
+
doc = cls(units=d.get("units", "mm"))
|
|
207
|
+
doc.meta = d.get("meta", doc.meta)
|
|
208
|
+
doc._counter = int(d.get("counter", 0))
|
|
209
|
+
doc.layers = d.get("layers", doc.layers)
|
|
210
|
+
doc.materials = d.get("materials", doc.materials)
|
|
211
|
+
doc.params = d.get("params", {})
|
|
212
|
+
doc.levels = d.get("levels", {})
|
|
213
|
+
doc.recipe = d.get("recipe", [])
|
|
214
|
+
doc.entities = d.get("entities", {})
|
|
215
|
+
return doc
|
|
216
|
+
|
|
217
|
+
def save(self, path: str | None = None):
|
|
218
|
+
path = path or self.path
|
|
219
|
+
if not path:
|
|
220
|
+
raise CadError("no_path", "document has no file path")
|
|
221
|
+
self.meta["modified"] = _now()
|
|
222
|
+
d = os.path.dirname(os.path.abspath(path))
|
|
223
|
+
if d and not os.path.isdir(d):
|
|
224
|
+
os.makedirs(d, exist_ok=True)
|
|
225
|
+
tmp = path + ".tmp"
|
|
226
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
227
|
+
json.dump(self.to_dict(), f, ensure_ascii=False, separators=(",", ":"))
|
|
228
|
+
os.replace(tmp, path)
|
|
229
|
+
self.path = path
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def load(cls, path: str) -> "Document":
|
|
233
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
234
|
+
doc = cls.from_dict(json.load(f))
|
|
235
|
+
doc.path = path
|
|
236
|
+
return doc
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def open_or_create(cls, path: str, units: str = "mm") -> "Document":
|
|
240
|
+
if os.path.exists(path):
|
|
241
|
+
return cls.load(path)
|
|
242
|
+
doc = cls(units=units, name=os.path.splitext(os.path.basename(path))[0])
|
|
243
|
+
doc.path = path
|
|
244
|
+
return doc
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _now() -> str:
|
|
248
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S")
|