@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.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +388 -2
- package/evals/fit-grown-board/case.ts +71 -0
- package/lib/check.ts +379 -0
- package/lib/features.ts +424 -0
- package/lib/index.ts +8 -0
- package/lib/job.ts +388 -0
- package/lib/pyjson.ts +68 -0
- package/lib/standards.ts +302 -0
- package/ops/cad.ts +264 -0
- package/ops/package.ts +213 -0
- package/package.json +41 -4
- package/protoboard-extension.json +69 -0
- package/py/enclosure_cad.py +1731 -0
- package/py/fit.py +341 -0
- package/skills/enclosure-cad/SKILL.md +194 -0
package/lib/features.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enclosure features as UHD interfaces.
|
|
3
|
+
*
|
|
4
|
+
* An enclosure is a module whose interfaces are the features it must
|
|
5
|
+
* provide. Each feature is an ordinary mechanical interface carrying one
|
|
6
|
+
* `enclosure_feature` trait: what kind of feature it is, its size in mm, the
|
|
7
|
+
* standard part it takes (O-ring, insert, spring bar) and, for features that
|
|
8
|
+
* give access to something inside, the part it serves. Nothing here names a
|
|
9
|
+
* CAD tool: an Onshape or Zoo provider reads the same interfaces.
|
|
10
|
+
*
|
|
11
|
+
* opening a cut through a wall for a connector, a port or a vent
|
|
12
|
+
* button a bore and a separate plunger pressing a switch inside
|
|
13
|
+
* window an aperture with a pocket for a cover (glass, IR window)
|
|
14
|
+
* light_pipe a hole for a light pipe over an LED
|
|
15
|
+
* boss bosses with heat-set insert holes (a bolt pattern)
|
|
16
|
+
* seal_gland an O-ring groove: face (at the lid joint or at a frame) or radial
|
|
17
|
+
* thread an internal thread (a cap on a neck)
|
|
18
|
+
* strap_lugs a pair of lugs with spring-bar holes
|
|
19
|
+
* joint the lid joint and its fasteners
|
|
20
|
+
* twist_lid a lid that closes without screws: a threaded or bayonet collar
|
|
21
|
+
* on a round base, the lid's skirt over it, sealed by a radial
|
|
22
|
+
* O-ring on the collar (a SealGland radial at "joint")
|
|
23
|
+
*
|
|
24
|
+
* `serves` names an interface of a sibling module in the system the
|
|
25
|
+
* enclosure sits in ({ child: "board", interfaceId: "usb" }); the feature is
|
|
26
|
+
* placed on, and checked against, that interface's frame. This relation is a
|
|
27
|
+
* candidate for a UHD link kind; until then it is a trait parameter.
|
|
28
|
+
*/
|
|
29
|
+
import { BoltPattern, type BoltPatternConfig, type ArtifactDef, type GeometryFrame, type InterfaceDef, type ModuleDef } from "@deltarobotics/uhd";
|
|
30
|
+
import { as568, glandRow, lugHole, type Insert, type ORing, type Plug, type Screw, type SpringBar } from "./standards.ts";
|
|
31
|
+
|
|
32
|
+
export const FEATURE_TRAIT = "enclosure_feature";
|
|
33
|
+
export const FEATURE_KINDS = ["opening", "button", "window", "light_pipe", "boss", "seal_gland", "thread", "strap_lugs", "joint", "twist_lid"] as const;
|
|
34
|
+
export type FeatureKind = (typeof FEATURE_KINDS)[number];
|
|
35
|
+
/** Features that mate with something outside the enclosure (or in its wall: a window's cover in its pocket): what they hold is not inside it. */
|
|
36
|
+
export const EXTERNAL_KINDS: FeatureKind[] = ["thread", "strap_lugs", "window"];
|
|
37
|
+
|
|
38
|
+
/** An interface of a sibling module, in the system the enclosure sits in. */
|
|
39
|
+
export interface Serves {
|
|
40
|
+
child: string;
|
|
41
|
+
interfaceId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where a stated value comes from: a source, or why it was chosen. */
|
|
45
|
+
export interface Provenance {
|
|
46
|
+
source?: string;
|
|
47
|
+
assumption?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Base extends Provenance {
|
|
51
|
+
id: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
/** Where the feature is (module coordinates, mm; normal pointing out of the enclosure). */
|
|
54
|
+
frame?: GeometryFrame;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const feature = (c: Base, kind: FeatureKind, params: Record<string, unknown>, extra: Partial<InterfaceDef> = {}): InterfaceDef => {
|
|
58
|
+
const p = Object.fromEntries(Object.entries({ kind, ...params, source: c.source, assumption: c.assumption }).filter(([, v]) => v !== undefined));
|
|
59
|
+
return {
|
|
60
|
+
id: c.id,
|
|
61
|
+
...(c.name ? { name: c.name } : {}),
|
|
62
|
+
domain: "mechanical",
|
|
63
|
+
exposed: true,
|
|
64
|
+
protocols: [],
|
|
65
|
+
...extra,
|
|
66
|
+
traits: [...(extra.traits ?? []), { type: FEATURE_TRAIT, params: p }],
|
|
67
|
+
...(c.frame ? { geometry: { ...(extra.geometry ?? {}), frame: c.frame } } : {}),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export interface OpeningConfig extends Base {
|
|
72
|
+
serves?: Serves;
|
|
73
|
+
shape?: "rect" | "round";
|
|
74
|
+
widthMm?: number;
|
|
75
|
+
heightMm?: number;
|
|
76
|
+
diameterMm?: number;
|
|
77
|
+
cornerRadiusMm?: number;
|
|
78
|
+
/** Deepest the served part may sit behind the outer surface (a plug must still seat). */
|
|
79
|
+
maxRecessMm?: number;
|
|
80
|
+
/** The cable plug the opening takes (USB_C_PLUG): it must pass, and its overmold needs a flat seat around the opening. */
|
|
81
|
+
plug?: Plug;
|
|
82
|
+
/**
|
|
83
|
+
* "flat": on a round wall, add a flat seat for the plug's overmold (material out to the plane through the
|
|
84
|
+
* opening's outermost point, the overmold's face plus 0.3 mm a side). Without it the curve leaves a gap under
|
|
85
|
+
* the overmold's ends (ENCLOSURE_PLUG_FIT).
|
|
86
|
+
*/
|
|
87
|
+
seat?: "flat";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function Opening(c: OpeningConfig): InterfaceDef {
|
|
91
|
+
return feature(c, "opening", {
|
|
92
|
+
serves: c.serves,
|
|
93
|
+
shape: c.shape ?? (c.diameterMm !== undefined ? "round" : "rect"),
|
|
94
|
+
width_mm: c.widthMm,
|
|
95
|
+
height_mm: c.heightMm,
|
|
96
|
+
diameter_mm: c.diameterMm,
|
|
97
|
+
corner_radius_mm: c.cornerRadiusMm,
|
|
98
|
+
max_recess_mm: c.maxRecessMm,
|
|
99
|
+
plug: c.plug,
|
|
100
|
+
seat: c.seat,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ButtonConfig extends Base {
|
|
105
|
+
serves: Serves;
|
|
106
|
+
/** Bore through the wall for the plunger. */
|
|
107
|
+
holeDiameterMm: number;
|
|
108
|
+
/** Actuator travel of the switch (from its datasheet). */
|
|
109
|
+
travelMm?: number;
|
|
110
|
+
/** Gap left between the plunger tip and the actuator at rest. */
|
|
111
|
+
gapMm?: number;
|
|
112
|
+
protrudeMm?: number;
|
|
113
|
+
flangeDiameterMm?: number;
|
|
114
|
+
flangeMm?: number;
|
|
115
|
+
clearanceMm?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function Button(c: ButtonConfig): InterfaceDef {
|
|
119
|
+
return feature(c, "button", {
|
|
120
|
+
serves: c.serves,
|
|
121
|
+
hole_d_mm: c.holeDiameterMm,
|
|
122
|
+
travel_mm: c.travelMm,
|
|
123
|
+
gap_mm: c.gapMm,
|
|
124
|
+
protrude_mm: c.protrudeMm,
|
|
125
|
+
flange_d_mm: c.flangeDiameterMm,
|
|
126
|
+
flange_mm: c.flangeMm,
|
|
127
|
+
clearance_mm: c.clearanceMm,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type Outline = { widthMm: number; heightMm: number } | { diameterMm: number };
|
|
132
|
+
const outline = (o: Outline) => ("diameterMm" in o ? { diameter_mm: o.diameterMm } : { width_mm: o.widthMm, height_mm: o.heightMm });
|
|
133
|
+
|
|
134
|
+
export interface WindowConfig extends Base {
|
|
135
|
+
serves?: Serves;
|
|
136
|
+
aperture: Outline;
|
|
137
|
+
/** The cover: glass, a lens, an IR-transparent window. */
|
|
138
|
+
cover: Outline & { thicknessMm: number; material?: string };
|
|
139
|
+
/** Pocket for the cover on the outside (flush) or the inside of the wall. */
|
|
140
|
+
side?: "outside" | "inside";
|
|
141
|
+
cornerRadiusMm?: number;
|
|
142
|
+
clearanceMm?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function Window(c: WindowConfig): InterfaceDef {
|
|
146
|
+
return feature(c, "window", {
|
|
147
|
+
serves: c.serves,
|
|
148
|
+
aperture: outline(c.aperture),
|
|
149
|
+
cover: { ...outline(c.cover), thickness_mm: c.cover.thicknessMm, ...(c.cover.material ? { material: c.cover.material } : {}) },
|
|
150
|
+
side: c.side,
|
|
151
|
+
corner_radius_mm: c.cornerRadiusMm,
|
|
152
|
+
clearance_mm: c.clearanceMm,
|
|
153
|
+
}, {
|
|
154
|
+
// the cover's module (coverArtifacts) mates its seat here: custom peer, capability window_cover
|
|
155
|
+
protocols: [{ type: "custom", roles: ["peer"] }],
|
|
156
|
+
capabilities: ["window_cover"],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface LightPipeConfig extends Base {
|
|
161
|
+
serves?: Serves;
|
|
162
|
+
diameterMm: number;
|
|
163
|
+
clearanceMm?: number;
|
|
164
|
+
/** What fills the hole after printing (clear UV-cure resin): a post-processing step of the print order. */
|
|
165
|
+
fill?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function LightPipe(c: LightPipeConfig): InterfaceDef {
|
|
169
|
+
return feature(c, "light_pipe", { serves: c.serves, diameter_mm: c.diameterMm, clearance_mm: c.clearanceMm, fill: c.fill });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface BossConfig extends Omit<BoltPatternConfig, "role" | "threaded"> {
|
|
173
|
+
frame: GeometryFrame;
|
|
174
|
+
insert: Insert;
|
|
175
|
+
/** Boss outside diameter; default: insert hole + 2 × its minimum wall. */
|
|
176
|
+
bossDiameterMm?: number;
|
|
177
|
+
source?: string;
|
|
178
|
+
assumption?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Bosses with heat-set inserts: a structure bolt pattern whose frame is the boss tops (normal +Z). */
|
|
182
|
+
export function InsertBosses(c: BossConfig): InterfaceDef {
|
|
183
|
+
const { frame, insert, bossDiameterMm, source, assumption, ...pattern } = c;
|
|
184
|
+
const bp = BoltPattern({ ...pattern, role: "structure", threaded: true, note: pattern.note ?? `heat-set inserts (${insert.name})` });
|
|
185
|
+
return feature({ id: c.id, name: c.name, frame, source, assumption }, "boss", { insert, boss_d_mm: bossDiameterMm }, bp);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface SealGlandConfig extends Base {
|
|
189
|
+
type: "face" | "radial";
|
|
190
|
+
/** An AS568 size ("AS568-033") or the O-ring's dimensions. */
|
|
191
|
+
oring: string | ORing;
|
|
192
|
+
/**
|
|
193
|
+
* "joint": the lid joint's seal. A face gland's groove goes in the base rim; a radial gland's
|
|
194
|
+
* groove goes round the base's collar under a TwistLid's skirt (the skirt's bore is the sealing
|
|
195
|
+
* surface). Otherwise the frame places the gland.
|
|
196
|
+
*/
|
|
197
|
+
at?: "joint";
|
|
198
|
+
/** Which side the pressure is on (decides whether the groove is sized by the O-ring's ID or OD). */
|
|
199
|
+
pressure?: "external" | "internal";
|
|
200
|
+
/** Radial glands: the rod or tube the O-ring seals on. */
|
|
201
|
+
rodDiameterMm?: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function SealGland(c: SealGlandConfig): InterfaceDef {
|
|
205
|
+
const o = typeof c.oring === "string" ? as568(c.oring) : c.oring;
|
|
206
|
+
if (!o) throw new Error(`${c.id}: unknown O-ring ${String(c.oring)} (give an AS568 size or its dimensions)`);
|
|
207
|
+
return feature(c, "seal_gland", { type: c.type, oring: o, at: c.at, pressure: c.pressure, rod_d_mm: c.rodDiameterMm });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface ThreadConfig extends Base {
|
|
211
|
+
/** The neck's thread: major diameter (T), root diameter (E), pitch. */
|
|
212
|
+
majorDiameterMm: number;
|
|
213
|
+
rootDiameterMm: number;
|
|
214
|
+
pitchMm: number;
|
|
215
|
+
/** Skirt length holding the thread. */
|
|
216
|
+
lengthMm: number;
|
|
217
|
+
clearanceMm?: number;
|
|
218
|
+
crestWidthMm?: number;
|
|
219
|
+
flankAngleDeg?: number;
|
|
220
|
+
skirtWallMm?: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function Thread(c: ThreadConfig): InterfaceDef {
|
|
224
|
+
return feature(c, "thread", {
|
|
225
|
+
major_d_mm: c.majorDiameterMm,
|
|
226
|
+
root_d_mm: c.rootDiameterMm,
|
|
227
|
+
pitch_mm: c.pitchMm,
|
|
228
|
+
length_mm: c.lengthMm,
|
|
229
|
+
clearance_mm: c.clearanceMm,
|
|
230
|
+
crest_width_mm: c.crestWidthMm,
|
|
231
|
+
flank_angle_deg: c.flankAngleDeg,
|
|
232
|
+
skirt_wall_mm: c.skirtWallMm,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface StrapLugsConfig extends Base {
|
|
237
|
+
end: "+y" | "-y";
|
|
238
|
+
springBar: SpringBar;
|
|
239
|
+
/** Gap between the strap and the case under the bar. */
|
|
240
|
+
strapGapMm?: number;
|
|
241
|
+
lugWidthMm?: number;
|
|
242
|
+
lugHeightMm?: number;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function StrapLugs(c: StrapLugsConfig): InterfaceDef {
|
|
246
|
+
const h = lugHole(c.springBar);
|
|
247
|
+
return feature(c, "strap_lugs", {
|
|
248
|
+
end: c.end,
|
|
249
|
+
spring_bar: c.springBar,
|
|
250
|
+
strap_width_mm: c.springBar.lug_gap_mm,
|
|
251
|
+
bar_d_mm: c.springBar.bar_d_mm,
|
|
252
|
+
hole_d_mm: h.hole_d_mm,
|
|
253
|
+
hole_depth_mm: h.hole_depth_mm,
|
|
254
|
+
strap_gap_mm: c.strapGapMm,
|
|
255
|
+
lug_width_mm: c.lugWidthMm,
|
|
256
|
+
lug_height_mm: c.lugHeightMm,
|
|
257
|
+
}, {
|
|
258
|
+
// a spring bar part's bar interface (custom peer, capability strap_bar) links to the lugs that hold it
|
|
259
|
+
protocols: [{ type: "custom", roles: ["peer"] }],
|
|
260
|
+
capabilities: ["strap_bar"],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface JointConfig extends Base {
|
|
265
|
+
screw: Screw;
|
|
266
|
+
insert: Insert;
|
|
267
|
+
count?: number;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The lid joint: where the parts split and what holds them together. */
|
|
271
|
+
export function Joint(c: JointConfig): InterfaceDef {
|
|
272
|
+
// the joint links to itself through the fastener harness that closes it (its fastenerStack places the
|
|
273
|
+
// screws and inserts at the columns the provider reports): custom peer, capability lid_joint
|
|
274
|
+
return feature(c, "joint", { screw: c.screw, insert: c.insert, count: c.count ?? 4 }, { protocols: [{ type: "custom", roles: ["peer"] }], capabilities: ["lid_joint"] });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface TwistLidConfig extends Base {
|
|
278
|
+
/**
|
|
279
|
+
* How the lid holds: "thread" (an external thread on the base's collar, an internal one in the lid's
|
|
280
|
+
* skirt) or "bayonet" (lugs on the collar entering L-slots in the skirt; the slot's end fixes the
|
|
281
|
+
* lid's angle, and a key lug (keyLugWidthMm) lets it go on one way only, so features in the lid,
|
|
282
|
+
* such as light pipes, line up with what is under them).
|
|
283
|
+
*/
|
|
284
|
+
lock: "thread" | "bayonet";
|
|
285
|
+
/** Thread: pitch (mm), depth of the profile (radial, mm), flank half-angle (deg, default 30: 60° from the bed when printed upright). */
|
|
286
|
+
pitchMm?: number;
|
|
287
|
+
depthMm?: number;
|
|
288
|
+
flankAngleDeg?: number;
|
|
289
|
+
/** Turns of engagement (thread; default 2). */
|
|
290
|
+
turns?: number;
|
|
291
|
+
/** Bayonet: lug count (default 3), lug width along the collar (mm), radial depth and height (mm), and the twist that locks it (deg). */
|
|
292
|
+
lugs?: number;
|
|
293
|
+
lugWidthMm?: number;
|
|
294
|
+
lugDepthMm?: number;
|
|
295
|
+
lugHeightMm?: number;
|
|
296
|
+
twistDeg?: number;
|
|
297
|
+
/**
|
|
298
|
+
* Bayonet: the first lug's width (the one on +Y), wider than the others plus twice the clearance, so
|
|
299
|
+
* it enters only its own slot and the lid goes on in one position. Without it, equal lugs let the lid
|
|
300
|
+
* go on in as many positions as there are lugs, which the build refuses unless orientation is "any".
|
|
301
|
+
*/
|
|
302
|
+
keyLugWidthMm?: number;
|
|
303
|
+
/** Bayonet: "keyed" (default: the lid must go on one way) or "any" (nothing in the lid needs lining up). */
|
|
304
|
+
orientation?: "keyed" | "any";
|
|
305
|
+
/**
|
|
306
|
+
* How much larger (radial, mm) the skirt's bore is drawn than the bore it should print at (default 0,
|
|
307
|
+
* a machined bore). A printed bore comes out undersize; the O-ring groove's depth is measured from
|
|
308
|
+
* the printed bore, so Parker's squeeze holds when the print shrinks by this much.
|
|
309
|
+
*/
|
|
310
|
+
boreAllowanceMm?: number;
|
|
311
|
+
/** Radial clearance between the collar's lock and the skirt (mm, default 0.2: a printed fit). */
|
|
312
|
+
clearanceMm?: number;
|
|
313
|
+
/** Axial clearance in the lock (mm, default 0.15). */
|
|
314
|
+
axialClearanceMm?: number;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A lid that twists on instead of screwing down: the base's wall steps in to a collar at the top,
|
|
319
|
+
* the lid's skirt slides over it and locks (a thread, or a bayonet with lugs and L-slots), and a
|
|
320
|
+
* radial O-ring in a groove round the collar seals against the skirt's bore (a SealGland radial at
|
|
321
|
+
* "joint"). The shell's outside stays one diameter, so nothing reaches past it (no screw lugs).
|
|
322
|
+
*/
|
|
323
|
+
export function TwistLid(c: TwistLidConfig): InterfaceDef {
|
|
324
|
+
return feature(c, "twist_lid", {
|
|
325
|
+
lock: c.lock,
|
|
326
|
+
pitch_mm: c.pitchMm,
|
|
327
|
+
depth_mm: c.depthMm,
|
|
328
|
+
flank_angle_deg: c.flankAngleDeg,
|
|
329
|
+
turns: c.turns,
|
|
330
|
+
lugs: c.lugs,
|
|
331
|
+
lug_width_mm: c.lugWidthMm,
|
|
332
|
+
lug_depth_mm: c.lugDepthMm,
|
|
333
|
+
lug_height_mm: c.lugHeightMm,
|
|
334
|
+
twist_deg: c.twistDeg,
|
|
335
|
+
key_lug_width_mm: c.keyLugWidthMm,
|
|
336
|
+
orientation: c.orientation,
|
|
337
|
+
bore_allowance_mm: c.boreAllowanceMm,
|
|
338
|
+
clearance_mm: c.clearanceMm,
|
|
339
|
+
axial_clearance_mm: c.axialClearanceMm,
|
|
340
|
+
}, { protocols: [{ type: "custom", roles: ["peer"] }], capabilities: ["lid_joint"] });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Reading features back from a module
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
export interface FeatureSpec {
|
|
348
|
+
id: string;
|
|
349
|
+
kind: FeatureKind;
|
|
350
|
+
params: Record<string, unknown>;
|
|
351
|
+
serves?: Serves;
|
|
352
|
+
/** The frame the module declares, if any. */
|
|
353
|
+
frame?: GeometryFrame;
|
|
354
|
+
iface: InterfaceDef;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const num = (v: unknown) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
358
|
+
|
|
359
|
+
/** What each kind needs (positive numbers, or present objects). */
|
|
360
|
+
const REQUIRED: Record<FeatureKind, string[]> = {
|
|
361
|
+
opening: [],
|
|
362
|
+
button: ["hole_d_mm"],
|
|
363
|
+
window: ["aperture", "cover"],
|
|
364
|
+
light_pipe: ["diameter_mm"],
|
|
365
|
+
boss: ["insert"],
|
|
366
|
+
seal_gland: ["type", "oring"],
|
|
367
|
+
thread: ["major_d_mm", "root_d_mm", "pitch_mm", "length_mm"],
|
|
368
|
+
strap_lugs: ["end", "strap_width_mm", "bar_d_mm", "hole_d_mm", "hole_depth_mm"],
|
|
369
|
+
joint: [],
|
|
370
|
+
twist_lid: ["lock"],
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/** The module's enclosure features, and what is wrong with them. */
|
|
374
|
+
export function readFeatures(def: ModuleDef): { features: FeatureSpec[]; problems: { interface: string; message: string }[] } {
|
|
375
|
+
const features: FeatureSpec[] = [];
|
|
376
|
+
const problems: { interface: string; message: string }[] = [];
|
|
377
|
+
for (const iface of def.interfaces) {
|
|
378
|
+
const t = iface.traits?.find((x) => x.type === FEATURE_TRAIT);
|
|
379
|
+
if (!t) continue;
|
|
380
|
+
const { kind, serves, ...params } = (t.params ?? {}) as Record<string, unknown> & { kind?: string; serves?: Serves };
|
|
381
|
+
const bad = (message: string) => problems.push({ interface: iface.id, message });
|
|
382
|
+
if (!FEATURE_KINDS.includes(kind as FeatureKind)) {
|
|
383
|
+
bad(`unknown feature kind ${JSON.stringify(kind)} (known: ${FEATURE_KINDS.join(", ")})`);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const k = kind as FeatureKind;
|
|
387
|
+
const missing = REQUIRED[k].filter((key) => params[key] === undefined || (typeof params[key] === "number" && !num(params[key])));
|
|
388
|
+
if (missing.length) bad(`${k} needs ${missing.join(", ")}`);
|
|
389
|
+
if (k === "opening") {
|
|
390
|
+
const round = params.shape === "round";
|
|
391
|
+
if (round ? !num(params.diameter_mm) : !(num(params.width_mm) && num(params.height_mm))) bad(round ? "a round opening needs diameter_mm" : "an opening needs width_mm and height_mm");
|
|
392
|
+
}
|
|
393
|
+
if (["opening", "button", "window", "light_pipe"].includes(k) && !serves && !iface.geometry?.frame) bad(`${k} needs a frame or the part it serves (serves: { child, interfaceId })`);
|
|
394
|
+
if (k === "button" && !serves) bad("a button needs the switch it serves (serves: { child, interfaceId })");
|
|
395
|
+
if (k === "boss" && !iface.geometry?.frame) bad("bosses need a frame (the boss tops, normal +Z)");
|
|
396
|
+
if (k === "seal_gland") {
|
|
397
|
+
const o = params.oring as ORing | undefined;
|
|
398
|
+
if (params.type !== "face" && params.type !== "radial") bad('seal_gland type must be "face" or "radial"');
|
|
399
|
+
else if (o && !glandRow(params.type as "face" | "radial", o.w_mm)) bad(`no ${params.type} gland row for a ${o.w_mm} mm O-ring cross-section (Parker ORD 5700 Design Charts 4-1, 4-2)`);
|
|
400
|
+
if (params.type === "radial" && params.at !== "joint" && !num(params.rod_d_mm)) bad("a radial gland needs rod_d_mm (what the O-ring seals on), or at: \"joint\" under a TwistLid");
|
|
401
|
+
if (params.at !== "joint" && !iface.geometry?.frame) bad('a gland needs a frame, or at: "joint"');
|
|
402
|
+
}
|
|
403
|
+
if (serves && (typeof serves.child !== "string" || typeof serves.interfaceId !== "string")) bad("serves must be { child, interfaceId }");
|
|
404
|
+
features.push({ id: iface.id, kind: k, params, ...(serves ? { serves } : {}), ...(iface.geometry?.frame ? { frame: iface.geometry.frame } : {}), iface });
|
|
405
|
+
}
|
|
406
|
+
return { features, problems };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export const isEnclosure = (def: ModuleDef) => def.interfaces.some((i) => i.traits?.some((t) => t.type === FEATURE_TRAIT));
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The artifacts of a window's cover as cad:export writes them (`<out>/cover_<window id>.step|glb|dxf`), for
|
|
413
|
+
* the module that stands for the cover (a lens bought or cut as its own part). In the cover's own frame: its
|
|
414
|
+
* outer face at z 0, its body along +z; mate its frame (origin, normal +z) with the window's.
|
|
415
|
+
*/
|
|
416
|
+
export function coverArtifacts(out: string, windowId: string): ArtifactDef[] {
|
|
417
|
+
const name = `cover_${windowId}`;
|
|
418
|
+
const prov = { tool: "build123d", generatedFrom: "cad_source" };
|
|
419
|
+
return [
|
|
420
|
+
{ id: "cover_step", name: `${name} (STEP)`, type: "cad", role: "body", format: "step", units: "mm", filePath: `${out}/${name}.step`, provenance: prov },
|
|
421
|
+
{ id: "cover_glb", name: `${name} (GLB)`, type: "cad", role: "body", format: "glb", units: "mm", filePath: `${out}/${name}.glb`, provenance: { ...prov, generatedFrom: "cover_step" } },
|
|
422
|
+
{ id: "cover_dxf", name: `${name} outline (DXF, for cutting)`, type: "cad", role: "drawing", format: "dxf", units: "mm", filePath: `${out}/${name}.dxf`, tags: ["manufacturing", "cut"], provenance: { ...prov, generatedFrom: "cover_step" } },
|
|
423
|
+
];
|
|
424
|
+
}
|
package/lib/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authoring API for enclosure modules: the feature builders (tool-neutral
|
|
3
|
+
* UHD interfaces) and the cited standard parts they take.
|
|
4
|
+
*
|
|
5
|
+
* import { Opening, Button, InsertBosses, Joint, RUTHEX_RX_M2X4, M2X6_PAN } from "@protoboardai/ext-enclosure-cad/lib/index.ts";
|
|
6
|
+
*/
|
|
7
|
+
export * from "./features.ts";
|
|
8
|
+
export * from "./standards.ts";
|