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/voxels.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Occupancy grids and wall-thickness probes that scale to multi-million
|
|
3
|
+
triangle parts.
|
|
4
|
+
|
|
5
|
+
The checks used to voxelize through trimesh (`mesh.voxelized`, then a ray
|
|
6
|
+
parity vote per enclosed pocket) and measure walls with one ray per sampled
|
|
7
|
+
face. Both go through trimesh's ray broad phase, which on a 6.6M-triangle
|
|
8
|
+
TPMS heat exchanger hands every ray a candidate set covering most of the
|
|
9
|
+
mesh: the printability check reached 63 GB and the isolation check took 30
|
|
10
|
+
minutes. Everything here is plain numpy and bounded in memory:
|
|
11
|
+
|
|
12
|
+
mesh_solid — scanline rasterizer. Every voxel column along an axis is a
|
|
13
|
+
ray; each triangle is binned straight to the columns its
|
|
14
|
+
projection covers (no tree), the crossing heights are kept,
|
|
15
|
+
and parity along the column fills the solid. Cost is
|
|
16
|
+
O(triangles + crossings), memory is a bool grid plus 8 bytes
|
|
17
|
+
per crossing. Orientation independent, so inverted cavity
|
|
18
|
+
shells and nested bodies come out right; a mesh that is not
|
|
19
|
+
watertight gets a majority vote over the three axes.
|
|
20
|
+
field_solid — the same grid from an implicit field f(P) < 0, evaluated a
|
|
21
|
+
slab at a time. For parts built from a field this skips the
|
|
22
|
+
mesh altogether, and is exact at the voxel centres.
|
|
23
|
+
wall_thickness — member thickness on the medial ridge of the grid (twice
|
|
24
|
+
the distance to void at its local maxima), from a distance
|
|
25
|
+
transform computed in overlapping tiles so it never holds
|
|
26
|
+
more than one tile of floats.
|
|
27
|
+
|
|
28
|
+
Grids follow one convention: (bool grid, origin) where origin is the world
|
|
29
|
+
position of the CENTRE of voxel (0, 0, 0), with `pad` void voxels on every
|
|
30
|
+
side.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
from scipy import ndimage
|
|
36
|
+
|
|
37
|
+
__all__ = ["grid_for", "mesh_solid", "field_solid", "solid_grid", "wall_thickness",
|
|
38
|
+
"tiled_edt_min", "choose_pitch"]
|
|
39
|
+
|
|
40
|
+
# Column-centre jitter, as a share of the pitch: keeps every scanline off the
|
|
41
|
+
# exact edges and vertices of the mesh, where a crossing could be counted by
|
|
42
|
+
# both triangles sharing the edge (or by neither) and flip the parity.
|
|
43
|
+
_JITTER = (0.000137, 0.000291)
|
|
44
|
+
|
|
45
|
+
# Candidate (triangle, column) pairs tested per batch.
|
|
46
|
+
_BATCH = 2_000_000
|
|
47
|
+
|
|
48
|
+
# Floats held per tile by the tiled distance transform (~20 bytes each).
|
|
49
|
+
_TILE_VOXELS = 16_000_000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def choose_pitch(extents, target, budget, pad=2):
|
|
53
|
+
"""`target` pitch, coarsened by 1.25x steps until the grid fits `budget`
|
|
54
|
+
voxels. Returns the pitch actually used."""
|
|
55
|
+
extents = np.asarray(extents, float)
|
|
56
|
+
pitch = float(target)
|
|
57
|
+
while float(np.prod(np.ceil(extents / pitch) + 2 * pad + 1)) > budget:
|
|
58
|
+
pitch *= 1.25
|
|
59
|
+
return pitch
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def grid_for(lo, hi, pitch, pad=2):
|
|
63
|
+
"""(dims, origin) of a grid covering the box lo..hi with `pad` voxels of
|
|
64
|
+
margin. origin is the centre of voxel (0, 0, 0)."""
|
|
65
|
+
lo = np.asarray(lo, float)
|
|
66
|
+
hi = np.asarray(hi, float)
|
|
67
|
+
core = np.maximum(np.ceil((hi - lo) / pitch).astype(int), 1)
|
|
68
|
+
dims = core + 2 * pad
|
|
69
|
+
origin = lo - 0.5 * (core * pitch - (hi - lo)) + (0.5 - pad) * pitch
|
|
70
|
+
return dims, origin
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _crossings(tri, dims, origin, pitch, axis):
|
|
74
|
+
"""Scanline crossings of triangles `tri` (T, 3, 3) with the voxel columns
|
|
75
|
+
along `axis`. Returns (flat column index, crossing position along the
|
|
76
|
+
axis in cells from voxel 0's centre, the sign of the triangle's projected
|
|
77
|
+
area: which way it faces along the axis, up to the axis's handedness)."""
|
|
78
|
+
u_ax, v_ax = [a for a in range(3) if a != axis]
|
|
79
|
+
nu, nv, nw = dims[u_ax], dims[v_ax], dims[axis]
|
|
80
|
+
ou = origin[u_ax] + _JITTER[0] * pitch
|
|
81
|
+
ov = origin[v_ax] + _JITTER[1] * pitch
|
|
82
|
+
ow = origin[axis]
|
|
83
|
+
|
|
84
|
+
U, V, W = tri[:, :, u_ax], tri[:, :, v_ax], tri[:, :, axis]
|
|
85
|
+
i0 = np.clip(np.ceil((U.min(1) - ou) / pitch), 0, nu).astype(np.int64)
|
|
86
|
+
i1 = np.clip(np.floor((U.max(1) - ou) / pitch), -1, nu - 1).astype(np.int64)
|
|
87
|
+
j0 = np.clip(np.ceil((V.min(1) - ov) / pitch), 0, nv).astype(np.int64)
|
|
88
|
+
j1 = np.clip(np.floor((V.max(1) - ov) / pitch), -1, nv - 1).astype(np.int64)
|
|
89
|
+
wi = np.maximum(i1 - i0 + 1, 0)
|
|
90
|
+
wj = np.maximum(j1 - j0 + 1, 0)
|
|
91
|
+
count = wi * wj
|
|
92
|
+
# 2x the projected signed area; a triangle seen edge-on crosses no column
|
|
93
|
+
den = (V[:, 1] - V[:, 2]) * (U[:, 0] - U[:, 2]) + (U[:, 2] - U[:, 1]) * (V[:, 0] - V[:, 2])
|
|
94
|
+
keep = (count > 0) & (np.abs(den) > 1e-14)
|
|
95
|
+
idx = np.nonzero(keep)[0]
|
|
96
|
+
cols_out, ks_out, sg_out = [], [], []
|
|
97
|
+
if idx.size == 0:
|
|
98
|
+
return np.zeros(0, np.int64), np.zeros(0), np.zeros(0, np.int8)
|
|
99
|
+
csum = np.cumsum(count[idx])
|
|
100
|
+
start = 0
|
|
101
|
+
while start < idx.size:
|
|
102
|
+
base = csum[start - 1] if start else 0
|
|
103
|
+
stop = int(np.searchsorted(csum, base + _BATCH, side="right"))
|
|
104
|
+
stop = max(stop, start + 1)
|
|
105
|
+
t = idx[start:stop]
|
|
106
|
+
n = count[t]
|
|
107
|
+
rep = np.repeat(np.arange(t.size), n)
|
|
108
|
+
off = np.arange(int(n.sum())) - np.repeat(np.cumsum(n) - n, n)
|
|
109
|
+
ti = t[rep]
|
|
110
|
+
ii = i0[ti] + off // wj[ti]
|
|
111
|
+
jj = j0[ti] + off % wj[ti]
|
|
112
|
+
x = ou + ii * pitch
|
|
113
|
+
y = ov + jj * pitch
|
|
114
|
+
Ut, Vt, Wt, d = U[ti], V[ti], W[ti], den[ti]
|
|
115
|
+
l0 = ((Vt[:, 1] - Vt[:, 2]) * (x - Ut[:, 2]) + (Ut[:, 2] - Ut[:, 1]) * (y - Vt[:, 2])) / d
|
|
116
|
+
l1 = ((Vt[:, 2] - Vt[:, 0]) * (x - Ut[:, 2]) + (Ut[:, 0] - Ut[:, 2]) * (y - Vt[:, 2])) / d
|
|
117
|
+
l2 = 1.0 - l0 - l1
|
|
118
|
+
hit = (l0 >= 0) & (l1 >= 0) & (l2 >= 0)
|
|
119
|
+
if hit.any():
|
|
120
|
+
w = l0[hit] * Wt[hit, 0] + l1[hit] * Wt[hit, 1] + l2[hit] * Wt[hit, 2]
|
|
121
|
+
cols_out.append(ii[hit] * nv + jj[hit])
|
|
122
|
+
ks_out.append((w - ow) / pitch)
|
|
123
|
+
sg_out.append(np.sign(d[hit]).astype(np.int8))
|
|
124
|
+
start = stop
|
|
125
|
+
if not cols_out:
|
|
126
|
+
return np.zeros(0, np.int64), np.zeros(0), np.zeros(0, np.int8)
|
|
127
|
+
return np.concatenate(cols_out), np.concatenate(ks_out), np.concatenate(sg_out)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parity_axis(tri, dims, origin, pitch, axis, touch=None):
|
|
131
|
+
"""Solid by crossing parity along `axis`; returns (grid, odd-column
|
|
132
|
+
count, overlap cells). With `touch` (a bool grid in x, y, z order), also
|
|
133
|
+
marks there every voxel a crossing falls in.
|
|
134
|
+
|
|
135
|
+
Overlap cells are where the winding number (crossings counted by the way
|
|
136
|
+
their triangle faces) says solid and parity says void: two closed bodies
|
|
137
|
+
concatenated without a boolean, which parity cancels to void where they
|
|
138
|
+
overlap, or a cavity shell facing the wrong way. The grid stays parity
|
|
139
|
+
(it does not trust winding), but a caller can say the reading is off."""
|
|
140
|
+
u_ax, v_ax = [a for a in range(3) if a != axis]
|
|
141
|
+
nu, nv, nw = int(dims[u_ax]), int(dims[v_ax]), int(dims[axis])
|
|
142
|
+
cols, w, sg = _crossings(tri, dims, origin, pitch, axis)
|
|
143
|
+
# (u, v, axis) cyclic for x and z, anticyclic for y; entering the solid
|
|
144
|
+
# (an outward normal facing down the axis) counts +1
|
|
145
|
+
sg = -sg.astype(np.float64) * (-1.0 if axis == 1 else 1.0)
|
|
146
|
+
if touch is not None and cols.size:
|
|
147
|
+
k = np.rint(w).astype(np.int64)
|
|
148
|
+
ok = (k >= 0) & (k < nw)
|
|
149
|
+
ijk = np.empty((int(ok.sum()), 3), np.int64)
|
|
150
|
+
ijk[:, u_ax] = cols[ok] // nv
|
|
151
|
+
ijk[:, v_ax] = cols[ok] % nv
|
|
152
|
+
ijk[:, axis] = k[ok]
|
|
153
|
+
touch[ijk[:, 0], ijk[:, 1], ijk[:, 2]] = True
|
|
154
|
+
ks = np.clip(np.ceil(w), 0, nw).astype(np.int64)
|
|
155
|
+
del w
|
|
156
|
+
# Columns that did not close (odd crossings) leak to the top of the grid;
|
|
157
|
+
# count them so a caller can tell a sound mesh from a holed one.
|
|
158
|
+
odd_cols = int((np.bincount(cols, minlength=nu * nv) & 1).sum()) if cols.size else 0
|
|
159
|
+
out = np.zeros((nu, nv, nw), bool)
|
|
160
|
+
# A slab of u-rows at a time keeps the toggle counts small.
|
|
161
|
+
rows = max(1, int(_BATCH // max(nv * (nw + 1), 1)))
|
|
162
|
+
order = np.argsort(cols, kind="stable")
|
|
163
|
+
cols, ks, sg = cols[order], ks[order], sg[order]
|
|
164
|
+
row_of = cols // nv
|
|
165
|
+
overlap = 0
|
|
166
|
+
for r0 in range(0, nu, rows):
|
|
167
|
+
r1 = min(nu, r0 + rows)
|
|
168
|
+
a, b = np.searchsorted(row_of, [r0, r1])
|
|
169
|
+
if a == b:
|
|
170
|
+
continue
|
|
171
|
+
flat = (cols[a:b] - r0 * nv) * (nw + 1) + ks[a:b]
|
|
172
|
+
tog = np.bincount(flat, minlength=(r1 - r0) * nv * (nw + 1)).astype(np.uint8)
|
|
173
|
+
tog = tog.reshape(r1 - r0, nv, nw + 1)[:, :, :nw]
|
|
174
|
+
par = (np.cumsum(tog, axis=2, dtype=np.uint8) & 1).astype(bool)
|
|
175
|
+
out[r0:r1] = par
|
|
176
|
+
wind = np.bincount(flat, weights=sg[a:b], minlength=(r1 - r0) * nv * (nw + 1))
|
|
177
|
+
wind = np.cumsum(wind.reshape(r1 - r0, nv, nw + 1)[:, :, :nw], axis=2)
|
|
178
|
+
overlap += int(((np.abs(wind) > 0.5) & ~par).sum())
|
|
179
|
+
# back to (x, y, z) order
|
|
180
|
+
perm = np.argsort([u_ax, v_ax, axis])
|
|
181
|
+
return np.transpose(out, perm), odd_cols, overlap
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def mesh_solid(mesh, pitch, pad=2, bounds=None, axes=None, touch=False):
|
|
185
|
+
"""(bool grid, origin) of a triangle mesh's solid material. `bounds`
|
|
186
|
+
(lo, hi) limits the grid (crossings outside it still count, so parity
|
|
187
|
+
stays right). `axes`: scanline axes to vote over; default z alone for a
|
|
188
|
+
watertight mesh, all three (majority) otherwise. Also returns a dict with
|
|
189
|
+
the number of unclosed columns per axis, which is 0 for a sound mesh.
|
|
190
|
+
|
|
191
|
+
touch: also count as solid every voxel the surface passes through (found
|
|
192
|
+
from the crossings of all three axes), like a surface voxelization. That
|
|
193
|
+
thickens walls by up to a cell, which is what an isolation check wants:
|
|
194
|
+
sampling only voxel centres, a wall two cells thick lets a diagonal
|
|
195
|
+
26-connected chain of void through and reads as a phantom leak."""
|
|
196
|
+
tri = np.asarray(mesh.triangles, float)
|
|
197
|
+
lo, hi = (np.asarray(b, float) for b in (bounds if bounds is not None else mesh.bounds))
|
|
198
|
+
dims, origin = grid_for(lo, hi, pitch, pad)
|
|
199
|
+
if axes is None:
|
|
200
|
+
axes = (2,) if bool(getattr(mesh, "is_watertight", False)) else (0, 1, 2)
|
|
201
|
+
if bounds is not None:
|
|
202
|
+
# crossings below the grid still toggle parity: extend the grid down
|
|
203
|
+
# each scan axis to the mesh's own minimum, then crop
|
|
204
|
+
mlo = np.asarray(mesh.bounds, float)[0]
|
|
205
|
+
ext = np.maximum(np.ceil((lo - mlo) / pitch).astype(int) + 1, 0)
|
|
206
|
+
else:
|
|
207
|
+
ext = np.zeros(3, int)
|
|
208
|
+
votes = np.zeros(tuple(dims), np.uint8)
|
|
209
|
+
unclosed = {}
|
|
210
|
+
surf = np.zeros(tuple(dims), bool) if touch else None
|
|
211
|
+
overlaps = []
|
|
212
|
+
scan = list(axes) + ([a for a in (0, 1, 2) if a not in axes] if touch else [])
|
|
213
|
+
for ax in scan:
|
|
214
|
+
d2, o2 = dims.copy(), origin.copy()
|
|
215
|
+
d2[ax] += ext[ax]
|
|
216
|
+
o2[ax] -= ext[ax] * pitch
|
|
217
|
+
sl = [slice(None)] * 3
|
|
218
|
+
sl[ax] = slice(int(ext[ax]), None)
|
|
219
|
+
big = np.zeros(tuple(d2), bool) if touch else None
|
|
220
|
+
g, odd, over = _parity_axis(tri, d2, o2, pitch, ax, touch=big)
|
|
221
|
+
if touch:
|
|
222
|
+
surf |= big[tuple(sl)]
|
|
223
|
+
if ax in axes:
|
|
224
|
+
votes += g[tuple(sl)]
|
|
225
|
+
unclosed["xyz"[ax]] = odd
|
|
226
|
+
overlaps.append(over)
|
|
227
|
+
solid = votes * 2 > len(axes)
|
|
228
|
+
if touch:
|
|
229
|
+
solid |= surf
|
|
230
|
+
if pad:
|
|
231
|
+
solid[:pad] = solid[-pad:] = False
|
|
232
|
+
solid[:, :pad] = solid[:, -pad:] = False
|
|
233
|
+
solid[:, :, :pad] = solid[:, :, -pad:] = False
|
|
234
|
+
return solid, origin, {"unclosedColumns": unclosed, "axes": ["xyz"[a] for a in axes],
|
|
235
|
+
"overlapMm3": float(min(overlaps) * pitch ** 3) if overlaps else 0.0}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _sample(field, origin, dims, pitch, chunk):
|
|
239
|
+
"""field < 0 at the points origin + ijk * pitch, a slab at a time."""
|
|
240
|
+
nx, ny, nz = (int(d) for d in dims)
|
|
241
|
+
out = np.zeros((nx, ny, nz), bool)
|
|
242
|
+
ys = origin[1] + np.arange(ny) * pitch
|
|
243
|
+
zs = origin[2] + np.arange(nz) * pitch
|
|
244
|
+
gy, gz = np.meshgrid(ys, zs, indexing="ij")
|
|
245
|
+
plane = np.stack([np.zeros(gy.size), gy.ravel(), gz.ravel()], axis=1)
|
|
246
|
+
rows = max(1, chunk // max(plane.shape[0], 1))
|
|
247
|
+
for i0 in range(0, nx, rows):
|
|
248
|
+
i1 = min(nx, i0 + rows)
|
|
249
|
+
P = np.concatenate([plane + [origin[0] + i * pitch, 0.0, 0.0] for i in range(i0, i1)])
|
|
250
|
+
out[i0:i1] = np.asarray(field(P), float).reshape(i1 - i0, ny, nz) < 0.0
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def field_solid(field, lo, hi, pitch, pad=2, chunk=2_000_000, touch=False):
|
|
255
|
+
"""(bool grid, origin) of an implicit solid: field(P) < 0 inside, P an
|
|
256
|
+
(N, 3) array in mm. Evaluated at voxel centres, a slab at a time.
|
|
257
|
+
|
|
258
|
+
touch: also count a voxel as solid when any of its eight corners is in
|
|
259
|
+
the solid, the field's version of mesh_solid's touch. Corners, not a
|
|
260
|
+
distance threshold: a field is often not a true distance (smooth unions,
|
|
261
|
+
seal ramps, TPMS sheets read 0.35-2.5 per mm of gradient), and a level
|
|
262
|
+
of pitch/2 shut real channel throats where the gradient is shallow
|
|
263
|
+
(counterflow exchanger_core read each fluid split in two)."""
|
|
264
|
+
dims, origin = grid_for(lo, hi, pitch, pad)
|
|
265
|
+
solid = _sample(field, origin, dims, pitch, chunk)
|
|
266
|
+
if touch:
|
|
267
|
+
corner = _sample(field, origin - 0.5 * pitch, dims + 1, pitch, chunk)
|
|
268
|
+
for dx in (0, 1):
|
|
269
|
+
for dy in (0, 1):
|
|
270
|
+
for dz in (0, 1):
|
|
271
|
+
solid |= corner[dx:dx + dims[0], dy:dy + dims[1], dz:dz + dims[2]]
|
|
272
|
+
del corner
|
|
273
|
+
# the pad is void by construction, whatever the field says out there
|
|
274
|
+
if pad:
|
|
275
|
+
solid[:pad] = solid[-pad:] = False
|
|
276
|
+
solid[:, :pad] = solid[:, -pad:] = False
|
|
277
|
+
solid[:, :, :pad] = solid[:, :, -pad:] = False
|
|
278
|
+
return solid, origin
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def solid_grid(part, pitch, pad=2, bounds=None, touch=False):
|
|
282
|
+
"""Grid for either a mesh or a field. A field needs `bounds` (lo, hi)."""
|
|
283
|
+
if callable(part) and not hasattr(part, "triangles"):
|
|
284
|
+
if bounds is None:
|
|
285
|
+
raise ValueError("an implicit field needs bounds=(lo, hi) in mm")
|
|
286
|
+
solid, origin = field_solid(part, bounds[0], bounds[1], pitch, pad, touch=touch)
|
|
287
|
+
return solid, origin, {"source": "field"}
|
|
288
|
+
solid, origin, info = mesh_solid(part, pitch, pad, bounds, touch=touch)
|
|
289
|
+
info["source"] = "mesh"
|
|
290
|
+
return solid, origin, info
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _tiles(n, cap):
|
|
294
|
+
"""Slabs along axis 0: (core start, core stop, halo start, halo stop)."""
|
|
295
|
+
step = max(int(cap) * 4, 32)
|
|
296
|
+
for a in range(0, n, step):
|
|
297
|
+
b = min(n, a + step)
|
|
298
|
+
yield a, b, max(0, a - cap), min(n, b + cap)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _tile_len(shape, cap):
|
|
302
|
+
plane = int(shape[1]) * int(shape[2])
|
|
303
|
+
return max(int(_TILE_VOXELS // max(plane, 1)) - 2 * cap, 8)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def wall_thickness(solid, pitch, cap_mm=None, limit_mm=None, spots=5, origin=None):
|
|
307
|
+
"""Member thickness of a grid's solid, in mm, on the medial ridge (the
|
|
308
|
+
local maxima of the distance to void). The estimate 2*edt - 0.5 cells is
|
|
309
|
+
within half a cell of the true thickness either way, so a reading carries
|
|
310
|
+
+/- pitch/2 on top of the grid's own +/- pitch/2 staircase.
|
|
311
|
+
|
|
312
|
+
cap_mm: thicknesses above this are reported as the cap (the distance
|
|
313
|
+
transform only has to look that far, which is what lets it run in tiles).
|
|
314
|
+
limit_mm: also return the share of the surface lying on a member thinner
|
|
315
|
+
than this, and the thinnest spots (world coordinates when `origin` is
|
|
316
|
+
given). Returns a dict."""
|
|
317
|
+
shape = solid.shape
|
|
318
|
+
cap_mm = float(cap_mm) if cap_mm else float(max(shape) * pitch)
|
|
319
|
+
cap = int(np.ceil(cap_mm / (2.0 * pitch))) + 2 # edt cells to look
|
|
320
|
+
vals_all, where_all = [], []
|
|
321
|
+
thin_surf = 0
|
|
322
|
+
surf_total = 0
|
|
323
|
+
grow = int(np.ceil((limit_mm or 0.0) / (2.0 * pitch))) + 1 if limit_mm else 0
|
|
324
|
+
halo = cap + grow + 2
|
|
325
|
+
step = _tile_len(shape, halo)
|
|
326
|
+
for a in range(0, shape[0], step):
|
|
327
|
+
b = min(shape[0], a + step)
|
|
328
|
+
ha, hb = max(0, a - halo), min(shape[0], b + halo)
|
|
329
|
+
sub = solid[ha:hb]
|
|
330
|
+
# void outside the grid on the cross-section sides; along axis 0 the
|
|
331
|
+
# halo stands in for the neighbours (and the grid's pad is void)
|
|
332
|
+
padded = np.pad(sub, ((0, 0), (1, 1), (1, 1)))
|
|
333
|
+
if ha == 0:
|
|
334
|
+
padded = np.pad(padded, ((1, 0), (0, 0), (0, 0)))
|
|
335
|
+
if hb == shape[0]:
|
|
336
|
+
padded = np.pad(padded, ((0, 1), (0, 0), (0, 0)))
|
|
337
|
+
edt = ndimage.distance_transform_edt(padded).astype(np.float32)
|
|
338
|
+
edt = edt[(1 if ha == 0 else 0):edt.shape[0] - (1 if hb == shape[0] else 0), 1:-1, 1:-1]
|
|
339
|
+
np.minimum(edt, cap, out=edt)
|
|
340
|
+
ridge = sub & (edt >= ndimage.maximum_filter(edt, size=3)) & (edt > 0)
|
|
341
|
+
core = slice(a - ha, b - ha)
|
|
342
|
+
r = ridge[core]
|
|
343
|
+
e = edt[core][r]
|
|
344
|
+
vals_all.append((2.0 * e - 0.5) * pitch)
|
|
345
|
+
if spots and limit_mm:
|
|
346
|
+
ijk = np.argwhere(r)
|
|
347
|
+
ijk[:, 0] += a
|
|
348
|
+
where_all.append(ijk)
|
|
349
|
+
if limit_mm:
|
|
350
|
+
thin = ridge & ((2.0 * edt - 0.5) * pitch < limit_mm)
|
|
351
|
+
near = ndimage.binary_dilation(thin, iterations=grow) if thin.any() else thin
|
|
352
|
+
surf = sub & ~ndimage.binary_erosion(np.pad(sub, 1), border_value=0)[1:-1, 1:-1, 1:-1]
|
|
353
|
+
surf_total += int(surf[core].sum())
|
|
354
|
+
thin_surf += int((surf & near)[core].sum())
|
|
355
|
+
vals = np.concatenate(vals_all) if vals_all else np.zeros(0)
|
|
356
|
+
out = {"pitch": float(pitch), "capMm": round(2 * cap * pitch - 0.5 * pitch, 3), "ridgeCells": int(vals.size)}
|
|
357
|
+
if vals.size == 0:
|
|
358
|
+
out.update(minMm=None, p1Mm=None, p5Mm=None)
|
|
359
|
+
return out
|
|
360
|
+
out.update(minMm=float(vals.min()), p1Mm=float(np.percentile(vals, 1)), p5Mm=float(np.percentile(vals, 5)))
|
|
361
|
+
if limit_mm:
|
|
362
|
+
out["thinSurfaceFraction"] = thin_surf / surf_total if surf_total else 0.0
|
|
363
|
+
out["thinAreaMm2"] = float(thin_surf * pitch * pitch)
|
|
364
|
+
if spots:
|
|
365
|
+
ijk = np.concatenate(where_all)
|
|
366
|
+
order = np.argsort(vals, kind="stable")
|
|
367
|
+
picked, taken = [], []
|
|
368
|
+
sep = max(limit_mm * 4, 8 * pitch)
|
|
369
|
+
for k in order:
|
|
370
|
+
if vals[k] >= limit_mm or len(picked) >= spots:
|
|
371
|
+
break
|
|
372
|
+
p = ijk[k] * pitch + (origin if origin is not None else 0.0)
|
|
373
|
+
if all(np.linalg.norm(p - q) > sep for q in taken):
|
|
374
|
+
taken.append(p)
|
|
375
|
+
picked.append({"at": [round(float(c), 2) for c in p], "mm": round(float(vals[k]), 3)})
|
|
376
|
+
out["thinSpots"] = picked
|
|
377
|
+
return out
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def tiled_edt_min(source, targets, cap):
|
|
381
|
+
"""Smallest Euclidean distance, in cells, from any `source` cell to any
|
|
382
|
+
`targets` cell, looking at most `cap` cells (None beyond that). Tiled
|
|
383
|
+
along axis 0 so memory stays at one tile of floats."""
|
|
384
|
+
shape = source.shape
|
|
385
|
+
best = None
|
|
386
|
+
halo = int(cap) + 1
|
|
387
|
+
step = _tile_len(shape, halo)
|
|
388
|
+
for a in range(0, shape[0], step):
|
|
389
|
+
b = min(shape[0], a + step)
|
|
390
|
+
if not targets[a:b].any():
|
|
391
|
+
continue
|
|
392
|
+
ha, hb = max(0, a - halo), min(shape[0], b + halo)
|
|
393
|
+
src = source[ha:hb]
|
|
394
|
+
if not src.any():
|
|
395
|
+
continue
|
|
396
|
+
d = ndimage.distance_transform_edt(~src)
|
|
397
|
+
vals = d[a - ha:b - ha][targets[a:b]]
|
|
398
|
+
if vals.size:
|
|
399
|
+
m = float(vals.min())
|
|
400
|
+
if m <= cap and (best is None or m < best):
|
|
401
|
+
best = m
|
|
402
|
+
return best
|