@protoboardai/ext-enclosure-cad 0.0.0-stage → 0.2.0

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.
@@ -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()