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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Standard dimensions the enclosure provider designs to, each with its
3
+ * source. These are the provider's own tables (tool-integrations.md §4): a
4
+ * module states which O-ring, insert or spring bar it uses; the numbers
5
+ * behind the name come from here, and a module may override any of them.
6
+ *
7
+ * Values marked `assumption` are design choices, not read from a source.
8
+ */
9
+
10
+ export interface Cited {
11
+ source: string;
12
+ assumption?: string;
13
+ }
14
+
15
+ /** Parker O-Ring Handbook, catalog ORD 5700A/US (2001 edition). */
16
+ export const PARKER_ORD_5700 = "Parker O-Ring Handbook ORD 5700A/US (2001), http://robots.engin.umich.edu/uploads/ORD5700.pdf";
17
+
18
+ const IN = 25.4;
19
+ const mm = (inches: number) => Math.round(inches * IN * 1000) / 1000;
20
+
21
+ /** One row of a gland table, in mm (ranges are [min, max]). */
22
+ export interface GlandRow extends Cited {
23
+ /** O-ring cross-section W, nominal and ± tolerance. */
24
+ w_mm: number;
25
+ w_tol_mm: number;
26
+ /** Gland depth L. */
27
+ depth_mm: [number, number];
28
+ /** Squeeze, as the table states it (% of W). */
29
+ squeeze_pct: [number, number];
30
+ /** Groove width G (no back-up ring; liquids for face seals). */
31
+ width_mm: [number, number];
32
+ /** Diametral clearance E (radial glands). */
33
+ clearance_mm?: [number, number];
34
+ /** Groove radius R. */
35
+ radius_mm: [number, number];
36
+ }
37
+
38
+ const row = (source: string, w: number, wt: number, l: [number, number], sq: [number, number], g: [number, number], r: [number, number], e?: [number, number]): GlandRow => ({
39
+ source,
40
+ w_mm: mm(w),
41
+ w_tol_mm: mm(wt),
42
+ depth_mm: [mm(l[0]), mm(l[1])],
43
+ squeeze_pct: sq,
44
+ width_mm: [mm(g[0]), mm(g[1])],
45
+ radius_mm: [mm(r[0]), mm(r[1])],
46
+ ...(e ? { clearance_mm: [mm(e[0]), mm(e[1])] as [number, number] } : {}),
47
+ });
48
+
49
+ /**
50
+ * Face seal glands: Parker ORD 5700 Design Chart 4-2 (p. 4-14), "intended
51
+ * primarily for face type O-ring seals". Groove width for liquids.
52
+ */
53
+ export const FACE_GLANDS: GlandRow[] = [
54
+ row(`${PARKER_ORD_5700}, Design Chart 4-2`, 0.07, 0.003, [0.05, 0.054], [19, 32], [0.101, 0.107], [0.005, 0.015]),
55
+ row(`${PARKER_ORD_5700}, Design Chart 4-2`, 0.103, 0.003, [0.074, 0.08], [20, 30], [0.136, 0.142], [0.005, 0.015]),
56
+ row(`${PARKER_ORD_5700}, Design Chart 4-2`, 0.139, 0.004, [0.101, 0.107], [20, 30], [0.177, 0.187], [0.01, 0.025]),
57
+ row(`${PARKER_ORD_5700}, Design Chart 4-2`, 0.21, 0.005, [0.152, 0.162], [21, 30], [0.27, 0.29], [0.02, 0.035]),
58
+ row(`${PARKER_ORD_5700}, Design Chart 4-2`, 0.275, 0.006, [0.201, 0.211], [21, 29], [0.342, 0.362], [0.02, 0.035]),
59
+ ];
60
+
61
+ /**
62
+ * Static radial glands (male or female): Parker ORD 5700 Design Chart 4-1
63
+ * (p. 4-5), "Industrial O-Ring Static Seal Glands". Groove width without a
64
+ * back-up ring.
65
+ */
66
+ export const RADIAL_GLANDS: GlandRow[] = [
67
+ row(`${PARKER_ORD_5700}, Design Chart 4-1`, 0.07, 0.003, [0.05, 0.052], [22, 32], [0.093, 0.098], [0.005, 0.015], [0.002, 0.005]),
68
+ row(`${PARKER_ORD_5700}, Design Chart 4-1`, 0.103, 0.003, [0.081, 0.083], [17, 24], [0.14, 0.145], [0.005, 0.015], [0.002, 0.005]),
69
+ row(`${PARKER_ORD_5700}, Design Chart 4-1`, 0.139, 0.004, [0.111, 0.113], [16, 23], [0.187, 0.192], [0.01, 0.025], [0.003, 0.006]),
70
+ row(`${PARKER_ORD_5700}, Design Chart 4-1`, 0.21, 0.005, [0.17, 0.173], [15, 21], [0.281, 0.286], [0.02, 0.035], [0.003, 0.006]),
71
+ row(`${PARKER_ORD_5700}, Design Chart 4-1`, 0.275, 0.006, [0.226, 0.229], [15, 20], [0.375, 0.38], [0.02, 0.035], [0.004, 0.007]),
72
+ ];
73
+
74
+ /** The gland row for an O-ring cross-section (mm), or undefined when the table has none. */
75
+ export function glandRow(type: "face" | "radial", w_mm: number): GlandRow | undefined {
76
+ return (type === "face" ? FACE_GLANDS : RADIAL_GLANDS).find((r) => Math.abs(r.w_mm - w_mm) <= r.w_tol_mm);
77
+ }
78
+
79
+ /**
80
+ * Parker ORD 5700 §3.3 Assembly: "The I.D. stretch, as installed in the
81
+ * groove, should not be more than 5%."
82
+ */
83
+ export const MAX_INSTALLED_STRETCH = { pct: 5, source: `${PARKER_ORD_5700}, §3.3 Assembly` };
84
+
85
+ /**
86
+ * Parker ORD 5700 Design Chart 4-2 notes: for external (inward) pressure the
87
+ * groove's inside diameter is the O-ring's mean I.D., tolerance plus 1 %; for
88
+ * internal pressure its outside diameter is the O-ring's mean O.D., minus 1 %.
89
+ */
90
+ export const FACE_GROOVE_FIT = { pct: 1, source: `${PARKER_ORD_5700}, Design Chart 4-2` };
91
+
92
+ /**
93
+ * AS568 O-ring sizes in mm: "dash id id_tol w w_tol", from the Parker
94
+ * Series 2-XXX table (ORD 5700 p. 9-2 onward, metric columns, per AS 568A).
95
+ */
96
+ const AS568_ROWS = [
97
+ "001 0.74 0.10 1.02 0.08,002 1.07 0.10 1.27 0.08,003 1.42 0.10 1.52 0.08,004 1.78 0.13 1.78 0.08,005 2.57 0.13 1.78 0.08,006 2.90 0.13 1.78 0.08",
98
+ "007 3.68 0.13 1.78 0.08,008 4.47 0.13 1.78 0.08,009 5.28 0.13 1.78 0.08,010 6.07 0.13 1.78 0.08,011 7.65 0.13 1.78 0.08,012 9.25 0.13 1.78 0.08",
99
+ "013 10.82 0.13 1.78 0.08,014 12.42 0.13 1.78 0.08,015 14.00 0.18 1.78 0.08,016 15.60 0.23 1.78 0.08,017 17.17 0.23 1.78 0.08,018 18.77 0.23 1.78 0.08",
100
+ "019 20.35 0.23 1.78 0.08,020 21.95 0.23 1.78 0.08,021 23.52 0.23 1.78 0.08,022 25.12 0.25 1.78 0.08,023 26.70 0.25 1.78 0.08,024 28.30 0.25 1.78 0.08",
101
+ "025 29.87 0.28 1.78 0.08,026 31.47 0.28 1.78 0.08,027 33.05 0.28 1.78 0.08,028 34.65 0.33 1.78 0.08,029 37.82 0.33 1.78 0.08,030 41.00 0.33 1.78 0.08",
102
+ "031 44.17 0.38 1.78 0.08,032 47.35 0.38 1.78 0.08,033 50.52 0.46 1.78 0.08,034 53.70 0.46 1.78 0.08,035 56.87 0.46 1.78 0.08,036 60.05 0.46 1.78 0.08",
103
+ "037 63.22 0.46 1.78 0.08,038 66.40 0.51 1.78 0.08,039 69.57 0.51 1.78 0.08,040 72.75 0.51 1.78 0.08,041 75.92 0.61 1.78 0.08,042 82.27 0.61 1.78 0.08",
104
+ "043 88.62 0.61 1.78 0.08,044 94.97 0.69 1.78 0.08,045 101.32 0.69 1.78 0.08,046 107.67 0.76 1.78 0.08,047 114.02 0.76 1.78 0.08,048 120.37 0.76 1.78 0.08",
105
+ "049 126.72 0.94 1.78 0.08,050 133.07 0.94 1.78 0.08,102 1.24 0.13 2.62 0.08,103 2.06 0.13 2.62 0.08,104 2.84 0.13 2.62 0.08,105 3.63 0.13 2.62 0.08",
106
+ "106 4.42 0.13 2.62 0.08,107 5.23 0.13 2.62 0.08,108 6.02 0.13 2.62 0.08,109 7.59 0.13 2.62 0.08,110 9.19 0.13 2.62 0.08,111 10.77 0.13 2.62 0.08",
107
+ "112 12.37 0.13 2.62 0.08,113 13.94 0.18 2.62 0.08,114 15.54 0.23 2.62 0.08,115 17.12 0.23 2.62 0.08,116 18.72 0.23 2.62 0.08,117 20.30 0.25 2.62 0.08",
108
+ "118 21.89 0.25 2.62 0.08,119 23.47 0.25 2.62 0.08,120 25.07 0.25 2.62 0.08,121 26.64 0.25 2.62 0.08,122 28.24 0.25 2.62 0.08,123 29.82 0.30 2.62 0.08",
109
+ "124 31.42 0.30 2.62 0.08,125 32.99 0.30 2.62 0.08,126 34.59 0.30 2.62 0.08,127 36.17 0.30 2.62 0.08,128 37.77 0.30 2.62 0.08,129 39.34 0.38 2.62 0.08",
110
+ "130 40.94 0.38 2.62 0.08,131 42.52 0.38 2.62 0.08,132 44.12 0.38 2.62 0.08,133 45.69 0.38 2.62 0.08,134 47.29 0.38 2.62 0.08,135 48.90 0.43 2.62 0.08",
111
+ "136 50.47 0.43 2.62 0.08,137 52.07 0.43 2.62 0.08,138 53.64 0.43 2.62 0.08,139 55.25 0.43 2.62 0.08,140 56.82 0.43 2.62 0.08,141 58.42 0.51 2.62 0.08",
112
+ "142 59.99 0.51 2.62 0.08,143 61.60 0.51 2.62 0.08,144 63.17 0.51 2.62 0.08,145 64.77 0.51 2.62 0.08,146 66.34 0.51 2.62 0.08,147 67.95 0.56 2.62 0.08",
113
+ "148 69.52 0.56 2.62 0.08,149 71.12 0.56 2.62 0.08,150 72.69 0.56 2.62 0.08,151 75.87 0.61 2.62 0.08,152 82.22 0.61 2.62 0.08,153 88.57 0.61 2.62 0.08",
114
+ "154 94.92 0.71 2.62 0.08,155 101.27 0.71 2.62 0.08,156 107.62 0.76 2.62 0.08,157 113.97 0.76 2.62 0.08,158 120.32 0.76 2.62 0.08,159 126.67 0.89 2.62 0.08",
115
+ "160 133.02 0.89 2.62 0.08,161 139.37 0.89 2.62 0.08,162 145.72 0.89 2.62 0.08,163 152.07 0.89 2.62 0.08,164 158.42 1.02 2.62 0.08,165 164.77 1.02 2.62 0.08",
116
+ "166 171.12 1.02 2.62 0.08,167 177.47 1.02 2.62 0.08,168 183.82 1.14 2.62 0.08,169 190.17 1.14 2.62 0.08,170 196.52 1.14 2.62 0.08,171 202.87 1.14 2.62 0.08",
117
+ "172 209.22 1.27 2.62 0.08,173 215.57 1.27 2.62 0.08,174 221.92 1.27 2.62 0.08,175 228.27 1.27 2.62 0.08,176 234.62 1.40 2.62 0.08,177 240.97 1.40 2.62 0.08",
118
+ "178 247.32 1.40 2.62 0.08,201 4.34 0.13 3.53 0.10,202 5.94 0.13 3.53 0.10,203 7.52 0.13 3.53 0.10,204 9.12 0.13 3.53 0.10,205 10.69 0.13 3.53 0.10",
119
+ "206 12.29 0.13 3.53 0.10,207 13.87 0.18 3.53 0.10,208 15.47 0.23 3.53 0.10,209 17.04 0.23 3.53 0.10,210 18.64 0.25 3.53 0.10,211 20.22 0.25 3.53 0.10",
120
+ "212 21.82 0.25 3.53 0.10,213 23.39 0.25 3.53 0.10,214 24.99 0.25 3.53 0.10,215 26.57 0.25 3.53 0.10,216 28.17 0.30 3.53 0.10,217 29.74 0.30 3.53 0.10",
121
+ "218 31.34 0.30 3.53 0.10,219 32.92 0.30 3.53 0.10,220 34.52 0.30 3.53 0.10,221 36.09 0.30 3.53 0.10,222 37.69 0.38 3.53 0.10,223 40.87 0.38 3.53 0.10",
122
+ "224 44.04 0.38 3.53 0.10,225 47.22 0.46 3.53 0.10,226 50.39 0.46 3.53 0.10,227 53.57 0.46 3.53 0.10,228 56.74 0.51 3.53 0.10,229 59.92 0.51 3.53 0.10",
123
+ "230 63.09 0.51 3.53 0.10,231 66.27 0.51 3.53 0.10,232 69.44 0.61 3.53 0.10,233 72.62 0.61 3.53 0.10,234 75.79 0.61 3.53 0.10,235 78.97 0.61 3.53 0.10",
124
+ "236 82.14 0.61 3.53 0.10,237 85.32 0.61 3.53 0.10,238 88.49 0.61 3.53 0.10,239 91.67 0.71 3.53 0.10,240 94.84 0.71 3.53 0.10,241 98.02 0.71 3.53 0.10",
125
+ "242 101.19 0.71 3.53 0.10,243 104.37 0.71 3.53 0.10,244 107.54 0.76 3.53 0.10,245 110.72 0.76 3.53 0.10,246 113.89 0.76 3.53 0.10,247 117.07 0.76 3.53 0.10",
126
+ "248 120.24 0.76 3.53 0.10,249 123.42 0.89 3.53 0.10,250 126.59 0.89 3.53 0.10,251 129.77 0.89 3.53 0.10,252 132.94 0.89 3.53 0.10,253 136.12 0.89 3.53 0.10",
127
+ "254 139.29 0.89 3.53 0.10,255 142.47 0.89 3.53 0.10,256 145.64 0.89 3.53 0.10,257 148.82 0.89 3.53 0.10,258 151.99 0.89 3.53 0.10,259 158.34 1.02 3.53 0.10",
128
+ "260 164.69 1.02 3.53 0.10,261 171.04 1.02 3.53 0.10,262 177.39 1.02 3.53 0.10,263 183.74 1.14 3.53 0.10,264 190.09 1.14 3.53 0.10,265 196.44 1.14 3.53 0.10",
129
+ "266 202.79 1.14 3.53 0.10,267 209.14 1.27 3.53 0.10,268 215.49 1.27 3.53 0.10,269 221.84 1.27 3.53 0.10,270 228.19 1.27 3.53 0.10,271 234.54 1.40 3.53 0.10",
130
+ "272 240.89 1.40 3.53 0.10,273 247.24 1.40 3.53 0.10,274 253.59 1.40 3.53 0.10,275 266.29 1.40 3.53 0.10,276 278.99 1.65 3.53 0.10,277 291.69 1.65 3.53 0.10",
131
+ "278 304.39 1.65 3.53 0.10,279 329.79 1.65 3.53 0.10,280 355.19 1.65 3.53 0.10,281 380.59 1.65 3.53 0.10,282 405.26 1.91 3.53 0.10,283 430.66 2.03 3.53 0.10",
132
+ "284 456.06 2.16 3.53 0.10,309 10.46 0.13 5.33 0.13,310 12.07 0.13 5.33 0.13,311 13.64 0.18 5.33 0.13,312 15.24 0.23 5.33 0.13,313 16.81 0.23 5.33 0.13",
133
+ "314 18.42 0.25 5.33 0.13,315 19.99 0.25 5.33 0.13,316 21.59 0.25 5.33 0.13,317 23.16 0.25 5.33 0.13,318 24.77 0.25 5.33 0.13,319 26.34 0.25 5.33 0.13",
134
+ "320 27.94 0.30 5.33 0.13,321 29.51 0.30 5.33 0.13,322 31.12 0.30 5.33 0.13,323 32.69 0.30 5.33 0.13,324 34.29 0.30 5.33 0.13,325 37.47 0.38 5.33 0.13",
135
+ "326 40.64 0.38 5.33 0.13,327 43.82 0.38 5.33 0.13,328 46.99 0.38 5.33 0.13,329 50.17 0.46 5.33 0.13,330 53.34 0.46 5.33 0.13,331 56.52 0.46 5.33 0.13",
136
+ "332 59.69 0.46 5.33 0.13,333 62.87 0.51 5.33 0.13,334 66.04 0.51 5.33 0.13,335 69.22 0.51 5.33 0.13,336 72.39 0.51 5.33 0.13,337 75.57 0.61 5.33 0.13",
137
+ "338 78.74 0.61 5.33 0.13,339 81.92 0.61 5.33 0.13,340 85.09 0.61 5.33 0.13,341 88.27 0.61 5.33 0.13,342 91.44 0.71 5.33 0.13,343 94.62 0.71 5.33 0.13",
138
+ "344 97.79 0.71 5.33 0.13,345 100.97 0.71 5.33 0.13,346 104.14 0.71 5.33 0.13,347 107.32 0.76 5.33 0.13,348 110.49 0.76 5.33 0.13,349 113.67 0.76 5.33 0.13",
139
+ "350 116.84 0.76 5.33 0.13,351 120.02 0.76 5.33 0.13,352 123.19 0.76 5.33 0.13,353 126.37 0.94 5.33 0.13,354 129.54 0.94 5.33 0.13,355 132.72 0.94 5.33 0.13",
140
+ "356 135.89 0.94 5.33 0.13,357 139.07 0.94 5.33 0.13,358 142.24 0.94 5.33 0.13,359 145.42 0.94 5.33 0.13,360 148.59 0.94 5.33 0.13,361 151.77 0.94 5.33 0.13",
141
+ "362 158.12 1.02 5.33 0.13,363 164.47 1.02 5.33 0.13,364 170.82 1.02 5.33 0.13,365 177.17 1.02 5.33 0.13,366 183.52 1.14 5.33 0.13,367 189.87 1.14 5.33 0.13",
142
+ "368 196.22 1.14 5.33 0.13,369 202.57 1.14 5.33 0.13,370 208.92 1.27 5.33 0.13,371 215.27 1.27 5.33 0.13,372 221.62 1.27 5.33 0.13,373 227.97 1.27 5.33 0.13",
143
+ "374 234.32 1.40 5.33 0.13,375 240.67 1.40 5.33 0.13,376 247.02 1.40 5.33 0.13,377 253.37 1.40 5.33 0.13,378 266.07 1.52 5.33 0.13,379 278.77 1.52 5.33 0.13",
144
+ "380 291.47 1.65 5.33 0.13,381 304.17 1.65 5.33 0.13,382 329.57 1.65 5.33 0.13,383 354.97 1.78 5.33 0.13,384 380.37 1.78 5.33 0.13,385 405.26 1.91 5.33 0.13",
145
+ "386 430.66 2.03 5.33 0.13,387 456.06 2.16 5.33 0.13,388 481.41 2.29 5.33 0.13,389 506.81 2.41 5.33 0.13,390 532.21 2.41 5.33 0.13,391 557.61 2.54 5.33 0.13",
146
+ "392 582.68 2.67 5.33 0.13,393 608.08 2.79 5.33 0.13,394 633.48 2.92 5.33 0.13,395 658.88 3.05 5.33 0.13,425 113.67 0.84 6.99 0.15,426 116.84 0.84 6.99 0.15",
147
+ "427 120.02 0.84 6.99 0.15,428 123.19 0.84 6.99 0.15,429 126.37 0.94 6.99 0.15,430 129.54 0.94 6.99 0.15,431 132.72 0.94 6.99 0.15,432 135.89 0.94 6.99 0.15",
148
+ "433 139.07 0.94 6.99 0.15,434 142.24 0.94 6.99 0.15,435 145.42 0.94 6.99 0.15,436 148.59 0.94 6.99 0.15,437 151.77 0.94 6.99 0.15,438 158.12 1.02 6.99 0.15",
149
+ "439 164.47 1.02 6.99 0.15,440 170.82 1.02 6.99 0.15,441 177.17 1.02 6.99 0.15,442 183.52 1.14 6.99 0.15,443 189.87 1.14 6.99 0.15,444 196.22 1.14 6.99 0.15",
150
+ "445 202.57 1.14 6.99 0.15,446 215.27 1.40 6.99 0.15,447 227.97 1.40 6.99 0.15,448 240.67 1.40 6.99 0.15,449 253.37 1.40 6.99 0.15,450 266.07 1.52 6.99 0.15",
151
+ "451 278.77 1.52 6.99 0.15,452 291.47 1.52 6.99 0.15,453 304.17 1.52 6.99 0.15,454 316.87 1.52 6.99 0.15,455 329.57 1.52 6.99 0.15,456 342.27 1.78 6.99 0.15",
152
+ "457 354.97 1.78 6.99 0.15,458 367.67 1.78 6.99 0.15,459 380.37 1.78 6.99 0.15,460 393.07 1.78 6.99 0.15,461 405.26 1.91 6.99 0.15,462 417.96 1.91 6.99 0.15",
153
+ "463 430.66 2.03 6.99 0.15,464 443.36 2.16 6.99 0.15,465 456.06 2.16 6.99 0.15,466 468.76 2.16 6.99 0.15,467 481.46 2.29 6.99 0.15,468 494.16 2.29 6.99 0.15",
154
+ "469 506.86 2.41 6.99 0.15,470 532.26 2.41 6.99 0.15,471 557.66 2.54 6.99 0.15,472 582.68 2.67 6.99 0.15,473 608.08 2.79 6.99 0.15,474 633.48 2.92 6.99 0.15",
155
+ "475 658.88 3.05 6.99 0.15",].join(",");
156
+
157
+ export interface ORing extends Cited {
158
+ size: string;
159
+ id_mm: number;
160
+ id_tol_mm: number;
161
+ w_mm: number;
162
+ w_tol_mm: number;
163
+ }
164
+
165
+ const AS568 = new Map(
166
+ AS568_ROWS.split(",").map((r) => {
167
+ const [dash, id, idt, w, wt] = r.trim().split(/\s+/);
168
+ return [dash, { id_mm: Number(id), id_tol_mm: Number(idt), w_mm: Number(w), w_tol_mm: Number(wt) }] as const;
169
+ }),
170
+ );
171
+
172
+ /** An AS568 size ("AS568-138", "-138", "2-138" or "138"). */
173
+ export function as568(size: string): ORing | undefined {
174
+ const dash = /(\d{3})$/.exec(size.trim())?.[1];
175
+ const r = dash ? AS568.get(dash) : undefined;
176
+ return r && { size: `AS568-${dash}`, ...r, source: `${PARKER_ORD_5700}, Parker Series 2-XXX O-Ring Sizes (AS 568A)` };
177
+ }
178
+
179
+ /** A heat-set insert and the hole it goes into (mm). */
180
+ export interface Insert extends Cited {
181
+ name: string;
182
+ thread: string;
183
+ outer_d_mm: number;
184
+ length_mm: number;
185
+ hole_d_mm: number;
186
+ hole_depth_mm: number;
187
+ min_wall_mm: number;
188
+ }
189
+
190
+ /**
191
+ * ruthex RX-M2x4: M2, outer Ø3.6 (D1), length 4.0, recommended hole Ø3.2
192
+ * (D3), minimum wall 1.3 (product data as read by the watch and bottle
193
+ * projects, their sources W19 / B24). The hole depth is not stated.
194
+ */
195
+ export const RUTHEX_RX_M2X4: Insert = {
196
+ name: "ruthex RX-M2x4",
197
+ thread: "M2",
198
+ outer_d_mm: 3.6,
199
+ length_mm: 4.0,
200
+ hole_d_mm: 3.2,
201
+ hole_depth_mm: 5.0,
202
+ min_wall_mm: 1.3,
203
+ source: "https://www.ruthex.de/en/products/ruthex-gewindeeinsatz-m2-70-stuck-rx-m2x4-messing-gewindebuchsen (dimensions: https://www.3djake.com/ruthex/threaded-insert-m2-70-pieces)",
204
+ assumption: "hole depth = insert length + 1.0 mm, room for the plastic the insert displaces",
205
+ };
206
+
207
+ /**
208
+ * CNC Kitchen M2 standard insert: length 3.0, outer Ø3.6 (D1), hole Ø3.2
209
+ * (D3), minimum wall 1.3.
210
+ */
211
+ export const CNC_KITCHEN_M2: Insert = {
212
+ name: "CNC Kitchen M2 standard",
213
+ thread: "M2",
214
+ outer_d_mm: 3.6,
215
+ length_mm: 3.0,
216
+ hole_d_mm: 3.2,
217
+ hole_depth_mm: 4.0,
218
+ min_wall_mm: 1.3,
219
+ source: "https://www.3djake.com/cnc-kitchen/threaded-inserts-m2-standard",
220
+ assumption: "hole depth = insert length + 1.0 mm, room for the plastic the insert displaces",
221
+ };
222
+
223
+ export const INSERTS: Record<string, Insert> = { "ruthex-rx-m2x4": RUTHEX_RX_M2X4, "cnc-kitchen-m2": CNC_KITCHEN_M2 };
224
+
225
+ /** A screw that closes a joint (mm). */
226
+ export interface Screw extends Cited {
227
+ name: string;
228
+ d_mm: number;
229
+ length_mm: number;
230
+ head_d_mm: number;
231
+ head_h_mm: number;
232
+ clearance_d_mm: number;
233
+ }
234
+
235
+ /**
236
+ * Accu SPP-M2-6-A2, M2 × 6 pan head: head Ø4.0, head height 1.72 (as read
237
+ * by the watch project, source W20). Clearance hole Ø2.4: ISO 273 medium
238
+ * series for M2.
239
+ */
240
+ export const M2X6_PAN: Screw = {
241
+ name: "M2 × 6 pan head (ISO 7045)",
242
+ d_mm: 2,
243
+ length_mm: 6,
244
+ head_d_mm: 4.0,
245
+ head_h_mm: 1.72,
246
+ clearance_d_mm: 2.4,
247
+ source: "Accu SPP-M2-6-A2 product data (watch project source W20); clearance hole: ISO 273 medium series",
248
+ };
249
+
250
+ /** A watch spring bar (mm). */
251
+ export interface SpringBar extends Cited {
252
+ name: string;
253
+ bar_d_mm: number;
254
+ tip_d_mm: number;
255
+ /** How far each tip stands beyond the lug gap when the bar is fitted. */
256
+ tip_length_mm: number;
257
+ lug_gap_mm: number;
258
+ }
259
+
260
+ /**
261
+ * Esslinger 82.2225 (22 mm, Ø1.5 mm, double flange): overall 25.0,
262
+ * compressed 21.8, tip Ø0.80 (the watch project's source W18). Each tip
263
+ * stands (25.0 − 22) / 2 = 1.5 mm into its lug.
264
+ */
265
+ export const ESSLINGER_82_2225: SpringBar = {
266
+ name: "Esslinger 82.2225",
267
+ bar_d_mm: 1.5,
268
+ tip_d_mm: 0.8,
269
+ tip_length_mm: 1.5,
270
+ lug_gap_mm: 22,
271
+ source: "https://www.esslinger.com/stainless-steel-1-5mm-double-flange-watch-band-spring-bar-assortment-900-pc/",
272
+ };
273
+
274
+ /** Lug hole for a spring-bar tip: Ø tip + 0.2, depth tip length + 0.3 (printed-hole allowances; assumption). */
275
+ export const lugHole = (bar: SpringBar) => ({ hole_d_mm: Math.round((bar.tip_d_mm + 0.2) * 100) / 100, hole_depth_mm: Math.round((bar.tip_length_mm + 0.3) * 100) / 100 });
276
+
277
+ /**
278
+ * USB Type-C plug (USB Type-C Connectors and Cable Assemblies Compliance
279
+ * Document rev 1.2, 2017-08-22, Appendix B, Figure B-1): overmold width
280
+ * 12.35 max, overmold height 6.5 max, plug length 6.65 ± 0.10, plug width
281
+ * 8.25, plug thickness 2.40.
282
+ */
283
+ /** A cable plug a socket opening must pass, and the overmold that butts the wall around it. */
284
+ export interface Plug {
285
+ name: string;
286
+ overmold_width_mm: number;
287
+ overmold_height_mm: number;
288
+ plug_length_mm: number;
289
+ plug_width_mm: number;
290
+ plug_thickness_mm: number;
291
+ source: string;
292
+ }
293
+
294
+ export const USB_C_PLUG: Plug = {
295
+ name: "USB Type-C plug",
296
+ overmold_width_mm: 12.35,
297
+ overmold_height_mm: 6.5,
298
+ plug_length_mm: 6.65,
299
+ plug_width_mm: 8.25,
300
+ plug_thickness_mm: 2.4,
301
+ source: "https://www.usb.org/sites/default/files/USB_Type-C_Compliance_Document_rev_1_2.pdf, Appendix B, Figure B-1",
302
+ };
package/ops/cad.ts ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The `cad` capability for enclosures (tool-integrations.md §2–§4), with
3
+ * build123d.
4
+ *
5
+ * generate UHD module + assembly + cad.config.ts → native design in
6
+ * <module dir>/build123d/: <id>.job.json (the generator's
7
+ * inputs), <id>.py (the script that builds it, editable),
8
+ * <id>.report.json and <id>.source.json (what the build found,
9
+ * and the digests of the design it built) and build/ (the parts
10
+ * and body it built: a cache, regenerated by export)
11
+ * export build/ → out/: body.step, body.glb, body.manifest.json, one
12
+ * STL and one 3MF per printed part, section.svg. Rebuilds first
13
+ * when the native design was edited since the last build.
14
+ * extract the generated feature frames, by interface id, and the
15
+ * artifacts the module should reference
16
+ * check drift, fit and feature checks (lib/check.ts)
17
+ */
18
+ import { spawn } from "node:child_process";
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
21
+ import { createRequire } from "node:module";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import type { ArtifactDef } from "@deltarobotics/uhd";
25
+ import { stableStringify, type CapabilityContext, type CapabilityOperationFn, type ExtractedFacts } from "@protoboardai/core";
26
+ import { checkEnclosure, nativePaths, readJson, type Fit, type Report } from "../lib/check.ts";
27
+ import { buildJob, type Job } from "../lib/job.ts";
28
+ import { coverArtifacts } from "../lib/features.ts";
29
+
30
+ export const PY_DIR = fileURLToPath(new URL("../py/", import.meta.url));
31
+ export const python = () => process.env.PROTOBOARD_PYTHON || "python3";
32
+ /** ext-harness-cad's py/ folder: its voxel obstacle map and GLB reader are shared, not copied. */
33
+ export const harnessPy = () => join(dirname(createRequire(import.meta.url).resolve("@protoboardai/ext-harness-cad/package.json")), "py");
34
+
35
+ const sha = (b: string | Buffer) => "sha256:" + createHash("sha256").update(b).digest("hex");
36
+
37
+ function env() {
38
+ return { ...process.env, PYTHONHASHSEED: "0", PROTOBOARD_ENCLOSURE_CAD_PY: PY_DIR, PROTOBOARD_HARNESS_CAD_PY: harnessPy() };
39
+ }
40
+
41
+ /**
42
+ * Run the generator's Python. Asynchronous on purpose: a build or fit takes
43
+ * tens of seconds, and blocking the event loop that long stalls whatever
44
+ * shares the process (the dev server, a test runner's worker RPC).
45
+ */
46
+ function runPython(args: string[], cwd: string, input?: string): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
47
+ return new Promise((resolve) => {
48
+ const child = spawn(python(), args, { cwd, env: env(), stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], timeout: 900000 });
49
+ let stdout = "";
50
+ let stderr = "";
51
+ child.stdout!.setEncoding("utf8").on("data", (d: string) => (stdout += d));
52
+ child.stderr!.setEncoding("utf8").on("data", (d: string) => (stderr += d));
53
+ if (input !== undefined) child.stdin!.end(input);
54
+ child.on("error", (e) => resolve({ ok: false, error: e.message.split("\n")[0] }));
55
+ child.on("close", (code, signal) => {
56
+ if (code === 0) return resolve({ ok: true, stdout });
57
+ const message = `${python()} exited with ${code ?? signal}`;
58
+ resolve({ ok: false, error: (stderr || message).trim().split("\n").filter((l) => !/Warning|warnings\.warn|^\s/.test(l)).slice(-2).join(" | ") || message });
59
+ });
60
+ });
61
+ }
62
+
63
+ /** The native build script: loads the job next to it and builds into build/ (or the folder given). */
64
+ export function script(job: Job): string {
65
+ return `"""${job.name}: enclosure for ${job.module}, generated by @protoboardai/ext-enclosure-cad.
66
+
67
+ The inputs are in ${job.name}.job.json: what goes inside (bodies placed in
68
+ this enclosure's coordinates), the features from the module's interfaces,
69
+ and the design values from cad.config.ts. Edit either file to change the
70
+ design by hand; \`protoboard check\` reports where it no longer matches the
71
+ model, and \`protoboard run cad:export\` rebuilds from it.
72
+
73
+ PROTOBOARD_ENCLOSURE_CAD_PY=<ext-enclosure-cad>/py python ${job.name}.py [out-dir]
74
+ """
75
+ import json
76
+ import os
77
+ import sys
78
+ from pathlib import Path
79
+
80
+ HERE = Path(__file__).resolve().parent
81
+ GENERATOR = os.environ.get("PROTOBOARD_ENCLOSURE_CAD_PY")
82
+ if not GENERATOR:
83
+ sys.exit("set PROTOBOARD_ENCLOSURE_CAD_PY to the py/ folder of @protoboardai/ext-enclosure-cad (protoboard run cad:export does this)")
84
+ sys.path.insert(0, GENERATOR)
85
+ from enclosure_cad import build # noqa: E402
86
+
87
+ job = json.loads((HERE / "${job.name}.job.json").read_text())
88
+ report = build(job, Path(sys.argv[1]) if len(sys.argv) > 1 else HERE / "build", (HERE / job["root"]).resolve())
89
+ print(json.dumps({"name": report["name"], "problems": report["problems"], "fit": report["fit"]["bodies"]}))
90
+ `;
91
+ }
92
+
93
+ function files(dir: string, prefix = ""): string[] {
94
+ if (!existsSync(dir)) return [];
95
+ return readdirSync(dir)
96
+ .sort()
97
+ .flatMap((f) => (statSync(join(dir, f)).isDirectory() ? files(join(dir, f), `${prefix}${f}/`) : [`${prefix}${f}`]));
98
+ }
99
+
100
+ /** Run the native script into a scratch folder, then register everything it built under build/. */
101
+ async function buildNative(ctx: CapabilityContext): Promise<{ report?: Report; error?: string; files: number }> {
102
+ const p = ctx.project;
103
+ const paths = nativePaths(ctx.module.native, ctx.module.id);
104
+ const scratch = join(p.root, ".protoboard", "cache", "enclosure-cad", ctx.module.id);
105
+ rmSync(scratch, { recursive: true, force: true });
106
+ mkdirSync(scratch, { recursive: true });
107
+ const r = await runPython([join(p.root, paths.script), scratch], p.root);
108
+ if (!r.ok) return { error: r.error, files: 0 };
109
+ rmSync(join(p.root, paths.build), { recursive: true, force: true });
110
+ // the report sits next to the job, outside build/, so a project that commits its native design and out/ can be checked without rebuilding
111
+ const built = files(scratch).filter((f) => f !== "report.json");
112
+ for (const f of built) ctx.writeOutput(`${paths.build}/${f}`, readFileSync(join(scratch, f)), `${ctx.module.id}: ${f}`);
113
+ ctx.writeOutput(paths.report, readFileSync(join(scratch, "report.json")), `${ctx.module.id}: what the build found (cavity, features, walls, fit)`);
114
+ const source = { script: sha(readFileSync(join(p.root, paths.script))), job: sha(readFileSync(join(p.root, paths.job))) };
115
+ ctx.writeOutput(paths.source, stableStringify(source), "digests of the native design this build came from");
116
+ rmSync(scratch, { recursive: true, force: true });
117
+ return { report: JSON.parse(readFileSync(join(p.root, paths.report), "utf8")) as Report, files: built.length + 2 };
118
+ }
119
+
120
+ const fitSummary = (r: Report) => r.fit.bodies.map((b) => `${b.key} ${b.status}${b.min_clearance_mm !== undefined ? ` (${b.min_clearance_mm} mm)` : ""}`).join(", ");
121
+
122
+ export const generate: CapabilityOperationFn = async (ctx) => {
123
+ const p = ctx.project;
124
+ const def = ctx.module.definition;
125
+ const binding = { constraints: ctx.constraints, process: ctx.process, native: ctx.module.native, module: def.id, instances: ctx.module.instances };
126
+ const built = buildJob(p, binding, def, ctx.module.instance);
127
+ if (!built.job || built.problems.length) return { status: "failed", summary: `${def.id}: ${built.problems[0] ?? "no job"}`, data: { problems: built.problems, notes: built.notes } };
128
+ const job = built.job;
129
+ const paths = nativePaths(ctx.module.native, def.id);
130
+ ctx.writeOutput(paths.job, JSON.stringify(job, null, 2) + "\n", `${def.id}: generator inputs from the model, the assembly and cad.config.ts`);
131
+ ctx.writeOutput(paths.script, script(job), `${def.id}: build123d build script`);
132
+ const b = await buildNative(ctx);
133
+ if (!b.report) return { status: "failed", summary: `${def.id}: generator failed: ${b.error}` };
134
+ const r = b.report;
135
+ for (const note of built.notes) ctx.log(note);
136
+ ctx.log(`${def.id}: ${Object.keys(r.parts).join(", ")}; fit: ${fitSummary(r)}`);
137
+ return {
138
+ status: "succeeded",
139
+ summary: `${def.id}: ${Object.keys(r.parts).length} part(s) (${Object.keys(r.parts).join(", ")}), ${Object.keys(r.features).length} feature(s); fit: ${fitSummary(r)}${r.problems.length ? `; ${r.problems.length} problem(s): ${r.problems[0]}` : ""}`,
140
+ data: { native: ctx.module.native, parts: r.parts, cavity: r.cavity, overall: r.overall, features: Object.keys(r.features), problems: r.problems, notes: built.notes, fit: r.fit, toolVersion: r.toolVersion },
141
+ };
142
+ };
143
+
144
+ export const exportOutputs: CapabilityOperationFn = async (ctx) => {
145
+ const p = ctx.project;
146
+ const id = ctx.module.id;
147
+ const paths = nativePaths(ctx.module.native, id);
148
+ if (!existsSync(join(p.root, paths.script)) || !existsSync(join(p.root, paths.job))) return { status: "failed", summary: `${id} has no native design in ${ctx.module.native}/; run cad:generate first` };
149
+ const source = readJson<{ script: string; job: string }>(join(p.root, paths.source));
150
+ const now = { script: sha(readFileSync(join(p.root, paths.script))), job: sha(readFileSync(join(p.root, paths.job))) };
151
+ let rebuilt = false;
152
+ if (!source || source.script !== now.script || source.job !== now.job || !existsSync(join(p.root, paths.report))) {
153
+ const b = await buildNative(ctx);
154
+ if (!b.report) return { status: "failed", summary: `${id}: building the native design failed: ${b.error}` };
155
+ rebuilt = true;
156
+ }
157
+ const report = readJson<Report>(join(p.root, paths.report))!;
158
+ const job = readJson<Job>(join(p.root, paths.job))!;
159
+ const from = (f: string) => readFileSync(join(p.root, paths.build, f));
160
+ const out = ctx.module.out;
161
+ for (const f of ["body.step", "body.glb", "body.manifest.json"]) ctx.writeOutput(`${out}/${f}`, from(f), `${id}: ${f}`);
162
+ if (existsSync(join(p.root, paths.build, "section.svg"))) ctx.writeOutput(`${out}/section.svg`, from("section.svg"), `${id}: section through the cavity`);
163
+ const parts = Object.keys(report.parts);
164
+ for (const c of Object.values(report.covers ?? {})) {
165
+ for (const fmt of ["step", "glb", "dxf"]) ctx.writeOutput(`${out}/${c.part}.${fmt}`, from(`covers/${c.part}.${fmt}`), `${id}: ${c.part} (${fmt.toUpperCase()}${fmt === "dxf" ? ", outline for cutting" : ""})`);
166
+ }
167
+ if (job.process === "print") {
168
+ for (const part of parts) {
169
+ ctx.writeOutput(`${out}/${part}.stl`, from(`parts/${part}.stl`), `${id}: ${part} for printing (STL)`);
170
+ ctx.writeOutput(`${out}/${part}.3mf`, from(`parts/${part}.3mf`), `${id}: ${part} for printing (3MF)`);
171
+ }
172
+ }
173
+ return { status: "succeeded", summary: `${id}: body STEP/GLB/manifest${job.process === "print" ? ` and STL + 3MF for ${parts.join(", ")}` : ""} in ${out}/${rebuilt ? " (rebuilt from the edited native design)" : ""}`, data: { parts, rebuilt } };
174
+ };
175
+
176
+ export const extract: CapabilityOperationFn = (ctx) => {
177
+ const p = ctx.project;
178
+ const id = ctx.module.id;
179
+ const paths = nativePaths(ctx.module.native, id);
180
+ const report = readJson<Report>(join(p.root, paths.report));
181
+ if (!report) return { status: "failed", summary: `${id} has no built design (${paths.report}); run cad:generate` };
182
+ const interfaces = new Set(ctx.module.definition.interfaces.map((i) => i.id));
183
+ const frames = Object.fromEntries(Object.entries(report.features).filter(([fid, f]) => interfaces.has(fid) && f.frame).map(([fid, f]) => [fid, f.frame!]));
184
+ const out = ctx.module.out;
185
+ const have = (f: string) => existsSync(join(p.root, f));
186
+ const artifacts: ArtifactDef[] = [{ id: "cad_source", name: `${id} build123d design`, type: "cad", role: "source", format: "build123d", filePath: ctx.module.native }];
187
+ const prov = { tool: "build123d", toolVersion: report.toolVersion, sourceDigest: report.sourceDigest };
188
+ if (have(`${out}/body.step`)) {
189
+ artifacts.push(
190
+ { id: "cad_step", name: `${id} body`, type: "cad", role: "body", format: "step", units: "mm", filePath: `${out}/body.step`, provenance: { generatedFrom: "cad_source", ...prov } },
191
+ { id: "cad_glb", name: `${id} body (GLB)`, type: "cad", role: "body", format: "glb", units: "mm", filePath: `${out}/body.glb`, provenance: { generatedFrom: "cad_step", ...prov } },
192
+ { id: "cad_manifest", name: `${id} geometry manifest`, type: "cad", format: "json", filePath: `${out}/body.manifest.json`, provenance: { generatedFrom: "cad_step", ...prov } },
193
+ );
194
+ for (const part of Object.keys(report.parts)) {
195
+ for (const fmt of ["stl", "3mf"]) {
196
+ if (have(`${out}/${part}.${fmt}`)) artifacts.push({ id: `${part}_${fmt}`, name: `${part} (${fmt.toUpperCase()}, for printing)`, type: "cad", role: "body", format: fmt, units: "mm", filePath: `${out}/${part}.${fmt}`, tags: ["manufacturing", "print"], provenance: { generatedFrom: "cad_step", ...prov } });
197
+ }
198
+ }
199
+ }
200
+ // window covers are separate parts (cut, not printed): their artifacts are for the modules that stand for them
201
+ // (coverArtifacts in lib/), not the case's
202
+ const covers = Object.fromEntries(
203
+ Object.entries(report.covers ?? {}).map(([fid, c]) => [fid, { ...c, artifacts: coverArtifacts(out, fid).filter((a) => have(a.filePath!)).map((a) => ({ ...a, provenance: { ...a.provenance, ...prov } })) }]),
204
+ );
205
+ const facts: ExtractedFacts & { covers: typeof covers } = { frames, artifacts, covers };
206
+ const missing = [...interfaces].filter((i) => !frames[i] && ctx.module.definition.interfaces.find((x) => x.id === i)?.traits?.some((t) => t.type === "enclosure_feature"));
207
+ return {
208
+ status: "succeeded",
209
+ summary: `${id}: ${Object.keys(frames).length} frame(s), ${artifacts.length} artifact(s)${have(`${out}/body.step`) ? "" : " (not exported yet: native source only)"}${missing.length ? `; no frame for ${missing.join(", ")}` : ""}`,
210
+ data: facts,
211
+ };
212
+ };
213
+
214
+ export const check: CapabilityOperationFn = async (ctx) => {
215
+ const p = ctx.project;
216
+ const def = ctx.module.definition;
217
+ const binding = { constraints: ctx.constraints, process: ctx.process, native: ctx.module.native, module: def.id, instances: ctx.module.instances };
218
+ const current = buildJob(p, binding, def, ctx.module.instance);
219
+ const paths = nativePaths(ctx.module.native, def.id);
220
+ const refit = async (): Promise<Fit | { error: string }> => {
221
+ const report = readJson<Report>(join(p.root, paths.report));
222
+ const job = current.job;
223
+ if (!report || !job) return { error: "no current job" };
224
+ // the parts as built: the exported body (one node per part) when there is one, else the native build
225
+ const body = `${ctx.module.out}/body.glb`;
226
+ const byNode = existsSync(join(p.root, body));
227
+ const glb = (part: string) => (byNode ? body : `${paths.build}/parts/${part}.glb`);
228
+ const partNames = Object.keys(report.parts);
229
+ if (!byNode && partNames.some((k) => !existsSync(join(p.root, glb(k))))) return { error: `neither ${body} nor ${paths.build}/parts/ is there to analyse; run cad:export` };
230
+ const zones: Record<string, unknown[]> = {};
231
+ for (const f of job.features.filter((x) => x.kind === "boss")) {
232
+ const g = report.features[f.id];
233
+ if (!g) continue;
234
+ const top = g.top_z as number;
235
+ for (const key of f.mates ?? []) zones[key] = [...(zones[key] ?? []), ...(g.holes as number[][]).map((h) => ({ centre: h, radius: (g.boss_d_mm as number) / 2 + job.design.clearance + 0.5, z: [top - 1, top + 1] }))];
236
+ }
237
+ const res = job.design.resolution;
238
+ const plungers = Object.entries(report.features)
239
+ .filter(([, g]) => g.kind === "button" && partNames.includes(g.plunger as string))
240
+ .map(([fid, g]) => ({
241
+ key: g.plunger as string,
242
+ feature: fid,
243
+ glb: glb(g.plunger as string),
244
+ zones: [{ centre: g.tip, axis: g.frame?.normal, radius: (g.shaft_d_mm as number) / 2 + 0.01, z: [-0.01, (g.gap_mm as number) + res] }],
245
+ }));
246
+ const spec = {
247
+ root: p.root,
248
+ by_node: byNode,
249
+ parts: Object.fromEntries(partNames.filter((k) => !k.startsWith("plunger")).map((k) => [k, glb(k)])),
250
+ plungers,
251
+ contents: job.contents,
252
+ zones,
253
+ cavity: report.cavity,
254
+ resolution: job.design.resolution,
255
+ clearance: job.design.clearance,
256
+ };
257
+ // the spec goes in on stdin: check is pure and writes nothing
258
+ const r = await runPython([join(PY_DIR, "enclosure_cad.py"), "fit", "-"], p.root, JSON.stringify(spec));
259
+ return r.ok ? (JSON.parse(r.stdout.trim().split("\n").pop()!) as Fit) : { error: r.error };
260
+ };
261
+ const diagnostics = await checkEnclosure({ p, def, native: ctx.module.native, config: `${ctx.module.dir}/cad.config.ts`, current, refit });
262
+ const n = (s: string) => diagnostics.filter((d) => d.severity === s).length;
263
+ return { status: "succeeded", summary: `${def.id}: ${n("error")} error(s), ${n("warning")} warning(s)`, diagnostics };
264
+ };