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,478 @@
1
+ """Primitive fitting (planes, spheres, cylinders) on point clouds with RANSAC.
2
+
3
+ Pure NumPy implementation — no Open3D required — so it is deterministic (seeded),
4
+ portable and testable. Each fit is a RANSAC hypothesis search followed by a
5
+ least-squares refinement on the inliers. :func:`detect_primitives` extracts
6
+ several primitives sequentially, removing the inliers of each accepted one.
7
+
8
+ Normals, when available (sampled from a mesh), are used both to generate
9
+ cylinder hypotheses and to reject inliers whose orientation disagrees.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import asdict, dataclass, field
16
+ from pathlib import Path
17
+ from typing import Literal
18
+
19
+ import numpy as np
20
+
21
+ from reverse3d.config import Settings
22
+ from reverse3d.errors import InputValidationError
23
+ from reverse3d.mesh_io import load_geometry, sample_points
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ PrimitiveKind = Literal["plane", "sphere", "cylinder"]
28
+ ALL_KINDS: tuple[PrimitiveKind, ...] = ("plane", "cylinder", "sphere")
29
+ _EPS = 1e-12
30
+
31
+
32
+ @dataclass
33
+ class PrimitiveFit:
34
+ """A fitted primitive. ``params`` depend on ``kind``:
35
+
36
+ * plane: ``normal``, ``d`` (n·x + d = 0), ``centroid``, ``extent_2d``
37
+ * sphere: ``center``, ``radius``
38
+ * cylinder: ``axis_point``, ``axis_direction``, ``radius``, ``height``
39
+ """
40
+
41
+ kind: PrimitiveKind
42
+ params: dict[str, object]
43
+ inliers: int
44
+ inlier_ratio: float
45
+ rmse: float
46
+ inlier_indices: np.ndarray = field(repr=False, default_factory=lambda: np.empty(0, int))
47
+
48
+ def to_dict(self) -> dict[str, object]:
49
+ data = asdict(self)
50
+ data.pop("inlier_indices", None)
51
+ return data
52
+
53
+
54
+ @dataclass
55
+ class FitReport:
56
+ """Result of :func:`detect_primitives` / :func:`fit_primitives_file`."""
57
+
58
+ point_count: int
59
+ threshold: float
60
+ primitives: list[PrimitiveFit]
61
+ unexplained_ratio: float
62
+
63
+ def to_dict(self) -> dict[str, object]:
64
+ return {
65
+ "point_count": self.point_count,
66
+ "threshold": self.threshold,
67
+ "primitives": [p.to_dict() for p in self.primitives],
68
+ "unexplained_ratio": self.unexplained_ratio,
69
+ }
70
+
71
+
72
+ def _r(v: np.ndarray | float, nd: int = 6) -> list[float] | float:
73
+ if np.ndim(v) == 0:
74
+ return round(float(v), nd)
75
+ return [round(float(x), nd) for x in np.ravel(v)]
76
+
77
+
78
+ def _unit(v: np.ndarray) -> np.ndarray:
79
+ n = np.linalg.norm(v)
80
+ return v / n if n > _EPS else v
81
+
82
+
83
+ def _normal_ok(
84
+ normals: np.ndarray | None, expected: np.ndarray, cos_min: float
85
+ ) -> np.ndarray | bool:
86
+ if normals is None:
87
+ return True
88
+ return np.abs(np.einsum("ij,ij->i", normals, expected)) >= cos_min
89
+
90
+
91
+ # ─── plane ────────────────────────────────────────────────────────────────────
92
+
93
+
94
+ def _refine_plane(points: np.ndarray) -> tuple[np.ndarray, float, np.ndarray]:
95
+ centroid = points.mean(axis=0)
96
+ _, _, vt = np.linalg.svd(points - centroid, full_matrices=False)
97
+ normal = vt[2]
98
+ return normal, -float(normal @ centroid), centroid
99
+
100
+
101
+ def fit_plane(
102
+ points: np.ndarray,
103
+ normals: np.ndarray | None = None,
104
+ *,
105
+ threshold: float,
106
+ iterations: int = 500,
107
+ normal_angle_deg: float = 30.0,
108
+ rng: np.random.Generator | None = None,
109
+ ) -> PrimitiveFit | None:
110
+ """RANSAC plane fit. Returns ``None`` if fewer than 3 points."""
111
+ n = len(points)
112
+ if n < 3:
113
+ return None
114
+ rng = rng or np.random.default_rng(0)
115
+ cos_min = float(np.cos(np.radians(normal_angle_deg)))
116
+ best_mask: np.ndarray | None = None
117
+ for _ in range(iterations):
118
+ p0, p1, p2 = points[rng.choice(n, 3, replace=False)]
119
+ normal = np.cross(p1 - p0, p2 - p0)
120
+ if np.linalg.norm(normal) < _EPS:
121
+ continue
122
+ normal = _unit(normal)
123
+ dist = np.abs((points - p0) @ normal)
124
+ mask = dist < threshold
125
+ mask &= _normal_ok(normals, np.broadcast_to(normal, points.shape), cos_min)
126
+ if best_mask is None or mask.sum() > best_mask.sum():
127
+ best_mask = mask
128
+ if best_mask is None or best_mask.sum() < 3:
129
+ return None
130
+ normal, d, centroid = _refine_plane(points[best_mask])
131
+ dist = np.abs(points @ normal + d)
132
+ mask = dist < threshold
133
+ mask &= _normal_ok(normals, np.broadcast_to(normal, points.shape), cos_min)
134
+ if mask.sum() < 3:
135
+ return None
136
+
137
+ inl = points[mask]
138
+ # In-plane extent: project on two orthonormal in-plane axes.
139
+ u, v = plane_basis(normal)
140
+ local = np.stack([(inl - centroid) @ u, (inl - centroid) @ v], axis=1)
141
+ extent = local.max(axis=0) - local.min(axis=0)
142
+ return PrimitiveFit(
143
+ kind="plane",
144
+ params={
145
+ "normal": _r(normal),
146
+ "d": _r(d),
147
+ "centroid": _r(centroid),
148
+ "extent_2d": _r(extent),
149
+ },
150
+ inliers=int(mask.sum()),
151
+ inlier_ratio=round(float(mask.mean()), 4),
152
+ rmse=round(float(np.sqrt(np.mean(dist[mask] ** 2))), 6),
153
+ inlier_indices=np.flatnonzero(mask),
154
+ )
155
+
156
+
157
+ # ─── sphere ───────────────────────────────────────────────────────────────────
158
+
159
+
160
+ def _algebraic_sphere(points: np.ndarray) -> tuple[np.ndarray, float] | None:
161
+ """Least-squares sphere: x²+y²+z² + Dx + Ey + Fz + G = 0."""
162
+ a = np.hstack([points, np.ones((len(points), 1))])
163
+ b = -(points**2).sum(axis=1)
164
+ try:
165
+ sol, *_ = np.linalg.lstsq(a, b, rcond=None)
166
+ except np.linalg.LinAlgError:
167
+ return None
168
+ center = -0.5 * sol[:3]
169
+ r2 = float(center @ center - sol[3])
170
+ if r2 <= 0:
171
+ return None
172
+ return center, float(np.sqrt(r2))
173
+
174
+
175
+ def fit_sphere(
176
+ points: np.ndarray,
177
+ normals: np.ndarray | None = None,
178
+ *,
179
+ threshold: float,
180
+ iterations: int = 500,
181
+ max_radius: float | None = None,
182
+ normal_angle_deg: float = 30.0,
183
+ rng: np.random.Generator | None = None,
184
+ ) -> PrimitiveFit | None:
185
+ """RANSAC sphere fit (4-point minimal sample)."""
186
+ n = len(points)
187
+ if n < 4:
188
+ return None
189
+ rng = rng or np.random.default_rng(0)
190
+ cos_min = float(np.cos(np.radians(normal_angle_deg)))
191
+
192
+ def evaluate(center: np.ndarray, radius: float) -> tuple[np.ndarray, np.ndarray]:
193
+ offsets = points - center
194
+ norms = np.linalg.norm(offsets, axis=1)
195
+ dist = np.abs(norms - radius)
196
+ mask = dist < threshold
197
+ if normals is not None:
198
+ radial = offsets / np.maximum(norms, _EPS)[:, None]
199
+ mask &= _normal_ok(normals, radial, cos_min)
200
+ return mask, dist
201
+
202
+ best: tuple[np.ndarray, float] | None = None
203
+ best_count = 0
204
+ for _ in range(iterations):
205
+ sample = _algebraic_sphere(points[rng.choice(n, 4, replace=False)])
206
+ if sample is None:
207
+ continue
208
+ center, radius = sample
209
+ if max_radius is not None and radius > max_radius:
210
+ continue
211
+ mask, _ = evaluate(center, radius)
212
+ if mask.sum() > best_count:
213
+ best, best_count = sample, int(mask.sum())
214
+ if best is None or best_count < 4:
215
+ return None
216
+ mask, _ = evaluate(*best)
217
+ refined = _algebraic_sphere(points[mask])
218
+ if refined is not None:
219
+ mask, dist = evaluate(*refined)
220
+ best = refined
221
+ else:
222
+ _, dist = evaluate(*best)
223
+ if mask.sum() < 4:
224
+ return None
225
+ center, radius = best
226
+ return PrimitiveFit(
227
+ kind="sphere",
228
+ params={"center": _r(center), "radius": _r(radius)},
229
+ inliers=int(mask.sum()),
230
+ inlier_ratio=round(float(mask.mean()), 4),
231
+ rmse=round(float(np.sqrt(np.mean(dist[mask] ** 2))), 6),
232
+ inlier_indices=np.flatnonzero(mask),
233
+ )
234
+
235
+
236
+ # ─── cylinder ─────────────────────────────────────────────────────────────────
237
+
238
+
239
+ def plane_basis(axis: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
240
+ """Deterministic orthonormal ``(u, v)`` spanning the plane orthogonal to ``axis``."""
241
+ axis = np.asarray(axis, dtype=np.float64)
242
+ helper = np.array([1.0, 0.0, 0.0]) if abs(axis[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
243
+ u = _unit(np.cross(axis, helper))
244
+ return u, np.cross(axis, u)
245
+
246
+
247
+ def _fit_circle_2d(xy: np.ndarray) -> tuple[np.ndarray, float] | None:
248
+ """Kåsa algebraic circle fit."""
249
+ a = np.hstack([xy, np.ones((len(xy), 1))])
250
+ b = -(xy**2).sum(axis=1)
251
+ try:
252
+ sol, *_ = np.linalg.lstsq(a, b, rcond=None)
253
+ except np.linalg.LinAlgError:
254
+ return None
255
+ center = -0.5 * sol[:2]
256
+ r2 = float(center @ center - sol[2])
257
+ return (center, float(np.sqrt(r2))) if r2 > 0 else None
258
+
259
+
260
+ def fit_cylinder(
261
+ points: np.ndarray,
262
+ normals: np.ndarray | None,
263
+ *,
264
+ threshold: float,
265
+ iterations: int = 500,
266
+ max_radius: float | None = None,
267
+ normal_angle_deg: float = 30.0,
268
+ rng: np.random.Generator | None = None,
269
+ ) -> PrimitiveFit | None:
270
+ """RANSAC cylinder fit. Requires normals (axis = n1 × n2 hypothesis)."""
271
+ if normals is None:
272
+ return None
273
+ n = len(points)
274
+ if n < 6:
275
+ return None
276
+ rng = rng or np.random.default_rng(0)
277
+ cos_min = float(np.cos(np.radians(normal_angle_deg)))
278
+
279
+ def evaluate(
280
+ axis: np.ndarray, point: np.ndarray, radius: float
281
+ ) -> tuple[np.ndarray, np.ndarray]:
282
+ rel = points - point
283
+ along = rel @ axis
284
+ radial_vec = rel - np.outer(along, axis)
285
+ radial = np.linalg.norm(radial_vec, axis=1)
286
+ dist = np.abs(radial - radius)
287
+ mask = dist < threshold
288
+ mask &= _normal_ok(normals, radial_vec / np.maximum(radial, _EPS)[:, None], cos_min)
289
+ return mask, dist
290
+
291
+ def hypothesis(idx: np.ndarray, axis: np.ndarray) -> tuple[np.ndarray, float] | None:
292
+ u, v = plane_basis(axis)
293
+ xy = np.stack([points[idx] @ u, points[idx] @ v], axis=1)
294
+ circle = _fit_circle_2d(xy)
295
+ if circle is None:
296
+ return None
297
+ (cx, cy), radius = circle
298
+ return cx * u + cy * v, radius
299
+
300
+ best: tuple[np.ndarray, np.ndarray, float] | None = None
301
+ best_count = 0
302
+ for _ in range(iterations):
303
+ i, j, k = rng.choice(n, 3, replace=False)
304
+ axis = np.cross(normals[i], normals[j])
305
+ if np.linalg.norm(axis) < 0.05: # nearly parallel normals: no axis information
306
+ continue
307
+ axis = _unit(axis)
308
+ hyp = hypothesis(np.array([i, j, k]), axis)
309
+ if hyp is None:
310
+ continue
311
+ point, radius = hyp
312
+ if max_radius is not None and radius > max_radius:
313
+ continue
314
+ mask, _ = evaluate(axis, point, radius)
315
+ if mask.sum() > best_count:
316
+ best, best_count = (axis, point, radius), int(mask.sum())
317
+ if best is None or best_count < 6:
318
+ return None
319
+
320
+ # Refinement: axis = direction least represented in the inlier normals.
321
+ axis, point, radius = best
322
+ mask, _ = evaluate(axis, point, radius)
323
+ scatter = normals[mask].T @ normals[mask]
324
+ _, eigvecs = np.linalg.eigh(scatter)
325
+ refined_axis = _unit(eigvecs[:, 0])
326
+ hyp = hypothesis(np.flatnonzero(mask), refined_axis)
327
+ if hyp is not None:
328
+ new_mask, _ = evaluate(refined_axis, *hyp)
329
+ if new_mask.sum() >= mask.sum():
330
+ axis, (point, radius) = refined_axis, hyp
331
+ mask, dist = evaluate(axis, point, radius)
332
+ if mask.sum() < 6:
333
+ return None
334
+
335
+ along = (points[mask] - point) @ axis
336
+ lo, hi = float(along.min()), float(along.max())
337
+ base = point + lo * axis
338
+ return PrimitiveFit(
339
+ kind="cylinder",
340
+ params={
341
+ "axis_point": _r(base),
342
+ "axis_direction": _r(axis),
343
+ "radius": _r(radius),
344
+ "height": _r(hi - lo),
345
+ },
346
+ inliers=int(mask.sum()),
347
+ inlier_ratio=round(float(mask.mean()), 4),
348
+ rmse=round(float(np.sqrt(np.mean(dist[mask] ** 2))), 6),
349
+ inlier_indices=np.flatnonzero(mask),
350
+ )
351
+
352
+
353
+ # ─── sequential extraction ────────────────────────────────────────────────────
354
+
355
+
356
+ def detect_primitives(
357
+ points: np.ndarray,
358
+ normals: np.ndarray | None = None,
359
+ *,
360
+ kinds: tuple[PrimitiveKind, ...] = ALL_KINDS,
361
+ threshold: float | None = None,
362
+ min_inlier_ratio: float = 0.05,
363
+ max_primitives: int = 8,
364
+ iterations: int = 500,
365
+ seed: int = 0,
366
+ ) -> FitReport:
367
+ """Extract up to ``max_primitives`` primitives, best-first.
368
+
369
+ Each round fits every requested kind on the remaining points and keeps the
370
+ one explaining the most points (ties favour the simpler model: plane <
371
+ cylinder < sphere). Its inliers are then removed.
372
+
373
+ Args:
374
+ threshold: inlier distance; defaults to 1% of the cloud's bbox diagonal.
375
+ min_inlier_ratio: stop when the best candidate explains less than this
376
+ fraction of the *original* points.
377
+ """
378
+ points = np.asarray(points, dtype=np.float64)
379
+ if points.ndim != 2 or points.shape[1] != 3 or len(points) < 10:
380
+ raise InputValidationError("points must be an (N>=10, 3) array.")
381
+ if normals is not None:
382
+ normals = np.asarray(normals, dtype=np.float64)
383
+ if normals.shape != points.shape:
384
+ raise InputValidationError("normals must have the same shape as points.")
385
+ unknown = set(kinds) - set(ALL_KINDS)
386
+ if unknown or not kinds:
387
+ raise InputValidationError(f"kinds must be a non-empty subset of {ALL_KINDS}.")
388
+ if not 1 <= max_primitives <= 50:
389
+ raise InputValidationError("max_primitives must be in [1, 50].")
390
+ if not 10 <= iterations <= 20_000:
391
+ raise InputValidationError("iterations must be in [10, 20000].")
392
+
393
+ diagonal = float(np.linalg.norm(points.max(axis=0) - points.min(axis=0)))
394
+ threshold = threshold if threshold and threshold > 0 else max(diagonal * 0.01, 1e-9)
395
+ max_radius = diagonal * 2
396
+ rng = np.random.default_rng(seed)
397
+ total = len(points)
398
+ remaining = np.arange(total)
399
+ found: list[PrimitiveFit] = []
400
+ preference = {"plane": 0, "cylinder": 1, "sphere": 2}
401
+
402
+ while len(found) < max_primitives and len(remaining) >= 10:
403
+ pts = points[remaining]
404
+ nrm = normals[remaining] if normals is not None else None
405
+ candidates: list[PrimitiveFit] = []
406
+ for kind in kinds:
407
+ fit: PrimitiveFit | None
408
+ if kind == "plane":
409
+ fit = fit_plane(pts, nrm, threshold=threshold, iterations=iterations, rng=rng)
410
+ elif kind == "sphere":
411
+ fit = fit_sphere(
412
+ pts,
413
+ nrm,
414
+ threshold=threshold,
415
+ iterations=iterations,
416
+ max_radius=max_radius,
417
+ rng=rng,
418
+ )
419
+ else:
420
+ fit = fit_cylinder(
421
+ pts,
422
+ nrm,
423
+ threshold=threshold,
424
+ iterations=iterations,
425
+ max_radius=max_radius,
426
+ rng=rng,
427
+ )
428
+ if fit is not None:
429
+ candidates.append(fit)
430
+ if not candidates:
431
+ break
432
+ best = max(candidates, key=lambda f: (f.inliers, -preference[f.kind]))
433
+ if best.inliers < min_inlier_ratio * total:
434
+ break
435
+ # Report inliers relative to the full cloud and map indices back.
436
+ best.inlier_indices = remaining[best.inlier_indices]
437
+ best.inlier_ratio = round(best.inliers / total, 4)
438
+ found.append(best)
439
+ keep = np.ones(len(remaining), dtype=bool)
440
+ keep[np.searchsorted(remaining, best.inlier_indices)] = False
441
+ remaining = remaining[keep]
442
+
443
+ explained = total - len(remaining)
444
+ return FitReport(
445
+ point_count=total,
446
+ threshold=round(threshold, 9),
447
+ primitives=found,
448
+ unexplained_ratio=round(1 - explained / total, 4),
449
+ )
450
+
451
+
452
+ def fit_primitives_file(
453
+ path: str | Path,
454
+ settings: Settings,
455
+ *,
456
+ kinds: tuple[PrimitiveKind, ...] = ALL_KINDS,
457
+ sample_count: int = 20_000,
458
+ threshold: float | None = None,
459
+ max_primitives: int = 8,
460
+ min_inlier_ratio: float = 0.05,
461
+ seed: int = 0,
462
+ ) -> FitReport:
463
+ """Load a mesh / point cloud, sample points and run :func:`detect_primitives`."""
464
+ if not 100 <= sample_count <= 500_000:
465
+ raise InputValidationError("sample_count must be in [100, 500000].")
466
+ geometry = load_geometry(path, settings)
467
+ points, normals = sample_points(geometry, sample_count, seed=seed)
468
+ report = detect_primitives(
469
+ points,
470
+ normals,
471
+ kinds=kinds,
472
+ threshold=threshold,
473
+ max_primitives=max_primitives,
474
+ min_inlier_ratio=min_inlier_ratio,
475
+ seed=seed,
476
+ )
477
+ logger.info("Detected %d primitive(s) in %s", len(report.primitives), Path(path).name)
478
+ return report
@@ -0,0 +1,87 @@
1
+ """Mesh statistics and principal dimensions (cotas)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import trimesh
10
+
11
+ from reverse3d.config import Settings
12
+ from reverse3d.mesh_io import load_mesh
13
+
14
+
15
+ def _r(values: np.ndarray | list[float], nd: int = 6) -> list[float]:
16
+ return [round(float(v), nd) for v in values]
17
+
18
+
19
+ def boundary_edge_count(mesh: trimesh.Trimesh) -> int:
20
+ """Number of edges used by a single face (open boundary edges)."""
21
+ if len(mesh.faces) == 0:
22
+ return 0
23
+ groups = trimesh.grouping.group_rows(mesh.edges_sorted, require_count=1)
24
+ return len(groups)
25
+
26
+
27
+ @dataclass
28
+ class MeshStats:
29
+ """Topology and dimension summary of a triangle mesh.
30
+
31
+ Lengths are in the mesh's own units (whatever the source file used).
32
+ ``obb_extents`` are the sorted (largest first) side lengths of the minimum
33
+ oriented bounding box: the most robust "overall dimensions" of a part.
34
+ """
35
+
36
+ vertices: int
37
+ faces: int
38
+ components: int
39
+ watertight: bool
40
+ winding_consistent: bool
41
+ boundary_edges: int
42
+ euler_number: int
43
+ aabb_min: list[float]
44
+ aabb_max: list[float]
45
+ aabb_extents: list[float]
46
+ obb_extents: list[float]
47
+ surface_area: float
48
+ volume: float | None
49
+ center_mass: list[float] | None
50
+
51
+ def to_dict(self) -> dict[str, object]:
52
+ return asdict(self)
53
+
54
+
55
+ def compute_stats(mesh: trimesh.Trimesh) -> MeshStats:
56
+ """Compute :class:`MeshStats` for ``mesh`` (no file I/O)."""
57
+ if len(mesh.faces) == 0:
58
+ raise ValueError("Mesh has no faces.")
59
+ watertight = bool(mesh.is_watertight)
60
+ try:
61
+ _, obb_extents = trimesh.bounds.oriented_bounds(mesh)
62
+ except Exception: # degenerate (flat/collinear) input
63
+ obb_extents = mesh.extents
64
+ components = len(
65
+ trimesh.graph.connected_components(mesh.face_adjacency, nodes=np.arange(len(mesh.faces)))
66
+ )
67
+ return MeshStats(
68
+ vertices=len(mesh.vertices),
69
+ faces=len(mesh.faces),
70
+ components=components,
71
+ watertight=watertight,
72
+ winding_consistent=bool(mesh.is_winding_consistent),
73
+ boundary_edges=boundary_edge_count(mesh),
74
+ euler_number=int(mesh.euler_number),
75
+ aabb_min=_r(mesh.bounds[0]),
76
+ aabb_max=_r(mesh.bounds[1]),
77
+ aabb_extents=_r(mesh.extents),
78
+ obb_extents=_r(sorted(obb_extents, reverse=True)),
79
+ surface_area=round(float(mesh.area), 6),
80
+ volume=round(abs(float(mesh.volume)), 6) if watertight else None,
81
+ center_mass=_r(mesh.center_mass) if watertight else None,
82
+ )
83
+
84
+
85
+ def measure_mesh_file(path: str | Path, settings: Settings) -> MeshStats:
86
+ """Load a mesh file and return its :class:`MeshStats`."""
87
+ return compute_stats(load_mesh(path, settings))