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
|
@@ -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())
|