@protoboardai/ext-frame-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/checks/frames.ts +25 -0
- package/checks/hardware.ts +26 -0
- package/evals/regenerate-after-vtx-move/case.json +175 -0
- package/lib/alias.ts +57 -0
- package/lib/artifacts.ts +185 -0
- package/lib/hardware-cad.ts +127 -0
- package/lib/hardware.ts +100 -0
- package/lib/params.ts +255 -0
- package/lib/plate.ts +233 -0
- package/lib/run.ts +24 -0
- package/ops/cad.ts +78 -0
- package/ops/frame-cad.ts +46 -0
- package/ops/hardware-cad.ts +54 -0
- package/ops/package.ts +96 -0
- package/ops/rebind.ts +94 -0
- package/package.json +42 -4
- package/protoboard-extension.json +187 -0
- package/py/frame_cad.py +343 -0
- package/py/hardware_cad.py +111 -0
- package/skills/frame-cad/SKILL.md +279 -0
- package/skills/frame-cad/references/generator.md +110 -0
- package/skills/frame-cad/references/manufacturing.md +42 -0
- package/skills/frame-cad-verify/SKILL.md +117 -0
- package/skills/frame-cad-verify/scripts/dxf_report.py +124 -0
- package/README.md +0 -3
package/lib/plate.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plates as a `cad` provider (docs/specs/tool-integrations.md §2–§4).
|
|
3
|
+
*
|
|
4
|
+
* generate UHD module + constraints → native design in <dir>/frame-cad/:
|
|
5
|
+
* params.json (what the generator runs on) and frames.json (the
|
|
6
|
+
* interface frames of the generated geometry)
|
|
7
|
+
* export params.json → build123d → standard outputs in <dir>/out/:
|
|
8
|
+
* body.step, body.glb, body.manifest.json, body.dxf and
|
|
9
|
+
* interfaces/<id>.glb
|
|
10
|
+
* extract frames.json → frames by interface id, plus the artifacts
|
|
11
|
+
* check model vs native design (frames, stale parameters) and native
|
|
12
|
+
* design vs out/ (an export older than the design)
|
|
13
|
+
*
|
|
14
|
+
* The generator is deterministic for fixed params and build123d version, so
|
|
15
|
+
* export rebuilds exactly the body that generate measured.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import type { ArtifactDef, ModuleDef } from "@deltarobotics/uhd";
|
|
21
|
+
import { sha256, stableStringify, type CapabilityBinding, type OperationContext, type ProjectDiagnostic } from "@protoboardai/core";
|
|
22
|
+
import { plateCadArtifacts } from "./artifacts.ts";
|
|
23
|
+
import { buildParams, plateFiles, sameVec, type FramesFile, type PlateParams } from "./params.ts";
|
|
24
|
+
import { runAsync } from "./run.ts";
|
|
25
|
+
|
|
26
|
+
export const GENERATOR = fileURLToPath(new URL("../py/frame_cad.py", import.meta.url));
|
|
27
|
+
export const python = () => process.env.PROTOBOARD_PYTHON || "python3";
|
|
28
|
+
|
|
29
|
+
/** What generate and export need from their context (a capability context, or the frame-cad alias's). */
|
|
30
|
+
export type Writer = Pick<OperationContext, "project" | "writeOutput" | "log">;
|
|
31
|
+
|
|
32
|
+
/** A plate module and its provider directory. */
|
|
33
|
+
export interface PlateTarget {
|
|
34
|
+
def: ModuleDef;
|
|
35
|
+
binding: Pick<CapabilityBinding, "dir" | "native" | "out" | "constraints" | "process" | "config">;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Signature {
|
|
39
|
+
area_mm2: number;
|
|
40
|
+
centroid: number[];
|
|
41
|
+
normal?: number[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Generated {
|
|
45
|
+
toolVersion: string;
|
|
46
|
+
sourceDigest: string;
|
|
47
|
+
features: Record<string, Signature>;
|
|
48
|
+
frames: FramesFile;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Outcome<T> = { ok: true; value: T } | { ok: false; summary: string; problems?: string[] };
|
|
52
|
+
|
|
53
|
+
/** What the module currently binds, compared with what was generated: the re-binding worklist. */
|
|
54
|
+
export function bindingDiff(def: ModuleDef, features: Record<string, Signature>, frames: FramesFile) {
|
|
55
|
+
const signatures: { interface: string; feature: string; bound?: Signature; generated?: Signature }[] = [];
|
|
56
|
+
const frameChanges: { interface: string; declared?: number[]; generated?: number[] }[] = [];
|
|
57
|
+
for (const i of def.interfaces) {
|
|
58
|
+
for (const ref of i.geometry?.refs ?? []) {
|
|
59
|
+
if (ref.kind !== "feature" || ref.artifact !== "cad_step") continue;
|
|
60
|
+
const now = features[ref.name];
|
|
61
|
+
const was = ref.signature;
|
|
62
|
+
const close = was && now && Math.abs(was.area_mm2 - now.area_mm2) <= 0.01 * Math.max(was.area_mm2, now.area_mm2, 1) && sameVec(was.centroid, now.centroid, 0.05);
|
|
63
|
+
if (!close) signatures.push({ interface: i.id, feature: ref.name, bound: was, generated: now });
|
|
64
|
+
}
|
|
65
|
+
const f = frames[i.id];
|
|
66
|
+
if (i.geometry?.frame && f && !(sameVec(i.geometry.frame.origin, f.origin) && sameVec(i.geometry.frame.normal, f.normal))) {
|
|
67
|
+
frameChanges.push({ interface: i.id, declared: i.geometry.frame.origin, generated: f.origin });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { signatures, frames: frameChanges };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Run the generator on a params file into a fresh scratch folder; returns its summary and the folder. */
|
|
74
|
+
async function runGenerator(root: string, paramsFile: string, name: string): Promise<Outcome<{ scratch: string; summary: Generated }>> {
|
|
75
|
+
const scratch = join(root, ".protoboard", "cache", "frame-cad", name);
|
|
76
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
77
|
+
mkdirSync(scratch, { recursive: true });
|
|
78
|
+
const r = await runAsync(python(), [GENERATOR, paramsFile, scratch], { timeout: 300000, env: { ...process.env, PYTHONHASHSEED: "0" } });
|
|
79
|
+
if (r.ok) return { ok: true, value: { scratch, summary: JSON.parse(r.stdout.trim().split("\n").pop()!) as Generated } };
|
|
80
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
81
|
+
return { ok: false, summary: `${name}: generator failed: ${r.error}` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** generate: write the params from the module and constraints, run the generator, keep the frames it measured. */
|
|
85
|
+
export async function generatePlate(ctx: Writer, t: PlateTarget) {
|
|
86
|
+
const p = ctx.project;
|
|
87
|
+
const { def, binding: b } = t;
|
|
88
|
+
const files = plateFiles(b);
|
|
89
|
+
const { params, problems } = buildParams(def, def.id, b.constraints);
|
|
90
|
+
if (!params || problems.length) return { ok: false as const, summary: problems[0], problems };
|
|
91
|
+
ctx.writeOutput(files.params, stableStringify(params), `${def.id}: generator parameters from its interfaces and ${b.config}`);
|
|
92
|
+
const run = await runGenerator(p.root, join(p.root, files.params), params.name);
|
|
93
|
+
if (!run.ok) return run;
|
|
94
|
+
const { scratch, summary } = run.value;
|
|
95
|
+
ctx.writeOutput(files.frames, readFileSync(join(scratch, `${params.name}.frames.json`)), `${def.id}: interface frames of the generated geometry`);
|
|
96
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
97
|
+
ctx.log(`${def.id}: ${Object.keys(summary.features).length} features; native design in ${b.native}`);
|
|
98
|
+
return {
|
|
99
|
+
ok: true as const,
|
|
100
|
+
value: {
|
|
101
|
+
module: def.id,
|
|
102
|
+
dir: b.dir,
|
|
103
|
+
native: b.native!,
|
|
104
|
+
toolVersion: summary.toolVersion,
|
|
105
|
+
sourceDigest: summary.sourceDigest,
|
|
106
|
+
features: summary.features,
|
|
107
|
+
frames: summary.frames,
|
|
108
|
+
rebind: bindingDiff(def, summary.features, summary.frames),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** export: rebuild the body from the native params and write the standard outputs. */
|
|
114
|
+
export async function exportPlate(ctx: Writer, t: PlateTarget) {
|
|
115
|
+
const p = ctx.project;
|
|
116
|
+
const { def, binding: b } = t;
|
|
117
|
+
const files = plateFiles(b);
|
|
118
|
+
if (b.process === "print") return { ok: false as const, summary: `${def.id}: ext-frame-cad cuts plates (DXF profiles for routing or waterjet); process "print" needs STL or 3MF, which it does not write. Use process "cut" or "machine" in ${b.config}` };
|
|
119
|
+
if (!existsSync(join(p.root, files.params))) return { ok: false as const, summary: `${def.id} has no native design (${files.params}); run \`protoboard run cad:generate --module ${def.id}\` first` };
|
|
120
|
+
const paramsBytes = readFileSync(join(p.root, files.params));
|
|
121
|
+
const params = JSON.parse(paramsBytes.toString("utf8")) as PlateParams;
|
|
122
|
+
const run = await runGenerator(p.root, join(p.root, files.params), params.name);
|
|
123
|
+
if (!run.ok) return run;
|
|
124
|
+
const { scratch, summary } = run.value;
|
|
125
|
+
const n = params.name;
|
|
126
|
+
ctx.writeOutput(`${b.out}/body.step`, readFileSync(join(scratch, `${n}.step`)), `${def.id}: body with one named sub-shape per interface feature`);
|
|
127
|
+
ctx.writeOutput(`${b.out}/body.glb`, readFileSync(join(scratch, `${n}.glb`)), `${def.id}: viewer mesh`);
|
|
128
|
+
ctx.writeOutput(`${b.out}/body.dxf`, readFileSync(join(scratch, `${n}.dxf`)), `${def.id}: cutting profiles`);
|
|
129
|
+
const interfaces = existsSync(join(scratch, "interfaces")) ? readdirSync(join(scratch, "interfaces")).sort() : [];
|
|
130
|
+
for (const f of interfaces) ctx.writeOutput(`${b.out}/interfaces/${f}`, readFileSync(join(scratch, "interfaces", f)), `${def.id}: ${f.replace(/\.glb$/, "")} geometry`);
|
|
131
|
+
// the generator's manifest, named for out/: it describes body.*, and records the design it was exported from
|
|
132
|
+
const manifest = JSON.parse(readFileSync(join(scratch, `${n}.manifest.json`), "utf8")) as Record<string, unknown> & { manufacturing?: Record<string, unknown> };
|
|
133
|
+
const standard = {
|
|
134
|
+
...manifest,
|
|
135
|
+
artifact: "body",
|
|
136
|
+
params: `../${b.native!.split("/").pop()}/params.json`,
|
|
137
|
+
...(manifest.manufacturing ? { manufacturing: { ...manifest.manufacturing, dxf: "body.dxf" } } : {}),
|
|
138
|
+
exportedFrom: { file: files.params, digest: sha256(paramsBytes) },
|
|
139
|
+
};
|
|
140
|
+
ctx.writeOutput(files.manifest, JSON.stringify(standard, null, 2) + "\n", `${def.id}: geometry manifest (features that carry interfaces, volume)`);
|
|
141
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
142
|
+
ctx.log(`${def.id}: ${4 + interfaces.length} files in ${b.out}`);
|
|
143
|
+
return { ok: true as const, value: { module: def.id, out: b.out, volume_mm3: manifest.volume_mm3 as number, features: summary.features, interfaces: interfaces.map((f) => f.replace(/\.glb$/, "")) } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const readJson = <T>(file: string): T | undefined => (existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as T) : undefined);
|
|
147
|
+
|
|
148
|
+
/** extract: the generated frames by interface id, and the module's artifacts that exist. */
|
|
149
|
+
export function extractPlate(root: string, t: PlateTarget) {
|
|
150
|
+
const { def, binding: b } = t;
|
|
151
|
+
const files = plateFiles(b);
|
|
152
|
+
const frames = readJson<FramesFile>(join(root, files.frames));
|
|
153
|
+
if (!frames) return { ok: false as const, summary: `${def.id} has no native design (${files.frames}); run \`protoboard run cad:generate --module ${def.id}\` first` };
|
|
154
|
+
const artifacts: ArtifactDef[] = plateCadArtifacts({ dir: b.dir, name: def.id, interfaces: Object.keys(frames) }).filter((a) => a.filePath && existsSync(join(root, a.filePath)));
|
|
155
|
+
return { ok: true as const, value: { frames, artifacts } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const fmt = (v?: number[]) => (v ? `[${v.map((n) => Number(n.toFixed(3))).join(", ")}]` : "—");
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* check: every interface frame equals the generated frame; the params the
|
|
162
|
+
* design was generated from are still what the interfaces and constraints
|
|
163
|
+
* give (otherwise the CAD is stale); and out/ was exported from the current
|
|
164
|
+
* params.
|
|
165
|
+
*/
|
|
166
|
+
export function plateDiagnostics(root: string, t: PlateTarget, source: ProjectDiagnostic["source"]): Omit<ProjectDiagnostic, "validator">[] {
|
|
167
|
+
const out: Omit<ProjectDiagnostic, "validator">[] = [];
|
|
168
|
+
const { def, binding: b } = t;
|
|
169
|
+
const files = plateFiles(b);
|
|
170
|
+
const entity = { kind: "definition" as const, id: def.id };
|
|
171
|
+
const generated = readJson<FramesFile>(join(root, files.frames));
|
|
172
|
+
if (!generated) {
|
|
173
|
+
out.push({ code: "FRAME_CAD_NOT_GENERATED", severity: "info", entity, message: `${def.id} has no generated frames (${files.frames}); run \`protoboard run cad:generate --module ${def.id}\``, source });
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
for (const [id, f] of Object.entries(generated)) {
|
|
177
|
+
const iface = def.interfaces.find((i) => i.id === id);
|
|
178
|
+
if (!iface) {
|
|
179
|
+
out.push({ code: "FRAME_CAD_UNKNOWN_INTERFACE", severity: "error", entity: { kind: "interface", id: `${def.id}:${id}` }, message: `generated frames name "${id}", which ${def.id} does not have (renamed or removed? regenerate)`, source });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const d = iface.geometry?.frame;
|
|
183
|
+
if (!d) {
|
|
184
|
+
out.push({ code: "FRAME_CAD_FRAME_MISSING", severity: "warning", entity: { kind: "interface", id: `${def.id}:${id}` }, message: `${id} has generated geometry but no frame in the model`, source });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const diffs = [
|
|
188
|
+
!sameVec(d.origin, f.origin) && `origin ${fmt(d.origin)} vs generated ${fmt(f.origin)}`,
|
|
189
|
+
!sameVec(d.normal, f.normal) && `normal ${fmt(d.normal)} vs generated ${fmt(f.normal)}`,
|
|
190
|
+
f.xAxis && d.xAxis && !sameVec(d.xAxis, f.xAxis) && `xAxis ${fmt(d.xAxis)} vs generated ${fmt(f.xAxis)}`,
|
|
191
|
+
(f.symmetryDeg ?? null) !== (d.symmetryDeg ?? null) && `symmetry ${d.symmetryDeg ?? "none"} vs generated ${f.symmetryDeg ?? "none"}`,
|
|
192
|
+
].filter(Boolean);
|
|
193
|
+
if (diffs.length) {
|
|
194
|
+
out.push({
|
|
195
|
+
code: "FRAME_CAD_FRAME_MISMATCH",
|
|
196
|
+
severity: "error",
|
|
197
|
+
entity: { kind: "interface", id: `${def.id}:${id}` },
|
|
198
|
+
message: `${id}: ${diffs.join("; ")} — the model and the plate disagree; review, then \`protoboard run frame-cad-rebind --input module=${def.id}\``,
|
|
199
|
+
source,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const paramsFile = join(root, files.params);
|
|
204
|
+
if (existsSync(paramsFile)) {
|
|
205
|
+
const bytes = readFileSync(paramsFile);
|
|
206
|
+
const recorded = JSON.parse(bytes.toString("utf8")) as PlateParams;
|
|
207
|
+
const { params } = buildParams(def, def.id, b.constraints);
|
|
208
|
+
if (params && stableStringify(params) !== stableStringify(recorded)) {
|
|
209
|
+
const changed = [
|
|
210
|
+
...params.interfaces.filter((i) => stableStringify(i) !== stableStringify(recorded.interfaces?.find((r) => r.id === i.id))).map((i) => i.id),
|
|
211
|
+
...(stableStringify(params.plate) !== stableStringify(recorded.plate) ? ["plate"] : []),
|
|
212
|
+
];
|
|
213
|
+
out.push({
|
|
214
|
+
code: "FRAME_CAD_STALE",
|
|
215
|
+
severity: "warning",
|
|
216
|
+
entity,
|
|
217
|
+
message: `${def.id}: the CAD was generated from different parameters (${changed.join(", ") || "interfaces"} changed); run \`protoboard run cad:generate --module ${def.id}\`, then cad:export`,
|
|
218
|
+
source,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const manifest = readJson<{ exportedFrom?: { digest?: string } }>(join(root, files.manifest));
|
|
222
|
+
if (manifest && manifest.exportedFrom?.digest !== sha256(bytes)) {
|
|
223
|
+
out.push({
|
|
224
|
+
code: "FRAME_CAD_EXPORT_STALE",
|
|
225
|
+
severity: "warning",
|
|
226
|
+
entity,
|
|
227
|
+
message: `${def.id}: ${b.out}/ was exported from other parameters than ${files.params}; run \`protoboard run cad:export --module ${def.id}\``,
|
|
228
|
+
source,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
package/lib/run.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a generator's Python without blocking: a build takes tens of seconds,
|
|
3
|
+
* and a synchronous child process holds the event loop of whatever shares the
|
|
4
|
+
* process (the dev server, a test runner's worker RPC) for all of it.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
export type RunResult = { ok: true; stdout: string } | { ok: false; error: string };
|
|
9
|
+
|
|
10
|
+
/** `command args…` with stdout collected; on failure, the last line of stderr (or why it did not run). */
|
|
11
|
+
export function runAsync(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number } = {}): Promise<RunResult> {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], timeout: options.timeout });
|
|
14
|
+
let stdout = "";
|
|
15
|
+
let stderr = "";
|
|
16
|
+
child.stdout!.setEncoding("utf8").on("data", (d: string) => (stdout += d));
|
|
17
|
+
child.stderr!.setEncoding("utf8").on("data", (d: string) => (stderr += d));
|
|
18
|
+
child.on("error", (e) => resolve({ ok: false, error: e.message.split("\n")[0] }));
|
|
19
|
+
child.on("close", (code, signal) => {
|
|
20
|
+
if (code === 0) resolve({ ok: true, stdout });
|
|
21
|
+
else resolve({ ok: false, error: stderr.trim().split("\n").pop() || `${command} exited with ${code ?? signal}` });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
package/ops/cad.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `cad` capability (tool-integrations §2–§4, PB-822, PB-836):
|
|
3
|
+
* `protoboard run cad:<generate|export|extract|check> --module <id>` on a
|
|
4
|
+
* module whose `cad.config.ts` names @protoboardai/ext-frame-cad. Two kinds of
|
|
5
|
+
* module: plates (mechanical metadata plate_thickness_mm; lib/plate.ts,
|
|
6
|
+
* shared with the `frame-cad` alias) and mounting hardware (tagged fastener
|
|
7
|
+
* and screw, nut, standoff or spacer; lib/hardware-cad.ts, shared with the
|
|
8
|
+
* `hardware-cad` alias).
|
|
9
|
+
*/
|
|
10
|
+
import type { CapabilityContext, CapabilityOperationFn, ExtractedFacts } from "@protoboardai/core";
|
|
11
|
+
import { HARDWARE_KINDS, hardwareKind } from "../lib/hardware.ts";
|
|
12
|
+
import { exportHardware, extractHardware, generateHardware, hardwareDiagnostics, type HardwareTarget } from "../lib/hardware-cad.ts";
|
|
13
|
+
import { isPlateModule } from "../lib/params.ts";
|
|
14
|
+
import { exportPlate, extractPlate, generatePlate, plateDiagnostics, type PlateTarget } from "../lib/plate.ts";
|
|
15
|
+
|
|
16
|
+
type Target = { kind: "plate"; t: PlateTarget } | { kind: "hardware"; t: HardwareTarget };
|
|
17
|
+
|
|
18
|
+
function target(ctx: CapabilityContext): Target | string {
|
|
19
|
+
const def = ctx.module.definition;
|
|
20
|
+
if (ctx.project.definitions.get(def.id)?.origin.kind !== "local") return `${def.id} is not a local definition; ext-frame-cad designs local modules only (derive a local variant)`;
|
|
21
|
+
const config = ctx.project.capabilities.find((b) => b.capability === "cad" && b.module === def.id)!.config;
|
|
22
|
+
const binding = { dir: ctx.module.dir, native: ctx.module.native, out: ctx.module.out, constraints: ctx.constraints, process: ctx.process, config };
|
|
23
|
+
if (isPlateModule(def)) return { kind: "plate", t: { def, binding } };
|
|
24
|
+
if (hardwareKind(def)) return { kind: "hardware", t: { def, binding } };
|
|
25
|
+
return `${def.id} is neither a plate (mechanical metadata plate_thickness_mm) nor mounting hardware (tags fastener and one of ${HARDWARE_KINDS.join(", ")}), so ext-frame-cad cannot design it`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const generate: CapabilityOperationFn = async (ctx) => {
|
|
29
|
+
const x = target(ctx);
|
|
30
|
+
if (typeof x === "string") return { status: "failed", summary: x };
|
|
31
|
+
if (x.kind === "hardware") {
|
|
32
|
+
const r = generateHardware(ctx, x.t);
|
|
33
|
+
if (!r.ok) return { status: "failed", summary: r.summary, ...(r.problems ? { data: { problems: r.problems } } : {}) };
|
|
34
|
+
return { status: "succeeded", summary: `${r.value.module}: ${r.value.kind} parameters in ${r.value.native}; run cad:export for the body`, data: { ...r.value, generator: "@protoboardai/ext-frame-cad/py/hardware_cad.py" } };
|
|
35
|
+
}
|
|
36
|
+
const t = x.t;
|
|
37
|
+
const r = await generatePlate(ctx, t);
|
|
38
|
+
if (!r.ok) return { status: "failed", summary: r.summary, ...(r.problems ? { data: { problems: r.problems } } : {}) };
|
|
39
|
+
const stale = r.value.rebind.signatures.length + r.value.rebind.frames.length;
|
|
40
|
+
return {
|
|
41
|
+
status: "succeeded",
|
|
42
|
+
summary: `${t.def.id}: ${Object.keys(r.value.features).length} features; ${stale ? `${stale} binding(s) changed: review, then run frame-cad-rebind after cad:export` : "all bindings still match"}`,
|
|
43
|
+
data: { ...r.value, generator: "@protoboardai/ext-frame-cad/py/frame_cad.py" },
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const exportOutputs: CapabilityOperationFn = async (ctx) => {
|
|
48
|
+
const x = target(ctx);
|
|
49
|
+
if (typeof x === "string") return { status: "failed", summary: x };
|
|
50
|
+
if (x.kind === "hardware") {
|
|
51
|
+
const r = await exportHardware(ctx, x.t);
|
|
52
|
+
if (!r.ok) return { status: "failed", summary: r.summary };
|
|
53
|
+
return { status: "succeeded", summary: `${r.value.module}: body.step, body.glb, body.manifest.json in ${r.value.out} (${r.value.volume_mm3} mm³)`, data: r.value };
|
|
54
|
+
}
|
|
55
|
+
const t = x.t;
|
|
56
|
+
const r = await exportPlate(ctx, t);
|
|
57
|
+
if (!r.ok) return { status: "failed", summary: r.summary };
|
|
58
|
+
return { status: "succeeded", summary: `${t.def.id}: body.step, body.glb, body.manifest.json, body.dxf and ${r.value.interfaces.length} interface GLBs in ${ctx.module.out}`, data: r.value };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const extract: CapabilityOperationFn = (ctx) => {
|
|
62
|
+
const x = target(ctx);
|
|
63
|
+
if (typeof x === "string") return { status: "failed", summary: x };
|
|
64
|
+
const r = x.kind === "hardware" ? extractHardware(ctx.project.root, x.t) : extractPlate(ctx.project.root, x.t);
|
|
65
|
+
if (!r.ok) return { status: "failed", summary: r.summary };
|
|
66
|
+
const facts: ExtractedFacts = r.value;
|
|
67
|
+
return { status: "succeeded", summary: x.kind === "hardware" ? `${facts.artifacts!.length} artifacts (a fastener's place comes from its fastener stack)` : `${Object.keys(facts.frames!).length} frames, ${facts.artifacts!.length} artifacts; re-bind with frame-cad-rebind`, data: facts };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const check: CapabilityOperationFn = (ctx) => {
|
|
71
|
+
const x = target(ctx);
|
|
72
|
+
if (typeof x === "string") {
|
|
73
|
+
return { status: "succeeded", summary: x, diagnostics: [{ code: "FRAME_CAD_NOT_A_PLATE", severity: "error", entity: { kind: "definition", id: ctx.module.id }, message: x }] };
|
|
74
|
+
}
|
|
75
|
+
const source = ctx.project.definitions.get(x.t.def.id)!.origin.source;
|
|
76
|
+
const diagnostics = x.kind === "hardware" ? hardwareDiagnostics(ctx.project.root, x.t, source) : plateDiagnostics(ctx.project.root, x.t, source);
|
|
77
|
+
return { status: "succeeded", summary: `${diagnostics.length} finding(s)`, diagnostics };
|
|
78
|
+
};
|
package/ops/frame-cad.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { OperationFn } from "@protoboardai/core";
|
|
2
|
+
import { generateThenExport } from "../lib/alias.ts";
|
|
3
|
+
import { PROVIDER, plateBinding, plateModules } from "../lib/params.ts";
|
|
4
|
+
import type { PlateTarget } from "../lib/plate.ts";
|
|
5
|
+
|
|
6
|
+
export { GENERATOR, bindingDiff, python } from "../lib/plate.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* frame-cad: the `cad` capability's generate and export for every plate
|
|
10
|
+
* module (or the named ones), in one operation. Each module needs a
|
|
11
|
+
* `cad.config.ts` naming @protoboardai/ext-frame-cad; its native design goes to
|
|
12
|
+
* `<dir>/frame-cad/` and its standard outputs to `<dir>/out/` by
|
|
13
|
+
* `cad:generate` and `cad:export` run for each module, each with its own
|
|
14
|
+
* operation record (lib/alias.ts), so the release can trace every `out/`.
|
|
15
|
+
* Returns the new signatures plus the bindings that no longer match
|
|
16
|
+
* (re-bind explicitly with frame-cad-rebind after reviewing them).
|
|
17
|
+
*/
|
|
18
|
+
export const frameCad: OperationFn = async (ctx) => {
|
|
19
|
+
const p = ctx.project;
|
|
20
|
+
const only = ctx.inputs.module?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
21
|
+
const modules = plateModules(p, only);
|
|
22
|
+
if (!modules.length) return { status: "failed", summary: "no plate modules (local definitions with mechanical metadata plate_thickness_mm) in the project" };
|
|
23
|
+
const targets: PlateTarget[] = [];
|
|
24
|
+
const problems = modules.flatMap((m) => m.problems);
|
|
25
|
+
for (const { def } of modules.filter((m) => !m.problems.length)) {
|
|
26
|
+
const binding = plateBinding(p, def.id);
|
|
27
|
+
if (binding) targets.push({ def, binding });
|
|
28
|
+
else problems.push(`${def.id} has no cad.config.ts naming ${PROVIDER} (or it has a problem; see protoboard capabilities); add one in the module's folder: export default defineCapabilityConfig({ provider: "${PROVIDER}", process: "cut" })`);
|
|
29
|
+
}
|
|
30
|
+
if (problems.length) return { status: "failed", summary: problems[0], data: { problems } };
|
|
31
|
+
|
|
32
|
+
const run = await generateThenExport(p, targets.map((t) => t.def.id));
|
|
33
|
+
if (!run.ok) return { status: "failed", summary: run.summary, data: { module: run.module, problems: run.problems ?? [run.summary], steps: run.steps } };
|
|
34
|
+
type Generated = { module: string; features: Record<string, unknown>; rebind: { signatures: unknown[]; frames: unknown[] } };
|
|
35
|
+
const results = targets.map((t) => ({
|
|
36
|
+
...(run.generated.get(t.def.id)!.data as Generated),
|
|
37
|
+
out: t.binding.out,
|
|
38
|
+
volume_mm3: (run.exported.get(t.def.id)!.data as { volume_mm3?: number }).volume_mm3,
|
|
39
|
+
}));
|
|
40
|
+
const stale = results.reduce((n, r) => n + r.rebind.signatures.length + r.rebind.frames.length, 0);
|
|
41
|
+
return {
|
|
42
|
+
status: "succeeded",
|
|
43
|
+
summary: `${results.map((r) => `${r.module} (${Object.keys(r.features).length} features)`).join(", ")}; ${stale ? `${stale} binding(s) changed: review, then run frame-cad-rebind` : "all bindings still match"}`,
|
|
44
|
+
data: { modules: results, generator: "@protoboardai/ext-frame-cad/py/frame_cad.py", steps: run.steps },
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { OperationFn } from "@protoboardai/core";
|
|
4
|
+
import { hardwareBinding, hardwareModules } from "../lib/hardware.ts";
|
|
5
|
+
import { generateThenExport } from "../lib/alias.ts";
|
|
6
|
+
import type { HardwareTarget } from "../lib/hardware-cad.ts";
|
|
7
|
+
|
|
8
|
+
export { HARDWARE_GENERATOR } from "../lib/hardware-cad.ts";
|
|
9
|
+
|
|
10
|
+
const readJson = <T>(file: string): T | undefined => (existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as T) : undefined);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* hardware-cad: the `cad` capability's generate and export for every
|
|
14
|
+
* mounting-hardware module (screws, nuts, standoffs, spacers), or the named
|
|
15
|
+
* ones, in one operation, as `frame-cad` is for plates. Each module needs a
|
|
16
|
+
* `cad.config.ts` naming @protoboardai/ext-frame-cad in its folder; its
|
|
17
|
+
* parameters go to `<dir>/frame-cad/params.json` and its body to
|
|
18
|
+
* `<dir>/out/` by `cad:generate` and `cad:export` run for each module, each
|
|
19
|
+
* with its own operation record (lib/alias.ts). Returns each body's volume with the volume it
|
|
20
|
+
* replaces (mass = volume × material density, so a change moves the system
|
|
21
|
+
* mass).
|
|
22
|
+
*/
|
|
23
|
+
export const hardwareCad: OperationFn = async (ctx) => {
|
|
24
|
+
const p = ctx.project;
|
|
25
|
+
const only = ctx.inputs.module?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
26
|
+
const modules = hardwareModules(p, only);
|
|
27
|
+
if (!modules.length) return { status: "failed", summary: "no hardware modules (local definitions tagged fastener and screw, nut, standoff or spacer) in the project" };
|
|
28
|
+
const problems = modules.flatMap((m) => m.problems);
|
|
29
|
+
const targets: HardwareTarget[] = [];
|
|
30
|
+
for (const { def } of modules.filter((m) => !m.problems.length)) {
|
|
31
|
+
const binding = hardwareBinding(p, def.id);
|
|
32
|
+
if (binding) targets.push({ def, binding });
|
|
33
|
+
else problems.push(`${def.id} has no cad.config.ts naming @protoboardai/ext-frame-cad (or it has a problem; see protoboard capabilities); add one in a folder of its own: export default defineCapabilityConfig({ provider: "@protoboardai/ext-frame-cad", module: "${def.id}" })`);
|
|
34
|
+
}
|
|
35
|
+
if (problems.length) return { status: "failed", summary: problems[0], data: { problems } };
|
|
36
|
+
|
|
37
|
+
// the volume each body replaces, read before the export empties out/
|
|
38
|
+
const before = new Map(targets.map((t) => [t.def.id, readJson<{ volume_mm3?: number }>(join(p.root, t.binding.out, "body.manifest.json"))?.volume_mm3 ?? null]));
|
|
39
|
+
const run = await generateThenExport(p, targets.map((t) => t.def.id));
|
|
40
|
+
if (!run.ok) return { status: "failed", summary: run.summary, data: { module: run.module, problems: run.problems ?? [run.summary], steps: run.steps } };
|
|
41
|
+
type Generated = { kind: string };
|
|
42
|
+
type Exported = { out: string; toolVersion: string; volume_mm3: number };
|
|
43
|
+
const results = targets.map((t) => {
|
|
44
|
+
const gen = run.generated.get(t.def.id)!.data as Generated;
|
|
45
|
+
const exp = run.exported.get(t.def.id)!.data as Exported;
|
|
46
|
+
return { module: t.def.id, kind: gen.kind, dir: t.binding.dir, out: exp.out, toolVersion: exp.toolVersion, volume_mm3: exp.volume_mm3, previousVolume_mm3: before.get(t.def.id) ?? null };
|
|
47
|
+
});
|
|
48
|
+
const changed = results.filter((r) => r.previousVolume_mm3 !== null && r.previousVolume_mm3 !== r.volume_mm3);
|
|
49
|
+
return {
|
|
50
|
+
status: "succeeded",
|
|
51
|
+
summary: `${results.length} hardware bodies; ${changed.length ? `volume changed for ${changed.map((r) => r.module).join(", ")} (system mass moves)` : "volumes unchanged"}`,
|
|
52
|
+
data: { modules: results, generator: "@protoboardai/ext-frame-cad/py/hardware_cad.py", steps: run.steps },
|
|
53
|
+
};
|
|
54
|
+
};
|
package/ops/package.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contributions to the Manufacturing Package (@protoboardai/ext-release section
|
|
3
|
+
* contract) for the plates this provider designs: the cut order of each
|
|
4
|
+
* plate in its unit's work order (process, material, stock thickness and
|
|
5
|
+
* size, quantities by DXF layer, hole sizes, the smallest feature the
|
|
6
|
+
* router bit must cut, what the DXF leaves out, edge finish), and the
|
|
7
|
+
* inspection of what the cut order asks for in the test plan. Values come
|
|
8
|
+
* from the module (material, interfaces), its cad.config.ts, the generator
|
|
9
|
+
* parameters (frame-cad/params.json) and the standard outputs (out/).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { CapabilityBinding, Project } from "@protoboardai/core";
|
|
14
|
+
import type { Block, SectionContext, SectionFn } from "@protoboardai/ext-release";
|
|
15
|
+
import { glbBox, perBuild, providerModules, readOutput } from "@protoboardai/ext-release/helpers";
|
|
16
|
+
import { DEFAULTS, mechanical, PROVIDER } from "../lib/params.ts";
|
|
17
|
+
|
|
18
|
+
interface Manifest {
|
|
19
|
+
dxf?: string;
|
|
20
|
+
layers?: string[];
|
|
21
|
+
quantities?: Record<string, number>;
|
|
22
|
+
notInDxf?: string[];
|
|
23
|
+
thickness_mm?: Record<string, number>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const n = (v: number, d = 1) => String(Number(v.toFixed(d)));
|
|
27
|
+
|
|
28
|
+
/** What the cut order needs for one plate module. */
|
|
29
|
+
export function cutOrder(p: Project, b: CapabilityBinding) {
|
|
30
|
+
const def = p.definitions.get(b.module!)!.def;
|
|
31
|
+
const m = readOutput<{ manufacturing?: Manifest }>(p, b, "body.manifest.json")?.manufacturing ?? {};
|
|
32
|
+
const c = { ...DEFAULTS, ...Object.fromEntries(Object.entries(b.constraints).map(([k, v]) => [k.replace(/_mm$/, ""), v])) } as Record<string, unknown>;
|
|
33
|
+
const mat = mechanical(def).material ?? (def.domains?.find((d) => d.domain === "mechanical") as { material?: unknown } | undefined)?.material;
|
|
34
|
+
const material = typeof mat === "object" && mat ? ((mat as { name?: string }).name ?? "") : typeof mat === "string" ? mat : "";
|
|
35
|
+
const params = b.native && existsSync(join(p.root, b.native, "params.json")) ? (JSON.parse(readFileSync(join(p.root, b.native, "params.json"), "utf8")) as { interfaces?: { fastener_d: number; hole_count: number; placement: string }[] }) : undefined;
|
|
36
|
+
const clearance = Number(c.hole_clearance ?? DEFAULTS.hole_clearance);
|
|
37
|
+
const holes = new Map<number, number>();
|
|
38
|
+
for (const i of params?.interfaces ?? []) if (i.placement !== "clamp") holes.set(i.fastener_d, (holes.get(i.fastener_d) ?? 0) + i.hole_count);
|
|
39
|
+
const slots = (Array.isArray(c.slots) ? (c.slots as { size: [number, number] }[]) : []).map((s) => Math.min(...s.size));
|
|
40
|
+
const armSlot = Number(c.arm_slot_width ?? 0);
|
|
41
|
+
const smallest = Math.min(...[...slots, ...(armSlot > 0 ? [armSlot] : [])].filter((x) => x > 0));
|
|
42
|
+
const box = glbBox(join(p.root, b.out, "body.glb"));
|
|
43
|
+
return { def, m, material, holes, clearance, smallest: Number.isFinite(smallest) ? smallest : undefined, corner: Number(c.corner_radius ?? DEFAULTS.corner_radius), box, carbon: /carbon/i.test(material) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const workOrder: SectionFn = ({ project: p, unit, release }: SectionContext) => {
|
|
47
|
+
const plates = providerModules(p, unit, PROVIDER).filter((b) => b.process === "cut");
|
|
48
|
+
if (!plates.length) return undefined;
|
|
49
|
+
const builds = unit.perProduct * release.build;
|
|
50
|
+
const blocks: Block[] = [{ kind: "paragraph", text: "Plates cut from sheet on a CNC router. Send the shop the DXF (one layer per profile) and the STEP for reference, with the values below. Quantities are for every build of this unit; order the shop's minimum or a spare if a plate is critical." }];
|
|
51
|
+
for (const b of plates) {
|
|
52
|
+
const o = cutOrder(p, b);
|
|
53
|
+
const per = perBuild(unit, o.def.id);
|
|
54
|
+
const layers = o.m.layers ?? [];
|
|
55
|
+
blocks.push({ kind: "heading", text: o.def.name ?? o.def.id });
|
|
56
|
+
blocks.push({
|
|
57
|
+
kind: "keyvalues",
|
|
58
|
+
rows: [
|
|
59
|
+
{ label: "Process", value: "CNC routing from sheet", note: `${b.config}: process cut` },
|
|
60
|
+
{ label: "Material", value: o.material || "not stated in the model: state it on the order" },
|
|
61
|
+
...Object.entries(o.m.thickness_mm ?? {}).map(([k, t]) => ({ label: `Stock: ${k}`, value: `${t} mm sheet`, note: "a stock thickness the shop lists; check before ordering" })),
|
|
62
|
+
...(o.box ? [{ label: "Plate size", value: `${n(o.box.size[0])} × ${n(o.box.size[1])} mm overall`, note: "bounding box of the body, clamps included; nest on the shop's sheet" }] : []),
|
|
63
|
+
{ label: "Quantity", value: layers.map((l) => `${(o.m.quantities?.[l] ?? 1) * per * builds} × ${l}`).join(", ") || `${per * builds}`, note: `${per} per build × ${builds} build${builds === 1 ? "" : "s"}` },
|
|
64
|
+
...[...o.holes].map(([d, count]) => ({ label: `Holes M${n(d)}`, value: `Ø${n(d + 2 * o.clearance, 2)} mm, ${count} per plate`, note: `fastener + 2 × ${o.clearance} mm clearance (hole_clearance_mm); hold the routing tolerance inside it` })),
|
|
65
|
+
...(o.smallest ? [{ label: "Router bit", value: `≤ ${n(o.smallest)} mm`, note: `the narrowest slot; inside corners take the bit's radius (outline corners ${o.corner} mm)` }] : []),
|
|
66
|
+
{ label: "Files", value: `files/mechanical/${o.def.id}/${o.def.id}.dxf (layers ${layers.join(", ") || "—"}), ${o.def.id}.step` },
|
|
67
|
+
...(o.m.notInDxf ?? []).map((x) => ({ label: "Not in the DXF", value: x, note: "agree with the shop how it is made, and record it" })),
|
|
68
|
+
{ label: "Edges", value: o.carbon ? "chamfer or sand every edge and hole, then seal the cut edges (thin CA or epoxy)" : "deburr every edge and hole", ...(o.carbon ? { note: "carbon edges fray and cut wires" } : {}) },
|
|
69
|
+
{ label: "Marking", value: "the release version on each plate" },
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return { title: "Cut parts", order: 30, blocks };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const testPlan: SectionFn = ({ project: p, unit }: SectionContext) => {
|
|
77
|
+
const plates = providerModules(p, unit, PROVIDER).filter((b) => b.process === "cut");
|
|
78
|
+
if (!plates.length) return undefined;
|
|
79
|
+
const rows = plates.flatMap((b) => {
|
|
80
|
+
const o = cutOrder(p, b);
|
|
81
|
+
const name = o.def.name ?? o.def.id;
|
|
82
|
+
return [
|
|
83
|
+
...[...o.holes].map(([d]) => ({ check: `Hole size M${n(d)}: ${name}`, how: `Pin gauge Ø${n(d + 2 * o.clearance, 2)} mm`, pass: `every M${n(d)} hole takes the gauge; the screw passes without force` })),
|
|
84
|
+
{ check: `Edges: ${name}`, how: "Visual and by touch", pass: o.carbon ? "edges chamfered or sanded and sealed; no fraying or delamination" : "no burrs" },
|
|
85
|
+
{ check: `Edge distance: ${name}`, how: "Visual around every hole", pass: "no breakout or crack between a hole and the edge" },
|
|
86
|
+
];
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
title: "Cut parts",
|
|
90
|
+
order: 30,
|
|
91
|
+
blocks: [
|
|
92
|
+
{ kind: "paragraph", text: "With the first-article inspection, before the plates are used: what the cut order asked for.", keep: true },
|
|
93
|
+
{ kind: "table", columns: [{ key: "check", label: "Check" }, { key: "how", label: "How" }, { key: "pass", label: "Pass" }], rows },
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
};
|
package/ops/rebind.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { OperationFn } from "@protoboardai/core";
|
|
4
|
+
import { plateBinding, plateFiles, plateModules, sameVec, type FramesFile } from "../lib/params.ts";
|
|
5
|
+
|
|
6
|
+
interface Signature {
|
|
7
|
+
area_mm2: number;
|
|
8
|
+
centroid: number[];
|
|
9
|
+
normal?: number[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const vec = (v: number[]) => `[${v.map((n) => String(Number(n.toFixed(3)) + 0)).join(", ")}]`;
|
|
13
|
+
const sig = (s: Signature) => `{ area_mm2: ${s.area_mm2}, centroid: ${vec(s.centroid)}${s.normal ? `, normal: ${vec(s.normal)}` : ""} }`;
|
|
14
|
+
const parseVec = (s: string) => s.split(",").map((x) => Number(x.trim()));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The part of a module source that holds this module's geometry: the
|
|
18
|
+
* `withGeometry(` call whose artifacts name the generated body. Several
|
|
19
|
+
* modules may share a file (frame and top plate), so re-binding is scoped to
|
|
20
|
+
* that call and never touches another module's literals.
|
|
21
|
+
*/
|
|
22
|
+
function geometryBlock(text: string, cadName: string): [number, number] | undefined {
|
|
23
|
+
const starts = [...text.matchAll(/withGeometry\(/g)].map((m) => m.index!);
|
|
24
|
+
const blocks = starts.map((s, k) => [s, starts[k + 1] ?? text.length] as [number, number]);
|
|
25
|
+
const hits = blocks.filter(([a, b]) => text.slice(a, b).includes(`name: "${cadName}"`));
|
|
26
|
+
if (hits.length === 1) return hits[0];
|
|
27
|
+
return blocks.length === 1 ? blocks[0] : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* frame-cad-rebind: explicit re-binding after a reviewed change (the step
|
|
32
|
+
* `cad:extract` leaves to the user, tool-integrations §8). Copies the
|
|
33
|
+
* exported feature signatures (out/body.manifest.json) into the module's
|
|
34
|
+
* `feature("<id>", { … })` literals and the generated frame origins (the
|
|
35
|
+
* native frame-cad/frames.json) into `<id>: { frame: { origin: [...] … } }`,
|
|
36
|
+
* in the module's own source file. Every change is returned; nothing else in
|
|
37
|
+
* the file is touched.
|
|
38
|
+
*/
|
|
39
|
+
export const rebind: OperationFn = (ctx) => {
|
|
40
|
+
const p = ctx.project;
|
|
41
|
+
const only = ctx.inputs.module?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
42
|
+
const targets = plateModules(p, only);
|
|
43
|
+
const blocked = targets.flatMap((t) => t.problems);
|
|
44
|
+
if (blocked.length) return { status: "failed", summary: blocked[0] };
|
|
45
|
+
const changes: { module: string; file: string; interface: string; what: "signature" | "frame origin"; from: string; to: string }[] = [];
|
|
46
|
+
const edited = new Map<string, string>();
|
|
47
|
+
let rebound = 0;
|
|
48
|
+
for (const { def } of targets) {
|
|
49
|
+
const binding = plateBinding(p, def.id);
|
|
50
|
+
const files = binding && plateFiles(binding);
|
|
51
|
+
const manifestFile = files && join(p.root, files.manifest);
|
|
52
|
+
const framesFile = files && join(p.root, files.frames);
|
|
53
|
+
if (!manifestFile || !framesFile || !existsSync(manifestFile) || !existsSync(framesFile)) {
|
|
54
|
+
if (only?.length) return { status: "failed", summary: `${def.id} has no generated and exported CAD${binding ? ` in ${binding.dir}` : " (no cad.config.ts)"}; run \`protoboard run cad:generate --module ${def.id}\` and cad:export first` };
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const features = (JSON.parse(readFileSync(manifestFile, "utf8")) as { features: Record<string, Signature> }).features;
|
|
58
|
+
const frames = JSON.parse(readFileSync(framesFile, "utf8")) as FramesFile;
|
|
59
|
+
const file = p.definitions.get(def.id)!.origin.source.file;
|
|
60
|
+
let text = edited.get(file) ?? readFileSync(join(p.root, file), "utf8");
|
|
61
|
+
const block = geometryBlock(text, def.id);
|
|
62
|
+
if (!block) return { status: "failed", summary: `${file}: cannot find the withGeometry(...) call for ${def.id} (artifacts named "${def.id}"); re-bind by hand from ${files.manifest}` };
|
|
63
|
+
let body = text.slice(block[0], block[1]);
|
|
64
|
+
body = body.replace(/feature\("([^"]+)", (\{ area_mm2: [^}]*\})/g, (all, name: string, old: string) => {
|
|
65
|
+
const now = features[name];
|
|
66
|
+
if (!now) return all;
|
|
67
|
+
const next = sig(now);
|
|
68
|
+
const was = /area_mm2: ([\d.e+-]+), centroid: \[([^\]]*)\]/.exec(old);
|
|
69
|
+
if (was && Math.abs(Number(was[1]) - now.area_mm2) < 1e-6 && sameVec(parseVec(was[2]), now.centroid, 1e-6)) return all;
|
|
70
|
+
changes.push({ module: def.id, file, interface: name, what: "signature", from: old, to: next });
|
|
71
|
+
return `feature("${name}", ${next}`;
|
|
72
|
+
});
|
|
73
|
+
for (const [id, f] of Object.entries(frames)) {
|
|
74
|
+
const re = new RegExp(`(\\b${id}:\\s*\\{\\s*frame:\\s*\\{\\s*origin:\\s*)\\[([^\\]]*)\\]`);
|
|
75
|
+
body = body.replace(re, (all, head: string, old: string) => {
|
|
76
|
+
if (sameVec(parseVec(old), f.origin, 1e-6)) return all;
|
|
77
|
+
changes.push({ module: def.id, file, interface: id, what: "frame origin", from: `[${old}]`, to: vec(f.origin) });
|
|
78
|
+
return `${head}${vec(f.origin)}`;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
text = text.slice(0, block[0]) + body + text.slice(block[1]);
|
|
82
|
+
edited.set(file, text);
|
|
83
|
+
rebound++;
|
|
84
|
+
}
|
|
85
|
+
for (const [file, text] of edited) {
|
|
86
|
+
if (changes.some((c) => c.file === file)) ctx.writeOutput(file, text, "re-bound geometry literals");
|
|
87
|
+
}
|
|
88
|
+
for (const c of changes) ctx.log(`${c.module}.${c.interface} ${c.what}: ${c.from} -> ${c.to}`);
|
|
89
|
+
return {
|
|
90
|
+
status: "succeeded",
|
|
91
|
+
summary: changes.length ? `${changes.length} literal(s) re-bound in ${[...new Set(changes.map((c) => c.file))].join(", ")}` : `${rebound} module(s) checked; nothing to re-bind`,
|
|
92
|
+
data: { changes },
|
|
93
|
+
};
|
|
94
|
+
};
|