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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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")