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.
- reverse3d/__init__.py +39 -0
- reverse3d/__main__.py +4 -0
- reverse3d/_about.py +15 -0
- reverse3d/_version.py +1 -0
- reverse3d/agent/__init__.py +5 -0
- reverse3d/agent/compact.py +110 -0
- reverse3d/agent/mcp_server.py +171 -0
- reverse3d/agent/prompts.py +173 -0
- reverse3d/agent/skill/SKILL.md +53 -0
- reverse3d/agent/tools.py +811 -0
- reverse3d/autopilot.py +355 -0
- reverse3d/cli.py +1054 -0
- reverse3d/config.py +167 -0
- reverse3d/doctor.py +113 -0
- reverse3d/errors.py +83 -0
- reverse3d/hardware.py +115 -0
- reverse3d/imaging.py +79 -0
- reverse3d/logging_utils.py +41 -0
- reverse3d/mesh_io.py +152 -0
- reverse3d/offline.py +313 -0
- reverse3d/profiles.py +33 -0
- reverse3d/py.typed +0 -0
- reverse3d/recipes.py +394 -0
- reverse3d/reconstruction/__init__.py +22 -0
- reverse3d/reconstruction/depth_estimation.py +263 -0
- reverse3d/reconstruction/photogrammetry.py +460 -0
- reverse3d/reconstruction/pointcloud.py +120 -0
- reverse3d/rendering/__init__.py +10 -0
- reverse3d/rendering/_blender_job.py +284 -0
- reverse3d/rendering/blender_render.py +215 -0
- reverse3d/rendering/preview.py +80 -0
- reverse3d/reverse_eng/__init__.py +33 -0
- reverse3d/reverse_eng/geometry_fit.py +478 -0
- reverse3d/reverse_eng/measure.py +87 -0
- reverse3d/reverse_eng/mesh_cleaner.py +304 -0
- reverse3d/reverse_eng/parametric.py +154 -0
- reverse3d/security.py +294 -0
- reverse3d/setup_agents.py +517 -0
- reverse3d/userconfig.py +156 -0
- reverse3d/vision/__init__.py +12 -0
- reverse3d/vision/feature_matcher.py +272 -0
- reverse3d/vision/mask_extractor.py +352 -0
- reverse3d/watch.py +238 -0
- reverse3d-0.4.0.dist-info/METADATA +650 -0
- reverse3d-0.4.0.dist-info/RECORD +50 -0
- reverse3d-0.4.0.dist-info/WHEEL +5 -0
- reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
- reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
- reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
- 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
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,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.
|