loadpath 0.1.1__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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/cavities.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal ceilings: the overhangs inside a part that no support can reach.
|
|
3
|
+
|
|
4
|
+
In powder-bed metal (LPBF: AlSi10Mg, Ti64, 316L) an overhang outside the
|
|
5
|
+
part gets a support that is cut off afterwards. An overhang inside a closed
|
|
6
|
+
channel or plenum cannot: a support there stays in the part, and without
|
|
7
|
+
one the ceiling curls, drosses or collapses. So a heat exchanger, a manifold
|
|
8
|
+
or anything with internal flow paths must be self-supporting inside, along
|
|
9
|
+
the direction it is built, and the same part built another way up can go
|
|
10
|
+
from hundreds of square centimetres of flat ceiling to almost none (a box
|
|
11
|
+
plenum built along +z has a flat roof; built along the cube diagonal every
|
|
12
|
+
face of it leans 54.7 degrees).
|
|
13
|
+
|
|
14
|
+
`internal_ceilings` measures that: every face of the part whose material is
|
|
15
|
+
above it and whose cavity below it is enclosed (not open to the outside once
|
|
16
|
+
the ports are plugged), leaning flatter than `limit` from level. Faces deep
|
|
17
|
+
inside a TPMS core can be left out with `exclude`: a sheet gyroid or diamond
|
|
18
|
+
prints unsupported at the usual cell sizes, though its surface has
|
|
19
|
+
down-facing points.
|
|
20
|
+
|
|
21
|
+
`rank_build_directions` measures it for many directions at once (the mesh
|
|
22
|
+
and the cavity labelling are done once; each direction is a dot product), so
|
|
23
|
+
an agent can be told which way up to print before it designs for one.
|
|
24
|
+
|
|
25
|
+
The part can be a field (`field(P) -> sdf`, negative = solid, mm) over
|
|
26
|
+
bounds, or a mesh.
|
|
27
|
+
|
|
28
|
+
rep = internal_ceilings(field, bounds, up="+z", plugs=plugs, pitch=0.5)
|
|
29
|
+
best = rank_build_directions(field, bounds, plugs=plugs, pitch=0.5)[0]
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
from scipy import ndimage
|
|
35
|
+
|
|
36
|
+
__all__ = ["CavitySurface", "cavity_surface", "internal_ceilings", "rank_build_directions",
|
|
37
|
+
"candidate_directions", "LIMIT"]
|
|
38
|
+
|
|
39
|
+
# self-supporting limit, degrees from level, for powder-bed metal and FDM alike
|
|
40
|
+
LIMIT = 45.0
|
|
41
|
+
# "flat": within this many degrees of level, the ceilings that fail first
|
|
42
|
+
FLAT = 20.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CavitySurface:
|
|
46
|
+
"""The part's surface split once into faces: centres, unit normals
|
|
47
|
+
(out of the material), areas, and whether the space just off each face
|
|
48
|
+
is an enclosed cavity. Every ceiling question below is a dot product on
|
|
49
|
+
these."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, centers, normals, areas, internal, pitch, cavity_volume):
|
|
52
|
+
self.centers = centers
|
|
53
|
+
self.normals = normals
|
|
54
|
+
self.areas = areas
|
|
55
|
+
self.internal = internal
|
|
56
|
+
self.pitch = pitch
|
|
57
|
+
self.cavity_volume = cavity_volume
|
|
58
|
+
|
|
59
|
+
def ceilings(self, up, limit=LIMIT, flat=FLAT) -> dict:
|
|
60
|
+
from .am import build_vector, parse_build_direction
|
|
61
|
+
|
|
62
|
+
u = build_vector(parse_build_direction(up) if isinstance(up, str) else up)
|
|
63
|
+
down = -(self.normals @ u) # 1 for a face looking straight down
|
|
64
|
+
over = self.internal & (down > np.cos(np.radians(90.0 - limit)))
|
|
65
|
+
flat_ = self.internal & (down > np.cos(np.radians(flat)))
|
|
66
|
+
out = {"up": np.round(u, 4).tolist(),
|
|
67
|
+
"internalOverhangArea": float(self.areas[over].sum()),
|
|
68
|
+
"internalFlatArea": float(self.areas[flat_].sum()),
|
|
69
|
+
"limit": limit, "pitch": self.pitch}
|
|
70
|
+
if over.any():
|
|
71
|
+
# where the worst of it is: the 10 mm cube holding the most flat ceiling, else overhang
|
|
72
|
+
from .heatx import _densest
|
|
73
|
+
|
|
74
|
+
sel = flat_ if flat_.any() else over
|
|
75
|
+
out["worstAt"] = _densest(self.centers[sel], self.areas[sel], 10.0)
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _plugged(solid, lo, pitch, plugs):
|
|
80
|
+
if not plugs:
|
|
81
|
+
return solid
|
|
82
|
+
from .checks.networks import _refill_plugs
|
|
83
|
+
|
|
84
|
+
s = solid.copy()
|
|
85
|
+
_refill_plugs(s, np.asarray(lo, float), pitch, plugs)
|
|
86
|
+
return s
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _enclosed(solid):
|
|
90
|
+
"""Void not connected to the grid's border (6-connected: a cavity behind
|
|
91
|
+
a one-voxel wall stays a cavity)."""
|
|
92
|
+
lbl, _ = ndimage.label(~solid)
|
|
93
|
+
border = np.unique(np.concatenate([lbl[0].ravel(), lbl[-1].ravel(), lbl[:, 0].ravel(), lbl[:, -1].ravel(),
|
|
94
|
+
lbl[:, :, 0].ravel(), lbl[:, :, -1].ravel()]))
|
|
95
|
+
return (lbl > 0) & ~np.isin(lbl, border)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cavity_surface(part, bounds=None, pitch=None, plugs=None, exclude=None, max_cells=40_000_000) -> CavitySurface:
|
|
99
|
+
"""Split `part` (a field with `bounds` = (lo, hi), or a trimesh) into
|
|
100
|
+
faces and label the ones that face an enclosed cavity. `plugs` (as for
|
|
101
|
+
check_networks) close the ports first, so the fluid spaces count as
|
|
102
|
+
enclosed; `exclude(points) -> bool` drops faces (e.g. the TPMS core's
|
|
103
|
+
own sheet)."""
|
|
104
|
+
if callable(part):
|
|
105
|
+
from skimage.measure import marching_cubes
|
|
106
|
+
|
|
107
|
+
lo, hi = (np.asarray(b, float) for b in bounds)
|
|
108
|
+
if pitch is None:
|
|
109
|
+
pitch = max(float(np.prod(hi - lo) / max_cells) ** (1 / 3), 0.2)
|
|
110
|
+
lo = lo - 0.0137 * pitch
|
|
111
|
+
dims = np.maximum(np.ceil((hi - lo) / pitch).astype(int) + 1, 2)
|
|
112
|
+
if np.prod(dims) > max_cells:
|
|
113
|
+
raise ValueError(f"a {pitch:g} mm grid over this part is {np.prod(dims) / 1e6:.0f}M cells; "
|
|
114
|
+
f"use pitch >= {float(np.prod(hi - lo) / max_cells) ** (1 / 3):.2f}")
|
|
115
|
+
axes = [lo[i] + np.arange(dims[i]) * pitch for i in range(3)]
|
|
116
|
+
F = np.empty(tuple(dims), np.float32)
|
|
117
|
+
for i, x in enumerate(axes[0]): # a slab at a time: the field's temporaries stay small
|
|
118
|
+
Y, Z = np.meshgrid(axes[1], axes[2], indexing="ij")
|
|
119
|
+
F[i] = np.asarray(part(np.stack([np.full(Y.size, x), Y.ravel(), Z.ravel()], 1)), float).reshape(Y.shape)
|
|
120
|
+
F = np.pad(F, 1, constant_values=float(3 * pitch))
|
|
121
|
+
lo = lo - pitch
|
|
122
|
+
F[F == 0] = 1e-6
|
|
123
|
+
verts, faces, _, _ = marching_cubes(F, 0.0, spacing=(pitch,) * 3, allow_degenerate=False)
|
|
124
|
+
verts += lo
|
|
125
|
+
tri = verts[faces]
|
|
126
|
+
solid = F < 0
|
|
127
|
+
else:
|
|
128
|
+
from .sdf_kit import _solid_voxels
|
|
129
|
+
|
|
130
|
+
mesh = part
|
|
131
|
+
if pitch is None:
|
|
132
|
+
pitch = max(float(np.prod(mesh.extents) / max_cells) ** (1 / 3), 0.2)
|
|
133
|
+
solid, origin = _solid_voxels(mesh, pitch, pad=2)
|
|
134
|
+
lo = origin
|
|
135
|
+
tri = np.asarray(mesh.triangles)
|
|
136
|
+
cross = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
|
|
137
|
+
areas = 0.5 * np.linalg.norm(cross, axis=1)
|
|
138
|
+
keep = areas > 0
|
|
139
|
+
normals = np.zeros_like(cross)
|
|
140
|
+
normals[keep] = cross[keep] / (2.0 * areas[keep, None])
|
|
141
|
+
centers = tri.mean(axis=1)
|
|
142
|
+
if callable(part):
|
|
143
|
+
# marching cubes on (negative = solid) winds its faces into the material: flip
|
|
144
|
+
normals = -normals
|
|
145
|
+
# vertex-averaged normals: the facets of marching cubes scatter a few degrees
|
|
146
|
+
g = np.stack(np.gradient(F, pitch), -1)
|
|
147
|
+
idx = np.clip(np.round((centers - lo) / pitch).astype(int), 0, np.asarray(F.shape) - 1)
|
|
148
|
+
gn = g[idx[:, 0], idx[:, 1], idx[:, 2]]
|
|
149
|
+
gl = np.linalg.norm(gn, axis=1)
|
|
150
|
+
ok = gl > 1e-9
|
|
151
|
+
normals[ok] = gn[ok] / gl[ok, None]
|
|
152
|
+
del g
|
|
153
|
+
cav = _enclosed(_plugged(solid, lo, pitch, plugs))
|
|
154
|
+
probe = centers + normals * (1.0 * pitch)
|
|
155
|
+
ijk = np.round((probe - lo) / pitch).astype(int)
|
|
156
|
+
inb = np.all((ijk >= 0) & (ijk < np.asarray(cav.shape)), axis=1)
|
|
157
|
+
internal = np.zeros(len(centers), bool)
|
|
158
|
+
internal[inb] = cav[ijk[inb, 0], ijk[inb, 1], ijk[inb, 2]]
|
|
159
|
+
internal &= keep
|
|
160
|
+
if exclude is not None:
|
|
161
|
+
internal &= ~np.asarray(exclude(centers), bool)
|
|
162
|
+
return CavitySurface(centers, normals, areas, internal, float(pitch), float(cav.sum()) * pitch ** 3)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def internal_ceilings(part, bounds=None, up="+z", pitch=None, plugs=None, exclude=None,
|
|
166
|
+
limit=LIMIT, flat=FLAT) -> dict:
|
|
167
|
+
"""Area (mm^2) of the part's internal faces that lean flatter than
|
|
168
|
+
`limit` degrees from level when built along `up` (internalOverhangArea),
|
|
169
|
+
and of those within `flat` degrees of level (internalFlatArea)."""
|
|
170
|
+
return cavity_surface(part, bounds, pitch, plugs, exclude).ceilings(up, limit, flat)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def candidate_directions(extra: int = 0):
|
|
174
|
+
"""The 6 axes, 12 edge diagonals and 8 corner diagonals of a cube, both
|
|
175
|
+
ways up, plus `extra` more spread over the sphere (Fibonacci)."""
|
|
176
|
+
d = []
|
|
177
|
+
for v in np.array(np.meshgrid([-1, 0, 1], [-1, 0, 1], [-1, 0, 1])).reshape(3, -1).T:
|
|
178
|
+
if v.any():
|
|
179
|
+
d.append(v / np.linalg.norm(v))
|
|
180
|
+
if extra:
|
|
181
|
+
k = np.arange(extra) + 0.5
|
|
182
|
+
phi = np.arccos(1 - 2 * k / extra)
|
|
183
|
+
th = np.pi * (1 + 5 ** 0.5) * k
|
|
184
|
+
d.extend(np.stack([np.cos(th) * np.sin(phi), np.sin(th) * np.sin(phi), np.cos(phi)], 1))
|
|
185
|
+
return np.asarray(d)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def rank_build_directions(part, bounds=None, pitch=None, plugs=None, exclude=None, candidates=None,
|
|
189
|
+
limit=LIMIT, flat=FLAT, surface: CavitySurface | None = None) -> list:
|
|
190
|
+
"""Every candidate build direction, least internal overhang first (ties:
|
|
191
|
+
least flat ceiling). Each entry is internal_ceilings' report plus
|
|
192
|
+
"label"."""
|
|
193
|
+
from .am import direction_label, parse_build_direction
|
|
194
|
+
|
|
195
|
+
s = surface or cavity_surface(part, bounds, pitch, plugs, exclude)
|
|
196
|
+
cands = candidate_directions() if candidates is None else [
|
|
197
|
+
parse_build_direction(c) if isinstance(c, str) else np.asarray(c, float) for c in candidates]
|
|
198
|
+
out = []
|
|
199
|
+
for c in cands:
|
|
200
|
+
if isinstance(c, tuple):
|
|
201
|
+
from .am import build_vector
|
|
202
|
+
v, lab = build_vector(c), direction_label(c)
|
|
203
|
+
else:
|
|
204
|
+
v = np.asarray(c, float) / np.linalg.norm(c)
|
|
205
|
+
pd = parse_build_direction(v.tolist())
|
|
206
|
+
lab = direction_label(pd)
|
|
207
|
+
r = s.ceilings(v, limit, flat)
|
|
208
|
+
r["label"] = lab
|
|
209
|
+
out.append(r)
|
|
210
|
+
out.sort(key=lambda r: (round(r["internalOverhangArea"], 1), r["internalFlatArea"]))
|
|
211
|
+
return out
|
|
File without changes
|
loadpath/checks/dfm.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Printability checks on a final part: wall thickness, overhangs, and trapped
|
|
3
|
+
volumes.
|
|
4
|
+
|
|
5
|
+
Watertight and manifold tell you the mesh is a valid solid. They say nothing
|
|
6
|
+
about whether it can be MADE. These three decide that:
|
|
7
|
+
|
|
8
|
+
min wall — a 0.4 mm rib is watertight and unprintable. Measured on a voxel
|
|
9
|
+
grid: twice the distance to void along the medial ridge (the
|
|
10
|
+
same reading verify.py uses), from a distance transform run in
|
|
11
|
+
tiles, so memory stays flat however many triangles the part
|
|
12
|
+
has. Reported in mm with the grid's tolerance (+/- one pitch).
|
|
13
|
+
`wallMethod: "ray"` keeps the older ray cast from each sampled
|
|
14
|
+
face along -normal: not quantized, but it goes through
|
|
15
|
+
trimesh's ray broad phase, which on a 6.6M-triangle TPMS pod
|
|
16
|
+
reached 63 GB. Use it only on small meshes.
|
|
17
|
+
overhangs — a downward-facing surface shallower than ~45 deg from
|
|
18
|
+
horizontal needs support. Reported as area and as a fraction
|
|
19
|
+
of total area, not a pass/fail, because "needs some support"
|
|
20
|
+
is a cost, not a defect.
|
|
21
|
+
trapped — an enclosed void cannot drain resin or release powder. Found by
|
|
22
|
+
labeling void space and keeping the components that never
|
|
23
|
+
reach the grid's boundary.
|
|
24
|
+
|
|
25
|
+
The part is a trimesh, or an implicit field f(P) < 0 inside (pass
|
|
26
|
+
spec["bounds"] = (lo, hi)); a field skips meshing and voxelizing altogether.
|
|
27
|
+
Overhangs need a mesh.
|
|
28
|
+
|
|
29
|
+
Resolution honesty, same contract as networks.py: the voxel probes are
|
|
30
|
+
verified at the reported `pitch`. A trapped void smaller than a voxel, or an
|
|
31
|
+
escape channel narrower than one, is below this check's resolution, and a
|
|
32
|
+
wall reading carries +/- pitch. When the voxel budget forces a pitch coarser
|
|
33
|
+
than half the minimum wall, the wall verdict is None (not checked), never a
|
|
34
|
+
guess.
|
|
35
|
+
"""
|
|
36
|
+
import os
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
from scipy import ndimage
|
|
40
|
+
|
|
41
|
+
from ..voxels import choose_pitch, solid_grid, wall_thickness
|
|
42
|
+
|
|
43
|
+
__all__ = ["check_dfm"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _env(name, default):
|
|
47
|
+
"""LOADPATH_<name>, falling back to the older CAD_<name>."""
|
|
48
|
+
return os.environ.get(f"LOADPATH_{name}", os.environ.get(f"CAD_{name}", default))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_DEFAULT_MIN_WALL_MM = 1.5
|
|
52
|
+
_DEFAULT_OVERHANG_DEG = 45.0
|
|
53
|
+
|
|
54
|
+
# Ray budget for the optional ray-cast thickness probe (wallMethod "ray").
|
|
55
|
+
_MAX_THICKNESS_RAYS = int(_env("DFM_MAX_RAYS", "4000"))
|
|
56
|
+
|
|
57
|
+
# Voxel ceiling for the grid (1 byte each, plus a 4-byte label per voxel
|
|
58
|
+
# while trapped voids are labeled; the distance transform runs in tiles).
|
|
59
|
+
_VOXEL_BUDGET = int(_env("DFM_VOXEL_BUDGET", str(100_000_000)))
|
|
60
|
+
|
|
61
|
+
# Cells across the minimum wall the pitch aims for, and the fewest it will
|
|
62
|
+
# accept before calling the wall reading inconclusive.
|
|
63
|
+
_CELLS_PER_WALL = 3.0
|
|
64
|
+
_MIN_CELLS_PER_WALL = 2.0
|
|
65
|
+
|
|
66
|
+
# 26-connectivity for the void: the LEAKIEST choice, so a diagonal voxel
|
|
67
|
+
# chain counts as an escape path. Better to miss a trapped void than invent
|
|
68
|
+
# one: a false "trapped" reading has someone drilling a drain hole through a
|
|
69
|
+
# part that never needed one.
|
|
70
|
+
_CONN = np.ones((3, 3, 3), bool)
|
|
71
|
+
|
|
72
|
+
# A void component this small is voxelization aliasing between shell voxels,
|
|
73
|
+
# not a pocket.
|
|
74
|
+
_TINY_VOID_VOX = 32
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _choose_pitch(extents, min_wall, requested=None):
|
|
78
|
+
"""Voxel pitch that fits the budget. Honors `requested` (else aims for
|
|
79
|
+
three cells across the minimum wall) unless it would blow the grid, in
|
|
80
|
+
which case it coarsens and the report says so."""
|
|
81
|
+
extents = np.asarray(extents, float)
|
|
82
|
+
span = float(max(extents.max(), 1e-6))
|
|
83
|
+
target = float(requested) if requested else max(min_wall / _CELLS_PER_WALL, span / 2048.0, 0.02)
|
|
84
|
+
return choose_pitch(extents, target, _VOXEL_BUDGET)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _voxel_wall(solid, origin, pitch, min_wall):
|
|
88
|
+
"""Grid wall thickness: (min, p1, thin area, thin fraction, spots)."""
|
|
89
|
+
w = wall_thickness(solid, pitch, cap_mm=_cap_mm(min_wall, pitch), limit_mm=min_wall, origin=origin)
|
|
90
|
+
if w["minMm"] is None:
|
|
91
|
+
return None, None, 0.0, 0.0, []
|
|
92
|
+
return w["minMm"], w["p1Mm"], w["thinAreaMm2"], w["thinSurfaceFraction"], w.get("thinSpots", [])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _cap_mm(min_wall, pitch):
|
|
96
|
+
"""Thickest wall the voxel probe resolves; thicker reads as this cap. It
|
|
97
|
+
sets the tile overlap, so it stays a few walls deep, not the whole part."""
|
|
98
|
+
return max(8.0 * min_wall, 16.0 * pitch)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ray_wall(mesh, min_wall):
|
|
102
|
+
"""Ray-cast wall thickness (wallMethod "ray"), in mm, sampled over faces.
|
|
103
|
+
|
|
104
|
+
From each sampled face centroid, step just inside the surface and cast
|
|
105
|
+
along -normal; the first hit is the far wall. Returns (min, p1, area
|
|
106
|
+
below `min_wall`, sampled area) — p1 as well as min because a single
|
|
107
|
+
degenerate sliver triangle drags the minimum down and says nothing about
|
|
108
|
+
the part, while the 1st percentile tracks real thin regions."""
|
|
109
|
+
faces = np.asarray(mesh.faces)
|
|
110
|
+
if len(faces) == 0:
|
|
111
|
+
return None, None, 0.0, 0.0
|
|
112
|
+
|
|
113
|
+
areas = np.asarray(mesh.area_faces, float)
|
|
114
|
+
normals = np.asarray(mesh.face_normals, float)
|
|
115
|
+
centers = np.asarray(mesh.triangles_center, float)
|
|
116
|
+
|
|
117
|
+
n = min(len(faces), _MAX_THICKNESS_RAYS)
|
|
118
|
+
if n < len(faces):
|
|
119
|
+
# Area-weighted without replacement: sample where the surface is.
|
|
120
|
+
weights = areas / areas.sum() if areas.sum() > 0 else None
|
|
121
|
+
rng = np.random.default_rng(0) # deterministic runs
|
|
122
|
+
idx = rng.choice(len(faces), size=n, replace=False, p=weights)
|
|
123
|
+
else:
|
|
124
|
+
idx = np.arange(len(faces))
|
|
125
|
+
|
|
126
|
+
# Step inside by a fraction of the local triangle scale, so the ray does
|
|
127
|
+
# not immediately re-hit its own source face.
|
|
128
|
+
eps = np.maximum(np.sqrt(areas[idx]) * 1e-3, 1e-6)[:, None]
|
|
129
|
+
origins = centers[idx] - normals[idx] * eps
|
|
130
|
+
directions = -normals[idx]
|
|
131
|
+
|
|
132
|
+
# Deliberately NOT caught here. trimesh's ray engine needs `rtree`, and a
|
|
133
|
+
# missing optional dep must surface as a recorded probe error, never as a
|
|
134
|
+
# quiet "no thin walls found" — the same silent-degradation trap
|
|
135
|
+
# requirements.txt already warns about for shapely/rtree/mapbox-earcut.
|
|
136
|
+
hits, ray_idx, _tri = mesh.ray.intersects_location(
|
|
137
|
+
origins, directions, multiple_hits=False
|
|
138
|
+
)
|
|
139
|
+
if len(ray_idx) == 0:
|
|
140
|
+
return None, None, 0.0, 0.0
|
|
141
|
+
|
|
142
|
+
dist = np.linalg.norm(hits - origins[ray_idx], axis=1)
|
|
143
|
+
sampled_area = float(areas[idx][ray_idx].sum())
|
|
144
|
+
thin = dist < min_wall
|
|
145
|
+
return (
|
|
146
|
+
float(dist.min()),
|
|
147
|
+
float(np.percentile(dist, 1)),
|
|
148
|
+
float(areas[idx][ray_idx][thin].sum()),
|
|
149
|
+
sampled_area,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _wall_thickness(part, min_wall, method="voxel", grid=None):
|
|
154
|
+
"""Wall thickness by `method`: (min, p1, thin area mm2, thin fraction,
|
|
155
|
+
thin spots). `grid` = (solid, origin, pitch) for the voxel probe."""
|
|
156
|
+
if method == "ray":
|
|
157
|
+
if callable(part) and not hasattr(part, "faces"):
|
|
158
|
+
raise ValueError("the ray probe needs a mesh; use the voxel probe for a field")
|
|
159
|
+
lo, p1, thin, sampled = _ray_wall(part, min_wall)
|
|
160
|
+
return lo, p1, thin, (thin / sampled if sampled > 0 else 0.0), []
|
|
161
|
+
solid, origin, pitch = grid
|
|
162
|
+
return _voxel_wall(solid, origin, pitch, min_wall)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _overhangs(mesh, overhang_deg, build_dir):
|
|
166
|
+
"""Downward-facing area shallower than `overhang_deg` from horizontal.
|
|
167
|
+
|
|
168
|
+
A face's inclination from horizontal is acos(-n . build_dir): a normal
|
|
169
|
+
pointing straight down is a 0-degree (horizontal ceiling) overhang, the
|
|
170
|
+
worst case. Faces sitting ON the build plate are excluded — the plate
|
|
171
|
+
supports them."""
|
|
172
|
+
normals = np.asarray(mesh.face_normals, float)
|
|
173
|
+
areas = np.asarray(mesh.area_faces, float)
|
|
174
|
+
total = float(areas.sum())
|
|
175
|
+
if total <= 0:
|
|
176
|
+
return 0.0, 0.0
|
|
177
|
+
|
|
178
|
+
down = -(normals @ build_dir)
|
|
179
|
+
with np.errstate(invalid="ignore"):
|
|
180
|
+
incline = np.degrees(np.arccos(np.clip(down, -1.0, 1.0)))
|
|
181
|
+
|
|
182
|
+
# Exclude the footprint: faces whose centroid sits within one layer of
|
|
183
|
+
# the lowest point along the build direction rest on the plate.
|
|
184
|
+
heights = np.asarray(mesh.triangles_center, float) @ build_dir
|
|
185
|
+
on_plate = heights <= heights.min() + 1e-3
|
|
186
|
+
|
|
187
|
+
flagged = (down > 0) & (incline < overhang_deg) & ~on_plate
|
|
188
|
+
return float(areas[flagged].sum()), total
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _trapped_voids(solid, origin, pitch):
|
|
192
|
+
"""Enclosed voids that cannot drain resin or release powder: label the
|
|
193
|
+
void with 26-connectivity and keep the components that never touch the
|
|
194
|
+
padded boundary. The scanline grid keeps internal cavities void (parity
|
|
195
|
+
sees the inner shell), which is what makes them findable here."""
|
|
196
|
+
lbl, count = ndimage.label(~solid, structure=_CONN)
|
|
197
|
+
if count == 0:
|
|
198
|
+
return [], 0.0
|
|
199
|
+
outside = set()
|
|
200
|
+
for face in (lbl[0], lbl[-1], lbl[:, 0], lbl[:, -1], lbl[:, :, 0], lbl[:, :, -1]):
|
|
201
|
+
outside.update(np.unique(face).tolist())
|
|
202
|
+
outside.discard(0)
|
|
203
|
+
sizes = np.bincount(lbl.ravel(), minlength=count + 1)
|
|
204
|
+
keep = [k for k in range(1, count + 1) if k not in outside and sizes[k] >= _TINY_VOID_VOX]
|
|
205
|
+
voxel_mm3 = pitch ** 3
|
|
206
|
+
voids = []
|
|
207
|
+
if keep:
|
|
208
|
+
centers = ndimage.center_of_mass(np.ones(lbl.shape, np.uint8), lbl, keep)
|
|
209
|
+
for k, c in zip(keep, centers):
|
|
210
|
+
voids.append({
|
|
211
|
+
"volumeMm3": round(float(sizes[k]) * voxel_mm3, 3),
|
|
212
|
+
"center": [round(float(v), 2) for v in (origin + np.asarray(c, float) * pitch)],
|
|
213
|
+
"voxels": int(sizes[k]),
|
|
214
|
+
})
|
|
215
|
+
voids.sort(key=lambda v: v["volumeMm3"], reverse=True)
|
|
216
|
+
return voids, float(sum(v["volumeMm3"] for v in voids))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def check_dfm(part, spec=None):
|
|
220
|
+
"""Printability report for a final part (a trimesh, or an implicit field
|
|
221
|
+
with spec["bounds"]). Never raises for a geometry it cannot measure: an
|
|
222
|
+
unmeasurable field comes back None/empty with the rest of the report
|
|
223
|
+
intact, so one bad probe never fails a run.
|
|
224
|
+
|
|
225
|
+
spec: minWall (mm, 1.5), overhangDeg (45), build (vector, +z), pitch (mm,
|
|
226
|
+
default a third of minWall within the voxel budget), wallMethod ("voxel"
|
|
227
|
+
or "ray"), bounds ((lo, hi) mm, required for a field)."""
|
|
228
|
+
spec = spec or {}
|
|
229
|
+
min_wall = float(spec.get("minWall", _DEFAULT_MIN_WALL_MM))
|
|
230
|
+
overhang_deg = float(spec.get("overhangDeg", _DEFAULT_OVERHANG_DEG))
|
|
231
|
+
build_dir = np.asarray(spec.get("build", (0.0, 0.0, 1.0)), float)
|
|
232
|
+
norm = np.linalg.norm(build_dir)
|
|
233
|
+
build_dir = build_dir / norm if norm > 0 else np.array([0.0, 0.0, 1.0])
|
|
234
|
+
method = str(spec.get("wallMethod", "voxel"))
|
|
235
|
+
is_field = callable(part) and not hasattr(part, "faces")
|
|
236
|
+
bounds = spec.get("bounds")
|
|
237
|
+
if is_field:
|
|
238
|
+
if bounds is None:
|
|
239
|
+
raise ValueError("check_dfm on a field needs spec['bounds'] = (lo, hi) in mm")
|
|
240
|
+
bounds = tuple(np.asarray(b, float) for b in bounds)
|
|
241
|
+
extents = bounds[1] - bounds[0]
|
|
242
|
+
else:
|
|
243
|
+
extents = np.asarray(part.extents, float) if len(part.faces) else np.ones(3)
|
|
244
|
+
|
|
245
|
+
pitch = _choose_pitch(extents, min_wall, spec.get("pitch"))
|
|
246
|
+
|
|
247
|
+
report = {
|
|
248
|
+
"pitch": round(pitch, 4),
|
|
249
|
+
"source": "field" if is_field else "mesh",
|
|
250
|
+
"minWallTargetMm": min_wall,
|
|
251
|
+
"overhangLimitDeg": overhang_deg,
|
|
252
|
+
"wallMethod": method,
|
|
253
|
+
}
|
|
254
|
+
if is_field:
|
|
255
|
+
report["watertight"] = True # an implicit solid is closed by construction
|
|
256
|
+
else:
|
|
257
|
+
report.update(watertight=bool(part.is_watertight), triangles=int(len(part.faces)),
|
|
258
|
+
volumeMm3=round(float(abs(part.volume)), 3) if len(part.faces) else 0.0)
|
|
259
|
+
|
|
260
|
+
grid = None
|
|
261
|
+
grid_error = None
|
|
262
|
+
try:
|
|
263
|
+
solid, origin, info = solid_grid(part, pitch, pad=2, bounds=bounds)
|
|
264
|
+
grid = (solid, origin, pitch)
|
|
265
|
+
report["gridCells"] = [int(n) for n in solid.shape]
|
|
266
|
+
if is_field:
|
|
267
|
+
report["volumeMm3"] = round(float(solid.sum()) * pitch ** 3, 3)
|
|
268
|
+
else:
|
|
269
|
+
if any(info.get("unclosedColumns", {}).values()):
|
|
270
|
+
report["unclosedColumns"] = info["unclosedColumns"]
|
|
271
|
+
if info.get("overlapMm3", 0.0) > 0.0:
|
|
272
|
+
# bodies concatenated without a union (or a cavity shell
|
|
273
|
+
# facing out): parity reads their overlap as void
|
|
274
|
+
report["overlapMm3"] = round(info["overlapMm3"], 3)
|
|
275
|
+
report["overlapWarning"] = (
|
|
276
|
+
"the mesh has overlapping shells (bodies joined without a boolean union) or an "
|
|
277
|
+
"outward-facing cavity; their overlap reads as void, so walls and voids there are "
|
|
278
|
+
"wrong. Union the bodies first.")
|
|
279
|
+
except Exception as err: # noqa: BLE001 — failure-isolated per probe
|
|
280
|
+
grid_error = f"voxelizing failed: {err}"
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
if method != "ray" and pitch > min_wall / _MIN_CELLS_PER_WALL + 1e-9:
|
|
284
|
+
# Too coarse to resolve the wall: say so rather than guess.
|
|
285
|
+
report["minWallOk"] = None
|
|
286
|
+
report["minWallInconclusive"] = (
|
|
287
|
+
f"pitch {pitch:g} mm (voxel budget) is coarser than half the {min_wall:g} mm minimum "
|
|
288
|
+
"wall; pass a smaller part, a finer pitch with a larger LOADPATH_DFM_VOXEL_BUDGET, or "
|
|
289
|
+
"bounds around the region to check")
|
|
290
|
+
else:
|
|
291
|
+
if method != "ray" and grid is None:
|
|
292
|
+
raise RuntimeError(grid_error)
|
|
293
|
+
thin_min, thin_p1, thin_area, thin_frac, spots = _wall_thickness(part, min_wall, method, grid)
|
|
294
|
+
report["minWallMm"] = None if thin_min is None else round(thin_min, 3)
|
|
295
|
+
report["wallP1Mm"] = None if thin_p1 is None else round(thin_p1, 3)
|
|
296
|
+
# the voxel reading is within half a cell of the true thickness,
|
|
297
|
+
# on a grid that itself staircases by half a cell
|
|
298
|
+
report["wallToleranceMm"] = 0.0 if method == "ray" else round(pitch, 4)
|
|
299
|
+
if method != "ray":
|
|
300
|
+
# members thicker than this all read as about this
|
|
301
|
+
report["wallCapMm"] = round(_cap_mm(min_wall, pitch), 3)
|
|
302
|
+
report["thinAreaMm2"] = round(thin_area, 3)
|
|
303
|
+
report["thinAreaFraction"] = round(thin_frac, 4)
|
|
304
|
+
if spots:
|
|
305
|
+
report["thinSpots"] = spots
|
|
306
|
+
# Judge on the 1st percentile, not the raw minimum: one sliver
|
|
307
|
+
# must not condemn an otherwise printable part. Give the voxel
|
|
308
|
+
# reading its half-cell estimator error, not the whole tolerance.
|
|
309
|
+
slack = 0.0 if method == "ray" else 0.5 * pitch
|
|
310
|
+
report["minWallOk"] = thin_p1 is None or thin_p1 + slack >= min_wall
|
|
311
|
+
if thin_p1 is not None and method != "ray" and abs(thin_p1 - min_wall) <= pitch:
|
|
312
|
+
report["minWallBorderline"] = True
|
|
313
|
+
except Exception as err: # noqa: BLE001
|
|
314
|
+
# Unknown, NOT pass. Only checks that RAN are ever counted as verified.
|
|
315
|
+
report["minWallError"] = str(err)
|
|
316
|
+
report["minWallOk"] = None
|
|
317
|
+
|
|
318
|
+
if is_field:
|
|
319
|
+
report["overhangError"] = "overhangs need a mesh; pass the meshed part"
|
|
320
|
+
else:
|
|
321
|
+
try:
|
|
322
|
+
over_area, total_area = _overhangs(part, overhang_deg, build_dir)
|
|
323
|
+
report["overhangAreaMm2"] = round(over_area, 3)
|
|
324
|
+
report["overhangFraction"] = (
|
|
325
|
+
round(over_area / total_area, 4) if total_area > 0 else 0.0
|
|
326
|
+
)
|
|
327
|
+
except Exception as err: # noqa: BLE001
|
|
328
|
+
report["overhangError"] = str(err)
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
if grid is None:
|
|
332
|
+
raise RuntimeError(grid_error)
|
|
333
|
+
voids, trapped_mm3 = _trapped_voids(*grid)
|
|
334
|
+
report["trappedVoids"] = voids[:10]
|
|
335
|
+
report["trappedVoidCount"] = len(voids)
|
|
336
|
+
report["trappedVolumeMm3"] = round(trapped_mm3, 3)
|
|
337
|
+
report["drainsOk"] = len(voids) == 0
|
|
338
|
+
except Exception as err: # noqa: BLE001
|
|
339
|
+
report["trappedError"] = str(err)
|
|
340
|
+
report["drainsOk"] = None
|
|
341
|
+
|
|
342
|
+
# `ok` is "every probe RAN and passed": an unrun probe (None) makes the
|
|
343
|
+
# part unverified, not fine. `ok: False` with a *Error or *Inconclusive
|
|
344
|
+
# field beside it means "could not check", not a geometry defect; that is
|
|
345
|
+
# what the explicit `probesRan` flag is for.
|
|
346
|
+
report["probesRan"] = (
|
|
347
|
+
report.get("minWallOk") is not None and report.get("drainsOk") is not None
|
|
348
|
+
)
|
|
349
|
+
report["ok"] = bool(
|
|
350
|
+
report.get("watertight")
|
|
351
|
+
and report.get("minWallOk") is True
|
|
352
|
+
and report.get("drainsOk") is True
|
|
353
|
+
)
|
|
354
|
+
return report
|