@protoboardai/ext-enclosure-cad 0.0.0-stage → 0.2.1
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.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +388 -2
- package/evals/fit-grown-board/case.ts +71 -0
- package/lib/check.ts +379 -0
- package/lib/features.ts +424 -0
- package/lib/index.ts +8 -0
- package/lib/job.ts +388 -0
- package/lib/pyjson.ts +68 -0
- package/lib/standards.ts +302 -0
- package/ops/cad.ts +264 -0
- package/ops/package.ts +213 -0
- package/package.json +52 -4
- package/protoboard-extension.json +130 -0
- package/py/enclosure_cad.py +1731 -0
- package/py/fit.py +341 -0
- package/skills/enclosure-cad/SKILL.md +194 -0
|
@@ -0,0 +1,1731 @@
|
|
|
1
|
+
"""enclosure-cad: an enclosure generated from what it holds (build123d).
|
|
2
|
+
|
|
3
|
+
python enclosure_cad.py build <job.json> <out_dir> [--root <project root>]
|
|
4
|
+
python enclosure_cad.py fit <fit.json | ->
|
|
5
|
+
|
|
6
|
+
The job (lib/job.ts) is everything the generator needs and nothing about
|
|
7
|
+
UHD: the bodies that go inside, placed in the enclosure's coordinates (mm,
|
|
8
|
+
Z up) as GLB files with a 4x4 matrix each; the features the enclosure must
|
|
9
|
+
provide (openings, button plungers, windows, light pipes, insert bosses,
|
|
10
|
+
seal glands, threads, strap lugs, the lid joint), each with its frame or
|
|
11
|
+
the frame of the part it serves; and the design values from the module's
|
|
12
|
+
cad.config.ts (process, material, wall, clearances, split plane, fasteners).
|
|
13
|
+
|
|
14
|
+
The cavity is the bounding box (or cylinder) of the contents plus the
|
|
15
|
+
clearance, unless the config fixes it. The shell is cut at the split plane
|
|
16
|
+
into a base and a lid; button plungers are separate printed parts, made
|
|
17
|
+
last so their flanges can be trimmed to clear the walls and what is inside.
|
|
18
|
+
Every feature is applied to every part its tool reaches; a cut through a
|
|
19
|
+
side wall is checked part by part across the wall (wall_bands), and a sliver
|
|
20
|
+
it would leave at the joint inside the wall is cut away with it.
|
|
21
|
+
|
|
22
|
+
`build` writes into <out_dir>:
|
|
23
|
+
parts/<part>.step|.glb|.stl|.3mf one printed part each
|
|
24
|
+
body.step, body.glb every part, plus one named sub-shape
|
|
25
|
+
per feature (named by its interface id)
|
|
26
|
+
body.manifest.json UHD geometry manifest: feature
|
|
27
|
+
signatures, volumes, frames
|
|
28
|
+
section.svg a section through the cavity centre
|
|
29
|
+
report.json cavity, parts, feature frames and facts
|
|
30
|
+
(recess, gap, squeeze, ledges, walls),
|
|
31
|
+
problems, and the fit analysis (bodies
|
|
32
|
+
inside, and the plungers)
|
|
33
|
+
and prints the report summary as one JSON line. (The provider moves
|
|
34
|
+
report.json next to the job, out of the build cache.)
|
|
35
|
+
|
|
36
|
+
`fit` runs only the fit analysis (fit.py) on parts already built and the
|
|
37
|
+
bodies a check names, and prints the result: the check uses it when what
|
|
38
|
+
goes inside has changed since the enclosure was generated.
|
|
39
|
+
|
|
40
|
+
Outputs are deterministic for a fixed job and build123d version.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import hashlib
|
|
45
|
+
import json
|
|
46
|
+
import math
|
|
47
|
+
import re
|
|
48
|
+
import sys
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
import build123d as bd
|
|
52
|
+
import numpy as np
|
|
53
|
+
from build123d import (
|
|
54
|
+
ExportDXF,
|
|
55
|
+
Unit,
|
|
56
|
+
Align,
|
|
57
|
+
Axis,
|
|
58
|
+
Box,
|
|
59
|
+
BuildLine,
|
|
60
|
+
BuildPart,
|
|
61
|
+
BuildSketch,
|
|
62
|
+
Circle,
|
|
63
|
+
Compound,
|
|
64
|
+
Cylinder,
|
|
65
|
+
Face,
|
|
66
|
+
Helix,
|
|
67
|
+
Location,
|
|
68
|
+
Mesher,
|
|
69
|
+
Plane,
|
|
70
|
+
Polyline,
|
|
71
|
+
Pos,
|
|
72
|
+
Rot,
|
|
73
|
+
Rectangle,
|
|
74
|
+
RectangleRounded,
|
|
75
|
+
Shape,
|
|
76
|
+
Solid,
|
|
77
|
+
Vector,
|
|
78
|
+
export_gltf,
|
|
79
|
+
export_step,
|
|
80
|
+
export_stl,
|
|
81
|
+
make_face,
|
|
82
|
+
offset,
|
|
83
|
+
revolve,
|
|
84
|
+
sweep,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
HERE = Path(__file__).resolve().parent
|
|
88
|
+
sys.path.insert(0, str(HERE))
|
|
89
|
+
from fit import analyse, analyse_plungers, load_mesh, sample_surface # noqa: E402
|
|
90
|
+
|
|
91
|
+
GENERATOR = Path(__file__)
|
|
92
|
+
FIXED_TIME = "2000-01-01T00:00:00"
|
|
93
|
+
TOOL = "build123d"
|
|
94
|
+
MIN = (Align.CENTER, Align.CENTER, Align.MIN)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------------------------
|
|
98
|
+
# Small geometry helpers
|
|
99
|
+
# ---------------------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def unit(v) -> np.ndarray:
|
|
103
|
+
v = np.asarray(v, dtype=float)
|
|
104
|
+
n = np.linalg.norm(v)
|
|
105
|
+
return v / n if n > 1e-12 else v
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def basis(normal, x_axis=None) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
109
|
+
"""Orthonormal x, y, z (= normal). Without an xAxis, x is horizontal (or +X for a vertical normal)."""
|
|
110
|
+
z = unit(normal)
|
|
111
|
+
if x_axis is not None:
|
|
112
|
+
x = np.asarray(x_axis, dtype=float)
|
|
113
|
+
elif abs(z[2]) > 0.9:
|
|
114
|
+
x = np.array([1.0, 0.0, 0.0])
|
|
115
|
+
else:
|
|
116
|
+
x = np.cross([0.0, 0.0, 1.0], z)
|
|
117
|
+
x = unit(x - z * np.dot(x, z))
|
|
118
|
+
return x, np.cross(z, x), z
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def plane_at(origin, normal, x_axis=None) -> Plane:
|
|
122
|
+
x, _, z = basis(normal, x_axis)
|
|
123
|
+
return Plane(origin=Vector(*map(float, origin)), x_dir=Vector(*x), z_dir=Vector(*z))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def r3(v) -> list[float]:
|
|
127
|
+
return [round(float(x), 3) + 0.0 for x in v]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def frame_of(origin, normal, x_axis=None) -> dict:
|
|
131
|
+
x, _, z = basis(normal, x_axis)
|
|
132
|
+
return {"origin": r3(origin), "normal": r3(z), "xAxis": r3(x)}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def prism(pl: Plane, sketch_face: Face, z0: float, z1: float) -> Solid:
|
|
136
|
+
"""Extrude a face drawn in the plane's local XY from local z0 to z1."""
|
|
137
|
+
f = sketch_face.moved(Location((0, 0, z0)))
|
|
138
|
+
s = Solid.extrude(f, Vector(0, 0, z1 - z0))
|
|
139
|
+
return pl.location * s
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def extrude_region(region, z0: float, dz: float) -> Shape:
|
|
143
|
+
"""Extrude a planar region (a face, or several) drawn at z = 0 from z0 by dz."""
|
|
144
|
+
solids = [Solid.extrude(f.moved(Location((0, 0, z0))), Vector(0, 0, dz)) for f in region.faces()]
|
|
145
|
+
out = solids[0]
|
|
146
|
+
for s in solids[1:]:
|
|
147
|
+
out = out + s
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def section_face(shape: str, width: float, height: float, radius: float = 0.0) -> Face:
|
|
152
|
+
if shape == "round":
|
|
153
|
+
return Circle(width / 2).face()
|
|
154
|
+
if radius > 1e-6 and radius * 2 < min(width, height):
|
|
155
|
+
return RectangleRounded(width, height, radius).face()
|
|
156
|
+
return Rectangle(width, height).face()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def signature(faces: list[Face]) -> dict:
|
|
160
|
+
"""Area, area-weighted centroid and, when every face is planar and parallel, the shared normal."""
|
|
161
|
+
area = sum(f.area for f in faces)
|
|
162
|
+
c = [sum(getattr(f.center(bd.CenterOf.MASS), k) * f.area for f in faces) / area for k in "XYZ"]
|
|
163
|
+
sig = {"area_mm2": round(area, 3), "centroid": [round(v, 3) + 0.0 for v in c], "faces": len(faces)}
|
|
164
|
+
normals = set()
|
|
165
|
+
for f in faces:
|
|
166
|
+
if f.geom_type != bd.GeomType.PLANE:
|
|
167
|
+
return sig
|
|
168
|
+
n = f.normal_at()
|
|
169
|
+
normals.add((round(n.X, 3) + 0.0, round(n.Y, 3) + 0.0, round(n.Z, 3) + 0.0))
|
|
170
|
+
if len(normals) == 1:
|
|
171
|
+
sig["normal"] = list(normals.pop())
|
|
172
|
+
return sig
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def faces_on(part: Shape, tool: Shape, tol: float = 1e-3) -> list[Face]:
|
|
176
|
+
"""Faces of `part` lying on the skin of `tool` (the faces a cut or an added solid made)."""
|
|
177
|
+
skin = Compound(tool.faces())
|
|
178
|
+
bb = tool.bounding_box()
|
|
179
|
+
out = []
|
|
180
|
+
for f in part.faces():
|
|
181
|
+
fb = f.bounding_box()
|
|
182
|
+
if fb.min.X > bb.max.X + tol or fb.max.X < bb.min.X - tol or fb.min.Y > bb.max.Y + tol or fb.max.Y < bb.min.Y - tol or fb.min.Z > bb.max.Z + tol or fb.max.Z < bb.min.Z - tol:
|
|
183
|
+
continue
|
|
184
|
+
vs, _ = f.tessellate(0.2)
|
|
185
|
+
pts = vs[:: max(1, len(vs) // 10)]
|
|
186
|
+
if all(skin.distance_to(p) < tol for p in pts):
|
|
187
|
+
out.append(f)
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def feature_shape(name: str, faces: list[Face], pad: float = 0.2) -> Compound | None:
|
|
192
|
+
"""A thin named solid over a feature's faces: its sub-shape in body.step and node in body.glb."""
|
|
193
|
+
solids = []
|
|
194
|
+
for f in faces:
|
|
195
|
+
try:
|
|
196
|
+
solids.append(Solid.extrude(f, f.normal_at() * pad) if f.geom_type == bd.GeomType.PLANE else Solid.thicken(f, pad))
|
|
197
|
+
except Exception: # noqa: BLE001 - a face the kernel cannot thicken is left out of the marker
|
|
198
|
+
continue
|
|
199
|
+
return Compound(label=name, children=solids) if solids else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------------------------
|
|
203
|
+
# The enclosure
|
|
204
|
+
# ---------------------------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Enclosure:
|
|
208
|
+
"""Cavity, outline and z levels, from the contents and the design values."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, job: dict, bodies: list[dict]):
|
|
211
|
+
self.job = job
|
|
212
|
+
self.d = d = job["design"]
|
|
213
|
+
self.problems: list[str] = []
|
|
214
|
+
self.shape = d["shape"]
|
|
215
|
+
self.wall = d["wall"]
|
|
216
|
+
self.floor = d["floor"]
|
|
217
|
+
self.lid = d["lid"]
|
|
218
|
+
self.clr = d["clearance"]
|
|
219
|
+
self.mw = d["min_wall"]
|
|
220
|
+
feats = job["features"]
|
|
221
|
+
self.joint_feature = next((f for f in feats if f["kind"] == "joint"), None)
|
|
222
|
+
self.joint_gland = next((f for f in feats if f["kind"] == "seal_gland" and f["params"].get("at") == "joint" and f["params"]["type"] == "face"), None)
|
|
223
|
+
self.collar_gland = next((f for f in feats if f["kind"] == "seal_gland" and f["params"].get("at") == "joint" and f["params"]["type"] == "radial"), None)
|
|
224
|
+
self.twist = next((f for f in feats if f["kind"] == "twist_lid"), None)
|
|
225
|
+
jd = d["joint"]
|
|
226
|
+
self.joint_style = "twist" if self.twist else "flat" if self.joint_gland else jd["style"]
|
|
227
|
+
if self.twist and self.joint_gland:
|
|
228
|
+
self.problems.append(f"{self.joint_gland['id']}: a twist lid seals on its collar: make the joint gland radial (SealGland type radial, at joint)")
|
|
229
|
+
if self.collar_gland and not self.twist:
|
|
230
|
+
self.problems.append(f"{self.collar_gland['id']}: a radial gland at the joint seals a twist lid's collar; the module has no TwistLid")
|
|
231
|
+
self.lip = jd["lip_height"]
|
|
232
|
+
self.jc = jd["clearance"]
|
|
233
|
+
# rabbet: the base's tongue inside, the lid's skirt outside it, the joint clearance between; tongue and skirt get the same width
|
|
234
|
+
self.w_out = (self.wall + self.jc) / 2
|
|
235
|
+
|
|
236
|
+
verts = [b["V"] for b in bodies if len(b["V"])]
|
|
237
|
+
if not verts and not d.get("cavity"):
|
|
238
|
+
raise SystemExit("nothing to enclose: no body inside has geometry, and the config fixes no cavity")
|
|
239
|
+
allv = np.concatenate(verts) if verts else np.zeros((0, 3))
|
|
240
|
+
self.contents_lo = allv.min(axis=0) if len(allv) else None
|
|
241
|
+
self.contents_hi = allv.max(axis=0) if len(allv) else None
|
|
242
|
+
|
|
243
|
+
fixed = d.get("cavity")
|
|
244
|
+
if self.shape == "round":
|
|
245
|
+
if fixed:
|
|
246
|
+
self.centre = np.array(fixed["centre"], dtype=float)
|
|
247
|
+
self.r_cav = float(fixed["radius"])
|
|
248
|
+
z0, z1 = fixed["z"]
|
|
249
|
+
else:
|
|
250
|
+
self.centre = (self.contents_lo[:2] + self.contents_hi[:2]) / 2
|
|
251
|
+
self.r_cav = float(np.max(np.hypot(allv[:, 0] - self.centre[0], allv[:, 1] - self.centre[1]))) + self.clr
|
|
252
|
+
z0, z1 = self.contents_lo[2] - self.clr, self.contents_hi[2] + d["clearance_top"]
|
|
253
|
+
self.cav_min = np.array([self.centre[0] - self.r_cav, self.centre[1] - self.r_cav, z0])
|
|
254
|
+
self.cav_max = np.array([self.centre[0] + self.r_cav, self.centre[1] + self.r_cav, z1])
|
|
255
|
+
else:
|
|
256
|
+
if fixed:
|
|
257
|
+
self.cav_min = np.array(fixed["min"], dtype=float)
|
|
258
|
+
self.cav_max = np.array(fixed["max"], dtype=float)
|
|
259
|
+
else:
|
|
260
|
+
self.cav_min = self.contents_lo - self.clr
|
|
261
|
+
self.cav_max = self.contents_hi + np.array([self.clr, self.clr, d["clearance_top"]])
|
|
262
|
+
self.centre = (self.cav_min[:2] + self.cav_max[:2]) / 2
|
|
263
|
+
|
|
264
|
+
# the floor must leave room below every insert boss for its insert hole and a skin
|
|
265
|
+
for f in feats:
|
|
266
|
+
if f["kind"] == "boss":
|
|
267
|
+
p = f["params"]
|
|
268
|
+
top = f["frame"]["origin"][2]
|
|
269
|
+
need = top - max(0.0, p["insert"]["hole_depth_mm"] + self.mw - self.floor)
|
|
270
|
+
if need < self.cav_min[2]:
|
|
271
|
+
if fixed:
|
|
272
|
+
self.problems.append(f"{f['id']}: the fixed cavity floor at z {self.cav_min[2]:.2f} leaves no room under the boss for its insert hole ({p['insert']['hole_depth_mm']} mm)")
|
|
273
|
+
else:
|
|
274
|
+
self.cav_min[2] = need
|
|
275
|
+
if top < self.cav_min[2] - 1e-6:
|
|
276
|
+
self.problems.append(f"{f['id']}: boss top z {top:.2f} is below the cavity floor z {self.cav_min[2]:.2f}")
|
|
277
|
+
self.z_floor = float(self.cav_min[2])
|
|
278
|
+
self.z_bottom = self.z_floor - self.floor
|
|
279
|
+
self.z_cav_top = float(self.cav_max[2])
|
|
280
|
+
self.z_top = self.z_cav_top + self.lid
|
|
281
|
+
split = d.get("split_z")
|
|
282
|
+
if split is None:
|
|
283
|
+
split = self.z_cav_top if self.joint_style in ("flat", "twist") else self.z_cav_top - self.lip
|
|
284
|
+
self.z_split = float(split)
|
|
285
|
+
if not (self.z_floor < self.z_split <= self.z_cav_top + 1e-6):
|
|
286
|
+
self.problems.append(f"split plane z {self.z_split:.2f} is outside the cavity ({self.z_floor:.2f} to {self.z_cav_top:.2f})")
|
|
287
|
+
|
|
288
|
+
# lid screws: columns at the cavity corners (box) or around it (round)
|
|
289
|
+
self.scheme = d["fasteners"]
|
|
290
|
+
self.columns: list[np.ndarray] = []
|
|
291
|
+
self.rc = 0.0
|
|
292
|
+
if self.scheme == "screws":
|
|
293
|
+
jf = self.joint_feature
|
|
294
|
+
if not jf or "insert" not in jf["params"] or "screw" not in jf["params"]:
|
|
295
|
+
raise SystemExit('fasteners "screws" needs a joint feature stating its screw and insert (enclosure_feature kind "joint")')
|
|
296
|
+
ins = jf["params"]["insert"]
|
|
297
|
+
self.rc = ins["hole_d_mm"] / 2 + max(ins.get("min_wall_mm", 0.0), self.mw)
|
|
298
|
+
self.columns = self.column_centres(jf["params"].get("count", 4))
|
|
299
|
+
self.cw = self.mw # wall around a column where it bulges out of the shell
|
|
300
|
+
self.tw = self.plan_twist() if self.twist else None
|
|
301
|
+
|
|
302
|
+
def plan_twist(self) -> dict | None:
|
|
303
|
+
"""A twist lid's collar: radii and levels (TwistLid). Everything is measured from the outer radius in.
|
|
304
|
+
|
|
305
|
+
The lid's skirt keeps the shell's outside diameter; its wall behind the lock is at least the
|
|
306
|
+
minimum wall. The skirt's bore is the sealing surface of the collar's O-ring (a radial gland,
|
|
307
|
+
Parker's static table: gland depth from the bore to the groove's bottom) and passes over the
|
|
308
|
+
collar's lock as it goes on. Bottom to top the collar holds: a ledge, the O-ring groove, a
|
|
309
|
+
ledge, the lock (the thread's turns, or the bayonet's lugs at the top).
|
|
310
|
+
"""
|
|
311
|
+
if self.shape != "round":
|
|
312
|
+
self.problems.append(f"{self.twist['id']}: a twist lid needs a round enclosure (design shape round)")
|
|
313
|
+
return None
|
|
314
|
+
p = self.twist["params"]
|
|
315
|
+
lock = p["lock"]
|
|
316
|
+
mw = self.mw
|
|
317
|
+
r_o = self.r_cav + self.wall
|
|
318
|
+
clr = p.get("clearance_mm", 0.2)
|
|
319
|
+
ax = p.get("axial_clearance_mm", 0.15)
|
|
320
|
+
g = self.collar_gland["params"] if self.collar_gland else None
|
|
321
|
+
row = g["table"] if g else None
|
|
322
|
+
L = (row["depth_mm"][0] + row["depth_mm"][1]) / 2 if row else 0.0
|
|
323
|
+
G = (row["width_mm"][0] + row["width_mm"][1]) / 2 if row else 0.0
|
|
324
|
+
E = ((row.get("clearance_mm") or [0.05, 0.13])[0] + (row.get("clearance_mm") or [0.05, 0.13])[1]) / 4 if row else 0.05 # radial: half the diametral clearance
|
|
325
|
+
# a printed bore comes out undersize: the drawn bore is this much larger (radial), so the bore as
|
|
326
|
+
# printed lands on Parker's clearance and the groove's depth is measured from that printed bore
|
|
327
|
+
a = p.get("bore_allowance_mm", 0.0)
|
|
328
|
+
t: dict = {"lock": lock, "r_o": r_o, "clr": clr, "ax": ax, "L": L, "G": G, "gap": E, "a": a}
|
|
329
|
+
if lock == "thread":
|
|
330
|
+
P = p.get("pitch_mm", 2.0)
|
|
331
|
+
d = p.get("depth_mm", round(0.45 * P, 3))
|
|
332
|
+
flank = math.radians(p.get("flank_angle_deg", 30.0))
|
|
333
|
+
turns = p.get("turns", 2.0)
|
|
334
|
+
skirt = max(mw, 1.2)
|
|
335
|
+
r_b = r_o - skirt # the skirt's bore as drawn: the lid thread's root and the seal's bore
|
|
336
|
+
t.update({"P": P, "d": d, "flank": flank, "turns": turns, "r_b": r_b, "r_bp": r_b - a, "r_c": r_b - a - E, "r_major": r_b - a - clr, "r_root": r_b - a - clr - d, "r_crest": r_b - d, "skirt": skirt})
|
|
337
|
+
lock_h = turns * P + P
|
|
338
|
+
t["base_wall_lock"] = t["r_root"] - self.r_cav
|
|
339
|
+
else:
|
|
340
|
+
d = p.get("lug_depth_mm", 1.0)
|
|
341
|
+
h = p.get("lug_height_mm", 2.0)
|
|
342
|
+
n = int(p.get("lugs", 3))
|
|
343
|
+
w = p.get("lug_width_mm", 6.0)
|
|
344
|
+
kw = p.get("key_lug_width_mm")
|
|
345
|
+
twist = p.get("twist_deg", 30.0)
|
|
346
|
+
r_c = r_o - mw - d - clr # the slot's floor leaves the minimum wall in the skirt
|
|
347
|
+
# the first lug (on +Y) may be wider, a key: its slot is the only one it enters, so the lid goes on one way
|
|
348
|
+
widths = [kw if kw else w] + [w] * (n - 1)
|
|
349
|
+
angles = [90.0 + 360.0 * k / n for k in range(n)]
|
|
350
|
+
t.update({"d": d, "h_lug": h, "n": n, "w": w, "widths": widths, "angles": angles, "twist": twist, "r_c": r_c, "r_bp": r_c + E, "r_b": r_c + E + a, "skirt": r_o - (r_c + E + a), "skirt_behind_slot": r_o - (r_c + d + clr)})
|
|
351
|
+
t["positions"] = bayonet_positions(angles, widths, clr, r_c)
|
|
352
|
+
tid = self.twist["id"]
|
|
353
|
+
if kw and kw <= w + 2 * clr + 1e-6:
|
|
354
|
+
self.problems.append(f"{tid}: the key lug ({kw} mm) must be wider than a slot for the others ({w} mm + 2 x {clr} mm clearance), or it enters any slot")
|
|
355
|
+
if t["positions"] > 1 and p.get("orientation", "keyed") != "any":
|
|
356
|
+
self.problems.append(f"{tid}: the lid goes on in {t['positions']} positions {360 / t['positions']:g}° apart, so what is in the lid may not stand over what it serves; key it (key_lug_width_mm wider than lug_width_mm + 2 x clearance), or say orientation \"any\"")
|
|
357
|
+
# neighbouring slots (each entry plus its run along the twist) keep the minimum wall between them
|
|
358
|
+
r_slot = r_c + d + clr
|
|
359
|
+
for i in range(n):
|
|
360
|
+
j = (i + 1) % n
|
|
361
|
+
c_ang = math.degrees(clr / r_c)
|
|
362
|
+
end_i = angles[i] + math.degrees(widths[i] / r_c) / 2 + c_ang
|
|
363
|
+
start_j = angles[j] + (360.0 if j == 0 else 0.0) - twist - math.degrees(widths[j] / r_c) / 2 - c_ang
|
|
364
|
+
web = math.radians(start_j - end_i) * r_slot
|
|
365
|
+
if web < mw - 1e-6:
|
|
366
|
+
self.problems.append(f"{tid}: the slots for lugs {i + 1} and {j + 1} leave {web:.2f} mm of skirt between them, under the {mw} mm minimum; fewer or narrower lugs, or a smaller twist")
|
|
367
|
+
lock_h = 2 * ax + mw + d + h
|
|
368
|
+
t["base_wall_lock"] = r_c - self.r_cav
|
|
369
|
+
t["r_g"] = t["r_bp"] - L if g else None
|
|
370
|
+
# levels, bottom to top. Thread: the groove under the thread (the lid's smooth skirt passes over
|
|
371
|
+
# the thread to it, and its thread never crosses the groove). Bayonet: the lugs low, the groove
|
|
372
|
+
# above them (the slots run up the skirt's lower part only; the bore above them is whole).
|
|
373
|
+
seal_h = (mw + G + mw) if g else 0.0
|
|
374
|
+
t["h"] = round(seal_h + lock_h + (ax if lock == "bayonet" else 0.0), 3)
|
|
375
|
+
z0 = t["z0"] = self.z_cav_top - t["h"]
|
|
376
|
+
if lock == "thread":
|
|
377
|
+
t["zg"] = (z0 + mw, z0 + mw + G) if g else None
|
|
378
|
+
t["zl"] = (z0 + seal_h, self.z_cav_top)
|
|
379
|
+
else:
|
|
380
|
+
zl0 = z0 + 2 * ax + mw # the skirt's holding face needs a minimum wall under the slot
|
|
381
|
+
zl1 = zl0 + t["d"] + t["h_lug"]
|
|
382
|
+
t["zl"] = (zl0, zl1)
|
|
383
|
+
t["zg"] = (zl1 + ax + mw, zl1 + ax + mw + G) if g else None
|
|
384
|
+
if t["z0"] < self.z_floor + mw:
|
|
385
|
+
self.problems.append(f"{self.twist['id']}: the collar ({t['h']:.2f} mm: the O-ring groove and the lock) reaches below the cavity floor; raise the cavity top")
|
|
386
|
+
if g and t["r_g"] - self.r_cav < mw - 1e-6:
|
|
387
|
+
self.problems.append(f"{self.collar_gland['id']}: the groove leaves {t['r_g'] - self.r_cav:.2f} mm of wall to the cavity, under the {mw} mm minimum; a thicker wall or a smaller cavity")
|
|
388
|
+
return t
|
|
389
|
+
|
|
390
|
+
def column_centres(self, count: int) -> list[np.ndarray]:
|
|
391
|
+
rc, clr = self.rc, self.clr
|
|
392
|
+
if self.shape == "round":
|
|
393
|
+
reach = self.r_cav + (self.gland_reach() if self.joint_gland else 0.0)
|
|
394
|
+
r = reach + rc
|
|
395
|
+
return [self.centre + r * np.array([math.cos(a), math.sin(a)]) for a in [math.pi / 4 + 2 * math.pi * k / count for k in range(count)]]
|
|
396
|
+
lo = self.contents_lo[:2] if self.contents_lo is not None else self.cav_min[:2]
|
|
397
|
+
hi = self.contents_hi[:2] if self.contents_hi is not None else self.cav_max[:2]
|
|
398
|
+
if self.joint_gland:
|
|
399
|
+
# outside the groove ring: the cavity corner plus the gland's reach and a wall, along the diagonal
|
|
400
|
+
k = (self.gland_reach() + self.mw + rc) / math.sqrt(2)
|
|
401
|
+
lo, hi = self.cav_min[:2], self.cav_max[:2]
|
|
402
|
+
else:
|
|
403
|
+
k = (rc + clr) / math.sqrt(2)
|
|
404
|
+
corners = [(lo[0] - k, lo[1] - k), (hi[0] + k, lo[1] - k), (hi[0] + k, hi[1] + k), (lo[0] - k, hi[1] + k)]
|
|
405
|
+
if count != 4:
|
|
406
|
+
self.problems.append(f"{count} lid screws: this provider places 4 (one per corner)")
|
|
407
|
+
return [np.array(c) for c in corners]
|
|
408
|
+
|
|
409
|
+
def gland_reach(self) -> float:
|
|
410
|
+
"""Distance from the cavity edge to the outer edge of the joint groove."""
|
|
411
|
+
g = self.joint_gland["params"]
|
|
412
|
+
return g["_inner_offset"] + g["_groove_width"]
|
|
413
|
+
|
|
414
|
+
# ---- 2D outlines (XY) ----
|
|
415
|
+
def cavity_face(self, grow: float = 0.0) -> Face:
|
|
416
|
+
if self.shape == "round":
|
|
417
|
+
return Pos(*self.centre) * Circle(self.r_cav + grow).face()
|
|
418
|
+
w, h = (self.cav_max[:2] - self.cav_min[:2]) + 2 * grow
|
|
419
|
+
r = grow if grow > 0 else 0.0
|
|
420
|
+
return Pos(*self.centre) * section_face("box", w, h, r)
|
|
421
|
+
|
|
422
|
+
def outer_face(self, inset: float = 0.0) -> Face:
|
|
423
|
+
"""The shell outline (cavity + wall, joined with the columns' bosses), inset by `inset`."""
|
|
424
|
+
base = self.cavity_face(self.wall - inset)
|
|
425
|
+
cols = [Pos(*c) * Circle(self.rc + self.cw - inset).face() for c in self.columns if self.rc + self.cw - inset > 0]
|
|
426
|
+
f = base
|
|
427
|
+
for c in cols:
|
|
428
|
+
f = f + c
|
|
429
|
+
return f.faces()[0] if len(f.faces()) == 1 else f
|
|
430
|
+
|
|
431
|
+
def cavity_region(self) -> Face:
|
|
432
|
+
f = self.cavity_face()
|
|
433
|
+
for c in self.columns:
|
|
434
|
+
f = f - Pos(*c) * Circle(self.rc).face()
|
|
435
|
+
return f
|
|
436
|
+
|
|
437
|
+
def outer_bounds(self) -> tuple[np.ndarray, np.ndarray]:
|
|
438
|
+
lo = np.array([*(self.cav_min[:2] - self.wall), self.z_bottom])
|
|
439
|
+
hi = np.array([*(self.cav_max[:2] + self.wall), self.z_top])
|
|
440
|
+
return lo, hi
|
|
441
|
+
|
|
442
|
+
def wall_bands(self) -> list[dict]:
|
|
443
|
+
"""Bands across a side wall (depth from the outer surface) and the z span each part holds in each.
|
|
444
|
+
|
|
445
|
+
A flat joint is one band split at the split plane. A rabbet is three: the
|
|
446
|
+
lid's skirt on the outside, the joint clearance, and the base's tongue on
|
|
447
|
+
the inside (the lid sits over the tongue, clear of it by the joint
|
|
448
|
+
clearance). Bands inside the wall are hidden once the case is closed.
|
|
449
|
+
"""
|
|
450
|
+
zb, zs, zt = self.z_bottom, self.z_split, self.z_top
|
|
451
|
+
if self.joint_style != "rabbet":
|
|
452
|
+
return [{"band": "wall", "depth": (0.0, self.wall), "hidden": False, "parts": {"base": (zb, zs), "lid": (zs, zt)}}]
|
|
453
|
+
skirt = self.w_out - self.jc
|
|
454
|
+
over = zs + self.lip + self.jc
|
|
455
|
+
return [
|
|
456
|
+
{"band": "lid skirt", "depth": (0.0, skirt), "hidden": False, "parts": {"base": (zb, zs), "lid": (zs, zt)}},
|
|
457
|
+
{"band": "joint clearance", "depth": (skirt, self.w_out), "hidden": True, "parts": {"base": (zb, zs), "lid": (over, zt)}},
|
|
458
|
+
{"band": "tongue", "depth": (self.w_out, self.wall), "hidden": True, "parts": {"base": (zb, zs + self.lip), "lid": (over, zt)}},
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
def wall_hit(self, origin, normal) -> tuple[np.ndarray | None, float, str]:
|
|
462
|
+
"""Where a ray from `origin` along `normal` leaves the shell, the wall thickness there, and which wall."""
|
|
463
|
+
o = np.asarray(origin, dtype=float)
|
|
464
|
+
n = unit(normal)
|
|
465
|
+
if abs(n[2]) > math.cos(math.radians(10)):
|
|
466
|
+
z = self.z_top if n[2] > 0 else self.z_bottom
|
|
467
|
+
t = (z - o[2]) / n[2]
|
|
468
|
+
return o + t * n, (self.lid if n[2] > 0 else self.floor), ("top" if n[2] > 0 else "bottom")
|
|
469
|
+
if abs(n[2]) > math.sin(math.radians(10)):
|
|
470
|
+
return None, 0.0, "a wall at an angle (this provider cuts openings square to a wall)"
|
|
471
|
+
if self.shape == "round":
|
|
472
|
+
c = np.array([*self.centre, o[2]])
|
|
473
|
+
r = self.r_cav + self.wall
|
|
474
|
+
d = o - c
|
|
475
|
+
b = np.dot(d[:2], n[:2])
|
|
476
|
+
cc = np.dot(d[:2], d[:2]) - r * r
|
|
477
|
+
disc = b * b - cc * np.dot(n[:2], n[:2])
|
|
478
|
+
if disc < 0:
|
|
479
|
+
return None, 0.0, "outside the case"
|
|
480
|
+
t = (-b + math.sqrt(disc)) / np.dot(n[:2], n[:2])
|
|
481
|
+
return o + t * n, self.wall, "side"
|
|
482
|
+
a = int(np.argmax(np.abs(n[:2])))
|
|
483
|
+
if abs(n[a]) < math.cos(math.radians(10)):
|
|
484
|
+
return None, 0.0, "a corner (the normal is not square to a side wall)"
|
|
485
|
+
lo, hi = self.outer_bounds()
|
|
486
|
+
plane = hi[a] if n[a] > 0 else lo[a]
|
|
487
|
+
t = (plane - o[a]) / n[a]
|
|
488
|
+
return o + t * n, self.wall, ("+x", "+y")[a] if n[a] > 0 else ("-x", "-y")[a]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ---------------------------------------------------------------------------------------------
|
|
492
|
+
# Features
|
|
493
|
+
# ---------------------------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class Built:
|
|
497
|
+
def __init__(self):
|
|
498
|
+
self.cuts: list[tuple[str, Shape]] = [] # (feature id, tool) cut from every part
|
|
499
|
+
self.adds: list[tuple[str, Shape]] = [] # (feature id, solid) added to the base
|
|
500
|
+
self.lid_adds: list[tuple[str, Shape]] = [] # (feature id, solid) added to the lid
|
|
501
|
+
self.base_steps: list[tuple[str, Shape]] = [] # (feature id, tool) cut from the base before anything is added (a twist lid's collar)
|
|
502
|
+
self.base_cuts: list[tuple[str, Shape]] = [] # (feature id, tool) cut from the base only (a twist lid's O-ring groove)
|
|
503
|
+
self.lid_cuts: list[tuple[str, Shape]] = [] # (feature id, tool) cut from the lid only (a twist lid's slots)
|
|
504
|
+
self.covers: dict[str, dict] = {} # window feature id -> its cover, in the cover's own frame (not part of the case body)
|
|
505
|
+
self.marks: dict[str, list[tuple[str, Shape]]] = {} # feature id -> (part, skin tool) for its signature faces
|
|
506
|
+
self.extra_parts: dict[str, Shape] = {}
|
|
507
|
+
self.frames: dict[str, dict] = {}
|
|
508
|
+
self.facts: dict[str, dict] = {}
|
|
509
|
+
self.walls: list[dict] = []
|
|
510
|
+
self.problems: list[str] = []
|
|
511
|
+
self.zones: dict[str, list[dict]] = {}
|
|
512
|
+
self.plungers: dict[str, dict] = {} # feature id -> what make_plunger needs
|
|
513
|
+
self.solids: list[dict] = [] # solids standing in the cavity (bosses, columns, gland bosses), for the exact fit
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def served_axis(f: dict) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
|
|
517
|
+
s = f.get("served") or {}
|
|
518
|
+
fr = s.get("frame") or f.get("frame")
|
|
519
|
+
if not fr:
|
|
520
|
+
raise ValueError(f"{f['id']}: no frame and nothing it serves")
|
|
521
|
+
return np.asarray(fr["origin"], dtype=float), unit(fr["normal"]), (np.asarray(fr["xAxis"], dtype=float) if fr.get("xAxis") else None)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def wall_feature(e: Enclosure, b: Built, f: dict) -> tuple[Plane, float, float] | None:
|
|
525
|
+
"""Plane on the outer wall where the feature's axis leaves the shell, the wall there, and the recess of what it serves."""
|
|
526
|
+
o, n, x = served_axis(f)
|
|
527
|
+
hit, wall, where = e.wall_hit(o, n)
|
|
528
|
+
if hit is None:
|
|
529
|
+
b.problems.append(f"{f['id']}: its axis meets {where}")
|
|
530
|
+
return None
|
|
531
|
+
pl = plane_at(hit, n, x)
|
|
532
|
+
xa = np.array([pl.x_dir.X, pl.x_dir.Y, pl.x_dir.Z])
|
|
533
|
+
b.frames[f["id"]] = frame_of(hit, n, xa)
|
|
534
|
+
recess = float(np.dot(hit - o, n))
|
|
535
|
+
return pl, wall, recess
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def through(e: Enclosure, pl: Plane, half: float) -> float:
|
|
539
|
+
"""How far past the wall (measured on the feature's axis) a cut must reach to clear the cavity
|
|
540
|
+
everywhere across its half-width: on a round shell's side wall the cavity face curves away from
|
|
541
|
+
the axis, by r - sqrt(r^2 - half^2) at the cut's edge."""
|
|
542
|
+
n = np.array([pl.z_dir.X, pl.z_dir.Y, pl.z_dir.Z])
|
|
543
|
+
if e.shape != "round" or abs(n[2]) > 0.5 or half >= e.r_cav:
|
|
544
|
+
return 0.2
|
|
545
|
+
return 0.2 + e.r_cav - math.sqrt(e.r_cav * e.r_cav - half * half)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def edge_margins(e: Enclosure, b: Built, f: dict, pl: Plane, width: float, height: float, what: str, depth: tuple[float, float] | None = None, corner: float = 0.0) -> dict:
|
|
549
|
+
"""What each part keeps above and below a cut through a side wall, band by band across the wall (exact).
|
|
550
|
+
|
|
551
|
+
`depth` is how far into the wall the cut reaches from the outer surface
|
|
552
|
+
(default: through it); `corner` the radius of the cut's top and bottom
|
|
553
|
+
corners. A sliver thinner than the minimum wall that the cut leaves at the
|
|
554
|
+
joint inside the wall (on the rabbet's tongue, or the lid over it) is cut
|
|
555
|
+
away with it: the other part closes the gap there, and nothing shows from
|
|
556
|
+
outside. Anything else thinner than the minimum (on the outer face, at the
|
|
557
|
+
top or bottom of the case) is reported as a wall.
|
|
558
|
+
"""
|
|
559
|
+
o = np.array([pl.origin.X, pl.origin.Y, pl.origin.Z])
|
|
560
|
+
n = np.array([pl.z_dir.X, pl.z_dir.Y, pl.z_dir.Z])
|
|
561
|
+
if abs(n[2]) > 0.5:
|
|
562
|
+
return {}
|
|
563
|
+
x = np.array([pl.x_dir.X, pl.x_dir.Y, pl.x_dir.Z])
|
|
564
|
+
y = np.array([pl.y_dir.X, pl.y_dir.Y, pl.y_dir.Z])
|
|
565
|
+
half_z = abs(x[2]) * width / 2 + abs(y[2]) * height / 2
|
|
566
|
+
h = unit(np.cross([0.0, 0.0, 1.0], n))
|
|
567
|
+
half_h = abs(np.dot(x, h)) * width / 2 + abs(np.dot(y, h)) * height / 2
|
|
568
|
+
lo_c, hi_c = o[2] - half_z, o[2] + half_z
|
|
569
|
+
d0, d1 = depth if depth is not None else (0.0, e.wall)
|
|
570
|
+
edges = (e.z_top, e.z_bottom)
|
|
571
|
+
keep: dict[tuple[str, str], tuple[float, str]] = {}
|
|
572
|
+
cleared = []
|
|
573
|
+
for band in e.wall_bands():
|
|
574
|
+
b0, b1 = band["depth"]
|
|
575
|
+
if d1 <= b0 + 1e-9 or d0 >= b1 - 1e-9:
|
|
576
|
+
continue # the cut does not reach this band
|
|
577
|
+
for part, (plo, phi) in band["parts"].items():
|
|
578
|
+
if hi_c <= plo + 1e-9 or lo_c >= phi - 1e-9:
|
|
579
|
+
continue # the cut misses this part in this band
|
|
580
|
+
for side, left, edge in (("above", phi - hi_c, phi), ("below", lo_c - plo, plo)):
|
|
581
|
+
if left <= 1e-6:
|
|
582
|
+
continue
|
|
583
|
+
at_joint = all(abs(edge - z) > 1e-6 for z in edges)
|
|
584
|
+
if left < e.mw and at_joint and band["hidden"]:
|
|
585
|
+
z0, z1 = (hi_c - corner, phi + 0.01) if side == "above" else (plo - 0.01, lo_c + corner)
|
|
586
|
+
side_plane = Plane(origin=Vector(*o), x_dir=Vector(*h), z_dir=Vector(*n))
|
|
587
|
+
face = Pos(0, (z0 + z1) / 2 - o[2]) * Rectangle(2 * half_h, z1 - z0)
|
|
588
|
+
b.cuts.append((f["id"], prism(side_plane, face.face(), -(min(b1, d1) + 0.01), -(max(b0, d0) - 0.01))))
|
|
589
|
+
cleared.append({"part": part, "band": band["band"], "side": side, "mm": round(left, 3)})
|
|
590
|
+
continue
|
|
591
|
+
k = (part, side)
|
|
592
|
+
if k not in keep or left < keep[k][0]:
|
|
593
|
+
keep[k] = (left, band["band"])
|
|
594
|
+
margins: dict[str, dict] = {}
|
|
595
|
+
for (part, side), (left, band) in sorted(keep.items()):
|
|
596
|
+
margins.setdefault(part, {})[side] = round(left, 3)
|
|
597
|
+
b.walls.append({"feature": f["id"], "what": f"{part} {side} the {what} ({band})", "mm": round(left, 3), "min_mm": e.mw})
|
|
598
|
+
if cleared:
|
|
599
|
+
margins["cleared"] = cleared
|
|
600
|
+
return margins
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def side_half(pl: Plane, width: float, height: float) -> float:
|
|
604
|
+
"""Half the extent of a width x height cut across the horizontal of the wall it passes through (the
|
|
605
|
+
direction a round cavity's inner face curves away in), for `through`."""
|
|
606
|
+
n = np.array([pl.z_dir.X, pl.z_dir.Y, pl.z_dir.Z])
|
|
607
|
+
if abs(n[2]) > 0.5:
|
|
608
|
+
return max(width, height) / 2
|
|
609
|
+
x = np.array([pl.x_dir.X, pl.x_dir.Y, pl.x_dir.Z])
|
|
610
|
+
y = np.array([pl.y_dir.X, pl.y_dir.Y, pl.y_dir.Z])
|
|
611
|
+
h = unit(np.cross([0.0, 0.0, 1.0], n))
|
|
612
|
+
return abs(np.dot(x, h)) * width / 2 + abs(np.dot(y, h)) * height / 2
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def opening(e: Enclosure, b: Built, f: dict) -> None:
|
|
616
|
+
p = f["params"]
|
|
617
|
+
w = wall_feature(e, b, f)
|
|
618
|
+
if not w:
|
|
619
|
+
return
|
|
620
|
+
pl, wall, recess = w
|
|
621
|
+
shape = p.get("shape", "rect")
|
|
622
|
+
width = p["diameter_mm"] if shape == "round" else p["width_mm"]
|
|
623
|
+
height = p["diameter_mm"] if shape == "round" else p["height_mm"]
|
|
624
|
+
corner = width / 2 if shape == "round" else p.get("corner_radius_mm", 0.0)
|
|
625
|
+
tool = prism(pl, section_face(shape, width, height, p.get("corner_radius_mm", 0.0)), -(wall + through(e, pl, side_half(pl, width, height))), 1.0)
|
|
626
|
+
b.cuts.append((f["id"], tool))
|
|
627
|
+
b.marks[f["id"]] = [("*", tool)]
|
|
628
|
+
b.facts[f["id"]] = {"kind": "opening", "size_mm": [width, height], "recess_mm": round(recess, 3), "wall_mm": wall, "margins_mm": edge_margins(e, b, f, pl, width, height, "opening", corner=corner)}
|
|
629
|
+
if p.get("plug"):
|
|
630
|
+
fit = plug_fit(e, pl, width, height, p["plug"])
|
|
631
|
+
if p.get("seat") == "flat" and fit.get("seat_gap_mm", 0.0) > 0.0:
|
|
632
|
+
seat_pad(e, b, f, pl, p["plug"], fit)
|
|
633
|
+
b.facts[f["id"]]["plug"] = fit
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
SEAT_MARGIN_MM = 0.3 # the flat seat stands this far beyond the overmold on each side
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def seat_pad(e: Enclosure, b: Built, f: dict, pl: Plane, plug: dict, fit: dict) -> None:
|
|
640
|
+
"""A flat seat for a plug's overmold on a round wall: material added outside the wall up to the plane
|
|
641
|
+
through the opening's outermost point, over the overmold's face plus a margin, within the case's height.
|
|
642
|
+
The plug then bears on a flat face, and nothing stands proud of that plane (the case's extent along the
|
|
643
|
+
plug axis is unchanged). The part above the split goes to the lid, the rest to the base."""
|
|
644
|
+
ow, oh = plug["overmold_width_mm"] + 2 * SEAT_MARGIN_MM, plug["overmold_height_mm"] + 2 * SEAT_MARGIN_MM
|
|
645
|
+
depth = fit["seat_gap_mm"] + 0.3 # from the plane into the wall, past where the curve leaves it
|
|
646
|
+
pad = prism(pl, Rectangle(ow, oh).face(), -depth, 0.0)
|
|
647
|
+
lo, hi = e.outer_bounds()
|
|
648
|
+
span = float(np.max(hi[:2] - lo[:2])) + 20
|
|
649
|
+
slab = lambda z0, z1: Pos(*e.centre, z0) * Box(span, span, z1 - z0, align=MIN) # noqa: E731
|
|
650
|
+
pad = pad & slab(e.z_bottom, e.z_top)
|
|
651
|
+
cavity = extrude_region(e.cavity_region(), e.z_floor - 1, e.z_cav_top - e.z_floor + 2)
|
|
652
|
+
pad = pad - cavity
|
|
653
|
+
below, above = pad & slab(e.z_bottom - 1, e.z_split), pad & slab(e.z_split, e.z_top + 1)
|
|
654
|
+
if below is not None and below.volume > 1e-6:
|
|
655
|
+
b.adds.append((f["id"], below))
|
|
656
|
+
if above is not None and above.volume > 1e-6:
|
|
657
|
+
b.lid_adds.append((f["id"], above))
|
|
658
|
+
fit["seat"] = {"kind": "flat", "width_mm": round(ow, 3), "height_mm": round(oh, 3), "proud_mm": fit["seat_gap_mm"]}
|
|
659
|
+
fit["seat_gap_mm"] = 0.0
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def plug_fit(e: Enclosure, pl: Plane, width: float, height: float, plug: dict) -> dict:
|
|
663
|
+
"""Whether the plug a socket opening takes passes through it, and how its overmold seats on the outer face.
|
|
664
|
+
|
|
665
|
+
The overmold butts the wall around the opening, so the face must be flat
|
|
666
|
+
under it: on a box, within the flat side and clear of the screw columns'
|
|
667
|
+
bulges (seat_mm, the least margin; negative where it overhangs); on a round
|
|
668
|
+
case, the gap the curve leaves under the overmold's ends (seat_gap_mm).
|
|
669
|
+
"""
|
|
670
|
+
out = {"name": plug.get("name"), "passes": width >= plug["plug_width_mm"] - 1e-6 and height >= plug["plug_thickness_mm"] - 1e-6, "plug_mm": [plug["plug_width_mm"], plug["plug_thickness_mm"]]}
|
|
671
|
+
o = np.array([pl.origin.X, pl.origin.Y, pl.origin.Z])
|
|
672
|
+
n = np.array([pl.z_dir.X, pl.z_dir.Y, pl.z_dir.Z])
|
|
673
|
+
if abs(n[2]) > 0.5:
|
|
674
|
+
return out
|
|
675
|
+
x = np.array([pl.x_dir.X, pl.x_dir.Y, pl.x_dir.Z])
|
|
676
|
+
ow, oh = plug["overmold_width_mm"], plug["overmold_height_mm"]
|
|
677
|
+
# the overmold's width lies along the opening's width
|
|
678
|
+
half_h = (abs(x[2]) * oh + (1 - abs(x[2])) * ow) / 2
|
|
679
|
+
half_z = (abs(x[2]) * ow + (1 - abs(x[2])) * oh) / 2
|
|
680
|
+
margins = []
|
|
681
|
+
# past the case's front or back face the overmold overhangs an edge into air: reported, not a seat fault
|
|
682
|
+
out["edge_overhang_mm"] = round(max(0.0, -min(o[2] - half_z - e.z_bottom, e.z_top - (o[2] + half_z))), 3)
|
|
683
|
+
if e.shape == "round":
|
|
684
|
+
r = e.r_cav + e.wall
|
|
685
|
+
out["seat_gap_mm"] = round(r - math.sqrt(max(r * r - half_h * half_h, 0.0)), 3)
|
|
686
|
+
else:
|
|
687
|
+
a = int(np.argmax(np.abs(n[:2])))
|
|
688
|
+
lat = 1 - a
|
|
689
|
+
c = o[lat]
|
|
690
|
+
margins += [c - half_h - e.cav_min[lat], e.cav_max[lat] - (c + half_h)]
|
|
691
|
+
rr = e.rc + e.cw
|
|
692
|
+
for col in e.columns:
|
|
693
|
+
d = (col[a] - o[a]) * math.copysign(1.0, n[a]) # how far the column's centre stands out of the face
|
|
694
|
+
if d <= -rr:
|
|
695
|
+
continue
|
|
696
|
+
# the lateral span over which its bulge stands out of the face
|
|
697
|
+
span = rr if d >= 0 else math.sqrt(rr * rr - d * d)
|
|
698
|
+
margins.append(max(col[lat] - span - (c + half_h), (c - half_h) - (col[lat] + span)))
|
|
699
|
+
out["overmold_mm"] = [ow, oh]
|
|
700
|
+
if margins:
|
|
701
|
+
out["seat_mm"] = round(min(margins), 3)
|
|
702
|
+
return out
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def light_pipe(e: Enclosure, b: Built, f: dict) -> None:
|
|
706
|
+
p = f["params"]
|
|
707
|
+
w = wall_feature(e, b, f)
|
|
708
|
+
if not w:
|
|
709
|
+
return
|
|
710
|
+
pl, wall, recess = w
|
|
711
|
+
d = p["diameter_mm"] + 2 * p.get("clearance_mm", 0.1)
|
|
712
|
+
tool = prism(pl, Circle(d / 2).face(), -(wall + through(e, pl, d / 2)), 1.0)
|
|
713
|
+
b.cuts.append((f["id"], tool))
|
|
714
|
+
b.marks[f["id"]] = [("*", tool)]
|
|
715
|
+
b.facts[f["id"]] = {"kind": "light_pipe", "hole_d_mm": round(d, 3), "recess_mm": round(recess, 3), "wall_mm": wall, "margins_mm": edge_margins(e, b, f, pl, d, d, "light pipe hole", corner=d / 2)}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def window(e: Enclosure, b: Built, f: dict) -> None:
|
|
719
|
+
p = f["params"]
|
|
720
|
+
w = wall_feature(e, b, f)
|
|
721
|
+
if not w:
|
|
722
|
+
return
|
|
723
|
+
pl, wall, recess = w
|
|
724
|
+
ap, cv = p["aperture"], p["cover"]
|
|
725
|
+
shape = "round" if "diameter_mm" in ap else "rect"
|
|
726
|
+
aw, ah = (ap["diameter_mm"],) * 2 if shape == "round" else (ap["width_mm"], ap["height_mm"])
|
|
727
|
+
c = p.get("clearance_mm", 0.1)
|
|
728
|
+
cw, ch = (cv["diameter_mm"] + 2 * c,) * 2 if shape == "round" else (cv["width_mm"] + 2 * c, cv["height_mm"] + 2 * c)
|
|
729
|
+
t = cv["thickness_mm"]
|
|
730
|
+
r = p.get("corner_radius_mm", 0.0)
|
|
731
|
+
aperture = prism(pl, section_face(shape, aw, ah, r), -(wall + through(e, pl, max(aw, ah) / 2)), 1.0)
|
|
732
|
+
side = p.get("side", "outside")
|
|
733
|
+
z0, z1 = (-t, 1.0) if side == "outside" else (-(wall + through(e, pl, max(cw, ch) / 2)), -(wall - t))
|
|
734
|
+
pocket = prism(pl, section_face(shape, cw, ch, r + c if r else 0.0), z0, z1)
|
|
735
|
+
b.cuts += [(f["id"], aperture), (f["id"], pocket)]
|
|
736
|
+
b.marks[f["id"]] = [("*", pocket)]
|
|
737
|
+
ledge = min(cw - aw, ch - ah) / 2
|
|
738
|
+
corner_a, corner_c = (aw / 2, cw / 2) if shape == "round" else (r, r + c if r else 0.0)
|
|
739
|
+
edge_margins(e, b, f, pl, aw, ah, "aperture", corner=corner_a)
|
|
740
|
+
edge_margins(e, b, f, pl, cw, ch, "cover pocket", depth=(0.0, t) if side == "outside" else (wall - t, wall), corner=corner_c)
|
|
741
|
+
b.facts[f["id"]] = {"kind": "window", "aperture_mm": [aw, ah], "pocket_mm": [round(cw, 3), round(ch, 3)], "pocket_depth_mm": t, "side": side, "ledge_mm": round(ledge, 3), "recess_mm": round(recess, 3), "wall_mm": wall}
|
|
742
|
+
# the cover itself, in its own frame: its outer face at z 0, its body behind it along +z (into the case), x
|
|
743
|
+
# along the window's x. The frame mates flush with the window's frame (the outer surface). An inside cover is
|
|
744
|
+
# not drawn: what holds it is the project's. Nor is a cover whose material is "none…": the pocket is only a
|
|
745
|
+
# counterbore (a plug's overmold seats in it) and nothing is fitted.
|
|
746
|
+
if side == "outside" and not str(cv.get("material") or "").lower().startswith("none"):
|
|
747
|
+
cr = r if shape != "round" else 0.0
|
|
748
|
+
outline = section_face(shape, cv["diameter_mm"] if shape == "round" else cv["width_mm"], cv["diameter_mm"] if shape == "round" else cv["height_mm"], cr)
|
|
749
|
+
b.covers[f["id"]] = {"solid": Solid.extrude(outline, Vector(0, 0, t)), "placed": prism(pl, outline, -t, 0.0), "outline": outline, "material": cv.get("material"), "thickness_mm": t, "size_mm": [cv["diameter_mm"]] if shape == "round" else [cv["width_mm"], cv["height_mm"]], "shape": shape}
|
|
750
|
+
b.walls.append({"feature": f["id"], "what": "ledge holding the cover", "mm": round(ledge, 3), "min_mm": e.mw})
|
|
751
|
+
b.walls.append({"feature": f["id"], "what": "wall left under the cover pocket", "mm": round(wall - t, 3), "min_mm": e.mw})
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def button(e: Enclosure, b: Built, f: dict) -> None:
|
|
755
|
+
p = f["params"]
|
|
756
|
+
w = wall_feature(e, b, f)
|
|
757
|
+
if not w:
|
|
758
|
+
return
|
|
759
|
+
pl, wall, recess = w
|
|
760
|
+
hole = p["hole_d_mm"]
|
|
761
|
+
shaft = hole - 2 * p.get("clearance_mm", 0.15)
|
|
762
|
+
gap = p.get("gap_mm", 0.1)
|
|
763
|
+
out = p.get("protrude_mm", 1.0)
|
|
764
|
+
fd, ft = p.get("flange_d_mm", hole + 2.0), p.get("flange_mm", 0.8)
|
|
765
|
+
bore = prism(pl, Circle(hole / 2).face(), -(wall + through(e, pl, hole / 2)), 1.0)
|
|
766
|
+
margins = edge_margins(e, b, f, pl, hole, hole, "plunger bore", corner=hole / 2)
|
|
767
|
+
b.cuts.append((f["id"], bore))
|
|
768
|
+
b.marks[f["id"]] = [("*", bore)]
|
|
769
|
+
tip = -(recess - gap)
|
|
770
|
+
room = recess - gap - wall - ft
|
|
771
|
+
if room < 0.1:
|
|
772
|
+
b.problems.append(f"{f['id']}: {recess:.2f} mm from the outer wall to the actuator leaves no room for a {ft} mm plunger flange behind a {wall} mm wall")
|
|
773
|
+
# the plunger is made once the walls are built: its flange is trimmed to clear them and what is inside
|
|
774
|
+
b.plungers[f["id"]] = {"plane": pl, "shaft": shaft, "hole": hole, "tip": tip, "out": out, "flange_d": fd, "flange": (-(wall + 0.1 + ft), -(wall + 0.1)), "gap": gap}
|
|
775
|
+
n = np.array([pl.z_dir.X, pl.z_dir.Y, pl.z_dir.Z])
|
|
776
|
+
tip_pt = np.array([pl.origin.X, pl.origin.Y, pl.origin.Z]) + n * tip
|
|
777
|
+
b.facts[f["id"]] = {
|
|
778
|
+
"kind": "button",
|
|
779
|
+
"hole_d_mm": hole,
|
|
780
|
+
"shaft_d_mm": round(shaft, 3),
|
|
781
|
+
"plunger": f"plunger_{f['id']}",
|
|
782
|
+
"tip": r3(tip_pt),
|
|
783
|
+
"gap_mm": gap,
|
|
784
|
+
"travel_mm": p.get("travel_mm"),
|
|
785
|
+
"length_mm": round(out - tip, 3),
|
|
786
|
+
"recess_mm": round(recess, 3),
|
|
787
|
+
"wall_mm": wall,
|
|
788
|
+
"margins_mm": margins,
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
# the plunger flange must overlap the wall around the bore by this much over at least half its edge, or the
|
|
793
|
+
# plunger can tilt out through the bore (assumption: about one 0.4 mm extrusion plus the shaft clearance)
|
|
794
|
+
FLANGE_HOLD_MM = 0.5
|
|
795
|
+
FLANGE_HOLD_FRACTION = 0.5
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def clip_polygon(poly: np.ndarray, axis: int, bound: float, keep_below: bool) -> np.ndarray:
|
|
799
|
+
"""Sutherland-Hodgman: the part of a convex polygon on one side of an axis-aligned plane."""
|
|
800
|
+
if not len(poly):
|
|
801
|
+
return poly
|
|
802
|
+
out = []
|
|
803
|
+
for i in range(len(poly)):
|
|
804
|
+
a, c = poly[i], poly[(i + 1) % len(poly)]
|
|
805
|
+
ina = a[axis] <= bound if keep_below else a[axis] >= bound
|
|
806
|
+
inc = c[axis] <= bound if keep_below else c[axis] >= bound
|
|
807
|
+
if ina:
|
|
808
|
+
out.append(a)
|
|
809
|
+
if ina != inc:
|
|
810
|
+
t = (bound - a[axis]) / (c[axis] - a[axis])
|
|
811
|
+
out.append(a + t * (c - a))
|
|
812
|
+
return np.array(out)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def points_near(V: np.ndarray, F: np.ndarray, pl: Plane, reach: float, z0: float, z1: float, step: float) -> np.ndarray:
|
|
816
|
+
"""Surface points of a mesh within `reach` of the plane's axis and between local z0 and z1, in the plane's local coordinates."""
|
|
817
|
+
if not len(F):
|
|
818
|
+
return np.zeros((0, 3))
|
|
819
|
+
o = np.array([pl.origin.X, pl.origin.Y, pl.origin.Z])
|
|
820
|
+
R = np.array([[pl.x_dir.X, pl.x_dir.Y, pl.x_dir.Z], [pl.y_dir.X, pl.y_dir.Y, pl.y_dir.Z], [pl.z_dir.X, pl.z_dir.Y, pl.z_dir.Z]])
|
|
821
|
+
tri = ((V - o) @ R.T)[F]
|
|
822
|
+
lo, hi = tri.min(axis=1), tri.max(axis=1)
|
|
823
|
+
sel = (lo[:, 0] <= reach) & (hi[:, 0] >= -reach) & (lo[:, 1] <= reach) & (hi[:, 1] >= -reach) & (lo[:, 2] <= z1) & (hi[:, 2] >= z0)
|
|
824
|
+
out = []
|
|
825
|
+
for t in tri[sel]:
|
|
826
|
+
poly = t
|
|
827
|
+
for axis, bound, below in ((0, reach, True), (0, -reach, False), (1, reach, True), (1, -reach, False), (2, z1, True), (2, z0, False)):
|
|
828
|
+
poly = clip_polygon(poly, axis, bound, below)
|
|
829
|
+
if len(poly) == 0:
|
|
830
|
+
continue
|
|
831
|
+
if len(poly) < 3:
|
|
832
|
+
out.append(poly)
|
|
833
|
+
continue
|
|
834
|
+
fan = np.array([[0, i, i + 1] for i in range(1, len(poly) - 1)])
|
|
835
|
+
out.append(sample_surface(poly, fan, step))
|
|
836
|
+
return np.concatenate(out) if out else np.zeros((0, 3))
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def flange_outline(obstacles: list[tuple[str, np.ndarray, float]], slab: tuple[float, float], clearance: float, r_max: float, count: int = 120) -> tuple[np.ndarray, np.ndarray, list[str]]:
|
|
840
|
+
"""Largest radius the flange may have at each angle and stay `clearance` clear of every obstacle point.
|
|
841
|
+
|
|
842
|
+
obstacles: (name, local points, normal margin): a point counts when it lies
|
|
843
|
+
within the margin of the flange's slab along the axis; its in-plane
|
|
844
|
+
allowance shrinks with that distance (a sphere of the clearance). Returns
|
|
845
|
+
the angles, the radius at each (taking the least of each angle and its two
|
|
846
|
+
neighbours, so the polygon's edges stay clear too) and what limited it.
|
|
847
|
+
"""
|
|
848
|
+
ang = np.arange(count) * 2 * math.pi / count
|
|
849
|
+
r = np.full(count, r_max)
|
|
850
|
+
who = np.full(count, -1)
|
|
851
|
+
names = []
|
|
852
|
+
for k, (name, pts, margin) in enumerate(obstacles):
|
|
853
|
+
names.append(name)
|
|
854
|
+
if not len(pts):
|
|
855
|
+
continue
|
|
856
|
+
dn = np.maximum(slab[0] - pts[:, 2], pts[:, 2] - slab[1])
|
|
857
|
+
dn = np.maximum(dn, 0.0)
|
|
858
|
+
ok = dn <= margin + 1e-9
|
|
859
|
+
pts, dn = pts[ok], dn[ok]
|
|
860
|
+
if not len(pts):
|
|
861
|
+
continue
|
|
862
|
+
ci = np.sqrt(np.maximum(clearance**2 - dn**2, 0.0)) if margin > 0 else np.full(len(pts), clearance)
|
|
863
|
+
# where the ray at each angle first comes within the allowance of each point: projected along it, less the half-chord
|
|
864
|
+
u = np.stack([np.cos(ang), np.sin(ang)], axis=1)
|
|
865
|
+
rh = np.full(count, np.inf)
|
|
866
|
+
for i in range(0, len(pts), 20000):
|
|
867
|
+
q, cq = pts[i : i + 20000, :2], ci[i : i + 20000]
|
|
868
|
+
along = u @ q.T
|
|
869
|
+
perp2 = np.maximum((q * q).sum(axis=1)[None, :] - along**2, 0.0)
|
|
870
|
+
hit = (along > 0) & (perp2 < cq[None, :] ** 2)
|
|
871
|
+
rh = np.minimum(rh, np.where(hit, along - np.sqrt(np.maximum(cq[None, :] ** 2 - perp2, 0.0)), np.inf).min(axis=1))
|
|
872
|
+
better = rh < r
|
|
873
|
+
r = np.where(better, rh, r)
|
|
874
|
+
who = np.where(better, k, who)
|
|
875
|
+
r = np.minimum.reduce([r, np.roll(r, 1), np.roll(r, -1)])
|
|
876
|
+
limited = sorted({names[k] for k in who if k >= 0})
|
|
877
|
+
return ang, r, limited
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def make_plunger(e: Enclosure, b: Built, fid: str, spec: dict, walls: dict[str, tuple[np.ndarray, np.ndarray]], bodies: list[dict]) -> Shape:
|
|
881
|
+
"""A plunger: the shaft from the tip to outside the wall, and a flange behind the wall trimmed to clear the walls and what is inside."""
|
|
882
|
+
pl = spec["plane"]
|
|
883
|
+
z0, z1 = spec["flange"]
|
|
884
|
+
shaft_r = spec["shaft"] / 2
|
|
885
|
+
r_max = spec["flange_d"] / 2
|
|
886
|
+
step = 0.1
|
|
887
|
+
# obstacle surfaces are sampled `step` apart, so the flange keeps half a step more than the clearance from each sample
|
|
888
|
+
c = e.clr + step / 2
|
|
889
|
+
reach = r_max + c + 0.1
|
|
890
|
+
# walls count only across the flange's own thickness (the wall it sits behind is parallel to it); bodies inside within the clearance of it
|
|
891
|
+
obstacles = [(k, points_near(v, f, pl, reach, z0, z1, step), 0.0) for k, (v, f) in sorted(walls.items())]
|
|
892
|
+
obstacles += [(body["key"], points_near(body["V"], body["F"], pl, reach, z0 - c, z1 + c, step), c) for body in bodies]
|
|
893
|
+
ang, r, limited = flange_outline(obstacles, (z0, z1), c, r_max)
|
|
894
|
+
hole_r = spec["hole"] / 2
|
|
895
|
+
held = float(np.mean(r - hole_r >= FLANGE_HOLD_MM - 1e-9))
|
|
896
|
+
if limited:
|
|
897
|
+
rr = np.maximum(r, shaft_r)
|
|
898
|
+
pts = [Vector(float(R * math.cos(a)), float(R * math.sin(a)), 0.0) for a, R in zip(ang, rr)]
|
|
899
|
+
face = Face(bd.Wire.make_polygon(pts, close=True))
|
|
900
|
+
# the polygon's chords cut a hair inside the shaft's circle where the flange is trimmed to it
|
|
901
|
+
joined = (face + Circle(shaft_r).face()).faces()
|
|
902
|
+
face = joined[0] if len(joined) == 1 else face
|
|
903
|
+
else:
|
|
904
|
+
face = Circle(r_max).face()
|
|
905
|
+
shaft_solid = prism(pl, Circle(shaft_r).face(), spec["tip"], spec["out"])
|
|
906
|
+
flange = prism(pl, face, z0, z1)
|
|
907
|
+
plunger = shaft_solid + flange
|
|
908
|
+
b.facts[fid].update(
|
|
909
|
+
{
|
|
910
|
+
"flange_d_mm": round(2 * r_max, 3),
|
|
911
|
+
"flange_trimmed_by": limited,
|
|
912
|
+
"flange_overlap_mm": [round(float(np.min(r - hole_r)), 3), round(float(np.max(r - hole_r)), 3)],
|
|
913
|
+
"flange_hold_pct": round(100 * held, 1),
|
|
914
|
+
"flange_hold_rule": {"overlap_mm": FLANGE_HOLD_MM, "pct": 100 * FLANGE_HOLD_FRACTION},
|
|
915
|
+
}
|
|
916
|
+
)
|
|
917
|
+
return plunger
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def boss(e: Enclosure, b: Built, f: dict) -> None:
|
|
921
|
+
p = f["params"]
|
|
922
|
+
ins = p["insert"]
|
|
923
|
+
fr = f["frame"]
|
|
924
|
+
top = fr["origin"][2]
|
|
925
|
+
if abs(unit(fr["normal"])[2] - 1) > 1e-6:
|
|
926
|
+
b.problems.append(f"{f['id']}: a boss frame must face +Z (bosses stand on the floor)")
|
|
927
|
+
return
|
|
928
|
+
od = ins["hole_d_mm"] + 2 * max(ins.get("min_wall_mm", 0.0), e.mw)
|
|
929
|
+
od = max(od, p.get("boss_d_mm", 0.0))
|
|
930
|
+
solids, holes = [], []
|
|
931
|
+
for hx, hy in p["holes"]:
|
|
932
|
+
solids.append(Pos(hx, hy, e.z_floor - 0.01) * Cylinder(od / 2, top - e.z_floor + 0.01, align=MIN))
|
|
933
|
+
holes.append(Pos(hx, hy, top - ins["hole_depth_mm"]) * Cylinder(ins["hole_d_mm"] / 2, ins["hole_depth_mm"] + 0.01, align=MIN))
|
|
934
|
+
for s in solids:
|
|
935
|
+
b.adds.append((f["id"], s))
|
|
936
|
+
for hx, hy in p["holes"]:
|
|
937
|
+
b.solids.append({"name": f"boss {f['id']}", "part": "base", "origin": [hx, hy, e.z_floor], "axis": [0.0, 0.0, 1.0], "radius": od / 2, "length": top - e.z_floor})
|
|
938
|
+
for h in holes:
|
|
939
|
+
b.cuts.append((f["id"], h))
|
|
940
|
+
tops = [Pos(hx, hy, top - 0.05) * Cylinder(od / 2 + 1e-3, 0.05, align=MIN) for hx, hy in p["holes"]]
|
|
941
|
+
b.marks[f["id"]] = [("base", t) for t in tops]
|
|
942
|
+
b.frames[f["id"]] = frame_of(fr["origin"], fr["normal"], fr.get("xAxis"))
|
|
943
|
+
b.facts[f["id"]] = {"kind": "boss", "boss_d_mm": round(od, 3), "top_z": top, "height_mm": round(top - e.z_floor, 3), "insert": ins, "holes": p["holes"]}
|
|
944
|
+
b.walls.append({"feature": f["id"], "what": "boss wall around the insert", "mm": round((od - ins["hole_d_mm"]) / 2, 3), "min_mm": max(ins.get("min_wall_mm", 0.0), e.mw)})
|
|
945
|
+
b.walls.append({"feature": f["id"], "what": "floor skin under the insert hole", "mm": round(top - ins["hole_depth_mm"] - e.z_bottom, 3), "min_mm": e.mw})
|
|
946
|
+
for key in f.get("mates", []):
|
|
947
|
+
b.zones.setdefault(key, []).extend({"centre": [hx, hy], "radius": od / 2 + e.clr + 0.5, "z": [top - 1.0, top + 1.0]} for hx, hy in p["holes"])
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def strap_lugs(e: Enclosure, b: Built, f: dict) -> None:
|
|
951
|
+
p = f["params"]
|
|
952
|
+
sgn = 1.0 if p["end"] == "+y" else -1.0
|
|
953
|
+
s = p["strap_width_mm"]
|
|
954
|
+
lw = p.get("lug_width_mm", p["hole_depth_mm"] + e.mw + 0.2)
|
|
955
|
+
bar_r = p["bar_d_mm"] / 2
|
|
956
|
+
edge = p.get("edge_mm", 1.0)
|
|
957
|
+
gap = p.get("strap_gap_mm", 2.0)
|
|
958
|
+
lh = p.get("lug_height_mm", min(4.0, e.z_split - e.z_bottom))
|
|
959
|
+
z0 = e.z_bottom
|
|
960
|
+
zc = z0 + lh / 2
|
|
961
|
+
cx, cy = e.centre
|
|
962
|
+
xs = [(cx + s / 2, cx + s / 2 + lw), (cx - s / 2 - lw, cx - s / 2)]
|
|
963
|
+
if e.shape == "round":
|
|
964
|
+
r_out = e.r_cav + e.wall
|
|
965
|
+
far = max(abs(xs[0][1] - cx), abs(xs[1][0] - cx))
|
|
966
|
+
if far >= r_out:
|
|
967
|
+
b.problems.append(f"{f['id']}: lugs {s} mm apart do not fit on a Ø{2 * r_out:.1f} mm case")
|
|
968
|
+
return
|
|
969
|
+
# lugs grow out of the case where they stand; the strap between them clears the case at its centre line
|
|
970
|
+
y_start = math.sqrt(r_out**2 - far**2) - 0.5
|
|
971
|
+
reach = r_out
|
|
972
|
+
else:
|
|
973
|
+
lo, hi = e.outer_bounds()
|
|
974
|
+
reach = (hi[1] - cy) if sgn > 0 else (cy - lo[1])
|
|
975
|
+
y_start = reach - 0.5
|
|
976
|
+
y_bar = reach + gap + bar_r
|
|
977
|
+
y_end = y_bar + bar_r + edge
|
|
978
|
+
for x0, x1 in xs:
|
|
979
|
+
b.adds.append((f["id"], Pos((x0 + x1) / 2, cy + sgn * (y_start + y_end) / 2, z0) * Box(x1 - x0, y_end - y_start, lh, align=MIN)))
|
|
980
|
+
hd, hdep = p["hole_d_mm"], p["hole_depth_mm"]
|
|
981
|
+
holes = []
|
|
982
|
+
for x_face, dirn in ((xs[0][0], 1.0), (xs[1][1], -1.0)):
|
|
983
|
+
pl = Plane(origin=Vector(x_face, cy + sgn * y_bar, zc), x_dir=Vector(0, 1, 0), z_dir=Vector(dirn, 0, 0))
|
|
984
|
+
holes.append(prism(pl, Circle(hd / 2).face(), -0.01, hdep))
|
|
985
|
+
for h in holes:
|
|
986
|
+
b.cuts.append((f["id"], h))
|
|
987
|
+
b.marks[f["id"]] = [("base", h) for h in holes]
|
|
988
|
+
b.frames[f["id"]] = frame_of([cx, cy + sgn * y_bar, zc], [0, sgn, 0], [1, 0, 0])
|
|
989
|
+
b.facts[f["id"]] = {"kind": "strap_lugs", "lug_gap_mm": s, "bar_axis": r3([cx, cy + sgn * y_bar, zc]), "hole_d_mm": hd, "hole_depth_mm": hdep, "bar_to_case_mm": round(gap, 3)}
|
|
990
|
+
b.walls.append({"feature": f["id"], "what": "lug wall beyond the spring-bar hole", "mm": round(lw - hdep, 3), "min_mm": e.mw})
|
|
991
|
+
b.walls.append({"feature": f["id"], "what": "lug edge beyond the spring-bar hole", "mm": round(y_end - y_bar - hd / 2, 3), "min_mm": e.mw})
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def thread(e: Enclosure, b: Built, f: dict) -> None:
|
|
995
|
+
"""Internal thread in a skirt under the floor (a cap screwing onto a neck)."""
|
|
996
|
+
p = f["params"]
|
|
997
|
+
if e.shape != "round":
|
|
998
|
+
b.problems.append(f"{f['id']}: threads need a round enclosure (design shape round)")
|
|
999
|
+
return
|
|
1000
|
+
T, E, P = p["major_d_mm"], p["root_d_mm"], p["pitch_mm"]
|
|
1001
|
+
L = p["length_mm"]
|
|
1002
|
+
clr = p.get("clearance_mm", 0.3)
|
|
1003
|
+
r_root = T / 2 + clr
|
|
1004
|
+
r_crest = E / 2 + clr
|
|
1005
|
+
h = r_root - r_crest
|
|
1006
|
+
skirt = p.get("skirt_wall_mm", e.wall)
|
|
1007
|
+
cx, cy = e.centre
|
|
1008
|
+
z_open = e.z_bottom - L
|
|
1009
|
+
ring = Pos(cx, cy, z_open) * (Cylinder(r_root + skirt, L + 0.01, align=MIN) - Cylinder(r_root, L + 0.01, align=MIN))
|
|
1010
|
+
b.adds.append((f["id"], ring))
|
|
1011
|
+
crest_w = p.get("crest_width_mm", 0.25 * P)
|
|
1012
|
+
flank = math.radians(p.get("flank_angle_deg", 30.0))
|
|
1013
|
+
base_w = crest_w + 2 * h * math.tan(flank)
|
|
1014
|
+
if base_w >= P:
|
|
1015
|
+
b.problems.append(f"{f['id']}: a {h:.2f} mm deep thread with {crest_w} mm crests and {math.degrees(flank):.0f}° flanks is wider ({base_w:.2f} mm) than its {P} mm pitch")
|
|
1016
|
+
return
|
|
1017
|
+
start, end = 0.6 + base_w / 2, L - 0.6 - base_w / 2
|
|
1018
|
+
if end - start < P:
|
|
1019
|
+
b.problems.append(f"{f['id']}: {L} mm of skirt holds less than one turn of a {P} mm pitch thread")
|
|
1020
|
+
return
|
|
1021
|
+
helix = Helix(pitch=P, height=end - start, radius=r_root)
|
|
1022
|
+
with BuildSketch(Plane.XZ) as sk:
|
|
1023
|
+
with BuildLine():
|
|
1024
|
+
Polyline([(r_root + 0.2, -base_w / 2), (r_crest, -crest_w / 2), (r_crest, crest_w / 2), (r_root + 0.2, base_w / 2)], close=True)
|
|
1025
|
+
make_face()
|
|
1026
|
+
ridge = sweep(sk.sketch.faces()[0], path=helix, is_frenet=True)
|
|
1027
|
+
ridge = Pos(cx, cy, z_open + start) * ridge
|
|
1028
|
+
b.adds.append((f["id"], ridge))
|
|
1029
|
+
b.marks[f["id"]] = [("base", ridge)]
|
|
1030
|
+
b.frames[f["id"]] = frame_of([cx, cy, z_open], [0, 0, -1], [1, 0, 0])
|
|
1031
|
+
b.facts[f["id"]] = {"kind": "thread", "major_d_mm": round(2 * r_root, 3), "minor_d_mm": round(2 * r_crest, 3), "pitch_mm": P, "turns": round((end - start) / P, 2), "skirt_length_mm": L, "depth_mm": round(h, 3)}
|
|
1032
|
+
b.walls.append({"feature": f["id"], "what": "skirt wall behind the thread root", "mm": skirt, "min_mm": e.mw})
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def seal_gland(e: Enclosure, b: Built, f: dict) -> None:
|
|
1036
|
+
p = f["params"]
|
|
1037
|
+
o = p["oring"]
|
|
1038
|
+
row = p["table"]
|
|
1039
|
+
L = (row["depth_mm"][0] + row["depth_mm"][1]) / 2
|
|
1040
|
+
G = (row["width_mm"][0] + row["width_mm"][1]) / 2
|
|
1041
|
+
W = o["w_mm"]
|
|
1042
|
+
fact = {"kind": "seal_gland", "type": p["type"], "oring": o["size"], "w_mm": W, "groove_depth_mm": round(L, 3), "groove_width_mm": round(G, 3), "table": row["source"]}
|
|
1043
|
+
if p["type"] == "radial" and p.get("at") == "joint":
|
|
1044
|
+
t = e.tw
|
|
1045
|
+
if not t:
|
|
1046
|
+
b.problems.append(f"{f['id']}: a radial gland at the joint seals a twist lid's collar; the module has none")
|
|
1047
|
+
return
|
|
1048
|
+
z0, z1 = t["zg"]
|
|
1049
|
+
groove = annulus(t["r_g"], t["r_c"] + 0.01, z0, z1, e.centre)
|
|
1050
|
+
b.base_cuts.append((f["id"], groove))
|
|
1051
|
+
b.marks[f["id"]] = [("base", groove)]
|
|
1052
|
+
b.frames[f["id"]] = frame_of([*e.centre, (z0 + z1) / 2], [0, 0, 1], [1, 0, 0])
|
|
1053
|
+
o = p["oring"]
|
|
1054
|
+
seat = 2 * t["r_g"]
|
|
1055
|
+
fact.update({"at": "joint", "seat_d_mm": round(seat, 3), "bore_d_mm": round(2 * t["r_b"], 3), "printed_bore_d_mm": round(2 * t["r_bp"], 3), "bore_allowance_mm": t["a"], "collar_d_mm": round(2 * t["r_c"], 3), "groove_d_mm": round(seat, 3), "groove_z_mm": [round(z0, 3), round(z1, 3)], "gland_depth_mm": round(t["L"], 3), "gap_mm": 0.0, "rod_offset_mm": round(t["r_bp"] - t["r_c"], 4), "gap_basis": "the lid skirt's bore is the sealing surface; the collar can sit off centre in it by the radial clearance" + (f"; the bore is drawn {t['a']:g} mm (radial) over the size it is meant to print at, and the groove's depth is from the printed bore" if t["a"] else ""), "stretch_pct": round((seat / o["id_mm"] - 1) * 100, 2)})
|
|
1056
|
+
b.walls.append({"feature": f["id"], "what": "base wall behind the collar's O-ring groove", "mm": round(t["r_g"] - e.r_cav, 3), "min_mm": e.mw})
|
|
1057
|
+
b.facts[f["id"]] = fact
|
|
1058
|
+
return
|
|
1059
|
+
if p["type"] == "radial":
|
|
1060
|
+
fr = f["frame"]
|
|
1061
|
+
if not fr:
|
|
1062
|
+
b.problems.append(f"{f['id']}: a radial gland needs a frame (bore axis)")
|
|
1063
|
+
return
|
|
1064
|
+
rod = p["rod_d_mm"]
|
|
1065
|
+
clr = (row["clearance_mm"][0] + row["clearance_mm"][1]) / 2
|
|
1066
|
+
bore_d = rod + clr
|
|
1067
|
+
groove_d = rod + 2 * L
|
|
1068
|
+
pl = plane_at(fr["origin"], fr["normal"], fr.get("xAxis"))
|
|
1069
|
+
o3, n3 = np.asarray(fr["origin"], dtype=float), unit(fr["normal"])
|
|
1070
|
+
hit, wall, _ = e.wall_hit(o3 - n3 * 50, n3)
|
|
1071
|
+
if hit is None or np.linalg.norm(hit - o3) > 0.05:
|
|
1072
|
+
b.problems.append(f"{f['id']}: a radial gland's frame must sit on the outer surface where the bore enters (it is {'off the shell' if hit is None else f'{np.linalg.norm(hit - o3):.2f} mm from it'})")
|
|
1073
|
+
return
|
|
1074
|
+
length = G + 2 * max(e.mw, 1.0)
|
|
1075
|
+
boss_d = groove_d + 2 * max(e.mw, 1.2)
|
|
1076
|
+
need = length - wall
|
|
1077
|
+
if need > 0:
|
|
1078
|
+
b.adds.append((f["id"], prism(pl, Circle(boss_d / 2).face(), -(wall + need), -wall + 0.01)))
|
|
1079
|
+
b.solids.append({"name": f"gland boss {f['id']}", "part": "base", "origin": r3(o3 - n3 * (wall + need)), "axis": r3(n3), "radius": boss_d / 2, "length": need})
|
|
1080
|
+
depth = max(length, wall)
|
|
1081
|
+
b.cuts.append((f["id"], prism(pl, Circle(bore_d / 2).face(), -(depth + 0.01), 1.0)))
|
|
1082
|
+
gz = -depth / 2
|
|
1083
|
+
groove = prism(pl, Circle(groove_d / 2).face(), gz - G / 2, gz + G / 2)
|
|
1084
|
+
b.cuts.append((f["id"], groove))
|
|
1085
|
+
b.marks[f["id"]] = [("*", groove)]
|
|
1086
|
+
b.frames[f["id"]] = frame_of(fr["origin"], fr["normal"], fr.get("xAxis"))
|
|
1087
|
+
# the rod is the sealing surface; it can sit off centre in the bore by the radial clearance, which moves the gland depth by as much
|
|
1088
|
+
fact.update({"rod_d_mm": rod, "bore_d_mm": round(bore_d, 3), "groove_d_mm": round(groove_d, 3), "gland_depth_mm": round(L, 3), "gap_mm": 0.0, "rod_offset_mm": round((bore_d - rod) / 2, 4), "gap_basis": "the rod is the sealing surface, off centre by up to the radial clearance"})
|
|
1089
|
+
b.walls.append({"feature": f["id"], "what": "wall around the gland groove", "mm": round((boss_d - groove_d) / 2 if need > 0 else e.wall, 3), "min_mm": e.mw})
|
|
1090
|
+
b.facts[f["id"]] = fact
|
|
1091
|
+
return
|
|
1092
|
+
|
|
1093
|
+
# face glands: in the base rim at the joint, or a ring at a frame
|
|
1094
|
+
ext = p.get("pressure", "external") == "external"
|
|
1095
|
+
if p.get("at") == "joint":
|
|
1096
|
+
inner_off = p["_inner_offset"]
|
|
1097
|
+
z = e.z_split
|
|
1098
|
+
groove = extrude_region(e.cavity_face(inner_off + G), z - L, L + 0.01) - extrude_region(e.cavity_face(inner_off), z - L - 0.01, L + 0.03)
|
|
1099
|
+
inner_perimeter = sum(ed.length for ed in e.cavity_face(inner_off).outer_wire().edges())
|
|
1100
|
+
b.walls.append({"feature": f["id"], "what": "rim ledge inside the groove", "mm": round(inner_off, 3), "min_mm": e.mw})
|
|
1101
|
+
b.walls.append({"feature": f["id"], "what": "rim ledge outside the groove", "mm": round(e.wall - inner_off - G, 3), "min_mm": e.mw})
|
|
1102
|
+
b.frames[f["id"]] = frame_of([*e.centre, z], [0, 0, 1], [1, 0, 0])
|
|
1103
|
+
# the joint is flat: the lid's face bears on the rim around the groove when its screws close it
|
|
1104
|
+
fact["gap_basis"] = "contact: the lid's flat face bears on the rim (the lid screws close the joint)" if e.scheme == "screws" else "assumed contact: nothing in the design holds the lid down on the rim"
|
|
1105
|
+
fact["gap_assumed"] = e.scheme != "screws"
|
|
1106
|
+
else:
|
|
1107
|
+
fr = f["frame"]
|
|
1108
|
+
pl = plane_at(fr["origin"], fr["normal"], fr.get("xAxis"))
|
|
1109
|
+
# external pressure: groove inner edge on the O-ring ID (+0.5 %); internal: groove outer edge on its OD (-0.5 %)
|
|
1110
|
+
r_in = o["id_mm"] * 1.005 / 2 if ext else (o["id_mm"] + 2 * W) * 0.995 / 2 - G
|
|
1111
|
+
groove = prism(pl, Circle(r_in + G).face(), -L, 0.01) - prism(pl, Circle(r_in).face(), -L - 0.01, 0.02)
|
|
1112
|
+
inner_perimeter = 2 * math.pi * r_in
|
|
1113
|
+
b.frames[f["id"]] = frame_of(fr["origin"], fr["normal"], fr.get("xAxis"))
|
|
1114
|
+
fact["gap_basis"] = "assumed contact: the mating face bears on the gland's face (what presses it, such as a thread's engagement, is not modelled)"
|
|
1115
|
+
fact["gap_assumed"] = True
|
|
1116
|
+
b.cuts.append((f["id"], groove))
|
|
1117
|
+
b.marks[f["id"]] = [("*", groove)]
|
|
1118
|
+
groove_outer = inner_perimeter + 2 * math.pi * G
|
|
1119
|
+
fact.update(
|
|
1120
|
+
{
|
|
1121
|
+
"gland_depth_mm": round(L, 3),
|
|
1122
|
+
"gap_mm": 0.0,
|
|
1123
|
+
"groove_inner_perimeter_mm": round(inner_perimeter, 2),
|
|
1124
|
+
"groove_outer_perimeter_mm": round(groove_outer, 2),
|
|
1125
|
+
"oring_inner_perimeter_mm": round(math.pi * o["id_mm"], 2),
|
|
1126
|
+
"oring_outer_perimeter_mm": round(math.pi * (o["id_mm"] + 2 * W), 2),
|
|
1127
|
+
"pressure": "external" if ext else "internal",
|
|
1128
|
+
}
|
|
1129
|
+
)
|
|
1130
|
+
b.facts[f["id"]] = fact
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def joint(e: Enclosure, b: Built, f: dict) -> None:
|
|
1134
|
+
b.frames[f["id"]] = frame_of([*e.centre, e.z_split], [0, 0, 1], [1, 0, 0])
|
|
1135
|
+
# the mating face: base faces in the split plane (the bottom of this slab)
|
|
1136
|
+
b.marks[f["id"]] = [("base", Pos(*e.centre, e.z_split) * Box(4 * (e.cav_max[0] - e.cav_min[0] + 40), 4 * (e.cav_max[1] - e.cav_min[1] + 40), 0.04, align=MIN))]
|
|
1137
|
+
fact = {"kind": "joint", "style": e.joint_style, "split_z": round(e.z_split, 3), "scheme": e.scheme}
|
|
1138
|
+
if e.columns:
|
|
1139
|
+
p = f["params"]
|
|
1140
|
+
ins, sc = p["insert"], p["screw"]
|
|
1141
|
+
grip = e.z_top - e.z_cav_top + (e.jc if e.joint_style == "rabbet" else 0.0)
|
|
1142
|
+
fact.update({"columns": [r3([c[0], c[1], e.z_cav_top]) for c in e.columns], "column_d_mm": round(2 * e.rc, 3), "grip_mm": round(grip, 3), "insert": ins, "screw": sc})
|
|
1143
|
+
b.walls.append({"feature": f["id"], "what": "column wall around the insert", "mm": round(e.rc - ins["hole_d_mm"] / 2, 3), "min_mm": max(ins.get("min_wall_mm", 0.0), e.mw)})
|
|
1144
|
+
if sc.get("length_mm") is not None:
|
|
1145
|
+
fact["screw_reach_mm"] = round(sc["length_mm"] - grip, 3)
|
|
1146
|
+
if sc["length_mm"] - grip > ins["hole_depth_mm"]:
|
|
1147
|
+
b.problems.append(f"{f['id']}: a {sc['length_mm']} mm screw through {grip:.2f} mm of lid reaches {sc['length_mm'] - grip:.2f} mm, deeper than the {ins['hole_depth_mm']} mm insert hole (it bottoms out)")
|
|
1148
|
+
b.facts[f["id"]] = fact
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def annulus(r_in: float, r_out: float, z0: float, z1: float, centre) -> Shape:
|
|
1152
|
+
"""A ring about the enclosure's axis from z0 to z1."""
|
|
1153
|
+
cx, cy = centre
|
|
1154
|
+
ring = Cylinder(r_out, z1 - z0, align=MIN)
|
|
1155
|
+
if r_in > 0:
|
|
1156
|
+
ring = ring - Cylinder(r_in, z1 - z0 + 0.02, align=MIN).moved(Location((0, 0, -0.01)))
|
|
1157
|
+
return Pos(cx, cy, z0) * ring
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def revolved(points: list[tuple[float, float]], start_deg: float, arc_deg: float, centre) -> Shape:
|
|
1161
|
+
"""A closed (r, z) profile turned about the enclosure's axis from start_deg through arc_deg."""
|
|
1162
|
+
with BuildSketch(Plane.XZ) as sk:
|
|
1163
|
+
with BuildLine():
|
|
1164
|
+
Polyline(points, close=True)
|
|
1165
|
+
make_face()
|
|
1166
|
+
solid = revolve(sk.sketch.faces()[0], Axis.Z, arc_deg)
|
|
1167
|
+
return Pos(centre[0], centre[1], 0) * (Rot(0, 0, start_deg) * solid)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def bayonet_positions(angles: list[float], widths: list[float], clr: float, r: float) -> int:
|
|
1171
|
+
"""In how many turned positions a bayonet's lid goes on: each lug must meet a slot wide enough for it
|
|
1172
|
+
(a slot is its lug's width plus the clearance each side). Lugs at angles (deg) on the collar's radius r."""
|
|
1173
|
+
n = len(angles)
|
|
1174
|
+
count = 0
|
|
1175
|
+
for j in range(n):
|
|
1176
|
+
turn = angles[j] - angles[0]
|
|
1177
|
+
ok = True
|
|
1178
|
+
for i in range(n):
|
|
1179
|
+
at = (angles[i] + turn) % 360.0
|
|
1180
|
+
k = next((k for k in range(n) if abs(((angles[k] - at + 180.0) % 360.0) - 180.0) < 1e-6), None)
|
|
1181
|
+
if k is None or widths[i] > widths[k] + 2 * clr + 1e-6:
|
|
1182
|
+
ok = False
|
|
1183
|
+
break
|
|
1184
|
+
count += ok
|
|
1185
|
+
return count
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def twist_lid(e: Enclosure, b: Built, f: dict) -> None:
|
|
1189
|
+
"""The base's collar and the lid's skirt (Enclosure.plan_twist), with the lock: a thread or a bayonet."""
|
|
1190
|
+
t = e.tw
|
|
1191
|
+
if not t:
|
|
1192
|
+
return
|
|
1193
|
+
cx, cy = e.centre
|
|
1194
|
+
z0, top = t["z0"], e.z_cav_top
|
|
1195
|
+
# the base steps in to the collar; the lid's skirt fills the step down to the axial clearance
|
|
1196
|
+
b.base_steps.append((f["id"], annulus(t["r_c"], t["r_o"] + 1.0, z0, top + 0.01, e.centre)))
|
|
1197
|
+
skirt = annulus(t["r_b"], t["r_o"], z0 + t["ax"], top + 0.01, e.centre)
|
|
1198
|
+
b.lid_adds.append((f["id"], skirt))
|
|
1199
|
+
# a lead-in on the skirt's bore so it rides over the O-ring instead of shearing it (about 20°, Parker ORD 5700 §4.3)
|
|
1200
|
+
lead = [(t["r_b"] - 0.01, z0 + t["ax"] - 0.01), (t["r_b"] + 0.35, z0 + t["ax"] - 0.01), (t["r_b"] - 0.01, z0 + t["ax"] + 1.0)]
|
|
1201
|
+
b.lid_cuts.append((f["id"], revolved(lead, 0.0, 360.0, e.centre)))
|
|
1202
|
+
fact = {
|
|
1203
|
+
"kind": "twist_lid",
|
|
1204
|
+
"lock": t["lock"],
|
|
1205
|
+
"collar_d_mm": round(2 * t["r_c"], 3),
|
|
1206
|
+
"bore_d_mm": round(2 * t["r_b"], 3),
|
|
1207
|
+
"outer_d_mm": round(2 * t["r_o"], 3),
|
|
1208
|
+
"collar_z_mm": [round(z0, 3), round(top, 3)],
|
|
1209
|
+
"skirt_wall_mm": round(t["r_o"] - t["r_b"], 3),
|
|
1210
|
+
"lead_in": "0.35 mm over 1.0 mm on the skirt's bore (about 19°)",
|
|
1211
|
+
"base_wall_at_lock_mm": round(t["base_wall_lock"], 3),
|
|
1212
|
+
"printed_bore_d_mm": round(2 * t["r_bp"], 3),
|
|
1213
|
+
"bore_allowance_mm": t["a"],
|
|
1214
|
+
"seal_fit_mm": round(2 * (t["r_bp"] - t["r_c"]), 3),
|
|
1215
|
+
"radial_clearance_mm": t["clr"],
|
|
1216
|
+
"axial_clearance_mm": t["ax"],
|
|
1217
|
+
"print": {
|
|
1218
|
+
"base": "upright, its floor on the bed: the collar's lock faces lean 45° or steeper outward, so they print without supports",
|
|
1219
|
+
"lid": "upside down, its top on the bed: the skirt stands up, and its slot or thread faces lean 45° or steeper, so they print without supports",
|
|
1220
|
+
},
|
|
1221
|
+
}
|
|
1222
|
+
zl0, zl1 = t["zl"]
|
|
1223
|
+
if t["lock"] == "thread":
|
|
1224
|
+
P, d, turns = t["P"], t["d"], t["turns"]
|
|
1225
|
+
crest_w = 0.25 * P
|
|
1226
|
+
base_w = crest_w + 2 * d * math.tan(t["flank"])
|
|
1227
|
+
if base_w >= P:
|
|
1228
|
+
b.problems.append(f"{f['id']}: a {d} mm deep thread with {math.degrees(t['flank']):.0f}° flanks is wider ({base_w:.2f} mm) than its {P} mm pitch")
|
|
1229
|
+
return
|
|
1230
|
+
height = turns * P
|
|
1231
|
+
z_start = zl0 + base_w / 2
|
|
1232
|
+
|
|
1233
|
+
def ridge(r_from: float, r_to: float, dz: float) -> Shape:
|
|
1234
|
+
helix = Helix(pitch=P, height=height, radius=r_from)
|
|
1235
|
+
with BuildSketch(Plane.XZ) as sk:
|
|
1236
|
+
with BuildLine():
|
|
1237
|
+
Polyline([(r_from - math.copysign(0.2, r_to - r_from), -base_w / 2), (r_to, -crest_w / 2), (r_to, crest_w / 2), (r_from - math.copysign(0.2, r_to - r_from), base_w / 2)], close=True)
|
|
1238
|
+
make_face()
|
|
1239
|
+
return Pos(cx, cy, z_start + dz) * sweep(sk.sketch.faces()[0], path=helix, is_frenet=True)
|
|
1240
|
+
|
|
1241
|
+
# the base's collar steps in again to the thread's root over the thread's zone
|
|
1242
|
+
b.base_steps.append((f["id"], annulus(t["r_root"], t["r_c"] + 0.01, zl0, top + 0.01, e.centre)))
|
|
1243
|
+
base_ridge = ridge(t["r_root"], t["r_major"], 0.0)
|
|
1244
|
+
lid_ridge = ridge(t["r_b"], t["r_crest"], P / 2)
|
|
1245
|
+
b.adds.append((f["id"], base_ridge))
|
|
1246
|
+
b.lid_adds.append((f["id"], lid_ridge))
|
|
1247
|
+
b.marks[f["id"]] = [("base", base_ridge), ("lid", lid_ridge)]
|
|
1248
|
+
fact.update({"pitch_mm": P, "depth_mm": d, "flank_angle_deg": round(math.degrees(t["flank"]), 2), "turns": turns, "major_d_mm": round(2 * t["r_major"], 3), "root_d_mm": round(2 * t["r_root"], 3), "lid_minor_d_mm": round(2 * t["r_crest"], 3), "thread_z_mm": [round(z_start - base_w / 2, 3), round(z_start + height + base_w / 2, 3)]})
|
|
1249
|
+
b.walls.append({"feature": f["id"], "what": "base wall behind the thread's root", "mm": round(t["base_wall_lock"], 3), "min_mm": e.mw})
|
|
1250
|
+
b.walls.append({"feature": f["id"], "what": "lid skirt behind its thread", "mm": round(t["r_o"] - t["r_b"], 3), "min_mm": e.mw})
|
|
1251
|
+
else:
|
|
1252
|
+
d, h, n, twist, clr, ax = t["d"], t["h_lug"], t["n"], t["twist"], t["clr"], t["ax"]
|
|
1253
|
+
r_c = t["r_c"]
|
|
1254
|
+
c_ang = math.degrees(clr / r_c)
|
|
1255
|
+
# the lug: low on the collar, its underside a 45° cone (no supports printed upright)
|
|
1256
|
+
lug = [(r_c - 0.3, zl0), (r_c, zl0), (r_c + d, zl0 + d), (r_c + d, zl1), (r_c - 0.3, zl1)]
|
|
1257
|
+
# the slot in the skirt: open at the bottom where the lug enters, then along the twist; its lower
|
|
1258
|
+
# face parallel to the lug's underside (the face that holds the lid down), the axial clearance between
|
|
1259
|
+
r_slot = r_c + d + clr
|
|
1260
|
+
z_bottom = z0 + t["ax"] - 0.05
|
|
1261
|
+
entry = [(t["r_b"] - 0.5, z_bottom), (r_slot, z_bottom), (r_slot, zl1 + ax), (t["r_b"] - 0.5, zl1 + ax)]
|
|
1262
|
+
along = [(t["r_b"] - 0.5, zl0 - ax), (r_c, zl0 - ax), (r_slot, zl0 + d + clr - ax), (r_slot, zl1 + ax), (t["r_b"] - 0.5, zl1 + ax)]
|
|
1263
|
+
angles = t["angles"] # the locked lugs' centres (first on +Y, the key when there is one)
|
|
1264
|
+
lugs = []
|
|
1265
|
+
for a, w in zip(angles, t["widths"]):
|
|
1266
|
+
alpha = math.degrees(w / r_c)
|
|
1267
|
+
s_lug = revolved(lug, a - alpha / 2, alpha, e.centre)
|
|
1268
|
+
b.adds.append((f["id"], s_lug))
|
|
1269
|
+
lugs.append(s_lug)
|
|
1270
|
+
b.lid_cuts.append((f["id"], revolved(entry, a - twist - alpha / 2 - c_ang, alpha + 2 * c_ang, e.centre)))
|
|
1271
|
+
b.lid_cuts.append((f["id"], revolved(along, a - twist - alpha / 2 - c_ang, twist + alpha + 2 * c_ang, e.centre)))
|
|
1272
|
+
b.marks[f["id"]] = [("base", s) for s in lugs]
|
|
1273
|
+
keyed = t["widths"][0] != t["w"]
|
|
1274
|
+
fact.update({"lugs": n, "lug_angles_deg": [round(a % 360, 3) for a in angles], "lug_width_mm": t["w"], "lug_widths_mm": t["widths"], "keyed": keyed, "lid_positions": t["positions"], "lug_depth_mm": d, "lug_height_mm": round(h + d, 3), "twist_deg": twist, "lug_z_mm": [round(zl0, 3), round(zl1, 3)], "slot_depth_mm": round(r_slot - t["r_b"], 3), "skirt_behind_slot_mm": round(t["skirt_behind_slot"], 3), "close": f"push the lid down with each lug in its slot's open end{' (the wide lug in the wide slot: it goes on one way only)' if keyed else ''}, then turn it {twist:g}° clockwise seen from above until the lugs stop at the slots' ends"})
|
|
1275
|
+
b.walls.append({"feature": f["id"], "what": "lid skirt behind the bayonet slots", "mm": round(t["skirt_behind_slot"], 3), "min_mm": e.mw})
|
|
1276
|
+
b.walls.append({"feature": f["id"], "what": "base wall of the collar", "mm": round(t["base_wall_lock"], 3), "min_mm": e.mw})
|
|
1277
|
+
b.frames[f["id"]] = frame_of([cx, cy, top], [0, 0, 1], [1, 0, 0])
|
|
1278
|
+
b.facts[f["id"]] = fact
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
KINDS = {"opening": opening, "light_pipe": light_pipe, "window": window, "button": button, "boss": boss, "strap_lugs": strap_lugs, "thread": thread, "seal_gland": seal_gland, "joint": joint, "twist_lid": twist_lid}
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def plan_joint_gland(e_job: dict, cav_shape: str, cav_size: np.ndarray, wall: float, mw: float) -> None:
|
|
1285
|
+
"""Where the joint groove goes: its inner edge offset from the cavity so the O-ring fits (Parker Design Chart 4-2)."""
|
|
1286
|
+
g = next((f for f in e_job["features"] if f["kind"] == "seal_gland" and f["params"].get("at") == "joint" and f["params"]["type"] == "face"), None)
|
|
1287
|
+
if not g:
|
|
1288
|
+
return
|
|
1289
|
+
p = g["params"]
|
|
1290
|
+
row = p["table"]
|
|
1291
|
+
G = (row["width_mm"][0] + row["width_mm"][1]) / 2
|
|
1292
|
+
o = p["oring"]
|
|
1293
|
+
ext = p.get("pressure", "external") == "external"
|
|
1294
|
+
# external pressure: groove inner edge on the O-ring ID (+0.5 %); internal: groove outer edge on its OD (-0.5 %)
|
|
1295
|
+
target = math.pi * o["id_mm"] * 1.005 if ext else math.pi * (o["id_mm"] + 2 * o["w_mm"]) * 0.995 - 2 * math.pi * G
|
|
1296
|
+
base = math.pi * cav_size[0] if cav_shape == "round" else 2 * (cav_size[0] + cav_size[1])
|
|
1297
|
+
off = (target - base) / (2 * math.pi)
|
|
1298
|
+
p["_inner_offset"] = max(off, mw)
|
|
1299
|
+
p["_groove_width"] = G
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
# ---------------------------------------------------------------------------------------------
|
|
1303
|
+
# Build
|
|
1304
|
+
# ---------------------------------------------------------------------------------------------
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def load_bodies(job: dict, root: Path) -> list[dict]:
|
|
1308
|
+
out = []
|
|
1309
|
+
for c in job["contents"]:
|
|
1310
|
+
v, f = load_mesh(root / c["glb"], c["matrix"], skip=c.get("skip_nodes"))
|
|
1311
|
+
out.append({"key": c["key"], "label": c.get("label", c["key"]), "V": v, "F": f, "zones": []})
|
|
1312
|
+
return out
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def build(job: dict, out: Path, root: Path) -> dict:
|
|
1316
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1317
|
+
(out / "parts").mkdir(exist_ok=True)
|
|
1318
|
+
bodies = load_bodies(job, root)
|
|
1319
|
+
d = job["design"]
|
|
1320
|
+
# the joint groove decides the rim and the screw columns, so it is planned from the cavity first
|
|
1321
|
+
probe = Enclosure({**job, "features": [f for f in job["features"] if f["kind"] != "seal_gland"], "design": {**d, "fasteners": "none"}}, bodies)
|
|
1322
|
+
size = (probe.cav_max - probe.cav_min)[:2] if probe.shape != "round" else np.array([2 * probe.r_cav, 2 * probe.r_cav])
|
|
1323
|
+
plan_joint_gland(job, probe.shape, size, d["wall"], d["min_wall"])
|
|
1324
|
+
e = Enclosure(job, bodies)
|
|
1325
|
+
b = Built()
|
|
1326
|
+
b.problems.extend(e.problems)
|
|
1327
|
+
for c in e.columns:
|
|
1328
|
+
b.solids.append({"name": "lid screw column", "origin": [float(c[0]), float(c[1]), e.z_floor], "axis": [0.0, 0.0, 1.0], "radius": e.rc, "length": e.z_cav_top - e.z_floor})
|
|
1329
|
+
|
|
1330
|
+
# ---- shell, cut at the split plane
|
|
1331
|
+
outer, cavity = e.outer_face(), e.cavity_region()
|
|
1332
|
+
H = e.z_top - e.z_bottom + 2
|
|
1333
|
+
shell = extrude_region(outer, e.z_bottom, e.z_top - e.z_bottom) - extrude_region(cavity, e.z_floor, e.z_cav_top - e.z_floor)
|
|
1334
|
+
lo, hi = e.outer_bounds()
|
|
1335
|
+
span = float(np.max(hi[:2] - lo[:2])) + 4 * (e.rc + e.cw) + 20
|
|
1336
|
+
below = Pos(*e.centre, e.z_split - H) * Box(span, span, H, align=MIN)
|
|
1337
|
+
above = Pos(*e.centre, e.z_split) * Box(span, span, H, align=MIN)
|
|
1338
|
+
base = shell & below
|
|
1339
|
+
lid = shell & above
|
|
1340
|
+
if e.joint_style == "rabbet":
|
|
1341
|
+
w_out = e.w_out
|
|
1342
|
+
tongue = offset(outer, -w_out) - cavity
|
|
1343
|
+
tongue_s = extrude_region(tongue, e.z_split - 0.01, e.lip + 0.01)
|
|
1344
|
+
groove = offset(outer, -(w_out - e.jc)) - cavity
|
|
1345
|
+
groove_s = extrude_region(groove, e.z_split - 0.01, e.lip + e.jc + 0.01)
|
|
1346
|
+
base = base + (tongue_s & shell)
|
|
1347
|
+
lid = lid - groove_s
|
|
1348
|
+
b.walls.append({"feature": e.joint_feature["id"] if e.joint_feature else "joint", "what": "base tongue of the rabbet", "mm": round(e.wall - w_out, 3), "min_mm": e.mw})
|
|
1349
|
+
b.walls.append({"feature": e.joint_feature["id"] if e.joint_feature else "joint", "what": "lid skirt outside the tongue", "mm": round(w_out - e.jc, 3), "min_mm": e.mw})
|
|
1350
|
+
parts: dict[str, Shape] = {"base": base, "lid": lid}
|
|
1351
|
+
|
|
1352
|
+
# ---- features
|
|
1353
|
+
for f in job["features"]:
|
|
1354
|
+
try:
|
|
1355
|
+
KINDS[f["kind"]](e, b, f)
|
|
1356
|
+
except Exception as ex: # noqa: BLE001 - one feature failing is a finding, not a crash
|
|
1357
|
+
b.problems.append(f"{f['id']}: {type(ex).__name__}: {ex}")
|
|
1358
|
+
for _fid, t in b.base_steps:
|
|
1359
|
+
parts["base"] = parts["base"] - t
|
|
1360
|
+
# a twist lid turns: nothing may cut the side wall where its skirt covers the collar
|
|
1361
|
+
if e.tw:
|
|
1362
|
+
z0, z1 = e.tw["z0"], e.z_cav_top
|
|
1363
|
+
for fid, fr in b.frames.items():
|
|
1364
|
+
fact = b.facts.get(fid, {})
|
|
1365
|
+
if fact.get("kind") not in ("opening", "window", "button"):
|
|
1366
|
+
continue
|
|
1367
|
+
if abs(fr["normal"][2]) > 0.5:
|
|
1368
|
+
continue # through the floor or the lid, not the side wall
|
|
1369
|
+
sizes = [v[1] for k in ("pocket_mm", "aperture_mm", "size_mm") if isinstance((v := fact.get(k)), list) and len(v) > 1]
|
|
1370
|
+
half = max([*(x / 2 for x in sizes), fact.get("hole_d_mm", 0) / 2, 0.0])
|
|
1371
|
+
lo, hi = fr["origin"][2] - half, fr["origin"][2] + half
|
|
1372
|
+
if hi > z0 - 1e-6 and lo < z1:
|
|
1373
|
+
b.problems.append(f"{fid}: it cuts the side wall at z {lo:.2f}-{hi:.2f}, where the twist lid's skirt covers the collar (from z {z0:.2f}); raise the cavity top or move it down")
|
|
1374
|
+
for _fid, s in b.adds:
|
|
1375
|
+
parts["base"] = parts["base"] + s
|
|
1376
|
+
for _fid, s in b.lid_adds:
|
|
1377
|
+
parts["lid"] = parts["lid"] + s
|
|
1378
|
+
# lid screws: inserts in the columns, clearance holes through the lid
|
|
1379
|
+
if e.columns:
|
|
1380
|
+
jp = e.joint_feature["params"]
|
|
1381
|
+
ins, sc = jp["insert"], jp["screw"]
|
|
1382
|
+
for c in e.columns:
|
|
1383
|
+
parts["base"] = parts["base"] - Pos(c[0], c[1], e.z_cav_top - ins["hole_depth_mm"]) * Cylinder(ins["hole_d_mm"] / 2, ins["hole_depth_mm"] + 0.01, align=MIN)
|
|
1384
|
+
parts["lid"] = parts["lid"] - Pos(c[0], c[1], e.z_split - 0.5) * Cylinder(sc["clearance_d_mm"] / 2, e.z_top - e.z_split + 1, align=MIN)
|
|
1385
|
+
# a face gland at a frame is cut into the surface its frame lies on
|
|
1386
|
+
skin = Compound(parts["base"].faces())
|
|
1387
|
+
for f in job["features"]:
|
|
1388
|
+
if f["kind"] == "seal_gland" and f["params"]["type"] == "face" and f["params"].get("at") != "joint" and f.get("frame"):
|
|
1389
|
+
gap = skin.distance_to(Vector(*f["frame"]["origin"]))
|
|
1390
|
+
if gap > 0.05:
|
|
1391
|
+
b.problems.append(f"{f['id']}: its frame is {gap:.2f} mm off the base's surface; a face gland's frame sits on the face the O-ring seals against")
|
|
1392
|
+
for _fid, t in b.cuts:
|
|
1393
|
+
for k in parts:
|
|
1394
|
+
parts[k] = parts[k] - t
|
|
1395
|
+
for _fid, t in b.base_cuts:
|
|
1396
|
+
parts["base"] = parts["base"] - t
|
|
1397
|
+
for _fid, t in b.lid_cuts:
|
|
1398
|
+
parts["lid"] = parts["lid"] - t
|
|
1399
|
+
# plungers: flanges trimmed to clear the walls as built and what is inside; then checked against the walls exactly
|
|
1400
|
+
if b.plungers:
|
|
1401
|
+
walls = {}
|
|
1402
|
+
for k in ("base", "lid"):
|
|
1403
|
+
if k in parts:
|
|
1404
|
+
vs, ts = parts[k].tessellate(0.01, 0.1)
|
|
1405
|
+
walls[k] = (np.array([[v.X, v.Y, v.Z] for v in vs]), np.array(ts, dtype=np.int64))
|
|
1406
|
+
for fid, spec in b.plungers.items():
|
|
1407
|
+
try:
|
|
1408
|
+
plunger = make_plunger(e, b, fid, spec, walls, bodies)
|
|
1409
|
+
except Exception as ex: # noqa: BLE001 - one feature failing is a finding, not a crash
|
|
1410
|
+
b.problems.append(f"{fid}: plunger: {type(ex).__name__}: {ex}")
|
|
1411
|
+
continue
|
|
1412
|
+
overlap = {}
|
|
1413
|
+
for k in ("base", "lid"):
|
|
1414
|
+
if k in parts:
|
|
1415
|
+
common = plunger & parts[k]
|
|
1416
|
+
overlap[k] = round(sum(x.volume for x in common.solids()) if common is not None else 0.0, 4)
|
|
1417
|
+
b.facts[fid]["wall_overlap_mm3"] = overlap
|
|
1418
|
+
plunger.label = f"plunger_{fid}"
|
|
1419
|
+
b.extra_parts[f"plunger_{fid}"] = plunger
|
|
1420
|
+
for k, s in b.extra_parts.items():
|
|
1421
|
+
parts[k] = s
|
|
1422
|
+
for k in list(parts):
|
|
1423
|
+
solids = parts[k].solids()
|
|
1424
|
+
if not solids:
|
|
1425
|
+
b.problems.append(f"part {k} came out empty")
|
|
1426
|
+
del parts[k]
|
|
1427
|
+
continue
|
|
1428
|
+
parts[k] = Compound(label=k, children=list(solids)) if len(solids) > 1 else solids[0]
|
|
1429
|
+
parts[k].label = k
|
|
1430
|
+
if len(solids) > 1 and not k.startswith("plunger"):
|
|
1431
|
+
b.problems.append(f"part {k} is in {len(solids)} pieces (a feature cut it apart?)")
|
|
1432
|
+
|
|
1433
|
+
# ---- feature faces (signatures) on the parts their tools reach
|
|
1434
|
+
features: dict[str, dict] = {}
|
|
1435
|
+
feature_parts: dict[str, list[str]] = {}
|
|
1436
|
+
shapes = []
|
|
1437
|
+
for fid, marks in b.marks.items():
|
|
1438
|
+
faces: list[Face] = []
|
|
1439
|
+
where: list[str] = []
|
|
1440
|
+
for part_name, tool in marks:
|
|
1441
|
+
for k, part in parts.items():
|
|
1442
|
+
if k.startswith("plunger") or (part_name != "*" and k != part_name):
|
|
1443
|
+
continue
|
|
1444
|
+
hit = faces_on(part, tool)
|
|
1445
|
+
if hit:
|
|
1446
|
+
faces += hit
|
|
1447
|
+
where.append(k)
|
|
1448
|
+
if not faces:
|
|
1449
|
+
b.problems.append(f"{fid}: the feature produced no faces on any part (its tool misses the shell?)")
|
|
1450
|
+
continue
|
|
1451
|
+
features[fid] = signature(faces)
|
|
1452
|
+
feature_parts[fid] = sorted(set(where))
|
|
1453
|
+
s = feature_shape(fid, faces)
|
|
1454
|
+
if s is not None:
|
|
1455
|
+
shapes.append(s)
|
|
1456
|
+
|
|
1457
|
+
# ---- write parts and the body
|
|
1458
|
+
files = []
|
|
1459
|
+
for k, part in sorted(parts.items()):
|
|
1460
|
+
export_step(part, str(out / "parts" / f"{k}.step"))
|
|
1461
|
+
normalise_step(out / "parts" / f"{k}.step")
|
|
1462
|
+
export_gltf(part, str(out / "parts" / f"{k}.glb"), binary=True, linear_deflection=0.02, angular_deflection=0.2)
|
|
1463
|
+
export_stl(part, str(out / "parts" / f"{k}.stl"), tolerance=0.01, angular_tolerance=0.1)
|
|
1464
|
+
m = Mesher()
|
|
1465
|
+
m.add_shape(part, linear_deflection=0.01, angular_deflection=0.1)
|
|
1466
|
+
m.write(str(out / "parts" / f"{k}.3mf"))
|
|
1467
|
+
repack_zip(out / "parts" / f"{k}.3mf")
|
|
1468
|
+
files.append(k)
|
|
1469
|
+
# window covers: each its own STEP, GLB and a DXF outline for cutting, in the cover's frame
|
|
1470
|
+
covers = {}
|
|
1471
|
+
if b.covers:
|
|
1472
|
+
(out / "covers").mkdir(exist_ok=True)
|
|
1473
|
+
for fid, c in sorted(b.covers.items()):
|
|
1474
|
+
name = f"cover_{fid}"
|
|
1475
|
+
solid = c["solid"]
|
|
1476
|
+
solid.label = name
|
|
1477
|
+
export_step(solid, str(out / "covers" / f"{name}.step"))
|
|
1478
|
+
normalise_step(out / "covers" / f"{name}.step")
|
|
1479
|
+
export_gltf(solid, str(out / "covers" / f"{name}.glb"), binary=True, linear_deflection=0.01, angular_deflection=0.1)
|
|
1480
|
+
dxf = ExportDXF(unit=Unit.MM)
|
|
1481
|
+
dxf.add_shape(c["outline"])
|
|
1482
|
+
dxf.write(str(out / "covers" / f"{name}.dxf"))
|
|
1483
|
+
normalise_dxf(out / "covers" / f"{name}.dxf")
|
|
1484
|
+
covers[fid] = {"part": name, "shape": c["shape"], "size_mm": c["size_mm"], "thickness_mm": c["thickness_mm"], "material": c["material"], "volume_mm3": round(solid.volume, 4), "frame": {"origin": [0.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0], "xAxis": [1.0, 0.0, 0.0]}}
|
|
1485
|
+
body = Compound(label=job["name"], children=[*(parts[k] for k in sorted(parts)), *shapes])
|
|
1486
|
+
export_step(body, str(out / "body.step"))
|
|
1487
|
+
normalise_step(out / "body.step")
|
|
1488
|
+
export_gltf(Compound(label=job["name"], children=[parts[k] for k in sorted(parts)]), str(out / "body.glb"), binary=True, linear_deflection=0.02, angular_deflection=0.2)
|
|
1489
|
+
try:
|
|
1490
|
+
write_section(parts, e, out / "section.svg", bodies, {f"cover_{k}": c["placed"] for k, c in b.covers.items()})
|
|
1491
|
+
except Exception as ex: # noqa: BLE001 - the render is optional
|
|
1492
|
+
b.problems.append(f"section render skipped: {type(ex).__name__}: {ex}")
|
|
1493
|
+
|
|
1494
|
+
# ---- fit: what goes inside against the walls
|
|
1495
|
+
for body_ in bodies:
|
|
1496
|
+
body_["zones"] = b.zones.get(body_["key"], [])
|
|
1497
|
+
wall_parts = {k: load_mesh(out / "parts" / f"{k}.glb") for k in parts if not k.startswith("plunger")}
|
|
1498
|
+
fit = analyse(wall_parts, bodies, cavity_spec(e, b), d["resolution"], e.clr)
|
|
1499
|
+
# plungers against what is inside (against the walls they are checked exactly, above)
|
|
1500
|
+
fit["plungers"] = analyse_plungers([plunger_spec(fid, b, out / "parts" / f"plunger_{fid}.glb", fit["resolution_mm"]) for fid in b.plungers if f"plunger_{fid}" in parts], bodies, fit["resolution_mm"], e.clr)
|
|
1501
|
+
for entry in fit["plungers"]:
|
|
1502
|
+
overlap = b.facts[entry["feature"]].get("wall_overlap_mm3", {})
|
|
1503
|
+
entry["wall_overlap_mm3"] = overlap
|
|
1504
|
+
if any(v > 1e-3 for v in overlap.values()):
|
|
1505
|
+
entry["status"] = "collision"
|
|
1506
|
+
|
|
1507
|
+
volumes = {k: round(parts[k].volume, 3) for k in sorted(parts)}
|
|
1508
|
+
shell_box = Compound(children=[parts[k] for k in parts if not k.startswith("plunger")]).bounding_box()
|
|
1509
|
+
digest = hashlib.sha256(GENERATOR.read_bytes() + (HERE / "fit.py").read_bytes() + json.dumps(job, sort_keys=True).encode()).hexdigest()
|
|
1510
|
+
manifest = {
|
|
1511
|
+
"artifact": "body",
|
|
1512
|
+
"tool": TOOL,
|
|
1513
|
+
"toolVersion": bd.__version__,
|
|
1514
|
+
"generator": "@protoboardai/ext-enclosure-cad py/enclosure_cad.py",
|
|
1515
|
+
"sourceDigest": digest,
|
|
1516
|
+
"units": "mm",
|
|
1517
|
+
"features": features,
|
|
1518
|
+
"volume_mm3": round(sum(volumes.values()), 3),
|
|
1519
|
+
"bodies": volumes,
|
|
1520
|
+
"frames": b.frames,
|
|
1521
|
+
"process": job["process"],
|
|
1522
|
+
"material": job.get("material"),
|
|
1523
|
+
}
|
|
1524
|
+
(out / "body.manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
|
1525
|
+
report = {
|
|
1526
|
+
"schema": "protoboard.enclosure-cad.report/v0",
|
|
1527
|
+
"module": job["module"],
|
|
1528
|
+
"name": job["name"],
|
|
1529
|
+
"toolVersion": bd.__version__,
|
|
1530
|
+
"sourceDigest": digest,
|
|
1531
|
+
"inputsDigest": job["inputsDigest"],
|
|
1532
|
+
"cavity": cavity_spec(e, b),
|
|
1533
|
+
"outer": {"min": r3(e.outer_bounds()[0]), "max": r3(e.outer_bounds()[1])},
|
|
1534
|
+
"overall": {"min": r3([shell_box.min.X, shell_box.min.Y, shell_box.min.Z]), "max": r3([shell_box.max.X, shell_box.max.Y, shell_box.max.Z])},
|
|
1535
|
+
"levels": {"bottom": round(e.z_bottom, 3), "floor": round(e.z_floor, 3), "split": round(e.z_split, 3), "cavity_top": round(e.z_cav_top, 3), "top": round(e.z_top, 3)},
|
|
1536
|
+
"parts": {k: {"volume_mm3": volumes[k], "printed": job["process"] == "print"} for k in files},
|
|
1537
|
+
"features": {fid: {**b.facts.get(fid, {}), "frame": b.frames.get(fid), "parts": feature_parts.get(fid, []), "signature": features.get(fid)} for fid in sorted(set(b.facts) | set(b.frames))},
|
|
1538
|
+
"walls": b.walls,
|
|
1539
|
+
"covers": covers,
|
|
1540
|
+
"problems": b.problems,
|
|
1541
|
+
"fit": fit,
|
|
1542
|
+
}
|
|
1543
|
+
(out / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
|
1544
|
+
return report
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def cavity_spec(e: Enclosure, b: Built) -> dict:
|
|
1548
|
+
"""The cavity and the solids standing in it, for the exact part of the fit analysis.
|
|
1549
|
+
|
|
1550
|
+
The side walls belong to the base up to `side_top` (the split, or the top of
|
|
1551
|
+
a rabbet's tongue) and to the lid above it; the floor to the base, the
|
|
1552
|
+
ceiling to the lid.
|
|
1553
|
+
"""
|
|
1554
|
+
spec = {"shape": e.shape, "min": r3(e.cav_min), "max": r3(e.cav_max)}
|
|
1555
|
+
if e.shape == "round":
|
|
1556
|
+
spec.update({"centre": r3(e.centre), "radius": round(e.r_cav, 3)})
|
|
1557
|
+
spec["split_z"] = round(e.z_split, 3)
|
|
1558
|
+
spec["side_top"] = round(e.z_split + (e.lip if e.joint_style == "rabbet" else 0.0), 3)
|
|
1559
|
+
spec["solids"] = [{**s, "origin": r3(s["origin"]), "radius": round(s["radius"], 4), "length": round(s["length"], 4)} for s in b.solids]
|
|
1560
|
+
return spec
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def plunger_spec(fid: str, b: Built, glb: Path, res: float) -> dict:
|
|
1564
|
+
"""A plunger for the fit: its mesh, and its tip (where it meets the switch by design) as a zone."""
|
|
1565
|
+
fact = b.facts[fid]
|
|
1566
|
+
pl = b.plungers[fid]["plane"]
|
|
1567
|
+
n = [pl.z_dir.X, pl.z_dir.Y, pl.z_dir.Z]
|
|
1568
|
+
v, f = load_mesh(glb)
|
|
1569
|
+
zone = {"centre": fact["tip"], "axis": r3(n), "radius": fact["shaft_d_mm"] / 2 + 0.01, "z": [-0.01, fact["gap_mm"] + res]}
|
|
1570
|
+
return {"key": f"plunger_{fid}", "feature": fid, "V": v, "F": f, "zones": [zone]}
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def repack_zip(path: Path) -> None:
|
|
1574
|
+
"""lib3mf writes ZIP64 containers with the time in each entry; repack as a plain ZIP with fixed times (reruns are byte-identical)."""
|
|
1575
|
+
import zipfile
|
|
1576
|
+
|
|
1577
|
+
with zipfile.ZipFile(path) as z:
|
|
1578
|
+
entries = [(i.filename, z.read(i.filename)) for i in z.infolist()]
|
|
1579
|
+
# lib3mf stamps random production-extension UUIDs; derive them from the part name and their order instead
|
|
1580
|
+
count = iter(range(1 << 30))
|
|
1581
|
+
|
|
1582
|
+
def uuid(_m: re.Match) -> str:
|
|
1583
|
+
h = hashlib.sha256(f"{path.stem}:{next(count)}".encode()).hexdigest()
|
|
1584
|
+
return f'p:UUID="{h[:8]}-{h[8:12]}-4{h[13:16]}-8{h[17:20]}-{h[20:32]}"'
|
|
1585
|
+
|
|
1586
|
+
entries = [(n, re.sub(rb'p:UUID="[0-9a-fA-F-]{36}"', lambda m: uuid(m).encode(), d) if n.endswith(".model") else d) for n, d in entries]
|
|
1587
|
+
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
|
1588
|
+
for name, data in entries:
|
|
1589
|
+
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
1590
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
1591
|
+
info.external_attr = 0o644 << 16
|
|
1592
|
+
z.writestr(info, data)
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
def normalise_step(path: Path) -> None:
|
|
1596
|
+
"""OCC stamps the time into FILE_NAME; fix it so reruns are byte-identical."""
|
|
1597
|
+
path.write_text(re.sub(r"FILE_NAME\('([^']*)','[^']*'", lambda m: f"FILE_NAME('{m.group(1)}','{FIXED_TIME}'", path.read_text(), count=1))
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def normalise_dxf(path: Path) -> None:
|
|
1601
|
+
"""ezdxf stamps the time and random GUIDs into the header; fix them, so reruns are byte-identical."""
|
|
1602
|
+
lines = path.read_text(encoding="utf-8").split("\n")
|
|
1603
|
+
fixed = {"$TDCREATE": "2451545.0", "$TDUCREATE": "2451545.0", "$TDUPDATE": "2451545.0", "$TDUUPDATE": "2451545.0", "$TDINDWG": "0.0", "$FINGERPRINTGUID": "{00000000-0000-0000-0000-000000000000}", "$VERSIONGUID": "{00000000-0000-0000-0000-000000000000}"}
|
|
1604
|
+
for i, l in enumerate(lines[:-2]):
|
|
1605
|
+
if l.strip() in fixed:
|
|
1606
|
+
lines[i + 2] = fixed[l.strip()]
|
|
1607
|
+
# ezdxf's own "<version> @ <timestamp>" marks
|
|
1608
|
+
lines = [re.sub(r"^(\d+\.\d+\.\d+) @ \d{4}-\d\d-\d\dT[\d:.+]+$", r"\1", l) for l in lines]
|
|
1609
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def write_section(parts: dict[str, Shape], e: Enclosure, path: Path, bodies: list[dict] | None = None, covers: dict[str, Shape] | None = None) -> None:
|
|
1613
|
+
"""A section on the XZ plane through the cavity centre, as SVG: the case's parts and covers cut and filled,
|
|
1614
|
+
and what goes inside (the contents' meshes) cut as outlines, each in its own colour, with a legend and a
|
|
1615
|
+
millimetre scale. +Z (towards the lid) is up."""
|
|
1616
|
+
y0 = float(e.centre[1])
|
|
1617
|
+
pl = Plane(origin=Vector(0, y0, 0), x_dir=Vector(1, 0, 0), z_dir=Vector(0, -1, 0))
|
|
1618
|
+
S = 8.0 # px per mm
|
|
1619
|
+
polys: list[tuple[str, list[list[tuple[float, float]]], str]] = [] # (label, rings in (x, z), fill)
|
|
1620
|
+
fills = {"base": "#b8c4d6", "lid": "#d3d9e2"}
|
|
1621
|
+
|
|
1622
|
+
def rings_of(face) -> list[list[tuple[float, float]]]:
|
|
1623
|
+
out = []
|
|
1624
|
+
for w in [face.outer_wire(), *face.inner_wires()]:
|
|
1625
|
+
pts = [w.position_at(t) for t in np.linspace(0, 1, 600)]
|
|
1626
|
+
ring = [(round(p.X, 3), round(p.Z, 3)) for p in pts]
|
|
1627
|
+
# the wire starts wherever the kernel put its first vertex: start at the least point, run one way
|
|
1628
|
+
k = ring.index(min(ring))
|
|
1629
|
+
ring = ring[k:] + ring[:k]
|
|
1630
|
+
if len(ring) > 2 and ring[1] > ring[-1]:
|
|
1631
|
+
ring = [ring[0]] + ring[1:][::-1]
|
|
1632
|
+
out.append(ring)
|
|
1633
|
+
return out
|
|
1634
|
+
|
|
1635
|
+
for k, part in sorted({**parts, **(covers or {})}.items()):
|
|
1636
|
+
try:
|
|
1637
|
+
sec = part.intersect(Pos(0, y0, 0) * Box(1000, 1e-3, 1000))
|
|
1638
|
+
except Exception: # noqa: BLE001 - one part that does not cut is left out of the drawing
|
|
1639
|
+
continue
|
|
1640
|
+
if sec is None:
|
|
1641
|
+
continue
|
|
1642
|
+
# the slab's two big faces are the same outline: one of them
|
|
1643
|
+
faces = sorted((f for f in (sec.faces() if hasattr(sec, "faces") else []) if f.normal_at().Y > 0.9), key=lambda f: (round(f.center().X, 3), round(f.center().Z, 3), round(f.area, 3)))
|
|
1644
|
+
if not faces:
|
|
1645
|
+
continue
|
|
1646
|
+
label = k.replace("_", " ")
|
|
1647
|
+
fill = fills.get(k, "#9fd3f0" if k.startswith("cover") else "#e6c79c")
|
|
1648
|
+
polys.append((label, [r for f in faces for r in rings_of(f)], fill))
|
|
1649
|
+
# contents: triangle meshes cut by the plane
|
|
1650
|
+
segs: list[tuple[str, list[tuple[float, float, float, float]]]] = []
|
|
1651
|
+
for body in bodies or []:
|
|
1652
|
+
V, F = body["V"], body["F"]
|
|
1653
|
+
if not len(F):
|
|
1654
|
+
continue
|
|
1655
|
+
d = V[:, 1] - y0
|
|
1656
|
+
F = np.asarray(F, dtype=np.int64)
|
|
1657
|
+
D = d[F]
|
|
1658
|
+
pos = D > 0
|
|
1659
|
+
cut = ~(pos.all(axis=1) | (~pos).all(axis=1))
|
|
1660
|
+
Fc, Dc = F[cut], D[cut]
|
|
1661
|
+
P = [[], []]
|
|
1662
|
+
for e0, e1 in ((0, 1), (1, 2), (2, 0)):
|
|
1663
|
+
m = (Dc[:, e0] > 0) != (Dc[:, e1] > 0)
|
|
1664
|
+
t = np.where(m, Dc[:, e0] / np.where(m, Dc[:, e0] - Dc[:, e1], 1.0), 0.0)
|
|
1665
|
+
q = V[Fc[:, e0]] + t[:, None] * (V[Fc[:, e1]] - V[Fc[:, e0]])
|
|
1666
|
+
P[0].append(np.where(m[:, None], q[:, [0, 2]], np.nan))
|
|
1667
|
+
pts = np.stack(P[0], axis=1) # (n, 3 edges, 2)
|
|
1668
|
+
out = []
|
|
1669
|
+
for row in pts:
|
|
1670
|
+
ok = row[~np.isnan(row[:, 0])]
|
|
1671
|
+
if len(ok) >= 2:
|
|
1672
|
+
out.append((float(ok[0][0]), float(ok[0][1]), float(ok[1][0]), float(ok[1][1])))
|
|
1673
|
+
if out:
|
|
1674
|
+
segs.append((body.get("label", body["key"]), out))
|
|
1675
|
+
xs = [x for _, rs, _ in polys for r in rs for x, _ in r] + [v for _, ss in segs for s_ in ss for v in (s_[0], s_[2])]
|
|
1676
|
+
zs = [z for _, rs, _ in polys for r in rs for _, z in r] + [v for _, ss in segs for s_ in ss for v in (s_[1], s_[3])]
|
|
1677
|
+
if not xs:
|
|
1678
|
+
return
|
|
1679
|
+
x0, x1, z0, z1 = min(xs) - 2, max(xs) + 2, min(zs) - 2, max(zs) + 2
|
|
1680
|
+
W, H = (x1 - x0) * S, (z1 - z0) * S
|
|
1681
|
+
legend_h = 16 * (len(polys) + len(segs)) + 30
|
|
1682
|
+
X = lambda x: (x - x0) * S # noqa: E731
|
|
1683
|
+
Z = lambda z: (z1 - z) * S # noqa: E731
|
|
1684
|
+
colors = ["#d94e00", "#1f6fb2", "#2e8b57", "#8e44ad", "#b8860b", "#c0392b", "#16a085", "#555555"]
|
|
1685
|
+
el = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W:.1f} {H + legend_h:.1f}" width="{W:.0f}" height="{H + legend_h:.0f}" font-family="sans-serif" font-size="11">', f'<rect width="100%" height="100%" fill="#fff"/>']
|
|
1686
|
+
for label, rings, fill in polys:
|
|
1687
|
+
dpath = " ".join("M" + " L".join(f"{X(x):.2f},{Z(z):.2f}" for x, z in r) + " Z" for r in rings)
|
|
1688
|
+
el.append(f'<path d="{dpath}" fill="{fill}" fill-rule="evenodd" stroke="#333" stroke-width="0.8"/>')
|
|
1689
|
+
for i, (label, ss) in enumerate(segs):
|
|
1690
|
+
col = colors[i % len(colors)]
|
|
1691
|
+
dpath = " ".join(f"M{X(a):.2f},{Z(bz):.2f} L{X(c):.2f},{Z(dz):.2f}" for a, bz, c, dz in ss)
|
|
1692
|
+
el.append(f'<path d="{dpath}" fill="none" stroke="{col}" stroke-width="1.2" stroke-linecap="round"/>')
|
|
1693
|
+
y = H + 18
|
|
1694
|
+
for label, _, fill in polys:
|
|
1695
|
+
el.append(f'<rect x="8" y="{y - 9}" width="14" height="10" fill="{fill}" stroke="#333" stroke-width="0.6"/><text x="28" y="{y}">{label} (cut)</text>')
|
|
1696
|
+
y += 16
|
|
1697
|
+
for i, (label, _) in enumerate(segs):
|
|
1698
|
+
el.append(f'<line x1="8" x2="22" y1="{y - 4}" y2="{y - 4}" stroke="{colors[i % len(colors)]}" stroke-width="2"/><text x="28" y="{y}">{label}</text>')
|
|
1699
|
+
y += 16
|
|
1700
|
+
# 5 mm scale bar
|
|
1701
|
+
el.append(f'<line x1="{W - 8 - 5 * S:.1f}" x2="{W - 8:.1f}" y1="{H + 14}" y2="{H + 14}" stroke="#000" stroke-width="2"/><text x="{W - 8:.1f}" y="{H + 28}" text-anchor="end">5 mm</text><text x="{W - 8:.1f}" y="{H + 42}" text-anchor="end">section at y = {y0:g} mm, +Z up</text>')
|
|
1702
|
+
el.append("</svg>")
|
|
1703
|
+
path.write_text("\n".join(el) + "\n", encoding="utf-8")
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def main() -> None:
|
|
1707
|
+
mode = sys.argv[1]
|
|
1708
|
+
if mode == "build":
|
|
1709
|
+
job = json.loads(Path(sys.argv[2]).read_text())
|
|
1710
|
+
out = Path(sys.argv[3])
|
|
1711
|
+
root = Path(sys.argv[sys.argv.index("--root") + 1]) if "--root" in sys.argv else Path.cwd()
|
|
1712
|
+
r = build(job, out, root)
|
|
1713
|
+
print(json.dumps({"name": r["name"], "problems": r["problems"], "fit": {"bodies": r["fit"]["bodies"], "walls": r["fit"]["walls"]}}))
|
|
1714
|
+
elif mode == "fit":
|
|
1715
|
+
spec = json.loads(sys.stdin.read() if sys.argv[2] == "-" else Path(sys.argv[2]).read_text())
|
|
1716
|
+
root = Path(spec["root"])
|
|
1717
|
+
meshes = {k: load_mesh(root / p, node=k if spec.get("by_node") else None) for k, p in {**spec["parts"], **{x["key"]: x["glb"] for x in spec.get("plungers", [])}}.items()}
|
|
1718
|
+
bodies = []
|
|
1719
|
+
for c in spec["contents"]:
|
|
1720
|
+
v, f = load_mesh(root / c["glb"], c["matrix"], skip=c.get("skip_nodes"))
|
|
1721
|
+
bodies.append({"key": c["key"], "label": c.get("label", c["key"]), "V": v, "F": f, "zones": spec.get("zones", {}).get(c["key"], [])})
|
|
1722
|
+
fit = analyse({k: meshes[k] for k in spec["parts"]}, bodies, spec["cavity"], spec["resolution"], spec["clearance"])
|
|
1723
|
+
plungers = [{"key": x["key"], "feature": x["feature"], "V": meshes[x["key"]][0], "F": meshes[x["key"]][1], "zones": x.get("zones", [])} for x in spec.get("plungers", [])]
|
|
1724
|
+
fit["plungers"] = analyse_plungers(plungers, bodies, fit["resolution_mm"], spec["clearance"])
|
|
1725
|
+
print(json.dumps(fit))
|
|
1726
|
+
else:
|
|
1727
|
+
raise SystemExit(f"unknown mode {mode}")
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
if __name__ == "__main__":
|
|
1731
|
+
main()
|