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/checks/fea.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fea_probe: coarse voxel linear-elastic critic for load-bearing parts. Seconds,
|
|
3
|
+
not minutes; advises, never generates — geometry always comes from the caller,
|
|
4
|
+
this just says "the root of that cantilever is the hotspot, thicken it".
|
|
5
|
+
|
|
6
|
+
The mesh is voxelized on a regular grid and every solid voxel becomes an
|
|
7
|
+
8-node hexahedral element (full 2x2x2 Gauss integration, one shared stiffness
|
|
8
|
+
matrix since all elements are identical cubes). E=1.0, nu=0.3 — RELATIVE
|
|
9
|
+
results only: use displacements/stresses to compare variants of the same part
|
|
10
|
+
under the same loads, never as absolute MPa. Real materials, units, and safety
|
|
11
|
+
factors are out of scope by design.
|
|
12
|
+
"""
|
|
13
|
+
import numpy as np
|
|
14
|
+
from scipy import ndimage
|
|
15
|
+
import scipy.sparse as sp
|
|
16
|
+
from scipy.sparse.linalg import cg
|
|
17
|
+
|
|
18
|
+
from ..sdf_kit import _solid_voxels
|
|
19
|
+
|
|
20
|
+
__all__ = ["fea_probe"]
|
|
21
|
+
|
|
22
|
+
_E, _NU = 1.0, 0.3
|
|
23
|
+
_MAX_SOLID = 200_000 # element-count refusal — keep the critic seconds-scale
|
|
24
|
+
_CG_MAXITER = 5000
|
|
25
|
+
_CG_RTOL = 1e-6
|
|
26
|
+
_HOTSPOTS = 5
|
|
27
|
+
|
|
28
|
+
# local corner order for the hex (matches the B-matrix DOF layout below)
|
|
29
|
+
_CORNERS = np.array([
|
|
30
|
+
[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0],
|
|
31
|
+
[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1],
|
|
32
|
+
])
|
|
33
|
+
_CONN26 = np.ones((3, 3, 3), bool)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _elastic_c(e, nu):
|
|
37
|
+
"""Isotropic elasticity matrix (6x6, engineering shear strains)."""
|
|
38
|
+
lam = e * nu / ((1 + nu) * (1 - 2 * nu))
|
|
39
|
+
mu = e / (2 * (1 + nu))
|
|
40
|
+
c = np.zeros((6, 6))
|
|
41
|
+
c[:3, :3] = lam
|
|
42
|
+
c[np.arange(3), np.arange(3)] = lam + 2 * mu
|
|
43
|
+
c[np.arange(3, 6), np.arange(3, 6)] = mu
|
|
44
|
+
return c
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _b_matrix(xi, eta, zeta, h):
|
|
48
|
+
"""Strain-displacement matrix (6x24) at a natural-coordinate point, for a
|
|
49
|
+
cube element of side h (Jacobian is diagonal h/2)."""
|
|
50
|
+
s = 2.0 * _CORNERS - 1.0 # corner signs
|
|
51
|
+
scale = 2.0 / h # dN/dx = dN/dxi * 2/h
|
|
52
|
+
dn = np.empty((8, 3))
|
|
53
|
+
dn[:, 0] = 0.125 * s[:, 0] * (1 + eta * s[:, 1]) * (1 + zeta * s[:, 2]) * scale
|
|
54
|
+
dn[:, 1] = 0.125 * s[:, 1] * (1 + xi * s[:, 0]) * (1 + zeta * s[:, 2]) * scale
|
|
55
|
+
dn[:, 2] = 0.125 * s[:, 2] * (1 + xi * s[:, 0]) * (1 + eta * s[:, 1]) * scale
|
|
56
|
+
b = np.zeros((6, 24))
|
|
57
|
+
for i in range(8):
|
|
58
|
+
dx, dy, dz = dn[i]
|
|
59
|
+
col = 3 * i
|
|
60
|
+
b[0, col] = dx
|
|
61
|
+
b[1, col + 1] = dy
|
|
62
|
+
b[2, col + 2] = dz
|
|
63
|
+
b[3, col], b[3, col + 1] = dy, dx
|
|
64
|
+
b[4, col + 1], b[4, col + 2] = dz, dy
|
|
65
|
+
b[5, col], b[5, col + 2] = dz, dx
|
|
66
|
+
return b
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _hex_ke(h, c):
|
|
70
|
+
"""Element stiffness (24x24) for a cube of side h, 2x2x2 Gauss."""
|
|
71
|
+
g = 1.0 / np.sqrt(3.0)
|
|
72
|
+
det_j = (h / 2.0) ** 3
|
|
73
|
+
ke = np.zeros((24, 24))
|
|
74
|
+
for xi in (-g, g):
|
|
75
|
+
for eta in (-g, g):
|
|
76
|
+
for zeta in (-g, g):
|
|
77
|
+
b = _b_matrix(xi, eta, zeta, h)
|
|
78
|
+
ke += b.T @ c @ b * det_j
|
|
79
|
+
return ke
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _nodes_in_spheres(coords, spheres):
|
|
83
|
+
"""Boolean mask of node coords within any {"point", "r"} sphere."""
|
|
84
|
+
hit = np.zeros(len(coords), bool)
|
|
85
|
+
for s in spheres:
|
|
86
|
+
pt = np.asarray(s["point"], float)
|
|
87
|
+
hit |= np.linalg.norm(coords - pt, axis=1) <= float(s["r"])
|
|
88
|
+
return hit
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _fail(out, msg):
|
|
92
|
+
out["error"] = msg
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fea_probe(mesh, loads, supports, resolution=32):
|
|
97
|
+
"""Coarse voxel FEA. loads: [{"point", "r", "force": [fx,fy,fz]}] — each
|
|
98
|
+
force split evenly over the nodes its sphere catches. supports:
|
|
99
|
+
[{"point", "r"}] — nodes inside are fully fixed.
|
|
100
|
+
|
|
101
|
+
Returns {"ok", "maxDisplacement", "compliance", "hotspots": top-5
|
|
102
|
+
[{"point", "vonMises"}], "resolution", "error"}. Comparative numbers only
|
|
103
|
+
(E=1): valid for "variant B is stiffer than variant A" and "the hotspot is
|
|
104
|
+
here", meaningless as absolute stress. Refuses (ok=False + error) on >200k
|
|
105
|
+
solid voxels, disconnected loads, or CG non-convergence.
|
|
106
|
+
"""
|
|
107
|
+
out = {"ok": False, "maxDisplacement": 0.0, "compliance": 0.0,
|
|
108
|
+
"hotspots": [], "resolution": int(resolution), "error": None}
|
|
109
|
+
try:
|
|
110
|
+
pitch = float(np.max(mesh.extents)) / float(resolution)
|
|
111
|
+
solid, origin = _solid_voxels(mesh, pitch, pad=1)
|
|
112
|
+
n_solid = int(solid.sum())
|
|
113
|
+
if n_solid == 0:
|
|
114
|
+
return _fail(out, "voxelization produced no solid elements")
|
|
115
|
+
if n_solid > _MAX_SOLID:
|
|
116
|
+
return _fail(out, f"{n_solid} solid voxels exceeds the "
|
|
117
|
+
f"{_MAX_SOLID} element budget — lower resolution")
|
|
118
|
+
|
|
119
|
+
eidx = np.argwhere(solid) # (ne, 3) element cells
|
|
120
|
+
dims = np.asarray(solid.shape) + 1 # node grid dims
|
|
121
|
+
# node (i,j,k) sits at the voxel-corner lattice; voxel centers are at
|
|
122
|
+
# origin + idx*pitch, so corners are offset by -pitch/2.
|
|
123
|
+
node0 = origin - 0.5 * pitch
|
|
124
|
+
|
|
125
|
+
def nid(multi): # (…,3) node multi-index -> flat node id
|
|
126
|
+
return (multi[..., 0] * dims[1] + multi[..., 1]) * dims[2] + multi[..., 2]
|
|
127
|
+
|
|
128
|
+
# drop solid islands with no support: they make K singular, and any
|
|
129
|
+
# load on them is a modeling error worth surfacing, not solving.
|
|
130
|
+
comp_lbl, _ = ndimage.label(solid, structure=_CONN26)
|
|
131
|
+
all_nodes_multi = np.stack(np.meshgrid(*[np.arange(d) for d in dims],
|
|
132
|
+
indexing="ij"), axis=-1)
|
|
133
|
+
all_coords = node0 + all_nodes_multi.reshape(-1, 3) * pitch
|
|
134
|
+
sup_node = _nodes_in_spheres(all_coords, supports).reshape(*dims)
|
|
135
|
+
if not sup_node.any():
|
|
136
|
+
return _fail(out, "no nodes inside any support sphere")
|
|
137
|
+
enodes_multi = eidx[:, None, :] + _CORNERS[None, :, :] # (ne, 8, 3)
|
|
138
|
+
e_supported = sup_node[tuple(np.moveaxis(enodes_multi, -1, 0))].any(axis=1)
|
|
139
|
+
good_comps = np.unique(comp_lbl[tuple(eidx[e_supported].T)]) \
|
|
140
|
+
if e_supported.any() else np.array([], int)
|
|
141
|
+
keep = np.isin(comp_lbl[tuple(eidx.T)], good_comps)
|
|
142
|
+
if not keep.any():
|
|
143
|
+
return _fail(out, "no solid material connected to the supports")
|
|
144
|
+
eidx = eidx[keep]
|
|
145
|
+
enodes_multi = enodes_multi[keep]
|
|
146
|
+
|
|
147
|
+
# compact node numbering over used nodes only
|
|
148
|
+
enodes_flat = nid(enodes_multi) # (ne, 8)
|
|
149
|
+
used, enodes = np.unique(enodes_flat, return_inverse=True)
|
|
150
|
+
enodes = enodes.reshape(enodes_flat.shape)
|
|
151
|
+
coords = node0 + np.stack(np.unravel_index(used, dims), axis=1) * pitch
|
|
152
|
+
n_node = len(used)
|
|
153
|
+
edofs = (3 * enodes[:, :, None] + np.arange(3)).reshape(-1, 24)
|
|
154
|
+
|
|
155
|
+
# loads and supports on the compacted nodes
|
|
156
|
+
fixed = np.repeat(_nodes_in_spheres(coords, supports), 3)
|
|
157
|
+
f = np.zeros(3 * n_node)
|
|
158
|
+
for load in loads:
|
|
159
|
+
hit = _nodes_in_spheres(coords, [load])
|
|
160
|
+
k = int(hit.sum())
|
|
161
|
+
if k == 0:
|
|
162
|
+
return _fail(out, f"load at {load['point']} catches no nodes "
|
|
163
|
+
"connected to the supports")
|
|
164
|
+
f[np.repeat(hit, 3)] += np.tile(np.asarray(load["force"], float) / k, k)
|
|
165
|
+
|
|
166
|
+
c = _elastic_c(_E, _NU)
|
|
167
|
+
ke = _hex_ke(pitch, c)
|
|
168
|
+
n_dof = 3 * n_node
|
|
169
|
+
# chunked assembly: 576 triplets per element adds up fast at the top
|
|
170
|
+
# of the element budget, so build and fold in ~2M-triplet slabs.
|
|
171
|
+
k_mat = sp.csr_matrix((n_dof, n_dof))
|
|
172
|
+
chunk = 4000
|
|
173
|
+
for i in range(0, len(edofs), chunk):
|
|
174
|
+
ed = edofs[i:i + chunk]
|
|
175
|
+
rows = np.repeat(ed, 24, axis=1).ravel()
|
|
176
|
+
cols = np.tile(ed, (1, 24)).ravel()
|
|
177
|
+
data = np.tile(ke.ravel(), len(ed))
|
|
178
|
+
k_mat += sp.coo_matrix((data, (rows, cols)),
|
|
179
|
+
shape=(n_dof, n_dof)).tocsr()
|
|
180
|
+
|
|
181
|
+
free = ~fixed
|
|
182
|
+
kff = k_mat[free][:, free]
|
|
183
|
+
diag = kff.diagonal()
|
|
184
|
+
diag[diag <= 0] = 1.0 # paranoia; pruning should preclude zero rows
|
|
185
|
+
precond = sp.diags(1.0 / diag) # Jacobi
|
|
186
|
+
u_f, info = cg(kff, f[free], rtol=_CG_RTOL, maxiter=_CG_MAXITER, M=precond)
|
|
187
|
+
if info != 0:
|
|
188
|
+
return _fail(out, f"CG failed to converge (info={info}) — the "
|
|
189
|
+
"structure may be near-mechanism at this resolution")
|
|
190
|
+
|
|
191
|
+
u = np.zeros(n_dof)
|
|
192
|
+
u[free] = u_f
|
|
193
|
+
out["maxDisplacement"] = float(
|
|
194
|
+
np.linalg.norm(u.reshape(-1, 3), axis=1).max())
|
|
195
|
+
out["compliance"] = float(f @ u)
|
|
196
|
+
|
|
197
|
+
# von Mises from the strain at each element center
|
|
198
|
+
b0 = _b_matrix(0.0, 0.0, 0.0, pitch)
|
|
199
|
+
stress = (u[edofs] @ b0.T) @ c.T # (ne, 6)
|
|
200
|
+
sx, sy, sz, txy, tyz, tzx = stress.T
|
|
201
|
+
vm = np.sqrt(0.5 * ((sx - sy) ** 2 + (sy - sz) ** 2 + (sz - sx) ** 2)
|
|
202
|
+
+ 3.0 * (txy ** 2 + tyz ** 2 + tzx ** 2))
|
|
203
|
+
top = np.argsort(vm)[::-1][:_HOTSPOTS]
|
|
204
|
+
centers = origin + eidx * pitch # voxel centers
|
|
205
|
+
out["hotspots"] = [
|
|
206
|
+
{"point": [float(x) for x in centers[i]], "vonMises": float(vm[i])}
|
|
207
|
+
for i in top
|
|
208
|
+
]
|
|
209
|
+
out["ok"] = True
|
|
210
|
+
return out
|
|
211
|
+
except Exception as exc: # critic must never take the run down with it
|
|
212
|
+
return _fail(out, f"{type(exc).__name__}: {exc}")
|
loadpath/checks/fit.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Component-fit verifier : does the built enclosure actually FIT the
|
|
3
|
+
component the brief promised it would?
|
|
4
|
+
|
|
5
|
+
`check_fit(mesh, spec)` voxelizes the part (reusing sdf_kit's occupancy
|
|
6
|
+
helper), solves where the component actually sits inside the interior air,
|
|
7
|
+
and verifies three machine-checkable contracts per component:
|
|
8
|
+
|
|
9
|
+
fit_cavity — a placement exists where the component envelope (PCB footprint
|
|
10
|
+
+ clearance, board top to tallest part + clearance) is fully in
|
|
11
|
+
air, and that placement is walled on all four sides (it's a
|
|
12
|
+
cavity, not empty space beside the part).
|
|
13
|
+
fit_bosses — at the solved placement, every mounting hole in the pattern
|
|
14
|
+
has a pilot bore (air) and boss/standoff material around it
|
|
15
|
+
just below the board seating plane.
|
|
16
|
+
fit_cutout — each verified port has a through-opening of (roughly) the
|
|
17
|
+
cutout size at the expected edge/offset/height.
|
|
18
|
+
|
|
19
|
+
Placement solve: the envelope box is slid over the air grid (uniform_filter =
|
|
20
|
+
"is every voxel under the box air?") for all four flat rotations; among
|
|
21
|
+
feasible poses we prefer the one matching the most mounting holes, then the
|
|
22
|
+
lowest seat. The cavity walls therefore PIN the board — bosses drilled 4 mm
|
|
23
|
+
off pattern cannot be rescued by sliding the board 4 mm, because the envelope
|
|
24
|
+
no longer fits.
|
|
25
|
+
|
|
26
|
+
Resolution honesty (same posture as networks.py): verified at voxel pitch p
|
|
27
|
+
means "no interference thicker than ~p" — sub-voxel interference can slip
|
|
28
|
+
through the occupancy grid. The report carries the pitch used; below-board
|
|
29
|
+
pin clearance is NOT yet asserted (bosses/standoffs already imply seating
|
|
30
|
+
height; a dedicated pins check is future work).
|
|
31
|
+
|
|
32
|
+
Spec shape:
|
|
33
|
+
{"components": [{
|
|
34
|
+
"id": "pico", "label": "Raspberry Pi Pico",
|
|
35
|
+
"boardMm": [L, W, T], "clearanceMm": c, "aboveMm": a, "belowMm": b,
|
|
36
|
+
"holes": {"positions": [[x, y], ...], "diaMm": d} | null,
|
|
37
|
+
"ports": [{"id": "micro-usb", "edge": "-x", "offsetMm": o,
|
|
38
|
+
"heightMm": h, "wMm": w, "hMm": hh},
|
|
39
|
+
{"id": "screen-window", "face": "+z", "xMm": x, "yMm": y,
|
|
40
|
+
"wMm": w, "hMm": hh}, ...]
|
|
41
|
+
}]}
|
|
42
|
+
|
|
43
|
+
Port kinds: lateral ports carry edge/offset/height and are checked with
|
|
44
|
+
outward rays through the wall; `face: "+z"` ports (screen windows, lenses,
|
|
45
|
+
shafts) carry a center in the component frame and are checked with
|
|
46
|
+
upward rays from the component top through the lid.
|
|
47
|
+
|
|
48
|
+
Component frame: origin at board center, +x along length, +y along width,
|
|
49
|
+
z up; port heights above the PCB TOP. Result ids: "fit:<id>:cavity",
|
|
50
|
+
"fit:<id>:bosses", "fit:<id>:port:<portId>" — matched by the TS side against
|
|
51
|
+
the DimensionTargets that requested them.
|
|
52
|
+
"""
|
|
53
|
+
import numpy as np
|
|
54
|
+
from scipy import ndimage
|
|
55
|
+
|
|
56
|
+
from ..sdf_kit import _solid_voxels
|
|
57
|
+
|
|
58
|
+
__all__ = ["check_fit"]
|
|
59
|
+
|
|
60
|
+
# Feasibility: every sampled envelope voxel must be air.
|
|
61
|
+
_AIR_FRACTION_OK = 0.999
|
|
62
|
+
# A lateral face counts as "walled" when at least this fraction of its
|
|
63
|
+
# outward rays hit solid before leaving the grid (port cutouts legitimately
|
|
64
|
+
# open part of a wall).
|
|
65
|
+
_WALL_MIN_BLOCKED = 0.35
|
|
66
|
+
# Boss ring: sample points on a circle this far outside the hole radius...
|
|
67
|
+
_RING_EXTRA_MM = 1.0
|
|
68
|
+
# ...and require at least this many of the 8 ring points to be solid.
|
|
69
|
+
_RING_POINTS = 8
|
|
70
|
+
_RING_MIN_SOLID = 6
|
|
71
|
+
# Cap on candidate placements scored per rotation (subsampled by z-order).
|
|
72
|
+
_MAX_CANDIDATES = 4000
|
|
73
|
+
# Voxel budget — pitch grows until the padded grid fits.
|
|
74
|
+
_MAX_VOXELS = 4.5e6
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _rot_xy(pts, rot):
|
|
78
|
+
"""Rotate (n, 2) component-frame XY points by rot degrees (multiple of 90)."""
|
|
79
|
+
pts = np.atleast_2d(np.asarray(pts, float))
|
|
80
|
+
if rot == 0:
|
|
81
|
+
return pts
|
|
82
|
+
if rot == 90:
|
|
83
|
+
return np.column_stack([-pts[:, 1], pts[:, 0]])
|
|
84
|
+
if rot == 180:
|
|
85
|
+
return -pts
|
|
86
|
+
return np.column_stack([pts[:, 1], -pts[:, 0]]) # 270
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _pitch_for(mesh, comps):
|
|
90
|
+
"""Voxel pitch: fine enough to resolve pilot bores, coarse enough to fit
|
|
91
|
+
the voxel budget."""
|
|
92
|
+
ext = np.asarray(mesh.extents, float)
|
|
93
|
+
pitch = float(max(ext)) / 110.0
|
|
94
|
+
pitch = min(max(pitch, 0.35), 0.8)
|
|
95
|
+
dias = [
|
|
96
|
+
float(c["holes"]["diaMm"])
|
|
97
|
+
for c in comps
|
|
98
|
+
if c.get("holes") and c["holes"].get("diaMm")
|
|
99
|
+
]
|
|
100
|
+
if dias:
|
|
101
|
+
pitch = min(pitch, min(dias) * 0.35)
|
|
102
|
+
while np.prod(np.ceil(ext / pitch) + 6) > _MAX_VOXELS:
|
|
103
|
+
pitch *= 1.25
|
|
104
|
+
return float(pitch)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _AirGrid:
|
|
108
|
+
"""Boolean air occupancy over the mesh bbox (padded). Points outside the
|
|
109
|
+
grid are air by definition (outside the part's bounding box)."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, mesh, pitch):
|
|
112
|
+
solid, origin = _solid_voxels(mesh, pitch, pad=3)
|
|
113
|
+
self.air = ~solid
|
|
114
|
+
self.origin = np.asarray(origin, float)
|
|
115
|
+
self.pitch = float(pitch)
|
|
116
|
+
|
|
117
|
+
def is_air(self, pts):
|
|
118
|
+
"""(n, 3) world points -> (n,) bool."""
|
|
119
|
+
pts = np.atleast_2d(np.asarray(pts, float))
|
|
120
|
+
idx = np.rint((pts - self.origin) / self.pitch).astype(int)
|
|
121
|
+
shape = np.asarray(self.air.shape)
|
|
122
|
+
inside = np.all((idx >= 0) & (idx < shape), axis=1)
|
|
123
|
+
out = np.ones(len(idx), bool)
|
|
124
|
+
ii = idx[inside]
|
|
125
|
+
if len(ii):
|
|
126
|
+
out[inside] = self.air[ii[:, 0], ii[:, 1], ii[:, 2]]
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
def ray_clear(self, start, direction, step=None):
|
|
130
|
+
"""True when marching from `start` along `direction` exits the grid
|
|
131
|
+
without ever touching solid — i.e. an opening goes all the way out."""
|
|
132
|
+
step = step or self.pitch * 0.9
|
|
133
|
+
d = np.asarray(direction, float)
|
|
134
|
+
d = d / np.linalg.norm(d)
|
|
135
|
+
span = float(np.linalg.norm(np.asarray(self.air.shape) * self.pitch))
|
|
136
|
+
n = int(span / step) + 2
|
|
137
|
+
ts = self.pitch * 0.6 + step * np.arange(n)
|
|
138
|
+
pts = np.asarray(start, float)[None, :] + ts[:, None] * d[None, :]
|
|
139
|
+
return bool(np.all(self.is_air(pts)))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _feasible_centers(grid, box_mm):
|
|
143
|
+
"""All voxel centers where an axis-aligned box of `box_mm` is entirely
|
|
144
|
+
air. Returns (centers_world (n,3), best_air_fraction)."""
|
|
145
|
+
airf = grid.air.astype(np.float32)
|
|
146
|
+
# Erode the sampled box by ~1 voxel per axis: surface voxelization already
|
|
147
|
+
# dilates the solid by ~half a pitch, and we must not double-count it.
|
|
148
|
+
size = np.maximum(np.rint(np.asarray(box_mm, float) / grid.pitch) - 1, 1).astype(int)
|
|
149
|
+
if np.any(size > np.asarray(grid.air.shape)):
|
|
150
|
+
return np.empty((0, 3)), 0.0
|
|
151
|
+
frac = ndimage.uniform_filter(airf, size=tuple(size), mode="constant", cval=0.0)
|
|
152
|
+
half = size // 2
|
|
153
|
+
valid = np.zeros_like(grid.air)
|
|
154
|
+
sx, sy, sz = grid.air.shape
|
|
155
|
+
valid[half[0]:sx - half[0], half[1]:sy - half[1], half[2]:sz - half[2]] = True
|
|
156
|
+
if not valid.any():
|
|
157
|
+
return np.empty((0, 3)), 0.0
|
|
158
|
+
best = float(frac[valid].max())
|
|
159
|
+
feas = (frac >= _AIR_FRACTION_OK) & valid
|
|
160
|
+
idxs = np.argwhere(feas)
|
|
161
|
+
centers = grid.origin + idxs * grid.pitch
|
|
162
|
+
return centers, best
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _boss_matches(grid, comp, centers, rot, env_z):
|
|
166
|
+
"""(n_candidates,) matched-hole counts at each candidate box center.
|
|
167
|
+
A hole matches when its pilot is air and a ring around it is solid just
|
|
168
|
+
below the board seating plane (= envelope bottom)."""
|
|
169
|
+
holes = comp.get("holes")
|
|
170
|
+
if not holes or not holes.get("positions"):
|
|
171
|
+
return np.zeros(len(centers), int)
|
|
172
|
+
pos = _rot_xy(holes["positions"], rot) # (H, 2) world-frame offsets
|
|
173
|
+
r_ring = float(holes["diaMm"]) / 2.0 + _RING_EXTRA_MM
|
|
174
|
+
ang = np.linspace(0, 2 * np.pi, _RING_POINTS, endpoint=False)
|
|
175
|
+
ring = np.column_stack([np.cos(ang), np.sin(ang)]) * r_ring # (P, 2)
|
|
176
|
+
depth1 = max(1.5 * grid.pitch, 1.0)
|
|
177
|
+
depth2 = depth1 + 1.2
|
|
178
|
+
|
|
179
|
+
matched = np.zeros(len(centers), int)
|
|
180
|
+
zb = centers[:, 2] - env_z / 2.0 # board bottom = envelope bottom
|
|
181
|
+
for h in range(len(pos)):
|
|
182
|
+
cxy = centers[:, :2] + pos[h][None, :] # (n, 2)
|
|
183
|
+
ok_h = np.ones(len(centers), bool)
|
|
184
|
+
# Pilot bore: air at the hole axis just below the seat (either depth).
|
|
185
|
+
pilot_ok = np.zeros(len(centers), bool)
|
|
186
|
+
for d in (depth1, depth2):
|
|
187
|
+
pts = np.column_stack([cxy, zb - d])
|
|
188
|
+
pilot_ok |= grid.is_air(pts)
|
|
189
|
+
ok_h &= pilot_ok
|
|
190
|
+
# Boss material: ring of solid around the bore (either depth).
|
|
191
|
+
ring_ok = np.zeros(len(centers), bool)
|
|
192
|
+
for d in (depth1, depth2):
|
|
193
|
+
solid_count = np.zeros(len(centers), int)
|
|
194
|
+
for p in range(_RING_POINTS):
|
|
195
|
+
pts = np.column_stack([cxy + ring[p][None, :], zb - d])
|
|
196
|
+
solid_count += (~grid.is_air(pts)).astype(int)
|
|
197
|
+
ring_ok |= solid_count >= _RING_MIN_SOLID
|
|
198
|
+
ok_h &= ring_ok
|
|
199
|
+
matched += ok_h.astype(int)
|
|
200
|
+
return matched
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _walls_blocked(grid, center, box_mm):
|
|
204
|
+
"""Per-face blocked fraction for the four lateral faces of the box at
|
|
205
|
+
`center`. Returns a dict face -> fraction of outward rays that hit solid."""
|
|
206
|
+
bx, by, bz = box_mm
|
|
207
|
+
zs = [center[2] - bz * 0.25, center[2] + bz * 0.25]
|
|
208
|
+
out = {}
|
|
209
|
+
for face, (axis, sign) in {
|
|
210
|
+
"+x": (0, 1), "-x": (0, -1), "+y": (1, 1), "-y": (1, -1),
|
|
211
|
+
}.items():
|
|
212
|
+
tangent = 1 - axis
|
|
213
|
+
half_t = (by if axis == 0 else bx) / 2.0 - grid.pitch
|
|
214
|
+
offs = np.linspace(-half_t, half_t, 5)
|
|
215
|
+
n_hit = 0
|
|
216
|
+
n_all = 0
|
|
217
|
+
d = np.zeros(3)
|
|
218
|
+
d[axis] = sign
|
|
219
|
+
start_axis = center[axis] + sign * ((bx if axis == 0 else by) / 2.0)
|
|
220
|
+
for z in zs:
|
|
221
|
+
for t in offs:
|
|
222
|
+
p = np.array(center, float)
|
|
223
|
+
p[axis] = start_axis
|
|
224
|
+
p[tangent] = center[tangent] + t
|
|
225
|
+
p[2] = z
|
|
226
|
+
n_all += 1
|
|
227
|
+
if not grid.ray_clear(p, d):
|
|
228
|
+
n_hit += 1
|
|
229
|
+
out[face] = n_hit / max(n_all, 1)
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _port_open(grid, comp, center, rot, env_z, port):
|
|
234
|
+
"""Is there a through-opening for `port` at the solved placement?
|
|
235
|
+
Five rays (center, ±width, ±height inside the cutout) must all exit the
|
|
236
|
+
grid without touching solid. Lateral ports shoot outward through the
|
|
237
|
+
wall; `face: "+z"` ports (screen windows) shoot upward through the lid."""
|
|
238
|
+
L, W, T = [float(v) for v in comp["boardMm"]]
|
|
239
|
+
zb = center[2] - env_z / 2.0
|
|
240
|
+
if port.get("face") == "+z":
|
|
241
|
+
# Top-face windows are usually cut at exactly the recommended size,
|
|
242
|
+
# and window-bearing components often have no bosses to pin the solved
|
|
243
|
+
# seat — allow 1 mm of seat slack on top of the voxel margin so a
|
|
244
|
+
# correct window over a slightly-slid seat still verifies.
|
|
245
|
+
margin = max(grid.pitch, 0.8) + 1.0
|
|
246
|
+
half_w = max(float(port["wMm"]) / 2.0 - margin, 0.0)
|
|
247
|
+
half_h = max(float(port["hMm"]) / 2.0 - margin, 0.0)
|
|
248
|
+
p_b = np.array([float(port.get("xMm") or 0.0), float(port.get("yMm") or 0.0)])
|
|
249
|
+
u_w = _rot_xy(np.array([1.0, 0.0]), rot)[0]
|
|
250
|
+
u_h = _rot_xy(np.array([0.0, 1.0]), rot)[0]
|
|
251
|
+
c_w = center[:2] + _rot_xy(p_b, rot)[0]
|
|
252
|
+
z0 = zb + T + grid.pitch
|
|
253
|
+
direction = np.array([0.0, 0.0, 1.0])
|
|
254
|
+
for dw, dh in ((0, 0), (half_w, 0), (-half_w, 0), (0, half_h), (0, -half_h)):
|
|
255
|
+
start = np.array([
|
|
256
|
+
c_w[0] + u_w[0] * dw + u_h[0] * dh,
|
|
257
|
+
c_w[1] + u_w[1] * dw + u_h[1] * dh,
|
|
258
|
+
z0,
|
|
259
|
+
])
|
|
260
|
+
if not grid.ray_clear(start, direction):
|
|
261
|
+
return False
|
|
262
|
+
return True
|
|
263
|
+
edge = port["edge"]
|
|
264
|
+
off = float(port.get("offsetMm") or 0.0)
|
|
265
|
+
if edge in ("+x", "-x"):
|
|
266
|
+
p_b = np.array([(L / 2.0) * (1 if edge == "+x" else -1), off])
|
|
267
|
+
n_b = np.array([1.0 if edge == "+x" else -1.0, 0.0])
|
|
268
|
+
t_b = np.array([0.0, 1.0])
|
|
269
|
+
else:
|
|
270
|
+
p_b = np.array([off, (W / 2.0) * (1 if edge == "+y" else -1)])
|
|
271
|
+
n_b = np.array([0.0, 1.0 if edge == "+y" else -1.0])
|
|
272
|
+
t_b = np.array([1.0, 0.0])
|
|
273
|
+
p_w = center[:2] + _rot_xy(p_b, rot)[0]
|
|
274
|
+
n_w = _rot_xy(n_b, rot)[0]
|
|
275
|
+
t_w = _rot_xy(t_b, rot)[0]
|
|
276
|
+
z_c = zb + T + float(port.get("heightMm") or 0.0)
|
|
277
|
+
margin = max(grid.pitch, 0.8)
|
|
278
|
+
half_w = max(float(port["wMm"]) / 2.0 - margin, 0.0)
|
|
279
|
+
half_h = max(float(port["hMm"]) / 2.0 - margin, 0.0)
|
|
280
|
+
direction = np.array([n_w[0], n_w[1], 0.0])
|
|
281
|
+
for dw, dh in ((0, 0), (half_w, 0), (-half_w, 0), (0, half_h), (0, -half_h)):
|
|
282
|
+
start = np.array([
|
|
283
|
+
p_w[0] + t_w[0] * dw,
|
|
284
|
+
p_w[1] + t_w[1] * dw,
|
|
285
|
+
z_c + dh,
|
|
286
|
+
])
|
|
287
|
+
if not grid.ray_clear(start, direction):
|
|
288
|
+
return False
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _check_component(grid, comp):
|
|
293
|
+
"""All fit results for one component spec. Never raises past the caller's
|
|
294
|
+
failure isolation — plain dict results only."""
|
|
295
|
+
cid = str(comp.get("id") or "component")
|
|
296
|
+
label = str(comp.get("label") or cid)
|
|
297
|
+
L, W, T = [float(v) for v in comp["boardMm"]]
|
|
298
|
+
c = float(comp.get("clearanceMm") or 1.0)
|
|
299
|
+
above = float(comp.get("aboveMm") or 0.0)
|
|
300
|
+
env = np.array([L + 2 * c, W + 2 * c, T + above + c])
|
|
301
|
+
holes = comp.get("holes")
|
|
302
|
+
ports = comp.get("ports") or []
|
|
303
|
+
n_holes = len(holes["positions"]) if holes and holes.get("positions") else 0
|
|
304
|
+
|
|
305
|
+
results = []
|
|
306
|
+
|
|
307
|
+
# ---- Placement solve over the four flat rotations ------------------
|
|
308
|
+
# The envelope box is 180°-symmetric, so rotations share feasible sets in
|
|
309
|
+
# pairs: {0, 180} use env as-is, {90, 270} use the XY-swapped box.
|
|
310
|
+
env_swapped = np.array([env[1], env[0], env[2]])
|
|
311
|
+
feas = {0: _feasible_centers(grid, env), 90: _feasible_centers(grid, env_swapped)}
|
|
312
|
+
best_frac = max(feas[0][1], feas[90][1])
|
|
313
|
+
best = None # ((matched, ports_open, -z), center, rot, box)
|
|
314
|
+
for rot in (0, 90, 180, 270):
|
|
315
|
+
box = env if rot % 180 == 0 else env_swapped
|
|
316
|
+
centers = feas[rot % 180][0]
|
|
317
|
+
if len(centers) == 0:
|
|
318
|
+
continue
|
|
319
|
+
if len(centers) > _MAX_CANDIDATES:
|
|
320
|
+
order = np.argsort(centers[:, 2], kind="stable")
|
|
321
|
+
centers = centers[order][:: max(1, len(centers) // _MAX_CANDIDATES)]
|
|
322
|
+
matched = _boss_matches(grid, comp, centers, rot, env[2])
|
|
323
|
+
# Best candidate for this rotation: most holes matched, then lowest.
|
|
324
|
+
order = np.lexsort((centers[:, 2], -matched))
|
|
325
|
+
cand = centers[order[0]]
|
|
326
|
+
m = int(matched[order[0]])
|
|
327
|
+
ports_open = sum(
|
|
328
|
+
1 for p in ports if _port_open(grid, comp, cand, rot, env[2], p)
|
|
329
|
+
)
|
|
330
|
+
key = (m, ports_open, -cand[2])
|
|
331
|
+
if best is None or key > best[0]:
|
|
332
|
+
best = (key, cand, rot, box)
|
|
333
|
+
|
|
334
|
+
if best is None:
|
|
335
|
+
results.append({
|
|
336
|
+
"id": f"fit:{cid}:cavity",
|
|
337
|
+
"kind": "fit_cavity",
|
|
338
|
+
"ok": False,
|
|
339
|
+
"got": round(best_frac, 3),
|
|
340
|
+
"note": (
|
|
341
|
+
f"{label} envelope {env[0]:.1f} x {env[1]:.1f} x {env[2]:.1f} mm "
|
|
342
|
+
f"(board + clearance) has NO placement fully inside interior air — "
|
|
343
|
+
f"best candidate still overlaps solid (max air fraction "
|
|
344
|
+
f"{best_frac:.0%}). Enlarge the cavity so the board plus "
|
|
345
|
+
f"clearance actually fits."
|
|
346
|
+
),
|
|
347
|
+
})
|
|
348
|
+
if n_holes:
|
|
349
|
+
results.append({
|
|
350
|
+
"id": f"fit:{cid}:bosses",
|
|
351
|
+
"kind": "fit_bosses",
|
|
352
|
+
"ok": None,
|
|
353
|
+
"note": "not evaluated — no feasible cavity placement",
|
|
354
|
+
})
|
|
355
|
+
for p in ports:
|
|
356
|
+
results.append({
|
|
357
|
+
"id": f"fit:{cid}:port:{p['id']}",
|
|
358
|
+
"kind": "fit_cutout",
|
|
359
|
+
"ok": None,
|
|
360
|
+
"note": "not evaluated — no feasible cavity placement",
|
|
361
|
+
})
|
|
362
|
+
return results
|
|
363
|
+
|
|
364
|
+
_key, center, rot, box = best
|
|
365
|
+
|
|
366
|
+
# ---- Cavity: feasible + actually enclosed ---------------------------
|
|
367
|
+
walls = _walls_blocked(grid, center, box)
|
|
368
|
+
open_faces = [f for f, blocked in walls.items() if blocked < _WALL_MIN_BLOCKED]
|
|
369
|
+
cavity_ok = len(open_faces) == 0
|
|
370
|
+
note = (
|
|
371
|
+
f"{label} seats at ({center[0]:.1f}, {center[1]:.1f}), board bottom "
|
|
372
|
+
f"z={center[2] - env[2] / 2:.1f}, rotated {rot} deg (verified at voxel "
|
|
373
|
+
f"pitch {grid.pitch:.2f} mm)"
|
|
374
|
+
)
|
|
375
|
+
if not cavity_ok:
|
|
376
|
+
note = (
|
|
377
|
+
f"{label} fits in the air volume but is not enclosed: side(s) "
|
|
378
|
+
f"{', '.join(sorted(open_faces))} of the solved seat have no wall "
|
|
379
|
+
f"(blocked fractions {', '.join(f'{f}={walls[f]:.0%}' for f in sorted(walls))}). "
|
|
380
|
+
f"Walls must surround the component seat."
|
|
381
|
+
)
|
|
382
|
+
results.append({
|
|
383
|
+
"id": f"fit:{cid}:cavity",
|
|
384
|
+
"kind": "fit_cavity",
|
|
385
|
+
"ok": bool(cavity_ok),
|
|
386
|
+
"got": 1.0 if cavity_ok else 0.0,
|
|
387
|
+
"note": note,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
# ---- Bosses ----------------------------------------------------------
|
|
391
|
+
if n_holes:
|
|
392
|
+
matched = int(_boss_matches(grid, comp, center[None, :], rot, env[2])[0])
|
|
393
|
+
ok = matched == n_holes
|
|
394
|
+
if ok:
|
|
395
|
+
bnote = f"all {n_holes} mounting holes have a pilot bore and boss material at the seat"
|
|
396
|
+
else:
|
|
397
|
+
bnote = (
|
|
398
|
+
f"only {matched}/{n_holes} mounting holes align with boss/standoff "
|
|
399
|
+
f"material + pilot bore at the solved seat (pattern: "
|
|
400
|
+
f"{holes['positions']}, D{holes['diaMm']} mm, board frame from "
|
|
401
|
+
f"center, rotated {rot} deg). Bosses must sit exactly under the "
|
|
402
|
+
f"hole pattern with a pilot hole (~{float(holes['diaMm']) - 0.4:.1f} mm) each."
|
|
403
|
+
)
|
|
404
|
+
results.append({
|
|
405
|
+
"id": f"fit:{cid}:bosses",
|
|
406
|
+
"kind": "fit_bosses",
|
|
407
|
+
"ok": bool(ok),
|
|
408
|
+
"got": matched,
|
|
409
|
+
"note": bnote,
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
# ---- Ports -----------------------------------------------------------
|
|
413
|
+
for p in ports:
|
|
414
|
+
ok = _port_open(grid, comp, center, rot, env[2], p)
|
|
415
|
+
if p.get("face") == "+z":
|
|
416
|
+
where_ok = f"{p['id']} opening verified through the top face"
|
|
417
|
+
where_bad = (
|
|
418
|
+
f"no through-opening for {p['id']} ({p['wMm']} x {p['hMm']} mm) "
|
|
419
|
+
f"above the component: window center at ({p.get('xMm', 0)}, "
|
|
420
|
+
f"{p.get('yMm', 0)}) in the board frame. The lid there is "
|
|
421
|
+
f"solid, or the window is misplaced / too small."
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
where_ok = f"{p['id']} opening verified through the {p['edge']} wall"
|
|
425
|
+
where_bad = (
|
|
426
|
+
f"no through-opening for {p['id']} ({p['wMm']} x {p['hMm']} mm) "
|
|
427
|
+
f"where the connector sits: board edge {p['edge']}, "
|
|
428
|
+
f"{p['offsetMm']} mm along the edge, center "
|
|
429
|
+
f"{p['heightMm']} mm above the board top. The wall there is "
|
|
430
|
+
f"solid, or the cutout is on the wrong face / misplaced / too small."
|
|
431
|
+
)
|
|
432
|
+
results.append({
|
|
433
|
+
"id": f"fit:{cid}:port:{p['id']}",
|
|
434
|
+
"kind": "fit_cutout",
|
|
435
|
+
"ok": bool(ok),
|
|
436
|
+
"got": 1.0 if ok else 0.0,
|
|
437
|
+
"note": where_ok if ok else where_bad,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
return results
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def check_fit(mesh, spec):
|
|
444
|
+
"""Verify component fit of `mesh` against `spec` (see module docstring).
|
|
445
|
+
|
|
446
|
+
Returns {"pitchMm": p, "results": [{id, kind, ok, got?, note}, ...]}.
|
|
447
|
+
`ok: null` = that check could not be evaluated (reported honest-not-run
|
|
448
|
+
upstream). Raises only on a completely unusable spec/mesh — the caller
|
|
449
|
+
(_run_checks) converts that into {"error": ...}.
|
|
450
|
+
"""
|
|
451
|
+
comps = list(spec.get("components") or [])
|
|
452
|
+
if not comps:
|
|
453
|
+
return {"pitchMm": None, "results": []}
|
|
454
|
+
if not bool(mesh.is_watertight):
|
|
455
|
+
# Occupancy classification needs a closed surface; report honestly.
|
|
456
|
+
return {
|
|
457
|
+
"pitchMm": None,
|
|
458
|
+
"results": [],
|
|
459
|
+
"error": "fit checks need a watertight mesh; run them after repair",
|
|
460
|
+
}
|
|
461
|
+
pitch = _pitch_for(mesh, comps)
|
|
462
|
+
grid = _AirGrid(mesh, pitch)
|
|
463
|
+
results = []
|
|
464
|
+
for comp in comps:
|
|
465
|
+
try:
|
|
466
|
+
results.extend(_check_component(grid, comp))
|
|
467
|
+
except Exception as err: # noqa: BLE001 — isolate per component
|
|
468
|
+
cid = str(comp.get("id") or "component")
|
|
469
|
+
results.append({
|
|
470
|
+
"id": f"fit:{cid}:cavity",
|
|
471
|
+
"kind": "fit_cavity",
|
|
472
|
+
"ok": None,
|
|
473
|
+
"note": f"fit check failed to run: {err}",
|
|
474
|
+
})
|
|
475
|
+
return {"pitchMm": pitch, "results": results}
|