reverse3d 0.4.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.
Files changed (50) hide show
  1. reverse3d/__init__.py +39 -0
  2. reverse3d/__main__.py +4 -0
  3. reverse3d/_about.py +15 -0
  4. reverse3d/_version.py +1 -0
  5. reverse3d/agent/__init__.py +5 -0
  6. reverse3d/agent/compact.py +110 -0
  7. reverse3d/agent/mcp_server.py +171 -0
  8. reverse3d/agent/prompts.py +173 -0
  9. reverse3d/agent/skill/SKILL.md +53 -0
  10. reverse3d/agent/tools.py +811 -0
  11. reverse3d/autopilot.py +355 -0
  12. reverse3d/cli.py +1054 -0
  13. reverse3d/config.py +167 -0
  14. reverse3d/doctor.py +113 -0
  15. reverse3d/errors.py +83 -0
  16. reverse3d/hardware.py +115 -0
  17. reverse3d/imaging.py +79 -0
  18. reverse3d/logging_utils.py +41 -0
  19. reverse3d/mesh_io.py +152 -0
  20. reverse3d/offline.py +313 -0
  21. reverse3d/profiles.py +33 -0
  22. reverse3d/py.typed +0 -0
  23. reverse3d/recipes.py +394 -0
  24. reverse3d/reconstruction/__init__.py +22 -0
  25. reverse3d/reconstruction/depth_estimation.py +263 -0
  26. reverse3d/reconstruction/photogrammetry.py +460 -0
  27. reverse3d/reconstruction/pointcloud.py +120 -0
  28. reverse3d/rendering/__init__.py +10 -0
  29. reverse3d/rendering/_blender_job.py +284 -0
  30. reverse3d/rendering/blender_render.py +215 -0
  31. reverse3d/rendering/preview.py +80 -0
  32. reverse3d/reverse_eng/__init__.py +33 -0
  33. reverse3d/reverse_eng/geometry_fit.py +478 -0
  34. reverse3d/reverse_eng/measure.py +87 -0
  35. reverse3d/reverse_eng/mesh_cleaner.py +304 -0
  36. reverse3d/reverse_eng/parametric.py +154 -0
  37. reverse3d/security.py +294 -0
  38. reverse3d/setup_agents.py +517 -0
  39. reverse3d/userconfig.py +156 -0
  40. reverse3d/vision/__init__.py +12 -0
  41. reverse3d/vision/feature_matcher.py +272 -0
  42. reverse3d/vision/mask_extractor.py +352 -0
  43. reverse3d/watch.py +238 -0
  44. reverse3d-0.4.0.dist-info/METADATA +650 -0
  45. reverse3d-0.4.0.dist-info/RECORD +50 -0
  46. reverse3d-0.4.0.dist-info/WHEEL +5 -0
  47. reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
  48. reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
  49. reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
  50. reverse3d-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,517 @@
1
+ """Register the reverse3d MCP server in the user's AI agents (``reverse3d setup``).
2
+
3
+ Supported targets and the files they use:
4
+
5
+ =============== ==========================================================
6
+ claude-code ``claude mcp add --scope user`` (official CLI) + Agent Skill
7
+ claude-desktop ``<appdata>/Claude/claude_desktop_config.json``
8
+ cursor ``~/.cursor/mcp.json``
9
+ windsurf ``~/.codeium/windsurf/mcp_config.json``
10
+ vscode ``<appdata>/Code/User/mcp.json`` (GitHub Copilot agent mode)
11
+ gemini ``~/.gemini/settings.json``
12
+ opencode ``~/.config/opencode/opencode.json``
13
+ codex ``~/.codex/config.toml``
14
+ =============== ==========================================================
15
+
16
+ Safety rules (configuration files belong to the user and to other tools):
17
+
18
+ * Only the ``reverse3d`` entry is added, replaced or removed; everything else in
19
+ the file is preserved.
20
+ * Every modified file is backed up first (``<file>.reverse3d-backup-<ts>``).
21
+ * Files that cannot be parsed (e.g. JSON with comments) are **not** modified;
22
+ the manual snippet is reported instead.
23
+ * ``auto`` only touches agents whose configuration folder already exists.
24
+ * The server command is the absolute path of the current Python interpreter,
25
+ so it works without PATH changes and always uses this installation.
26
+
27
+ Auto-approval (``auto_approve=True``, the default) pre-authorises **only the
28
+ reverse3d tools**, so non-technical users are not asked to confirm every call:
29
+
30
+ * Claude Code: ``mcp__reverse3d`` in ``permissions.allow`` of
31
+ ``~/.claude/settings.json``.
32
+ * Gemini CLI: ``"trust": true`` on the reverse3d server entry.
33
+
34
+ This is safe because every tool is confined to the workspace sandbox, never
35
+ overwrites without ``overwrite=true`` and can only change harmless settings
36
+ (see ``docs/security-model.md``). Other agents have no per-server switch; they
37
+ ask once and offer "always allow".
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import os
44
+ import platform
45
+ import shutil
46
+ import sys
47
+ import time
48
+ from collections.abc import Callable
49
+ from dataclasses import asdict, dataclass
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ from reverse3d.errors import InputValidationError, Reverse3DError
54
+ from reverse3d.security import resolve_executable, run_command
55
+
56
+ SERVER_NAME = "reverse3d"
57
+ CLAUDE_PERMISSION = f"mcp__{SERVER_NAME}" # matches every tool of the server
58
+ _CODEX_BEGIN = "# >>> reverse3d (managed by `reverse3d setup`) >>>"
59
+ _CODEX_END = "# <<< reverse3d <<<"
60
+
61
+
62
+ @dataclass
63
+ class SetupResult:
64
+ agent: str
65
+ status: str # configured | removed | unchanged | not_detected | skipped | error | dry_run
66
+ detail: str = ""
67
+ path: str | None = None
68
+ backup: str | None = None
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ return {k: v for k, v in asdict(self).items() if v not in (None, "")}
72
+
73
+
74
+ # ─── locations (functions so tests can monkeypatch them) ──────────────────────
75
+
76
+
77
+ def _home() -> Path:
78
+ return Path.home()
79
+
80
+
81
+ def _appdata() -> Path:
82
+ system = platform.system()
83
+ if system == "Windows":
84
+ return Path(os.environ.get("APPDATA", _home() / "AppData" / "Roaming"))
85
+ if system == "Darwin":
86
+ return _home() / "Library" / "Application Support"
87
+ return Path(os.environ.get("XDG_CONFIG_HOME", _home() / ".config"))
88
+
89
+
90
+ def _xdg_config() -> Path:
91
+ return Path(os.environ.get("XDG_CONFIG_HOME", _home() / ".config"))
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class _Target:
96
+ key: str
97
+ label: str
98
+ path: Callable[[], Path]
99
+ detect: Callable[[], Path]
100
+ style: str # mcpServers | vscode | opencode | codex
101
+
102
+
103
+ TARGETS: dict[str, _Target] = {
104
+ t.key: t
105
+ for t in (
106
+ _Target(
107
+ "claude-desktop",
108
+ "Claude Desktop",
109
+ lambda: _appdata() / "Claude" / "claude_desktop_config.json",
110
+ lambda: _appdata() / "Claude",
111
+ "mcpServers",
112
+ ),
113
+ _Target(
114
+ "cursor",
115
+ "Cursor",
116
+ lambda: _home() / ".cursor" / "mcp.json",
117
+ lambda: _home() / ".cursor",
118
+ "mcpServers",
119
+ ),
120
+ _Target(
121
+ "windsurf",
122
+ "Windsurf",
123
+ lambda: _home() / ".codeium" / "windsurf" / "mcp_config.json",
124
+ lambda: _home() / ".codeium" / "windsurf",
125
+ "mcpServers",
126
+ ),
127
+ _Target(
128
+ "vscode",
129
+ "VS Code (Copilot)",
130
+ lambda: _appdata() / "Code" / "User" / "mcp.json",
131
+ lambda: _appdata() / "Code" / "User",
132
+ "vscode",
133
+ ),
134
+ _Target(
135
+ "gemini",
136
+ "Gemini CLI",
137
+ lambda: _home() / ".gemini" / "settings.json",
138
+ lambda: _home() / ".gemini",
139
+ "mcpServers",
140
+ ),
141
+ _Target(
142
+ "opencode",
143
+ "OpenCode",
144
+ lambda: _xdg_config() / "opencode" / "opencode.json",
145
+ lambda: _xdg_config() / "opencode",
146
+ "opencode",
147
+ ),
148
+ _Target(
149
+ "codex",
150
+ "Codex CLI",
151
+ lambda: _home() / ".codex" / "config.toml",
152
+ lambda: _home() / ".codex",
153
+ "codex",
154
+ ),
155
+ )
156
+ }
157
+ ALL_AGENTS = ("claude-code", *TARGETS)
158
+
159
+
160
+ # ─── server entry ─────────────────────────────────────────────────────────────
161
+
162
+
163
+ def server_command() -> tuple[str, list[str]]:
164
+ """Absolute interpreter + module invocation: independent of PATH."""
165
+ return sys.executable, ["-m", "reverse3d", "agent", "mcp"]
166
+
167
+
168
+ def _entry(style: str, env: dict[str, str]) -> dict[str, Any]:
169
+ command, args = server_command()
170
+ if style == "vscode":
171
+ return {"type": "stdio", "command": command, "args": args, "env": env}
172
+ if style == "opencode":
173
+ return {"type": "local", "command": [command, *args], "enabled": True, "environment": env}
174
+ return {"command": command, "args": args, "env": env}
175
+
176
+
177
+ def _container_key(style: str) -> str:
178
+ return {"vscode": "servers", "opencode": "mcp"}.get(style, "mcpServers")
179
+
180
+
181
+ # ─── JSON targets ─────────────────────────────────────────────────────────────
182
+
183
+
184
+ def _backup(path: Path) -> Path:
185
+ backup = path.with_name(f"{path.name}.reverse3d-backup-{time.strftime('%Y%m%d-%H%M%S')}")
186
+ shutil.copy2(path, backup)
187
+ return backup
188
+
189
+
190
+ def _write_text(path: Path, text: str) -> None:
191
+ path.parent.mkdir(parents=True, exist_ok=True)
192
+ tmp = path.with_name(path.name + ".reverse3d-tmp")
193
+ tmp.write_text(text, encoding="utf-8")
194
+ os.replace(tmp, path)
195
+
196
+
197
+ def _read_json_object(path: Path) -> dict[str, Any] | str:
198
+ """The file's top-level object (``{}`` if missing), or a reason it can't be used."""
199
+ if not path.exists():
200
+ return {}
201
+ try:
202
+ loaded = json.loads(path.read_text(encoding="utf-8") or "{}")
203
+ except (UnicodeDecodeError, json.JSONDecodeError):
204
+ return "Could not parse the file (comments?)"
205
+ if not isinstance(loaded, dict):
206
+ return "Top-level JSON is not an object"
207
+ return loaded
208
+
209
+
210
+ def _setup_json(
211
+ target: _Target, env: dict[str, str], *, remove: bool, dry_run: bool, auto_approve: bool
212
+ ) -> SetupResult:
213
+ path = target.path()
214
+ trust = auto_approve and target.key == "gemini"
215
+ loaded = _read_json_object(path)
216
+ if isinstance(loaded, str):
217
+ entry = _entry(target.style, env) | ({"trust": True} if trust else {})
218
+ snippet = json.dumps({_container_key(target.style): {SERVER_NAME: entry}}, indent=2)
219
+ return SetupResult(
220
+ target.key, "skipped", f"{loaded}. Add this manually:\n{snippet}", str(path)
221
+ )
222
+ data = loaded
223
+
224
+ key = _container_key(target.style)
225
+ container = data.get(key)
226
+ if container is not None and not isinstance(container, dict):
227
+ return SetupResult(target.key, "skipped", f"'{key}' is not an object", str(path))
228
+ container = dict(container or {})
229
+
230
+ if remove:
231
+ if SERVER_NAME not in container:
232
+ return SetupResult(target.key, "unchanged", "not registered", str(path))
233
+ container.pop(SERVER_NAME)
234
+ else:
235
+ new = _entry(target.style, env)
236
+ if trust:
237
+ # Gemini CLI: skip the confirmation prompt for this server's tools only.
238
+ new["trust"] = True
239
+ if container.get(SERVER_NAME) == new:
240
+ return SetupResult(target.key, "unchanged", "already up to date", str(path))
241
+ container[SERVER_NAME] = new
242
+ data[key] = container
243
+
244
+ if dry_run:
245
+ return SetupResult(target.key, "dry_run", "would update", str(path))
246
+ backup = _backup(path) if path.exists() else None
247
+ _write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
248
+ return SetupResult(
249
+ target.key,
250
+ "removed" if remove else "configured",
251
+ target.label,
252
+ str(path),
253
+ str(backup) if backup else None,
254
+ )
255
+
256
+
257
+ # ─── Codex (TOML) ─────────────────────────────────────────────────────────────
258
+
259
+
260
+ def _toml_str(value: str) -> str:
261
+ # JSON string escaping is a valid TOML basic string.
262
+ return json.dumps(value, ensure_ascii=False)
263
+
264
+
265
+ def _codex_block(env: dict[str, str]) -> str:
266
+ command, args = server_command()
267
+ lines = [
268
+ _CODEX_BEGIN,
269
+ f"[mcp_servers.{SERVER_NAME}]",
270
+ f"command = {_toml_str(command)}",
271
+ "args = [" + ", ".join(_toml_str(a) for a in args) + "]",
272
+ "",
273
+ f"[mcp_servers.{SERVER_NAME}.env]",
274
+ *(f"{k} = {_toml_str(v)}" for k, v in env.items()),
275
+ _CODEX_END,
276
+ ]
277
+ return "\n".join(lines) + "\n"
278
+
279
+
280
+ def _setup_codex(
281
+ target: _Target, env: dict[str, str], *, remove: bool, dry_run: bool
282
+ ) -> SetupResult:
283
+ path = target.path()
284
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
285
+ begin, end = text.find(_CODEX_BEGIN), text.find(_CODEX_END)
286
+ managed = begin != -1 and end > begin
287
+ outside = text if not managed else text[:begin] + text[end + len(_CODEX_END) :]
288
+ if f"[mcp_servers.{SERVER_NAME}]" in outside:
289
+ return SetupResult(
290
+ target.key,
291
+ "skipped",
292
+ "A manual [mcp_servers.reverse3d] section exists; left as is.",
293
+ str(path),
294
+ )
295
+ if remove:
296
+ if not managed:
297
+ return SetupResult(target.key, "unchanged", "not registered", str(path))
298
+ new_text = outside.rstrip() + "\n" if outside.strip() else ""
299
+ else:
300
+ block = _codex_block(env)
301
+ if managed and text[begin : end + len(_CODEX_END)] + "\n" == block:
302
+ return SetupResult(target.key, "unchanged", "already up to date", str(path))
303
+ base = outside.rstrip()
304
+ new_text = (base + "\n\n" if base else "") + block
305
+ if dry_run:
306
+ return SetupResult(target.key, "dry_run", "would update", str(path))
307
+ backup = _backup(path) if path.exists() else None
308
+ _write_text(path, new_text)
309
+ return SetupResult(
310
+ target.key,
311
+ "removed" if remove else "configured",
312
+ target.label,
313
+ str(path),
314
+ str(backup) if backup else None,
315
+ )
316
+
317
+
318
+ # ─── Claude Code (official CLI) ───────────────────────────────────────────────
319
+
320
+
321
+ def _claude_cli() -> Path | None:
322
+ try:
323
+ return resolve_executable(["claude", "claude.cmd", "claude.exe"], None, tool="claude")
324
+ except Reverse3DError:
325
+ return None
326
+
327
+
328
+ def skill_source() -> Path:
329
+ return Path(__file__).parent / "agent" / "skill" / "SKILL.md"
330
+
331
+
332
+ def _skill_dest() -> Path:
333
+ return _home() / ".claude" / "skills" / "reverse3d" / "SKILL.md"
334
+
335
+
336
+ def _claude_settings() -> Path:
337
+ return _home() / ".claude" / "settings.json"
338
+
339
+
340
+ def _claude_permissions(*, remove: bool) -> str:
341
+ """Add/remove ``mcp__reverse3d`` in Claude Code's allow list; returns a note."""
342
+ path = _claude_settings()
343
+ data = _read_json_object(path)
344
+ if isinstance(data, str):
345
+ return f'{data}: add "{CLAUDE_PERMISSION}" to permissions.allow in {path} by hand'
346
+ permissions = data.get("permissions")
347
+ if permissions is not None and not isinstance(permissions, dict):
348
+ return f"'permissions' in {path} is not an object; auto-approval skipped"
349
+ permissions = dict(permissions or {})
350
+ allow = permissions.get("allow")
351
+ if allow is not None and not isinstance(allow, list):
352
+ return f"'permissions.allow' in {path} is not a list; auto-approval skipped"
353
+ allow = list(allow or [])
354
+
355
+ if remove:
356
+ if CLAUDE_PERMISSION not in allow:
357
+ return ""
358
+ allow = [rule for rule in allow if rule != CLAUDE_PERMISSION]
359
+ else:
360
+ if CLAUDE_PERMISSION in allow:
361
+ return "tools already auto-approved"
362
+ allow.append(CLAUDE_PERMISSION)
363
+ permissions["allow"] = allow
364
+ data["permissions"] = permissions
365
+ if path.exists():
366
+ _backup(path)
367
+ _write_text(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
368
+ return "auto-approval removed" if remove else "reverse3d tools auto-approved"
369
+
370
+
371
+ def _setup_claude_code(
372
+ env: dict[str, str], *, remove: bool, dry_run: bool, install_skill: bool, auto_approve: bool
373
+ ) -> SetupResult:
374
+ cli = _claude_cli()
375
+ detected = cli is not None or (_home() / ".claude").is_dir()
376
+ if not detected:
377
+ return SetupResult("claude-code", "not_detected")
378
+ notes: list[str] = []
379
+ if dry_run:
380
+ return SetupResult("claude-code", "dry_run", "would run `claude mcp add` + install skill")
381
+
382
+ if cli is not None:
383
+ # Remove first so re-running setup updates the entry (idempotent).
384
+ run_command(
385
+ cli,
386
+ ["mcp", "remove", SERVER_NAME, "--scope", "user"],
387
+ timeout_s=60,
388
+ tool="claude",
389
+ allow_nonzero=True,
390
+ )
391
+ if not remove:
392
+ command, args = server_command()
393
+ env_args = [x for k, v in env.items() for x in ("-e", f"{k}={v}")]
394
+ run_command(
395
+ cli,
396
+ ["mcp", "add", "--scope", "user", *env_args, SERVER_NAME, "--", command, *args],
397
+ timeout_s=60,
398
+ tool="claude",
399
+ )
400
+ notes.append("MCP registered (user scope)")
401
+ else:
402
+ notes.append("`claude` CLI not on PATH: run `claude mcp add` manually (see README)")
403
+
404
+ dest = _skill_dest()
405
+ if remove:
406
+ if dest.exists():
407
+ dest.unlink()
408
+ notes.append("skill removed")
409
+ elif install_skill:
410
+ dest.parent.mkdir(parents=True, exist_ok=True)
411
+ shutil.copyfile(skill_source(), dest)
412
+ notes.append(f"skill installed at {dest}")
413
+ if remove or auto_approve:
414
+ note = _claude_permissions(remove=remove)
415
+ if note:
416
+ notes.append(note)
417
+ status = "removed" if remove else ("configured" if cli else "skipped")
418
+ return SetupResult("claude-code", status, "; ".join(notes))
419
+
420
+
421
+ # ─── public API ───────────────────────────────────────────────────────────────
422
+
423
+
424
+ def parse_agents(spec: str) -> tuple[list[str], bool]:
425
+ """``auto`` | ``all`` | comma list → (agents, only_detected)."""
426
+ spec = spec.strip().lower()
427
+ if spec in ("", "auto"):
428
+ return list(ALL_AGENTS), True
429
+ if spec == "all":
430
+ return list(ALL_AGENTS), False
431
+ agents = [a.strip() for a in spec.split(",") if a.strip()]
432
+ unknown = [a for a in agents if a not in ALL_AGENTS]
433
+ if unknown:
434
+ raise InputValidationError(
435
+ f"Unknown agent(s): {', '.join(unknown)}", hint=f"Valid: {', '.join(ALL_AGENTS)}"
436
+ )
437
+ return agents, False
438
+
439
+
440
+ def setup_agents(
441
+ agents: str = "auto",
442
+ *,
443
+ workspace: Path,
444
+ remove: bool = False,
445
+ dry_run: bool = False,
446
+ install_skill: bool = True,
447
+ auto_approve: bool = True,
448
+ extra_env: dict[str, str] | None = None,
449
+ ) -> list[SetupResult]:
450
+ """Register (or remove) the MCP server in the selected agents.
451
+
452
+ Args:
453
+ agents: ``auto`` (detected agents), ``all`` or a comma list of agent keys.
454
+ workspace: Sandbox folder the server is pinned to.
455
+ remove: Unregister instead of register (also removes auto-approval).
456
+ dry_run: Report what would change without writing anything.
457
+ install_skill: Copy the Claude Code skill.
458
+ auto_approve: Pre-authorise the reverse3d tools where the agent supports
459
+ it per server (Claude Code, Gemini CLI).
460
+ extra_env: Additional environment for the server entry.
461
+
462
+ Raises:
463
+ InputValidationError: ``agents`` names an unknown agent.
464
+ """
465
+ selected, only_detected = parse_agents(agents)
466
+ workspace = workspace.expanduser().resolve()
467
+ if not remove and not dry_run:
468
+ workspace.mkdir(parents=True, exist_ok=True)
469
+ env = {"REVERSE3D_WORKSPACE": str(workspace), **(extra_env or {})}
470
+
471
+ results: list[SetupResult] = []
472
+ for key in selected:
473
+ try:
474
+ if key == "claude-code":
475
+ results.append(
476
+ _setup_claude_code(
477
+ env,
478
+ remove=remove,
479
+ dry_run=dry_run,
480
+ install_skill=install_skill,
481
+ auto_approve=auto_approve,
482
+ )
483
+ )
484
+ continue
485
+ target = TARGETS[key]
486
+ if only_detected and not target.detect().is_dir():
487
+ results.append(SetupResult(key, "not_detected"))
488
+ continue
489
+ if target.style == "codex":
490
+ results.append(_setup_codex(target, env, remove=remove, dry_run=dry_run))
491
+ else:
492
+ results.append(
493
+ _setup_json(
494
+ target, env, remove=remove, dry_run=dry_run, auto_approve=auto_approve
495
+ )
496
+ )
497
+ except (Reverse3DError, OSError) as exc:
498
+ message = exc.message if isinstance(exc, Reverse3DError) else str(exc)
499
+ results.append(SetupResult(key, "error", message))
500
+ return results
501
+
502
+
503
+ def manual_snippets(workspace: Path) -> dict[str, Any]:
504
+ """Configuration snippets for agents the user wants to set up by hand."""
505
+ env = {"REVERSE3D_WORKSPACE": str(workspace.expanduser().resolve())}
506
+ command, args = server_command()
507
+ return {
508
+ "mcpServers": {"mcpServers": {SERVER_NAME: _entry("mcpServers", env)}},
509
+ "vscode": {"servers": {SERVER_NAME: _entry("vscode", env)}},
510
+ "opencode": {"mcp": {SERVER_NAME: _entry("opencode", env)}},
511
+ "codex_toml": _codex_block(env),
512
+ "claude_code": (
513
+ "claude mcp add --scope user "
514
+ f'-e REVERSE3D_WORKSPACE="{env["REVERSE3D_WORKSPACE"]}" '
515
+ f'{SERVER_NAME} -- "{command}" {" ".join(args)}'
516
+ ),
517
+ }
@@ -0,0 +1,156 @@
1
+ """Persistent user configuration (``~/.reverse3d/config.json``).
2
+
3
+ Managed with ``reverse3d config set/unset/show`` and, for a small allowlist of
4
+ harmless keys, by AI agents through the ``reverse3d_configure`` tool.
5
+
6
+ Security notes:
7
+
8
+ * Agents may only change :data:`AGENT_WRITABLE` keys (device, profile,
9
+ verbosity, threads). They can **not** move the workspace sandbox, raise
10
+ resource limits or point ``blender``/``colmap`` to another executable, which
11
+ would amount to arbitrary code execution.
12
+ * Every value is validated by building a :class:`~reverse3d.config.Settings`
13
+ from it before the file is written; the write is atomic.
14
+ * The file holds no secrets; reverse3d needs no credentials.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import tempfile
22
+ from collections.abc import Mapping
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from reverse3d.errors import InputValidationError
27
+
28
+ #: config key → environment variable it maps to.
29
+ CONFIG_KEYS: dict[str, str] = {
30
+ "workspace": "REVERSE3D_WORKSPACE",
31
+ "device": "REVERSE3D_DEVICE",
32
+ "profile": "REVERSE3D_PROFILE",
33
+ "verbosity": "REVERSE3D_VERBOSITY",
34
+ "toolset": "REVERSE3D_TOOLSET",
35
+ "offline": "REVERSE3D_OFFLINE",
36
+ "threads": "REVERSE3D_THREADS",
37
+ "timeout_s": "REVERSE3D_TIMEOUT_S",
38
+ "max_input_mb": "REVERSE3D_MAX_INPUT_MB",
39
+ "max_image_pixels": "REVERSE3D_MAX_IMAGE_PIXELS",
40
+ "max_images": "REVERSE3D_MAX_IMAGES",
41
+ "max_mesh_faces": "REVERSE3D_MAX_MESH_FACES",
42
+ "blender": "REVERSE3D_BLENDER",
43
+ "colmap": "REVERSE3D_COLMAP",
44
+ "log_level": "REVERSE3D_LOG_LEVEL",
45
+ }
46
+
47
+ #: Keys an AI agent may change through the reverse3d_configure tool.
48
+ AGENT_WRITABLE = frozenset({"device", "profile", "verbosity", "threads"})
49
+
50
+ _MAX_CONFIG_BYTES = 64 * 1024
51
+
52
+
53
+ def home_dir() -> Path:
54
+ """Folder holding config.json (``REVERSE3D_HOME`` or ``~/.reverse3d``)."""
55
+ raw = os.environ.get("REVERSE3D_HOME")
56
+ return Path(raw).expanduser() if raw else Path.home() / ".reverse3d"
57
+
58
+
59
+ def config_path() -> Path:
60
+ return home_dir() / "config.json"
61
+
62
+
63
+ def read_config(path: Path | None = None) -> dict[str, str]:
64
+ """Read the config file. A missing file is an empty config.
65
+
66
+ Raises:
67
+ InputValidationError: malformed file or unknown keys.
68
+ """
69
+ path = path or config_path()
70
+ if not path.is_file():
71
+ return {}
72
+ if path.stat().st_size > _MAX_CONFIG_BYTES:
73
+ raise InputValidationError(f"Config file too large: {path}")
74
+ try:
75
+ data = json.loads(path.read_text(encoding="utf-8"))
76
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
77
+ raise InputValidationError(
78
+ f"Invalid JSON in {path}", hint="Fix it or delete it to restore defaults."
79
+ ) from exc
80
+ if not isinstance(data, dict):
81
+ raise InputValidationError(f"{path} must contain a JSON object")
82
+ unknown = set(data) - set(CONFIG_KEYS)
83
+ if unknown:
84
+ raise InputValidationError(
85
+ f"Unknown config keys in {path}: {sorted(unknown)}",
86
+ hint=f"Valid keys: {', '.join(CONFIG_KEYS)}",
87
+ )
88
+ return {k: str(v) for k, v in data.items() if v is not None}
89
+
90
+
91
+ def config_as_env(config: Mapping[str, str]) -> dict[str, str]:
92
+ """Translate config keys into the environment variables they stand for."""
93
+ return {CONFIG_KEYS[k]: v for k, v in config.items() if k in CONFIG_KEYS}
94
+
95
+
96
+ def _validate(config: Mapping[str, str]) -> None:
97
+ from reverse3d.config import Settings
98
+
99
+ Settings.from_env(config_as_env(config))
100
+
101
+
102
+ def _write_atomic(path: Path, data: dict[str, str]) -> None:
103
+ path.parent.mkdir(parents=True, exist_ok=True)
104
+ fd, tmp = tempfile.mkstemp(prefix=".config-", suffix=".json", dir=path.parent)
105
+ try:
106
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
107
+ json.dump(data, fh, indent=2, sort_keys=True)
108
+ fh.write("\n")
109
+ os.replace(tmp, path)
110
+ except BaseException:
111
+ Path(tmp).unlink(missing_ok=True)
112
+ raise
113
+
114
+
115
+ def update_config(
116
+ updates: Mapping[str, Any],
117
+ *,
118
+ path: Path | None = None,
119
+ allowed: frozenset[str] | None = None,
120
+ ) -> dict[str, str]:
121
+ """Validate and persist ``updates``. A value of ``None`` removes the key.
122
+
123
+ Args:
124
+ allowed: restrict which keys may be written (used for agents).
125
+
126
+ Returns:
127
+ The new config dict.
128
+
129
+ Raises:
130
+ InputValidationError: unknown/forbidden key or invalid value.
131
+ """
132
+ path = path or config_path()
133
+ config = read_config(path)
134
+ for key, value in updates.items():
135
+ if key not in CONFIG_KEYS:
136
+ raise InputValidationError(
137
+ f"Unknown config key '{key}'", hint=f"Valid keys: {', '.join(CONFIG_KEYS)}"
138
+ )
139
+ if allowed is not None and key not in allowed:
140
+ raise InputValidationError(
141
+ f"Config key '{key}' cannot be changed from here.",
142
+ hint=f"Allowed: {', '.join(sorted(allowed))}. Use the CLI for the others.",
143
+ )
144
+ if value is None or str(value).strip() == "":
145
+ config.pop(key, None)
146
+ else:
147
+ config[key] = str(value).strip()
148
+ _validate(config)
149
+ _write_atomic(path, config)
150
+ return config
151
+
152
+
153
+ def env_overrides(env: Mapping[str, str] | None = None) -> list[str]:
154
+ """Config keys currently overridden by environment variables."""
155
+ env = os.environ if env is None else env
156
+ return sorted(k for k, var in CONFIG_KEYS.items() if env.get(var))
@@ -0,0 +1,12 @@
1
+ """2D analysis: masks, contours, silhouettes, image quality and feature matching."""
2
+
3
+ from reverse3d.vision.feature_matcher import analyze_image_set, assess_image, match_pair
4
+ from reverse3d.vision.mask_extractor import extract_mask, silhouette_to_mesh
5
+
6
+ __all__ = [
7
+ "analyze_image_set",
8
+ "assess_image",
9
+ "extract_mask",
10
+ "match_pair",
11
+ "silhouette_to_mesh",
12
+ ]