@protoboardai/ext-enclosure-cad 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +388 -2
- package/evals/fit-grown-board/case.ts +71 -0
- package/lib/check.ts +379 -0
- package/lib/features.ts +424 -0
- package/lib/index.ts +8 -0
- package/lib/job.ts +388 -0
- package/lib/pyjson.ts +68 -0
- package/lib/standards.ts +302 -0
- package/ops/cad.ts +264 -0
- package/ops/package.ts +213 -0
- package/package.json +52 -4
- package/protoboard-extension.json +130 -0
- package/py/enclosure_cad.py +1731 -0
- package/py/fit.py +341 -0
- package/skills/enclosure-cad/SKILL.md +194 -0
package/lib/job.ts
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UHD project → enclosure job.
|
|
3
|
+
*
|
|
4
|
+
* The job is everything the build123d generator needs and nothing it has to
|
|
5
|
+
* know about UHD:
|
|
6
|
+
*
|
|
7
|
+
* - what goes inside: every body the assembly places, other than the
|
|
8
|
+
* enclosure itself and what its outside features hold (a bottle on the
|
|
9
|
+
* thread, spring bars in the lugs), as a GLB with a matrix into the
|
|
10
|
+
* enclosure's coordinates. A module designed by a capability provider
|
|
11
|
+
* contributes its standard output (a board's out/board.glb); any other
|
|
12
|
+
* module its body artifact.
|
|
13
|
+
* - the features, from the module's interfaces (lib/features.ts), with the
|
|
14
|
+
* frame of the part each one serves resolved into the enclosure's
|
|
15
|
+
* coordinates: from the served module's standard geometry manifest when
|
|
16
|
+
* it has one (a board's connectors and switches), else from its UHD frame.
|
|
17
|
+
* - the design values from cad.config.ts.
|
|
18
|
+
*
|
|
19
|
+
* Placement is the assembly the viewer draws and ext-harness-cad routes
|
|
20
|
+
* through (UHD `assemble` with the project's display placements), imported
|
|
21
|
+
* from that package rather than re-implemented.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join, relative } from "node:path";
|
|
26
|
+
import {
|
|
27
|
+
applyMat4,
|
|
28
|
+
bodyKey,
|
|
29
|
+
boltPatternHoles,
|
|
30
|
+
frameMatrix,
|
|
31
|
+
multiplyMat4,
|
|
32
|
+
resolveLeafEndpoint,
|
|
33
|
+
type Assembly,
|
|
34
|
+
type GeometryFrame,
|
|
35
|
+
type GeometryManifest,
|
|
36
|
+
type Mat4,
|
|
37
|
+
type ModuleDef,
|
|
38
|
+
type Vec3,
|
|
39
|
+
} from "@deltarobotics/uhd";
|
|
40
|
+
import { stableStringify, type CadProcess, type CapabilityBinding, type Project } from "@protoboardai/core";
|
|
41
|
+
import { projectAssembly } from "@protoboardai/ext-harness-cad/job";
|
|
42
|
+
import { EXTERNAL_KINDS, readFeatures, type FeatureSpec } from "./features.ts";
|
|
43
|
+
import { glandRow, type ORing } from "./standards.ts";
|
|
44
|
+
|
|
45
|
+
export const JOB_SCHEMA = "protoboard.enclosure-cad.job/v0";
|
|
46
|
+
|
|
47
|
+
export interface Design {
|
|
48
|
+
shape: "box" | "round";
|
|
49
|
+
wall: number;
|
|
50
|
+
floor: number;
|
|
51
|
+
lid: number;
|
|
52
|
+
clearance: number;
|
|
53
|
+
clearance_top: number;
|
|
54
|
+
min_wall: number;
|
|
55
|
+
cavity: null | { min: Vec3; max: Vec3 } | { centre: [number, number]; radius: number; z: [number, number] };
|
|
56
|
+
split_z: number | null;
|
|
57
|
+
joint: { style: "rabbet" | "flat" | "twist"; lip_height: number; clearance: number };
|
|
58
|
+
fasteners: "screws" | "none";
|
|
59
|
+
resolution: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface JobContent {
|
|
63
|
+
key: string;
|
|
64
|
+
label: string;
|
|
65
|
+
/** Project-relative GLB. */
|
|
66
|
+
glb: string;
|
|
67
|
+
digest: string;
|
|
68
|
+
/** Body coordinates → enclosure coordinates (row-major 4×4). */
|
|
69
|
+
matrix: Mat4;
|
|
70
|
+
/** GLB nodes that are not the body: a UHD part's interface feature markers (thin solids on the faces its interfaces bind to). */
|
|
71
|
+
skip_nodes?: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface JobFeature {
|
|
75
|
+
id: string;
|
|
76
|
+
kind: string;
|
|
77
|
+
params: Record<string, unknown>;
|
|
78
|
+
/** The declared frame (module coordinates), when the module states one. */
|
|
79
|
+
frame?: GeometryFrame;
|
|
80
|
+
served?: { key: string; label: string; frame: GeometryFrame; from: "manifest" | "model" };
|
|
81
|
+
/** Content keys mated to this feature (contact there is by design). */
|
|
82
|
+
mates?: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface Job {
|
|
86
|
+
schema: typeof JOB_SCHEMA;
|
|
87
|
+
module: string;
|
|
88
|
+
name: string;
|
|
89
|
+
/** Project root, relative to the native folder (the generator resolves GLBs against it). */
|
|
90
|
+
root: string;
|
|
91
|
+
process: CadProcess;
|
|
92
|
+
material: string | null;
|
|
93
|
+
inputsDigest: string;
|
|
94
|
+
design: Design;
|
|
95
|
+
contents: JobContent[];
|
|
96
|
+
features: JobFeature[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Where the module sits, and how to get from the assembly root into its coordinates. */
|
|
100
|
+
export interface Placement {
|
|
101
|
+
path: string;
|
|
102
|
+
matrix: Mat4;
|
|
103
|
+
toLocal: Mat4;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Rigid transforms
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** Inverse of a rigid row-major 4×4 (rotation + translation). */
|
|
111
|
+
export function invertRigid(m: Mat4): Mat4 {
|
|
112
|
+
const r = [m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]];
|
|
113
|
+
const t = [m[3], m[7], m[11]];
|
|
114
|
+
const rt = [r[0], r[3], r[6], r[1], r[4], r[7], r[2], r[5], r[8]];
|
|
115
|
+
const ti = [0, 1, 2].map((i) => -(rt[i * 3] * t[0] + rt[i * 3 + 1] * t[1] + rt[i * 3 + 2] * t[2]));
|
|
116
|
+
return [rt[0], rt[1], rt[2], ti[0], rt[3], rt[4], rt[5], ti[1], rt[6], rt[7], rt[8], ti[2], 0, 0, 0, 1];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rotate = (m: Mat4, v: Vec3): Vec3 => [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[4] * v[0] + m[5] * v[1] + m[6] * v[2], m[8] * v[0] + m[9] * v[1] + m[10] * v[2]];
|
|
120
|
+
const r6 = (n: number) => Math.round(n * 1e6) / 1e6 + 0;
|
|
121
|
+
|
|
122
|
+
export function transformFrame(m: Mat4, f: GeometryFrame): GeometryFrame {
|
|
123
|
+
return {
|
|
124
|
+
origin: applyMat4(m, f.origin as Vec3).map(r6) as Vec3,
|
|
125
|
+
normal: rotate(m, f.normal as Vec3).map(r6) as Vec3,
|
|
126
|
+
...(f.xAxis ? { xAxis: rotate(m, f.xAxis as Vec3).map(r6) as Vec3 } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Design values
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Minimum wall by process: two extrusion widths of a 0.4 mm nozzle for
|
|
138
|
+
* printing (assumption: the common FDM nozzle); 0.5 mm for machining
|
|
139
|
+
* (assumption: a typical small-end-mill limit for plastics).
|
|
140
|
+
*/
|
|
141
|
+
export const MIN_WALL: Record<CadProcess, number> = { print: 0.8, machine: 0.5, cut: 0 };
|
|
142
|
+
|
|
143
|
+
/** cad.config.ts constraints → design values. */
|
|
144
|
+
export function designOf(constraints: Record<string, unknown>, process: CadProcess, features: FeatureSpec[]): { design: Design; problems: string[] } {
|
|
145
|
+
const problems: string[] = [];
|
|
146
|
+
const wall = num(constraints.wall_mm);
|
|
147
|
+
if (wall === undefined || wall <= 0) problems.push("constraint wall_mm (wall thickness) is required and must be positive");
|
|
148
|
+
const clearance = num(constraints.clearance_mm) ?? 0.3;
|
|
149
|
+
const shape = (constraints.shape as string) ?? "box";
|
|
150
|
+
if (shape !== "box" && shape !== "round") problems.push(`shape must be "box" or "round", not ${JSON.stringify(shape)}`);
|
|
151
|
+
const j = (constraints.joint ?? {}) as Record<string, unknown>;
|
|
152
|
+
const style = (j.style as string) ?? "rabbet";
|
|
153
|
+
if (style !== "rabbet" && style !== "flat" && style !== "twist") problems.push(`joint.style must be "rabbet", "flat" or "twist" (with a TwistLid)`);
|
|
154
|
+
const joint = features.find((f) => f.kind === "joint");
|
|
155
|
+
const twist = features.find((f) => f.kind === "twist_lid");
|
|
156
|
+
if (twist && joint) problems.push("a twist lid closes without screws: give the module a TwistLid or a Joint, not both");
|
|
157
|
+
if (twist && shape !== "round") problems.push(`a twist lid needs a round enclosure (shape "round"), not ${JSON.stringify(shape)}`);
|
|
158
|
+
if (twist && j.style !== undefined && j.style !== "twist") problems.push(`joint.style ${JSON.stringify(j.style)}: a twist lid makes the joint a twist joint`);
|
|
159
|
+
const lock = twist?.params.lock;
|
|
160
|
+
if (twist && lock !== "thread" && lock !== "bayonet") problems.push(`${twist.id}: lock must be "thread" or "bayonet", not ${JSON.stringify(lock)}`);
|
|
161
|
+
const fasteners = (constraints.fasteners as string) ?? (joint ? "screws" : "none");
|
|
162
|
+
if (twist && fasteners === "screws") problems.push('a twist lid needs no screws: fasteners must be "none"');
|
|
163
|
+
if (!twist && style === "twist") problems.push('joint.style "twist" needs a TwistLid interface in the module');
|
|
164
|
+
if (fasteners !== "screws" && fasteners !== "none") problems.push(`fasteners must be "screws" or "none" (snap fits are not generated yet), not ${JSON.stringify(fasteners)}`);
|
|
165
|
+
if (fasteners === "screws" && !joint) problems.push('fasteners "screws" needs a joint interface stating its screw and insert (Joint({ … }) in the module)');
|
|
166
|
+
const w = wall ?? 1;
|
|
167
|
+
return {
|
|
168
|
+
problems,
|
|
169
|
+
design: {
|
|
170
|
+
shape: shape as Design["shape"],
|
|
171
|
+
wall: w,
|
|
172
|
+
floor: num(constraints.floor_mm) ?? w,
|
|
173
|
+
lid: num(constraints.lid_mm) ?? w,
|
|
174
|
+
clearance,
|
|
175
|
+
clearance_top: num(constraints.clearance_top_mm) ?? clearance,
|
|
176
|
+
min_wall: num(constraints.min_wall_mm) ?? MIN_WALL[process],
|
|
177
|
+
cavity: (constraints.cavity_mm as Design["cavity"]) ?? null,
|
|
178
|
+
split_z: num(constraints.split_z_mm) ?? null,
|
|
179
|
+
joint: { style: (twist ? "twist" : style) as Design["joint"]["style"], lip_height: num(j.lip_height_mm) ?? 1.5, clearance: num(j.clearance_mm) ?? 0.15 },
|
|
180
|
+
fasteners: fasteners as Design["fasteners"],
|
|
181
|
+
resolution: num(constraints.resolution_mm) ?? 0.25,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// The job
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
export const sha256File = (file: string) => "sha256:" + createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
191
|
+
const posix = (s: string) => s.split("\\").join("/");
|
|
192
|
+
|
|
193
|
+
/** A module's body GLB: its capability provider's standard output, else its body artifact. */
|
|
194
|
+
export function bodyGlb(p: Project, def: ModuleDef, body?: string): string | undefined {
|
|
195
|
+
const bound = p.capabilities.find((b) => b.module === def.id && !b.problem);
|
|
196
|
+
if (bound) {
|
|
197
|
+
const f = join(p.root, bound.out, bound.capability === "circuit" ? "board.glb" : "body.glb");
|
|
198
|
+
if (existsSync(f)) return f;
|
|
199
|
+
}
|
|
200
|
+
const glb = (def.artifacts ?? []).find((a) => a.format === "glb" && a.role === "body" && (body ? a.id === body || a.provenance?.generatedFrom === body : true));
|
|
201
|
+
const file = glb && p.artifacts.find((a) => a.definition === def.id && a.id === glb.id)?.resolved;
|
|
202
|
+
return file && existsSync(file) ? file : undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A module's geometry manifest: its provider's standard output (a board's
|
|
207
|
+
* board.manifest.json, a part's body.manifest.json), else a manifest it
|
|
208
|
+
* references as an artifact.
|
|
209
|
+
*/
|
|
210
|
+
export function standardManifest(p: Project, defId: string): { manifest: GeometryManifest & { frames?: Record<string, GeometryFrame> }; file: string } | undefined {
|
|
211
|
+
const b = p.capabilities.find((x) => x.module === defId && !x.problem);
|
|
212
|
+
let file = b ? join(b.out, b.capability === "circuit" ? "board.manifest.json" : "body.manifest.json") : undefined;
|
|
213
|
+
if (!file || !existsSync(join(p.root, file))) {
|
|
214
|
+
const a = (p.lookup(defId)?.artifacts ?? []).find((x) => x.format === "json" && x.filePath?.endsWith("manifest.json"));
|
|
215
|
+
const resolved = a && p.artifacts.find((x) => x.definition === defId && x.id === a.id)?.resolved;
|
|
216
|
+
file = resolved ? posix(relative(p.root, resolved)) : undefined;
|
|
217
|
+
}
|
|
218
|
+
if (!file || !existsSync(join(p.root, file))) return undefined;
|
|
219
|
+
try {
|
|
220
|
+
return { manifest: JSON.parse(readFileSync(join(p.root, file), "utf8")), file };
|
|
221
|
+
} catch {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The nodes of a part's body GLB that are its interface feature markers, not
|
|
228
|
+
* its body: UHD CAD bodies carry a thin named solid per interface feature (on
|
|
229
|
+
* the face the interface binds to, 0.25 mm proud), which is no part of what
|
|
230
|
+
* the enclosure holds. A node that is also a body of the part (its manifest's
|
|
231
|
+
* `bodies`) stays. Provider outputs (a board's out/board.glb) have none.
|
|
232
|
+
*/
|
|
233
|
+
export function markerNodes(p: Project, def: ModuleDef): string[] {
|
|
234
|
+
if (p.capabilities.some((b) => b.module === def.id && !b.problem)) return [];
|
|
235
|
+
const bodies = new Set(Object.keys((standardManifest(p, def.id)?.manifest as { bodies?: Record<string, number> } | undefined)?.bodies ?? {}));
|
|
236
|
+
return def.interfaces.map((i) => i.id).filter((id) => !bodies.has(id)).sort();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The enclosure instance and its placement in the assembly. */
|
|
240
|
+
export function enclosurePlacement(asm: Assembly, instance: string): Placement | undefined {
|
|
241
|
+
const pl = asm.placements.find((x) => x.key === instance);
|
|
242
|
+
return pl && { path: instance, matrix: pl.matrix, toLocal: invertRigid(pl.matrix) };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The frame a feature serves, in the enclosure's coordinates. */
|
|
246
|
+
export function servedFrame(p: Project, asm: Assembly, enc: Placement, f: FeatureSpec): { result?: JobFeature["served"]; problem?: string } {
|
|
247
|
+
if (!f.serves) return {};
|
|
248
|
+
const segments = enc.path.split("/");
|
|
249
|
+
const parentPath = segments.slice(0, -1);
|
|
250
|
+
const parentDef = parentPath.length ? p.lookup(p.instances.find((i) => i.path === parentPath.join("/"))!.definition) : p.rootDef;
|
|
251
|
+
if (!parentDef) return { problem: `${f.id}: cannot find the module the enclosure sits in` };
|
|
252
|
+
const path = [...parentPath, f.serves.child].join("/");
|
|
253
|
+
const inst = p.instances.find((i) => i.path === path);
|
|
254
|
+
if (!inst) return { problem: `${f.id} serves ${f.serves.child}.${f.serves.interfaceId}, but ${parentDef.id} has no child ${f.serves.child}` };
|
|
255
|
+
const label = `${path}.${f.serves.interfaceId}`;
|
|
256
|
+
const placed = (key: string) => asm.placements.find((x) => x.key === key)?.matrix;
|
|
257
|
+
|
|
258
|
+
// 1. the served module's standard geometry manifest (a provider's view of where the connector is)
|
|
259
|
+
const std = standardManifest(p, inst.definition);
|
|
260
|
+
const m = placed(path);
|
|
261
|
+
if (std && m) {
|
|
262
|
+
const own = std.manifest.frames?.[f.serves.interfaceId];
|
|
263
|
+
const feat = std.manifest.features?.[f.serves.interfaceId];
|
|
264
|
+
const local: GeometryFrame | undefined = own ?? (feat?.normal ? { origin: feat.centroid, normal: feat.normal } : undefined);
|
|
265
|
+
if (local) return { result: { key: path, label, from: "manifest", frame: transformFrame(multiplyMat4(enc.toLocal, m), local) } };
|
|
266
|
+
}
|
|
267
|
+
// 2. the model: the interface's own frame, through exports to the leaf that owns it
|
|
268
|
+
const leaf = resolveLeafEndpoint(parentDef, { child: f.serves.child, interfaceId: f.serves.interfaceId }, p.lookup, parentPath);
|
|
269
|
+
if (!leaf) return { problem: `${f.id} serves ${label}, which does not exist` };
|
|
270
|
+
const frame = leaf.iface.geometry?.frame;
|
|
271
|
+
const lm = placed(bodyKey(leaf.path, frame?.artifact));
|
|
272
|
+
if (!frame) return { problem: `${f.id} serves ${label}, which has no frame${std ? ` and no feature with a normal in ${std.file}` : ""}` };
|
|
273
|
+
if (!lm) return { problem: `${f.id} serves ${label}, but ${leaf.path.join("/")} is not placed in the assembly` };
|
|
274
|
+
return { result: { key: leaf.path.join("/"), label, from: "model", frame: transformFrame(multiplyMat4(enc.toLocal, lm), frame) } };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface BuiltJob {
|
|
278
|
+
job?: Job;
|
|
279
|
+
/** Problems that stop generation (feature problems included). */
|
|
280
|
+
problems: string[];
|
|
281
|
+
/** The problems that are not about a feature's own declaration. */
|
|
282
|
+
input: string[];
|
|
283
|
+
/** Findings worth reporting that do not stop it. */
|
|
284
|
+
notes: string[];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Build the generator job for an enclosure bound by a cad config. */
|
|
288
|
+
export function buildJob(p: Project, binding: Pick<CapabilityBinding, "constraints" | "process" | "native"> & { module: string; instances: string[] }, def: ModuleDef, instance?: string): BuiltJob {
|
|
289
|
+
const problems: string[] = [];
|
|
290
|
+
const notes: string[] = [];
|
|
291
|
+
const process = binding.process ?? "print";
|
|
292
|
+
if (process === "cut") problems.push('process "cut" is not supported by this provider (enclosures are printed or machined; use a plate provider for cut panels)');
|
|
293
|
+
const { features, problems: featureProblems } = readFeatures(def);
|
|
294
|
+
const declared = featureProblems.map((x) => `${def.id}.${x.interface}: ${x.message}`);
|
|
295
|
+
if (!features.length) problems.push(`${def.id} has no enclosure features (interfaces with an enclosure_feature trait)`);
|
|
296
|
+
const { design, problems: designProblems } = designOf(binding.constraints, process, features);
|
|
297
|
+
problems.push(...designProblems);
|
|
298
|
+
const at = instance ?? binding.instances[0];
|
|
299
|
+
if (!at) {
|
|
300
|
+
problems.push(`${def.id} is not used in the system, so nothing says what goes inside it`);
|
|
301
|
+
return { problems: [...declared, ...problems], input: problems, notes };
|
|
302
|
+
}
|
|
303
|
+
if (!p.rootDef) return { problems: [...declared, ...problems, "the project has no root system"], input: [...problems, "the project has no root system"], notes };
|
|
304
|
+
const asm = projectAssembly(p);
|
|
305
|
+
const enc = enclosurePlacement(asm, at);
|
|
306
|
+
if (!enc) {
|
|
307
|
+
problems.push(`${at} is not placed in the assembly (mate it, or make it the display root)`);
|
|
308
|
+
return { problems: [...declared, ...problems], input: problems, notes };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// features: frames, served parts, mates
|
|
312
|
+
const external = new Set<string>();
|
|
313
|
+
const jobFeatures: JobFeature[] = [];
|
|
314
|
+
for (const f of features) {
|
|
315
|
+
const jf: JobFeature = { id: f.id, kind: f.kind, params: { ...f.params }, ...(f.frame ? { frame: f.frame } : {}) };
|
|
316
|
+
const mates = asm.mates
|
|
317
|
+
.filter((m) => (m.a.path.join("/") === at && m.a.iface.id === f.id) || (m.b.path.join("/") === at && m.b.iface.id === f.id))
|
|
318
|
+
.map((m) => (m.a.path.join("/") === at ? bodyKey(m.b.path, m.b.frame.artifact) : bodyKey(m.a.path, m.a.frame.artifact)));
|
|
319
|
+
if (mates.length) jf.mates = mates;
|
|
320
|
+
if (EXTERNAL_KINDS.includes(f.kind)) for (const k of mates) external.add(k);
|
|
321
|
+
if (f.serves) {
|
|
322
|
+
const s = servedFrame(p, asm, enc, f);
|
|
323
|
+
if (s.problem) problems.push(s.problem);
|
|
324
|
+
if (s.result) jf.served = s.result;
|
|
325
|
+
}
|
|
326
|
+
if (f.kind === "boss" && f.frame) {
|
|
327
|
+
const fm = frameMatrix(f.frame);
|
|
328
|
+
jf.params.holes = boltPatternHoles(f.iface).map(([x, y]) => applyMat4(fm, [x, y, 0]).slice(0, 2).map(r6));
|
|
329
|
+
if (!(jf.params.holes as unknown[]).length) problems.push(`${def.id}.${f.id}: the bolt pattern gives no holes (hole_spacing missing?)`);
|
|
330
|
+
}
|
|
331
|
+
if (f.kind === "seal_gland") {
|
|
332
|
+
const o = f.params.oring as ORing;
|
|
333
|
+
const row = o && glandRow(f.params.type as "face" | "radial", o.w_mm);
|
|
334
|
+
if (row) jf.params.table = { depth_mm: row.depth_mm, width_mm: row.width_mm, squeeze_pct: row.squeeze_pct, ...(row.clearance_mm ? { clearance_mm: row.clearance_mm } : {}), source: row.source };
|
|
335
|
+
}
|
|
336
|
+
jobFeatures.push(jf);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// contents: everything placed, except the enclosure, what its outside features hold, and exclusions
|
|
340
|
+
const exclude = ((binding.constraints.exclude ?? []) as string[]).map(String);
|
|
341
|
+
const under = (key: string, path: string) => key === path || key.startsWith(path + "/") || key.startsWith(path + "#");
|
|
342
|
+
const contents: JobContent[] = [];
|
|
343
|
+
const defAt = new Map(p.instances.map((i) => [i.path, i.definition]));
|
|
344
|
+
for (const pl of [...asm.placements].sort((a, b) => a.key.localeCompare(b.key))) {
|
|
345
|
+
const path = pl.path.join("/");
|
|
346
|
+
if (!path || under(pl.key, at) || exclude.some((x) => under(pl.key, x)) || external.has(pl.key)) continue;
|
|
347
|
+
const defId = defAt.get(path);
|
|
348
|
+
const d = defId && p.lookup(defId);
|
|
349
|
+
if (!d) continue;
|
|
350
|
+
// a module placed with its parent's body (a board's components) is inside that body already
|
|
351
|
+
const parentBody = (o: (typeof asm.placements)[number]) => {
|
|
352
|
+
const pd = p.lookup(defAt.get(o.path.join("/")) ?? "");
|
|
353
|
+
return !!pd && !!bodyGlb(p, pd);
|
|
354
|
+
};
|
|
355
|
+
if (asm.placements.some((o) => o !== pl && o.path.length && o.path.length < pl.path.length && path.startsWith(o.path.join("/") + "/") && parentBody(o))) continue;
|
|
356
|
+
const file = bodyGlb(p, d, pl.body);
|
|
357
|
+
if (!file) {
|
|
358
|
+
notes.push(`${pl.key} (${d.id}) is placed but has no GLB body, so the fit check cannot see it`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const skip = markerNodes(p, d);
|
|
362
|
+
contents.push({ key: pl.key, label: d.name ?? d.id, glb: posix(relative(p.root, file)), digest: sha256File(file), matrix: multiplyMat4(enc.toLocal, pl.matrix).map(r6), ...(skip.length ? { skip_nodes: skip } : {}) });
|
|
363
|
+
}
|
|
364
|
+
for (const h of asm.harnesses) {
|
|
365
|
+
if (exclude.some((x) => under(h.key, x))) continue;
|
|
366
|
+
const glb = (h.def.artifacts ?? []).find((a) => a.format === "glb" && a.role === "body");
|
|
367
|
+
const file = glb && p.artifacts.find((a) => a.definition === h.def.id && a.id === glb.id)?.resolved;
|
|
368
|
+
if (file && existsSync(file)) contents.push({ key: h.key, label: h.def.name ?? h.def.id, glb: posix(relative(p.root, file)), digest: sha256File(file), matrix: multiplyMat4(enc.toLocal, h.matrix).map(r6) });
|
|
369
|
+
}
|
|
370
|
+
if (!contents.length && !design.cavity) problems.push(`nothing is placed inside ${at}: mate the parts it holds to it, or give them display placements`);
|
|
371
|
+
|
|
372
|
+
const material = typeof binding.constraints.material === "string" ? binding.constraints.material : null;
|
|
373
|
+
const inputs = { process, material, design, contents: contents.map(({ label: _l, ...c }) => c), features: jobFeatures };
|
|
374
|
+
const native = binding.native ?? "";
|
|
375
|
+
const job: Job = {
|
|
376
|
+
schema: JOB_SCHEMA,
|
|
377
|
+
module: def.id,
|
|
378
|
+
name: def.id,
|
|
379
|
+
root: posix(relative(join(p.root, native), p.root)) || ".",
|
|
380
|
+
process,
|
|
381
|
+
material,
|
|
382
|
+
inputsDigest: "sha256:" + createHash("sha256").update(stableStringify(inputs)).digest("hex"),
|
|
383
|
+
design,
|
|
384
|
+
contents,
|
|
385
|
+
features: jobFeatures,
|
|
386
|
+
};
|
|
387
|
+
return { job, problems: [...declared, ...problems], input: problems, notes };
|
|
388
|
+
}
|
package/lib/pyjson.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python's `json.dumps(value, sort_keys=True)` with its defaults
|
|
3
|
+
* (`ensure_ascii=True`, separators `", "` and `": "`), for a value that came
|
|
4
|
+
* from JSON. The generator's `sourceDigest` hashes this text, so the check can
|
|
5
|
+
* recompute it without running Python (PB-809).
|
|
6
|
+
*
|
|
7
|
+
* Numbers: JSON integers stay integers in Python (`5`); every other number is
|
|
8
|
+
* a float, written as Python's `repr` (shortest round-trip digits, exponent
|
|
9
|
+
* form below 1e-4 and from 1e16, `.0` on a whole float). A value parsed by
|
|
10
|
+
* JavaScript has lost whether `5.0` was written with a point: the job files
|
|
11
|
+
* are written by JavaScript, which never writes one.
|
|
12
|
+
*/
|
|
13
|
+
export function pyJsonDumps(value: unknown): string {
|
|
14
|
+
if (value === null || value === undefined) return "null";
|
|
15
|
+
if (value === true) return "true";
|
|
16
|
+
if (value === false) return "false";
|
|
17
|
+
if (typeof value === "number") return pyNumber(value);
|
|
18
|
+
if (typeof value === "string") return pyString(value);
|
|
19
|
+
if (Array.isArray(value)) return `[${value.map(pyJsonDumps).join(", ")}]`;
|
|
20
|
+
if (typeof value === "object") {
|
|
21
|
+
const keys = Object.keys(value as object)
|
|
22
|
+
.filter((k) => (value as Record<string, unknown>)[k] !== undefined)
|
|
23
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
24
|
+
return `{${keys.map((k) => `${pyString(k)}: ${pyJsonDumps((value as Record<string, unknown>)[k])}`).join(", ")}}`;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`pyJsonDumps: cannot write ${typeof value}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pyString(s: string): string {
|
|
30
|
+
let out = '"';
|
|
31
|
+
for (let i = 0; i < s.length; i++) {
|
|
32
|
+
const c = s.charCodeAt(i);
|
|
33
|
+
const ch = s[i];
|
|
34
|
+
if (ch === '"') out += '\\"';
|
|
35
|
+
else if (ch === "\\") out += "\\\\";
|
|
36
|
+
else if (ch === "\n") out += "\\n";
|
|
37
|
+
else if (ch === "\r") out += "\\r";
|
|
38
|
+
else if (ch === "\t") out += "\\t";
|
|
39
|
+
else if (ch === "\b") out += "\\b";
|
|
40
|
+
else if (ch === "\f") out += "\\f";
|
|
41
|
+
else if (c < 0x20 || c > 0x7e) out += "\\u" + c.toString(16).padStart(4, "0");
|
|
42
|
+
else out += ch;
|
|
43
|
+
}
|
|
44
|
+
return out + '"';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pyNumber(n: number): string {
|
|
48
|
+
if (!Number.isFinite(n)) return Number.isNaN(n) ? "NaN" : n > 0 ? "Infinity" : "-Infinity";
|
|
49
|
+
// JSON.stringify writes whole numbers below 1e21 without a point or exponent: Python reads those as int
|
|
50
|
+
const js = JSON.stringify(n);
|
|
51
|
+
if (/^-?\d+$/.test(js)) return js;
|
|
52
|
+
// a float: shortest digits and exponent, then Python's repr layout
|
|
53
|
+
const [mant, expText] = n.toExponential().split("e");
|
|
54
|
+
const exp = Number(expText);
|
|
55
|
+
const neg = mant.startsWith("-");
|
|
56
|
+
const digits = mant.replace("-", "").replace(".", "");
|
|
57
|
+
let body: string;
|
|
58
|
+
if (exp < -4 || exp >= 16) {
|
|
59
|
+
body = digits[0] + (digits.length > 1 ? "." + digits.slice(1) : "") + "e" + (exp < 0 ? "-" : "+") + String(Math.abs(exp)).padStart(2, "0");
|
|
60
|
+
} else if (exp < 0) {
|
|
61
|
+
body = "0." + "0".repeat(-exp - 1) + digits;
|
|
62
|
+
} else {
|
|
63
|
+
const intPart = digits.slice(0, exp + 1).padEnd(exp + 1, "0");
|
|
64
|
+
const frac = digits.slice(exp + 1);
|
|
65
|
+
body = intPart + "." + (frac || "0");
|
|
66
|
+
}
|
|
67
|
+
return (neg ? "-" : "") + body;
|
|
68
|
+
}
|