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,304 @@
1
+ """Mesh repair, remeshing and alignment for scanned / reconstructed geometry.
2
+
3
+ Pipeline (each step optional, executed in this order)::
4
+
5
+ sanitize (NaN/inf, degenerate & duplicate faces, merge vertices)
6
+ → component filtering (drop floating debris)
7
+ → normal/winding repair
8
+ → hole filling (small holes by trimesh, larger loops by centroid fan)
9
+ → Taubin smoothing (volume-preserving, unlike Laplacian)
10
+ → quadric decimation to a target face count
11
+ → alignment (OBB / PCA / center) + optional ground placement
12
+ → uniform scale (unit conversion)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from dataclasses import asdict, dataclass, field
19
+ from pathlib import Path
20
+ from typing import Literal, cast
21
+
22
+ import numpy as np
23
+ import trimesh
24
+
25
+ from reverse3d.config import Settings
26
+ from reverse3d.errors import DependencyMissingError, InputValidationError, ProcessingError
27
+ from reverse3d.mesh_io import load_mesh, save_mesh
28
+ from reverse3d.reverse_eng.measure import MeshStats, compute_stats
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ AlignMode = Literal["none", "center", "obb", "pca"]
33
+
34
+
35
+ @dataclass
36
+ class CleanOptions:
37
+ """Options for :func:`clean_mesh`. Defaults are safe for most scans."""
38
+
39
+ keep_largest_only: bool = False
40
+ min_component_ratio: float = 0.01
41
+ fix_normals: bool = True
42
+ fill_holes: bool = True
43
+ max_hole_edges: int = 200
44
+ smooth_iterations: int = 0
45
+ target_faces: int | None = None
46
+ align: AlignMode = "none"
47
+ ground: bool = False
48
+ scale: float = 1.0
49
+
50
+ def validate(self) -> None:
51
+ if not 0 <= self.min_component_ratio < 1:
52
+ raise InputValidationError("min_component_ratio must be in [0, 1).")
53
+ if not 3 <= self.max_hole_edges <= 100_000:
54
+ raise InputValidationError("max_hole_edges must be in [3, 100000].")
55
+ if not 0 <= self.smooth_iterations <= 200:
56
+ raise InputValidationError("smooth_iterations must be in [0, 200].")
57
+ if self.target_faces is not None and self.target_faces < 4:
58
+ raise InputValidationError("target_faces must be >= 4.")
59
+ if self.align not in ("none", "center", "obb", "pca"):
60
+ raise InputValidationError("align must be none, center, obb or pca.")
61
+ if not 1e-9 < self.scale < 1e9:
62
+ raise InputValidationError("scale must be a positive, finite factor.")
63
+
64
+
65
+ @dataclass
66
+ class CleanReport:
67
+ """What :func:`clean_mesh` did, with before/after statistics."""
68
+
69
+ before: MeshStats
70
+ after: MeshStats
71
+ steps: list[str] = field(default_factory=list)
72
+ warnings: list[str] = field(default_factory=list)
73
+ output_path: str | None = None
74
+
75
+ def to_dict(self) -> dict[str, object]:
76
+ return asdict(self)
77
+
78
+
79
+ # ─── individual operations ────────────────────────────────────────────────────
80
+
81
+
82
+ def sanitize(mesh: trimesh.Trimesh) -> int:
83
+ """Remove NaN/inf vertices, degenerate and duplicate faces, merge coincident
84
+ vertices. Returns the number of faces removed."""
85
+ start = len(mesh.faces)
86
+ mesh.remove_infinite_values()
87
+ mesh.merge_vertices()
88
+ mesh.update_faces(mesh.nondegenerate_faces())
89
+ mesh.update_faces(mesh.unique_faces())
90
+ mesh.remove_unreferenced_vertices()
91
+ return start - len(mesh.faces)
92
+
93
+
94
+ def filter_components(
95
+ mesh: trimesh.Trimesh, *, keep_largest_only: bool, min_ratio: float
96
+ ) -> tuple[trimesh.Trimesh, int]:
97
+ """Drop small disconnected pieces. Returns ``(mesh, removed_count)``.
98
+
99
+ Size is measured by surface area, not face count: scan debris is often
100
+ densely tessellated while the real part may be a low-poly CAD body.
101
+ """
102
+ parts = mesh.split(only_watertight=False)
103
+ if len(parts) <= 1:
104
+ return mesh, 0
105
+ parts = sorted(parts, key=lambda m: m.area, reverse=True)
106
+ if keep_largest_only:
107
+ kept = parts[:1]
108
+ else:
109
+ threshold = min_ratio * parts[0].area
110
+ kept = [p for p in parts if p.area >= threshold]
111
+ return cast(trimesh.Trimesh, trimesh.util.concatenate(kept)), len(parts) - len(kept)
112
+
113
+
114
+ def _boundary_loops(mesh: trimesh.Trimesh) -> list[list[int]]:
115
+ """Return closed boundary loops as ordered vertex lists, oriented like the
116
+ adjacent faces' edges. Loops through non-manifold vertices are skipped."""
117
+ faces = mesh.faces
118
+ directed = np.concatenate([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]])
119
+ single = np.asarray(
120
+ trimesh.grouping.group_rows(np.sort(directed, axis=1), require_count=1)
121
+ ).reshape(-1)
122
+ if single.size == 0:
123
+ return []
124
+ boundary = directed[single]
125
+
126
+ successors: dict[int, int] = {}
127
+ ambiguous: set[int] = set()
128
+ for a, b in boundary:
129
+ if a in successors:
130
+ ambiguous.add(int(a))
131
+ successors[int(a)] = int(b)
132
+
133
+ loops: list[list[int]] = []
134
+ visited: set[int] = set()
135
+ for start in successors:
136
+ if start in visited or start in ambiguous:
137
+ continue
138
+ loop = [start]
139
+ visited.add(start)
140
+ current = successors[start]
141
+ valid = True
142
+ while current != start:
143
+ if current in visited or current in ambiguous or current not in successors:
144
+ valid = False
145
+ break
146
+ loop.append(current)
147
+ visited.add(current)
148
+ current = successors[current]
149
+ if valid and len(loop) >= 3:
150
+ loops.append(loop)
151
+ return loops
152
+
153
+
154
+ def fill_boundary_loops(mesh: trimesh.Trimesh, max_hole_edges: int) -> tuple[trimesh.Trimesh, int]:
155
+ """Close every boundary loop up to ``max_hole_edges`` edges with a fan of
156
+ triangles around the loop centroid. Returns ``(mesh, holes_filled)``.
157
+
158
+ Suitable for roughly planar/convex holes typical of scan occlusions; very
159
+ large or saddle-shaped holes need a dedicated surface reconstruction.
160
+ """
161
+ loops = [loop for loop in _boundary_loops(mesh) if len(loop) <= max_hole_edges]
162
+ if not loops:
163
+ return mesh, 0
164
+ vertices: list[np.ndarray] = [np.asarray(mesh.vertices)]
165
+ new_faces: list[np.ndarray] = [np.asarray(mesh.faces)]
166
+ next_index = len(mesh.vertices)
167
+ for loop in loops:
168
+ # Existing faces traverse a→b; the patch must traverse b→a to keep winding consistent.
169
+ if len(loop) == 3:
170
+ new_faces.append(np.array([[loop[2], loop[1], loop[0]]]))
171
+ continue
172
+ centroid = mesh.vertices[loop].mean(axis=0)
173
+ vertices.append(centroid[None, :])
174
+ ring = np.array(loop)
175
+ fan = np.stack([np.roll(ring, -1), ring, np.full(len(ring), next_index)], axis=1)
176
+ new_faces.append(fan)
177
+ next_index += 1
178
+ filled = trimesh.Trimesh(
179
+ vertices=np.vstack(vertices), faces=np.vstack(new_faces), process=False
180
+ )
181
+ return filled, len(loops)
182
+
183
+
184
+ def align_mesh(mesh: trimesh.Trimesh, mode: AlignMode) -> None:
185
+ """Align in place. ``obb``: minimum oriented box → axes (largest extent on X);
186
+ ``pca``: principal axes of the vertices; ``center``: bbox center to origin."""
187
+ if mode == "none":
188
+ return
189
+ if mode == "center":
190
+ mesh.apply_translation(-mesh.bounds.mean(axis=0))
191
+ return
192
+ if mode == "obb":
193
+ to_origin, extents = trimesh.bounds.oriented_bounds(mesh)
194
+ mesh.apply_transform(to_origin)
195
+ order = np.argsort(extents)[::-1]
196
+ permute = np.eye(4)
197
+ permute[:3, :3] = np.eye(3)[order]
198
+ if np.linalg.det(permute[:3, :3]) < 0:
199
+ permute[2, :3] *= -1
200
+ mesh.apply_transform(permute)
201
+ return
202
+ # PCA
203
+ centered = mesh.vertices - mesh.vertices.mean(axis=0)
204
+ _, _, vt = np.linalg.svd(centered, full_matrices=False)
205
+ rotation = vt.copy()
206
+ if np.linalg.det(rotation) < 0:
207
+ rotation[2] *= -1
208
+ transform = np.eye(4)
209
+ transform[:3, :3] = rotation
210
+ transform[:3, 3] = -rotation @ mesh.vertices.mean(axis=0)
211
+ mesh.apply_transform(transform)
212
+
213
+
214
+ # ─── public API ───────────────────────────────────────────────────────────────
215
+
216
+
217
+ def clean_mesh(
218
+ mesh: trimesh.Trimesh, options: CleanOptions | None = None
219
+ ) -> tuple[trimesh.Trimesh, CleanReport]:
220
+ """Repair ``mesh`` and return ``(clean_mesh, report)``. The input is not modified.
221
+
222
+ Raises:
223
+ InputValidationError: invalid options.
224
+ ProcessingError: the mesh became empty.
225
+ DependencyMissingError: decimation requested without ``fast-simplification``.
226
+ """
227
+ options = options or CleanOptions()
228
+ options.validate()
229
+ mesh = mesh.copy()
230
+ before = compute_stats(mesh)
231
+ steps: list[str] = []
232
+ warnings: list[str] = []
233
+
234
+ removed = sanitize(mesh)
235
+ steps.append(f"sanitize: removed {removed} degenerate/duplicate face(s)")
236
+
237
+ mesh, dropped = filter_components(
238
+ mesh, keep_largest_only=options.keep_largest_only, min_ratio=options.min_component_ratio
239
+ )
240
+ if dropped:
241
+ steps.append(f"components: dropped {dropped} small piece(s)")
242
+
243
+ if options.fill_holes:
244
+ trimesh.repair.fill_holes(mesh)
245
+ mesh, filled = fill_boundary_loops(mesh, options.max_hole_edges)
246
+ mesh.merge_vertices()
247
+ steps.append(f"holes: filled {filled} boundary loop(s)")
248
+
249
+ if options.fix_normals:
250
+ trimesh.repair.fix_normals(mesh)
251
+ steps.append("normals: winding and orientation fixed")
252
+
253
+ if options.smooth_iterations:
254
+ trimesh.smoothing.filter_taubin(mesh, iterations=options.smooth_iterations)
255
+ steps.append(f"smooth: Taubin x{options.smooth_iterations}")
256
+
257
+ if options.target_faces and len(mesh.faces) > options.target_faces:
258
+ try:
259
+ mesh = mesh.simplify_quadric_decimation(face_count=options.target_faces)
260
+ except ImportError as exc:
261
+ raise DependencyMissingError(
262
+ "Decimation requires the 'fast-simplification' package.",
263
+ hint="pip install fast-simplification",
264
+ ) from exc
265
+ steps.append(f"decimate: {len(mesh.faces)} face(s)")
266
+
267
+ if len(mesh.faces) == 0:
268
+ raise ProcessingError("Mesh is empty after cleaning; relax the options.")
269
+
270
+ align_mesh(mesh, options.align)
271
+ if options.align != "none":
272
+ steps.append(f"align: {options.align}")
273
+ if options.ground:
274
+ mesh.apply_translation([0.0, 0.0, -mesh.bounds[0][2]])
275
+ steps.append("ground: min Z moved to 0")
276
+ if options.scale != 1.0:
277
+ mesh.apply_scale(options.scale)
278
+ steps.append(f"scale: x{options.scale}")
279
+
280
+ after = compute_stats(mesh)
281
+ if not after.watertight:
282
+ warnings.append(
283
+ f"Mesh is still open ({after.boundary_edges} boundary edges); "
284
+ "raise max_hole_edges or re-scan the missing areas."
285
+ )
286
+ return mesh, CleanReport(before=before, after=after, steps=steps, warnings=warnings)
287
+
288
+
289
+ def clean_mesh_file(
290
+ input_path: str | Path,
291
+ output_path: str | Path,
292
+ settings: Settings,
293
+ options: CleanOptions | None = None,
294
+ *,
295
+ overwrite: bool = False,
296
+ ) -> CleanReport:
297
+ """Load, clean and save a mesh file. Returns the :class:`CleanReport`."""
298
+ mesh = load_mesh(input_path, settings)
299
+ cleaned, report = clean_mesh(mesh, options)
300
+ report.output_path = str(save_mesh(cleaned, output_path, overwrite=overwrite))
301
+ logger.info(
302
+ "Cleaned %s: %d → %d faces", Path(input_path).name, report.before.faces, report.after.faces
303
+ )
304
+ return report
@@ -0,0 +1,154 @@
1
+ """Generate parametric CAD source (OpenSCAD / CadQuery) from fitted primitives.
2
+
3
+ The output is a *starting point* for a CAD redesign: each detected primitive
4
+ becomes a named, editable feature. Only numeric values coming from the fit are
5
+ emitted (formatted floats), so no user/agent text can be injected into the
6
+ generated program.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+
17
+ from reverse3d._version import __version__
18
+ from reverse3d.errors import InputValidationError
19
+ from reverse3d.reverse_eng.geometry_fit import FitReport, PrimitiveFit, plane_basis
20
+ from reverse3d.security import prepare_output_file
21
+
22
+ PARAMETRIC_EXTENSIONS = frozenset({".scad", ".py"})
23
+
24
+
25
+ def _f(x: Any) -> str:
26
+ """Format a number. ``float()`` guarantees only numeric text is emitted."""
27
+ return f"{float(x):.6g}"
28
+
29
+
30
+ def _vec(values: object) -> np.ndarray:
31
+ return np.asarray(values, dtype=np.float64).reshape(3)
32
+
33
+
34
+ def _v3(values: np.ndarray) -> str:
35
+ return ", ".join(_f(x) for x in values)
36
+
37
+
38
+ def _slab_thickness(report: FitReport) -> float:
39
+ return max(report.threshold * 2, 1e-3)
40
+
41
+
42
+ def _rotation_from_z(axis: np.ndarray) -> tuple[float, np.ndarray]:
43
+ """Angle (deg) and rotation vector that map +Z onto ``axis``."""
44
+ z = np.array([0.0, 0.0, 1.0])
45
+ axis = axis / np.linalg.norm(axis)
46
+ cross = np.cross(z, axis)
47
+ if np.linalg.norm(cross) < 1e-9:
48
+ return (
49
+ (0.0, np.array([1.0, 0.0, 0.0])) if axis[2] > 0 else (180.0, np.array([1.0, 0.0, 0.0]))
50
+ )
51
+ angle = math.degrees(math.acos(float(np.clip(z @ axis, -1.0, 1.0))))
52
+ return angle, cross / np.linalg.norm(cross)
53
+
54
+
55
+ def _openscad_feature(idx: int, fit: PrimitiveFit, slab: float) -> str:
56
+ p = fit.params
57
+ header = f"// [{idx}] {fit.kind} inliers={fit.inliers} rmse={_f(fit.rmse)}"
58
+ if fit.kind == "sphere":
59
+ center = _v3(_vec(p["center"]))
60
+ return f"{header}\ntranslate([{center}]) sphere(r={_f(p['radius'])});"
61
+ if fit.kind == "cylinder":
62
+ base, axis = _vec(p["axis_point"]), _vec(p["axis_direction"])
63
+ angle, rot = _rotation_from_z(axis)
64
+ return (
65
+ f"{header}\ntranslate([{_v3(base)}]) rotate(a={_f(angle)}, v=[{_v3(rot)}]) "
66
+ f"cylinder(h={_f(p['height'])}, r={_f(p['radius'])});"
67
+ )
68
+ normal, centroid = _vec(p["normal"]), _vec(p["centroid"])
69
+ u, v = plane_basis(normal)
70
+ ex, ey = np.asarray(p["extent_2d"], dtype=np.float64).reshape(2)
71
+ rows = [
72
+ [u[0], v[0], normal[0], centroid[0]],
73
+ [u[1], v[1], normal[1], centroid[1]],
74
+ [u[2], v[2], normal[2], centroid[2]],
75
+ [0, 0, 0, 1],
76
+ ]
77
+ matrix = "[" + ", ".join("[" + ", ".join(_f(x) for x in r) + "]" for r in rows) + "]"
78
+ return f"{header}\nmultmatrix({matrix}) cube([{_f(ex)}, {_f(ey)}, {_f(slab)}], center=true);"
79
+
80
+
81
+ def generate_openscad(report: FitReport) -> str:
82
+ """Return an OpenSCAD program with one feature per detected primitive."""
83
+ slab = _slab_thickness(report)
84
+ features = "\n\n".join(
85
+ _openscad_feature(i, fit, slab) for i, fit in enumerate(report.primitives)
86
+ )
87
+ return (
88
+ f"// Auto-generated by reverse3d {__version__} (https://voxel.lat)\n"
89
+ "// Draft reconstruction from RANSAC primitive fitting. Review before manufacturing.\n"
90
+ f"// points={report.point_count} threshold={_f(report.threshold)} "
91
+ f"unexplained={report.unexplained_ratio}\n"
92
+ "$fn = 96;\n\n"
93
+ f"union() {{\n{_indent(features)}\n}}\n"
94
+ )
95
+
96
+
97
+ def _cadquery_feature(idx: int, fit: PrimitiveFit, slab: float) -> str:
98
+ p = fit.params
99
+ name = f"f{idx}_{fit.kind}"
100
+ if fit.kind == "sphere":
101
+ return (
102
+ f"{name} = cq.Workplane('XY').sphere({_f(p['radius'])})"
103
+ f".translate(({_v3(_vec(p['center']))}))"
104
+ )
105
+ if fit.kind == "cylinder":
106
+ base, axis = _vec(p["axis_point"]), _vec(p["axis_direction"])
107
+ u, _ = plane_basis(axis)
108
+ return (
109
+ f"{name} = (cq.Workplane(cq.Plane(origin=({_v3(base)}), xDir=({_v3(u)}), "
110
+ f"normal=({_v3(axis)})))"
111
+ f".circle({_f(p['radius'])}).extrude({_f(p['height'])}))"
112
+ )
113
+ normal, centroid = _vec(p["normal"]), _vec(p["centroid"])
114
+ u, _ = plane_basis(normal)
115
+ ex, ey = np.asarray(p["extent_2d"], dtype=np.float64).reshape(2)
116
+ return (
117
+ f"{name} = (cq.Workplane(cq.Plane(origin=({_v3(centroid)}), xDir=({_v3(u)}), "
118
+ f"normal=({_v3(normal)})))"
119
+ f".rect({_f(ex)}, {_f(ey)}).extrude({_f(slab / 2)}, both=True))"
120
+ )
121
+
122
+
123
+ def generate_cadquery(report: FitReport) -> str:
124
+ """Return a CadQuery (Python) script building the detected primitives."""
125
+ slab = _slab_thickness(report)
126
+ lines = [_cadquery_feature(i, fit, slab) for i, fit in enumerate(report.primitives)]
127
+ names = [f"f{i}_{fit.kind}" for i, fit in enumerate(report.primitives)]
128
+ union = names[0] + "".join(f".union({n})" for n in names[1:]) if names else "cq.Workplane('XY')"
129
+ return (
130
+ f'"""Auto-generated by reverse3d {__version__} (https://voxel.lat).\n\n'
131
+ "Draft reconstruction from RANSAC primitive fitting. Review before manufacturing.\n"
132
+ 'Run with CadQuery (pip install cadquery) or open in CQ-editor.\n"""\n\n'
133
+ "import cadquery as cq\n\n" + "\n".join(lines) + f"\n\nresult = {union}\n"
134
+ "cq.exporters.export(result, 'reverse3d_model.step')\n"
135
+ )
136
+
137
+
138
+ def export_parametric(
139
+ report: FitReport, output_path: str | Path, *, overwrite: bool = False
140
+ ) -> Path:
141
+ """Write OpenSCAD (``.scad``) or CadQuery (``.py``) source for ``report``."""
142
+ if not report.primitives:
143
+ raise InputValidationError("No primitives to export.")
144
+ out = prepare_output_file(
145
+ output_path, allowed_extensions=PARAMETRIC_EXTENSIONS, overwrite=overwrite
146
+ )
147
+ code = generate_openscad(report) if out.suffix.lower() == ".scad" else generate_cadquery(report)
148
+ out.write_text(code, encoding="utf-8")
149
+ return out
150
+
151
+
152
+ def _indent(text: str, spaces: int = 4) -> str:
153
+ pad = " " * spaces
154
+ return "\n".join(pad + line if line else line for line in text.splitlines())