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
reverse3d/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """reverse3d — AI-agent-ready toolkit for 3D reverse engineering.
2
+
3
+ Developed and maintained by Voxel Enterprise (https://voxel.lat).
4
+
5
+ Subpackages are imported lazily by the caller so that ``import reverse3d`` stays
6
+ cheap and never pulls optional heavy stacks (Open3D, PyTorch, PyVista).
7
+ """
8
+
9
+ from reverse3d._about import AUTHOR as _AUTHOR
10
+ from reverse3d._about import COPYRIGHT as _COPYRIGHT
11
+ from reverse3d._about import LICENSE as _LICENSE
12
+ from reverse3d._version import __version__
13
+ from reverse3d.config import Settings
14
+ from reverse3d.errors import (
15
+ DependencyMissingError,
16
+ ExternalToolError,
17
+ InputValidationError,
18
+ ProcessingError,
19
+ Reverse3DError,
20
+ SecurityViolationError,
21
+ )
22
+
23
+ __author__ = _AUTHOR
24
+ __copyright__ = _COPYRIGHT
25
+ __license__ = _LICENSE
26
+
27
+ __all__ = [
28
+ "DependencyMissingError",
29
+ "ExternalToolError",
30
+ "InputValidationError",
31
+ "ProcessingError",
32
+ "Reverse3DError",
33
+ "SecurityViolationError",
34
+ "Settings",
35
+ "__author__",
36
+ "__copyright__",
37
+ "__license__",
38
+ "__version__",
39
+ ]
reverse3d/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from reverse3d.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
reverse3d/_about.py ADDED
@@ -0,0 +1,15 @@
1
+ """Authorship and provenance of reverse3d. Single source of truth.
2
+
3
+ These values are protected: ``scripts/check_authorship.py`` (CI + pre-commit)
4
+ fails if they change, and ``.github/CODEOWNERS`` requires the owner's approval.
5
+ Forks must keep this attribution (Apache-2.0 §4) and may not present
6
+ themselves as the official project (see TRADEMARKS.md).
7
+ """
8
+
9
+ AUTHOR = "Voxel Enterprise"
10
+ AUTHOR_EMAIL = "info@voxel.lat"
11
+ AUTHOR_URL = "https://voxel.lat"
12
+ COPYRIGHT = "Copyright 2026 Voxel Enterprise"
13
+ LICENSE = "Apache-2.0"
14
+ OFFICIAL_REPOSITORY = "https://github.com/rellytechgame/Reverse3D-tool"
15
+ OFFICIAL_DOCS = OFFICIAL_REPOSITORY + "/blob/main/docs/authenticity.md"
reverse3d/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -0,0 +1,5 @@
1
+ """Agent integration: tool schemas, sandboxed dispatcher and MCP server."""
2
+
3
+ from reverse3d.agent.tools import TOOLS, execute_tool, tool_definitions
4
+
5
+ __all__ = ["TOOLS", "execute_tool", "tool_definitions"]
@@ -0,0 +1,110 @@
1
+ """Token-efficient shaping of tool results for LLMs (``verbosity=compact``).
2
+
3
+ * Absolute paths inside the workspace become short relative paths.
4
+ * Floats are rounded to 6 significant digits.
5
+ * ``None`` values and empty containers are dropped.
6
+ * Long lists of objects are truncated with an ``{"_omitted": n}`` marker.
7
+ * Per-tool reducers keep only what an agent needs to decide the next step
8
+ (e.g. only the images *with problems* from an image-set analysis).
9
+
10
+ Nothing is lost: recipes write the full report to disk, and
11
+ ``REVERSE3D_VERBOSITY=full`` disables compaction.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+ from pathlib import Path, PurePath
18
+ from typing import Any
19
+
20
+ _MAX_LIST = 12
21
+
22
+
23
+ def _relativize(value: str, root: str) -> str:
24
+ if len(value) < 3 or not PurePath(value).is_absolute():
25
+ return value
26
+ try:
27
+ rel = Path(value).relative_to(root)
28
+ except ValueError:
29
+ return value
30
+ return rel.as_posix() or "."
31
+
32
+
33
+ def shrink(value: Any, root: str, *, max_list: int = _MAX_LIST) -> Any:
34
+ """Recursively compact a JSON-like structure."""
35
+ if isinstance(value, bool) or value is None or isinstance(value, int):
36
+ return value
37
+ if isinstance(value, float):
38
+ return float(f"{value:.6g}")
39
+ if isinstance(value, str):
40
+ return _relativize(value, root)
41
+ if isinstance(value, dict):
42
+ out = {}
43
+ for key, item in value.items():
44
+ item = shrink(item, root, max_list=max_list)
45
+ if item is None or item == [] or item == {}:
46
+ continue
47
+ out[key] = item
48
+ return out
49
+ if isinstance(value, (list, tuple)):
50
+ items = [shrink(v, root, max_list=max_list) for v in value]
51
+ if len(items) > max_list and any(isinstance(v, dict) for v in items):
52
+ return [*items[:max_list], {"_omitted": len(items) - max_list}]
53
+ return items
54
+ return value
55
+
56
+
57
+ def _reduce_analysis(result: dict[str, Any]) -> dict[str, Any]:
58
+ images = result.get("images", [])
59
+ pairs = result.get("pairs", [])
60
+ return {
61
+ "image_count": result.get("image_count"),
62
+ "suitable_for_photogrammetry": result.get("suitable_for_photogrammetry"),
63
+ "recommendations": result.get("recommendations"),
64
+ "images_with_issues": [
65
+ {"name": i["name"], "issues": i["issues"]} for i in images if i.get("issues")
66
+ ],
67
+ "weak_links": result.get("weak_links"),
68
+ "median_pair_inliers": sorted(p["inliers"] for p in pairs)[len(pairs) // 2]
69
+ if pairs
70
+ else None,
71
+ }
72
+
73
+
74
+ def _reduce_clean(result: dict[str, Any]) -> dict[str, Any]:
75
+ keys = ("faces", "watertight", "components", "boundary_edges", "obb_extents", "volume")
76
+ return {
77
+ "output_path": result.get("output_path"),
78
+ "before": {k: result["before"].get(k) for k in keys},
79
+ "after": {k: result["after"].get(k) for k in keys},
80
+ "steps": result.get("steps"),
81
+ "warnings": result.get("warnings"),
82
+ }
83
+
84
+
85
+ def _reduce_photogrammetry(result: dict[str, Any]) -> dict[str, Any]:
86
+ return {k: v for k, v in result.items() if k not in ("stages", "sparse_model_dir")}
87
+
88
+
89
+ def _reduce_fit(result: dict[str, Any]) -> dict[str, Any]:
90
+ prims = [
91
+ {"kind": p["kind"], "inlier_ratio": p["inlier_ratio"], **p["params"]}
92
+ for p in result.get("primitives", [])
93
+ ]
94
+ return {**{k: v for k, v in result.items() if k != "primitives"}, "primitives": prims}
95
+
96
+
97
+ REDUCERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
98
+ "reverse3d_analyze_images": _reduce_analysis,
99
+ "reverse3d_clean_mesh": _reduce_clean,
100
+ "reverse3d_photogrammetry": _reduce_photogrammetry,
101
+ "reverse3d_fit_primitives": _reduce_fit,
102
+ }
103
+
104
+
105
+ def compact_result(tool: str, result: Any, workspace: Path) -> Any:
106
+ """Apply the tool's reducer (if any) and :func:`shrink`."""
107
+ reducer = REDUCERS.get(tool)
108
+ if reducer is not None and isinstance(result, dict):
109
+ result = reducer(result)
110
+ return shrink(result, str(workspace))
@@ -0,0 +1,171 @@
1
+ """Model Context Protocol (MCP) server over stdio (``[mcp]`` extra).
2
+
3
+ Exposes the reverse3d tools and slash-command prompts to any MCP client
4
+ (Claude Desktop, Claude Code, Cursor, VS Code, Windsurf, Gemini CLI, Codex,
5
+ OpenCode...).
6
+
7
+ * Transport is **stdio only**: no network port is opened, so the server is only
8
+ reachable by the local client process that spawned it.
9
+ * Settings are re-read on every call, so ``reverse3d_configure`` (e.g. switching
10
+ to GPU) takes effect immediately without restarting the client.
11
+ * Tool calls run in a worker thread and are serialised by a semaphore: heavy
12
+ jobs (photogrammetry, rendering) cannot be launched concurrently by a
13
+ runaway agent (OWASP LLM10).
14
+ * All logging goes to stderr; stdout carries only MCP frames.
15
+ * Works with both MCP Python SDK generations: 1.x registers handlers with
16
+ decorators, 2.x passes them to the ``Server`` constructor.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ from collections.abc import Callable
24
+ from typing import Any
25
+
26
+ from reverse3d._version import __version__
27
+ from reverse3d.agent.prompts import PROMPTS, SERVER_INSTRUCTIONS, render_prompt
28
+ from reverse3d.agent.tools import execute_tool, input_schema, visible_tools
29
+ from reverse3d.config import Settings
30
+ from reverse3d.errors import DependencyMissingError
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ SettingsProvider = Callable[[], Settings]
35
+
36
+
37
+ def build_server(settings_provider: SettingsProvider, *, max_concurrent: int = 1) -> Any:
38
+ """Create the MCP ``Server`` with tool and prompt handlers registered.
39
+
40
+ Args:
41
+ settings_provider: returns fresh settings for each request.
42
+
43
+ Raises:
44
+ DependencyMissingError: the ``mcp`` package is not installed.
45
+ """
46
+ try:
47
+ import anyio
48
+ import mcp.types as types
49
+ from mcp.server.lowlevel import Server
50
+ except ImportError as exc:
51
+ raise DependencyMissingError(
52
+ "The MCP server requires the 'mcp' package.", hint='pip install "reverse3d[mcp]"'
53
+ ) from exc
54
+
55
+ limiter = anyio.Semaphore(max(1, max_concurrent))
56
+ toolset = settings_provider().toolset # the tool list is fixed per server session
57
+
58
+ # camelCase keys are field names in SDK 1.x and aliases in 2.x: model_validate
59
+ # accepts the same payload on both generations.
60
+ def tool_list() -> list[Any]:
61
+ return [
62
+ types.Tool.model_validate(
63
+ {"name": s.name, "description": s.description, "inputSchema": input_schema(s)}
64
+ )
65
+ for s in visible_tools(toolset)
66
+ ]
67
+
68
+ def prompt_list() -> list[Any]:
69
+ return [
70
+ types.Prompt.model_validate(
71
+ {
72
+ "name": p.name,
73
+ "description": p.description,
74
+ "arguments": [
75
+ {"name": a.name, "description": a.description, "required": a.required}
76
+ for a in p.arguments
77
+ ],
78
+ }
79
+ )
80
+ for p in PROMPTS.values()
81
+ ]
82
+
83
+ def prompt_result(name: str, arguments: dict[str, str] | None) -> Any:
84
+ try:
85
+ text = render_prompt(name, arguments)
86
+ except KeyError:
87
+ text = f"Unknown prompt '{name}'. Available: {', '.join(PROMPTS)}"
88
+ except ValueError as exc:
89
+ text = str(exc)
90
+ return types.GetPromptResult.model_validate(
91
+ {
92
+ "description": PROMPTS[name].description if name in PROMPTS else None,
93
+ "messages": [{"role": "user", "content": {"type": "text", "text": text}}],
94
+ }
95
+ )
96
+
97
+ async def run_tool(name: str, arguments: dict[str, Any] | None) -> tuple[Any, bool]:
98
+ async with limiter:
99
+ result = await anyio.to_thread.run_sync(
100
+ execute_tool, name, arguments, settings_provider()
101
+ )
102
+ text = types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))
103
+ return text, not result.get("ok", False)
104
+
105
+ if hasattr(Server, "list_tools"): # MCP SDK 1.x: decorator registration
106
+ try:
107
+ server: Any = Server("reverse3d", instructions=SERVER_INSTRUCTIONS)
108
+ except TypeError: # very old 1.x without instructions support
109
+ server = Server("reverse3d")
110
+
111
+ @server.list_tools()
112
+ async def list_tools() -> list[Any]:
113
+ return tool_list()
114
+
115
+ @server.call_tool()
116
+ async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[Any]:
117
+ content, _ = await run_tool(name, arguments)
118
+ return [content]
119
+
120
+ @server.list_prompts()
121
+ async def list_prompts() -> list[Any]:
122
+ return prompt_list()
123
+
124
+ @server.get_prompt()
125
+ async def get_prompt(name: str, arguments: dict[str, str] | None) -> Any:
126
+ return prompt_result(name, arguments)
127
+
128
+ return server
129
+
130
+ # MCP SDK 2.x: constructor registration with (ctx, params) handlers.
131
+ async def on_list_tools(ctx: Any, params: Any) -> Any:
132
+ return types.ListToolsResult.model_validate({"tools": tool_list()})
133
+
134
+ async def on_call_tool(ctx: Any, params: Any) -> Any:
135
+ content, is_error = await run_tool(params.name, params.arguments)
136
+ return types.CallToolResult.model_validate({"content": [content], "isError": is_error})
137
+
138
+ async def on_list_prompts(ctx: Any, params: Any) -> Any:
139
+ return types.ListPromptsResult.model_validate({"prompts": prompt_list()})
140
+
141
+ async def on_get_prompt(ctx: Any, params: Any) -> Any:
142
+ return prompt_result(params.name, params.arguments)
143
+
144
+ from reverse3d._about import AUTHOR, AUTHOR_URL
145
+
146
+ return Server(
147
+ "reverse3d",
148
+ version=__version__,
149
+ title="reverse3d",
150
+ description=f"3D modeling and reverse engineering toolkit by {AUTHOR}",
151
+ website_url=AUTHOR_URL,
152
+ instructions=SERVER_INSTRUCTIONS,
153
+ on_list_tools=on_list_tools,
154
+ on_call_tool=on_call_tool,
155
+ on_list_prompts=on_list_prompts,
156
+ on_get_prompt=on_get_prompt,
157
+ )
158
+
159
+
160
+ def serve(settings_provider: SettingsProvider, *, max_concurrent: int = 1) -> None:
161
+ """Run the MCP stdio server until the client disconnects."""
162
+ server = build_server(settings_provider, max_concurrent=max_concurrent)
163
+ import anyio
164
+ from mcp.server.stdio import stdio_server
165
+
166
+ async def _main() -> None:
167
+ async with stdio_server() as (read_stream, write_stream):
168
+ await server.run(read_stream, write_stream, server.create_initialization_options())
169
+
170
+ logger.info("reverse3d MCP server starting (workspace=%s)", settings_provider().workspace.name)
171
+ anyio.run(_main)
@@ -0,0 +1,173 @@
1
+ """MCP prompts: ready-made commands that clients show as slash commands.
2
+
3
+ Example in Claude Code: ``/mcp__reverse3d__fotos-a-3d capturas/taza``. Each
4
+ prompt expands into a short instruction that makes the agent call the right
5
+ tool in one step, so users don't need to know tool names or arguments.
6
+
7
+ Prompt arguments come from the user and are only interpolated into text sent
8
+ to the model (never executed); they are length-capped.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ _ARG_MAX = 300
16
+
17
+ #: Short guidance sent to MCP clients that support server instructions.
18
+ SERVER_INSTRUCTIONS = (
19
+ "reverse3d: local 3D reverse-engineering tools. Prefer reverse3d_run_recipe (one call per "
20
+ "workflow; recipe=auto when unsure of the input type). Works offline. Paths are relative "
21
+ "to the workspace. Use reverse3d_configure to switch "
22
+ "device (cpu/gpu) or profile (fast/balanced/quality). If a result has ok=false, read "
23
+ "error.hint and retry. Report sizes with units and show the render path to the user."
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class PromptArg:
29
+ name: str
30
+ description: str
31
+ required: bool = True
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class PromptSpec:
36
+ name: str
37
+ description: str
38
+ template: str
39
+ arguments: tuple[PromptArg, ...] = field(default_factory=tuple)
40
+
41
+
42
+ PROMPTS: dict[str, PromptSpec] = {
43
+ p.name: p
44
+ for p in (
45
+ PromptSpec(
46
+ "procesar",
47
+ "Lo que sea (fotos, plano, foto o malla) → detecta qué es y lo procesa solo.",
48
+ "Run reverse3d_run_recipe with recipe=auto, input_path='{entrada}', "
49
+ "output_dir='{salida}'. Tell the user what was detected, the dimensions, the "
50
+ "result files and the next_steps from the summary. If it failed, explain the hint.",
51
+ (
52
+ PromptArg("entrada", "Carpeta de fotos, imagen o archivo 3D dentro del workspace"),
53
+ PromptArg("salida", "Carpeta de resultados", required=False),
54
+ ),
55
+ ),
56
+ PromptSpec(
57
+ "fotos-a-3d",
58
+ "Fotos de un objeto → modelo 3D medido y renderizado (fotogrametría).",
59
+ "Run reverse3d_run_recipe with recipe=photos_to_model, input_path='{carpeta}', "
60
+ "output_dir='{salida}'. If the photo check fails, explain the recommendations to the "
61
+ "user before retrying. Then report the dimensions and the render path.",
62
+ (
63
+ PromptArg("carpeta", "Carpeta con las fotos, dentro del workspace"),
64
+ PromptArg("salida", "Carpeta de resultados", required=False),
65
+ ),
66
+ ),
67
+ PromptSpec(
68
+ "plano-a-pieza",
69
+ "Plano, careta o silueta → pieza 3D extruida con escala real.",
70
+ "Run reverse3d_run_recipe with recipe=plan_to_part, input_path='{imagen}', "
71
+ "output_dir='{salida}', thickness={espesor}{escala}. Report the dimensions and the "
72
+ "render path.",
73
+ (
74
+ PromptArg("imagen", "Imagen del plano o careta"),
75
+ PromptArg("espesor", "Espesor de extrusión (p. ej. 5)", required=False),
76
+ PromptArg("mm_por_pixel", "Escala, milímetros por píxel", required=False),
77
+ PromptArg("salida", "Carpeta de resultados", required=False),
78
+ ),
79
+ ),
80
+ PromptSpec(
81
+ "escaneo-a-cad",
82
+ "Malla o escaneo → limpieza, cotas, primitivas y código CAD (OpenSCAD/CadQuery).",
83
+ "Run reverse3d_run_recipe with recipe=scan_to_cad, input_path='{malla}', "
84
+ "output_dir='{salida}'. Summarise the dimensions, the detected primitives "
85
+ "(radii, planes) and the generated CAD files.",
86
+ (
87
+ PromptArg("malla", "Archivo STL/OBJ/PLY/GLB"),
88
+ PromptArg("salida", "Carpeta de resultados", required=False),
89
+ ),
90
+ ),
91
+ PromptSpec(
92
+ "foto-a-relieve",
93
+ "Una sola foto → relieve 2.5D aproximado (IA de profundidad).",
94
+ "Run reverse3d_run_recipe with recipe=photo_to_relief, input_path='{imagen}', "
95
+ "output_dir='{salida}'. Remind the user that the depth is relative, not metric.",
96
+ (
97
+ PromptArg("imagen", "Foto de entrada"),
98
+ PromptArg("salida", "Carpeta de resultados", required=False),
99
+ ),
100
+ ),
101
+ PromptSpec(
102
+ "medir",
103
+ "Cotas, volumen y render de una malla existente.",
104
+ "Run reverse3d_run_recipe with recipe=mesh_report, input_path='{malla}', "
105
+ "output_dir='{salida}'. Report the dimensions in a small table.",
106
+ (
107
+ PromptArg("malla", "Archivo de malla"),
108
+ PromptArg("salida", "Carpeta de resultados", required=False),
109
+ ),
110
+ ),
111
+ PromptSpec(
112
+ "usar-gpu",
113
+ "Usar la GPU (CUDA/Apple) en todas las operaciones.",
114
+ "Call reverse3d_configure with device='gpu'. Then tell the user which GPU was "
115
+ "detected, or that it will fall back to CPU if none is usable.",
116
+ ),
117
+ PromptSpec(
118
+ "usar-cpu",
119
+ "Forzar CPU en todas las operaciones.",
120
+ "Call reverse3d_configure with device='cpu' and confirm the new setting.",
121
+ ),
122
+ PromptSpec(
123
+ "rendimiento",
124
+ "Elegir perfil: fast (rápido), balanced (equilibrado) o quality (máxima calidad).",
125
+ "Call reverse3d_configure with profile='{perfil}' and confirm the new setting.",
126
+ (PromptArg("perfil", "fast | balanced | quality"),),
127
+ ),
128
+ PromptSpec(
129
+ "estado",
130
+ "Ver capacidades instaladas, GPU detectada y configuración actual.",
131
+ "Call reverse3d_environment_check and reverse3d_configure (no arguments). Summarise "
132
+ "in 5 lines what is available and what is missing, with how to install it.",
133
+ ),
134
+ )
135
+ }
136
+
137
+
138
+ def _clip(value: str) -> str:
139
+ return value.replace("'", "").replace("\n", " ").strip()[:_ARG_MAX]
140
+
141
+
142
+ def render_prompt(name: str, arguments: dict[str, str] | None) -> str:
143
+ """Expand a prompt template with user arguments and sensible defaults.
144
+
145
+ Raises:
146
+ KeyError: unknown prompt.
147
+ ValueError: missing required argument.
148
+ """
149
+ spec = PROMPTS[name]
150
+ args = {k: _clip(str(v)) for k, v in (arguments or {}).items() if v not in (None, "")}
151
+ for arg in spec.arguments:
152
+ if arg.required and arg.name not in args:
153
+ raise ValueError(f"Missing argument: {arg.name}")
154
+ source = (
155
+ args.get("entrada")
156
+ or args.get("carpeta")
157
+ or args.get("imagen")
158
+ or args.get("malla")
159
+ or "input"
160
+ )
161
+ stem = source.rstrip("/\\").replace("\\", "/").split("/")[-1].rsplit(".", 1)[0] or "result"
162
+ values = {
163
+ "salida": args.get("salida", f"out/{stem}"),
164
+ "espesor": args.get("espesor", "5"),
165
+ "escala": f", units_per_pixel={args['mm_por_pixel']}" if "mm_por_pixel" in args else "",
166
+ **args,
167
+ }
168
+ return spec.template.format_map(_Defaults(values))
169
+
170
+
171
+ class _Defaults(dict[str, str]):
172
+ def __missing__(self, key: str) -> str:
173
+ return ""
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: reverse3d
3
+ description: 3D modeling and reverse engineering with the local reverse3d toolkit. Use when the user wants to turn photos, a plan/stencil/silhouette, or an existing mesh (STL/OBJ/PLY/GLB) into a 3D model, measure dimensions, repair a mesh, get CAD code (OpenSCAD/CadQuery), render a preview, or switch reverse3d between CPU and GPU.
4
+ ---
5
+
6
+ # reverse3d
7
+
8
+ Local toolkit by Voxel Enterprise. Exposed as MCP tools named `reverse3d_*`. If those tools are not available, use the CLI: `reverse3d --json agent call <tool> --args '<json>'`. It has the same sandbox and the same output.
9
+
10
+ ## Golden rules (save tokens)
11
+
12
+ 1. **Use `reverse3d_run_recipe` first.** One call runs a whole workflow and returns a short summary. Full details go to `<output_dir>/report.json`; read that file only if the user asks for details.
13
+ 2. Paths are **relative to the workspace** (`REVERSE3D_WORKSPACE`). If the user attached a file, save it inside the workspace first.
14
+ 3. On `ok:false`, read `error.hint`, fix the arguments and retry once. Do not loop.
15
+ 4. Report sizes with units and give the user the `render` path. Say clearly when values are estimates.
16
+
17
+ ## Recipes
18
+
19
+ | User wants | recipe | input_path |
20
+ |---|---|---|
21
+ | **Not sure what the input is / "process this"** | `auto` (detects it and retries smartly) | anything |
22
+ | Photos of an object → 3D model | `photos_to_model` | folder of photos |
23
+ | Plan, stencil or silhouette → part | `plan_to_part` (+ `thickness`, `units_per_pixel`) | image |
24
+ | Scan or mesh → clean + dimensions + CAD | `scan_to_cad` | mesh |
25
+ | A single photo → approximate relief | `photo_to_relief` | image |
26
+ | Dimensions + preview of a mesh | `mesh_report` | mesh |
27
+
28
+ Example: `reverse3d_run_recipe {"recipe":"plan_to_part","input_path":"in/bracket.png","output_dir":"out/bracket","thickness":3,"units_per_pixel":0.5}`.
29
+
30
+ Scale tips: if the user knows one real dimension (e.g. "the plate is 100 mm wide and spans 200 px"), then `units_per_pixel = 100/200`. To convert meters to millimeters, use `scale: 1000`.
31
+
32
+ `auto` returns `auto.detected` (what it found), `next_steps` (plain-language advice) and writes `RESUMEN.md` next to the results: relay those to the user.
33
+
34
+ ## Offline
35
+
36
+ reverse3d works without internet. It switches to offline mode by itself when there is no connection. The only thing that needs a download is the AI depth model, and the installer fetches it. If a result says the model is missing, tell the user to run `reverse3d offline prepare` once while online. `reverse3d offline status` shows what works offline.
37
+
38
+ ## Autopilot without an agent
39
+
40
+ `reverse3d watch` watches `<workspace>/bandeja`: anything dropped there is processed automatically into `<workspace>/resultados/<name>/`. Suggest it to users who want to work hands-free.
41
+
42
+ ## Device and performance
43
+
44
+ - "use the GPU" → `reverse3d_configure {"device":"gpu"}`. It falls back to CPU if no usable GPU.
45
+ - "use CPU" → `{"device":"cpu"}`. Faster / better quality → `{"profile":"fast"}` or `{"profile":"quality"}`.
46
+ - Call `reverse3d_configure {}` to show the current settings and the detected GPU.
47
+ - Call `reverse3d_environment_check` when something is missing (COLMAP, Blender, extras). It reports how to install it.
48
+
49
+ ## Individual tools (only for custom steps)
50
+
51
+ `analyze_images`, `match_images`, `extract_mask`, `silhouette_to_mesh`, `photogrammetry`, `estimate_depth`, `mesh_from_point_cloud`, `clean_mesh`, `measure_mesh`, `fit_primitives`, `render`. All of them are prefixed with `reverse3d_`.
52
+
53
+ Never overwrite user files unless the user asks. Pass `overwrite:true` only then.