@protoboardai/ext-harness-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/lib/job.ts ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * UHD project → harness routing job.
3
+ *
4
+ * The job is everything the Python router needs and nothing it has to know
5
+ * about UHD: the assembled model as placed GLB bodies (obstacles), and for
6
+ * each wire harness its conductors, length and ends, each end resolved to
7
+ * the world positions its conductors land on (the linked part's pads, or the
8
+ * pins of the socket its plug goes into).
9
+ *
10
+ * Placement is UHD `assemble`, with the project's display placements for
11
+ * modules no mechanical link reaches (battery, receiver, capacitor): the same
12
+ * assembly the viewer draws.
13
+ *
14
+ * The job always holds every routable harness of the system: the router
15
+ * routes them in the system's order, each around the ones before it, and
16
+ * writes routes only for the job's `targets`.
17
+ */
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import {
21
+ assemble,
22
+ bodyKey,
23
+ combineFrames,
24
+ isConnector,
25
+ linkEndFrames,
26
+ type Assembly,
27
+ type GeometryFrame,
28
+ type InterfaceDef,
29
+ type InterfaceLink,
30
+ type Mat4,
31
+ type ModuleDef,
32
+ } from "@deltarobotics/uhd";
33
+ import type { Project } from "@protoboardai/core";
34
+ import { SILICONE_OD_MM, WIRE_COLORS, termination, type Termination } from "./connectors.ts";
35
+
36
+ export const JOB_SCHEMA = "protoboard.harness-cad.job/v0";
37
+ type Vec3 = [number, number, number];
38
+
39
+ export interface CableBuild {
40
+ /** Insulation colour per position of the first end (names from WIRE_COLORS or #rrggbb). */
41
+ colors: string[];
42
+ /** twisted: helix around the route; parallel: side by side (ribbon-like). */
43
+ bundle: "twisted" | "parallel";
44
+ /** mm per full turn when twisted. */
45
+ twist_pitch_mm?: number;
46
+ /** Conductor outside diameter override (mm); default from wire_gauge_awg. */
47
+ od_mm?: number;
48
+ /** Bare (uninsulated) conductors: component leads. */
49
+ bare?: boolean;
50
+ /** Stripped, tinned length at a soldered end (mm). */
51
+ strip_mm?: number;
52
+ /** Centre spacing of parallel conductors (mm); default: touching. */
53
+ spacing_mm?: number;
54
+ }
55
+
56
+ export interface JobEnd {
57
+ interfaceId: string;
58
+ linkId: string;
59
+ counterpart: string;
60
+ connector: string;
61
+ termination: Termination;
62
+ /** Where each position lands (world, mm) and the direction the conductor arrives from (outward from the part). */
63
+ pins: { position: string; at: Vec3; out: Vec3 }[];
64
+ /** The frame the harness end must carry: the counterpart's combined frame, normal reversed. */
65
+ frame: GeometryFrame;
66
+ /** Counterpart frame (outward). */
67
+ counterpartFrame: GeometryFrame;
68
+ }
69
+
70
+ export interface JobHarness {
71
+ id: string;
72
+ path: string;
73
+ name: string;
74
+ length_mm: number;
75
+ awg?: number;
76
+ conductors: { position: string; color: string; od: number; bare: boolean }[];
77
+ /** Conductor k connects ends[0].pins[k] to ends[1].pins[pair[k]]. */
78
+ pairs: number[];
79
+ build: Required<Pick<CableBuild, "bundle">> & CableBuild;
80
+ ends: JobEnd[];
81
+ }
82
+
83
+ export interface Job {
84
+ schema: typeof JOB_SCHEMA;
85
+ bodies: { key: string; glb: string; matrix: Mat4 }[];
86
+ harnesses: JobHarness[];
87
+ /** Harness definition ids whose routes the router writes (default: all). */
88
+ targets?: string[];
89
+ problems: string[];
90
+ }
91
+
92
+ const neg = (v: Vec3): Vec3 => [-v[0], -v[1], -v[2]];
93
+ const add = (a: Vec3, b: Vec3, s = 1): Vec3 => [a[0] + b[0] * s, a[1] + b[1] * s, a[2] + b[2] * s];
94
+
95
+ export function displayHints(p: Project): { root?: string; fixed: Record<string, Mat4> } {
96
+ const file = join(p.root, p.config.display ?? ".protoboard/display.json");
97
+ if (!existsSync(file)) return { fixed: {} };
98
+ const d = JSON.parse(readFileSync(file, "utf8")) as { spatial?: { root?: string; placements?: Record<string, { matrix: Mat4 }> } };
99
+ return { root: d.spatial?.root, fixed: Object.fromEntries(Object.entries(d.spatial?.placements ?? {}).map(([k, v]) => [k, v.matrix])) };
100
+ }
101
+
102
+ /** The assembly the viewer draws: UHD assemble from the display root, with display placements. */
103
+ export function projectAssembly(p: Project): Assembly {
104
+ const { root, fixed } = displayHints(p);
105
+ const rootPath = root ? root.split("/") : [p.instances.find((i) => i.parent === "" && p.lookup(i.definition)?.interfaces.some((f) => f.geometry?.frame))?.path ?? ""];
106
+ return assemble(p.rootDef!, p.lookup, { root: rootPath, fixed });
107
+ }
108
+
109
+ /** Every link below the root with the module that declares it and its path. */
110
+ function allLinks(root: ModuleDef, lookup: Project["lookup"]) {
111
+ const out: { owner: ModuleDef; prefix: string[]; link: InterfaceLink }[] = [];
112
+ const walk = (d: ModuleDef, prefix: string[]) => {
113
+ for (const link of d.links ?? []) out.push({ owner: d, prefix, link });
114
+ for (const c of d.children ?? []) {
115
+ const def = lookup(c.moduleDefId);
116
+ if (def && def.kind !== "harness" && def.children?.length) walk(def, [...prefix, c.id]);
117
+ }
118
+ };
119
+ walk(root, []);
120
+ return out;
121
+ }
122
+
123
+ export const cableBuild = (def: ModuleDef): CableBuild | undefined => def.traits?.find((t) => t.type === "cable_build")?.params as CableBuild | undefined;
124
+ export const cableLength = (def: ModuleDef): number | undefined => {
125
+ const t = def.traits?.find((x) => x.type === "performance" && x.params?.kind === "cable");
126
+ return typeof t?.params?.length_mm === "number" ? (t.params.length_mm as number) : undefined;
127
+ };
128
+ const cableAwg = (def: ModuleDef): number | undefined => {
129
+ const t = def.traits?.find((x) => x.type === "performance" && x.params?.kind === "cable");
130
+ return typeof t?.params?.wire_gauge_awg === "number" ? (t.params.wire_gauge_awg as number) : undefined;
131
+ };
132
+ const connectorType = (i: InterfaceDef) => String(i.traits?.find((t) => t.type === "connector")?.params?.connector_type ?? "");
133
+
134
+ /** Wire harness instances: kind harness with connector-composite ends and a cable_build trait. */
135
+ export function wireHarnesses(asm: Assembly, only?: string[]) {
136
+ return asm.instances.filter(
137
+ (i) => i.kind === "harness" && i.def.interfaces.some(isConnector) && cableBuild(i.def) && (!only?.length || only.includes(i.def.id) || only.includes(i.path.join("/"))),
138
+ );
139
+ }
140
+
141
+ export function buildJob(p: Project): Job {
142
+ const problems: string[] = [];
143
+ const asm = projectAssembly(p);
144
+ const placed = new Map(asm.placements.map((x) => [x.key, x]));
145
+ const art = (def: string, id: string) => p.artifacts.find((a) => a.definition === def && a.id === id);
146
+
147
+ // obstacles: every placed body with a GLB, and every placed piece of hardware
148
+ const bodies: Job["bodies"] = [];
149
+ const defAt = new Map(asm.instances.map((i) => [bodyKey(i.path), i.def]));
150
+ // a placed module with children (a board carrying its components, a housing carrying its inserts) is not a leaf instance
151
+ const innerAt = (path: string[]): ModuleDef | undefined => {
152
+ let d: ModuleDef | undefined = p.rootDef;
153
+ for (const id of path) {
154
+ const ref: { moduleDefId: string } | undefined = d?.children?.find((c) => c.id === id);
155
+ d = ref ? p.lookup(ref.moduleDefId) : undefined;
156
+ }
157
+ return d;
158
+ };
159
+ for (const pl of asm.placements) {
160
+ const inner = defAt.has(bodyKey(pl.path)) ? undefined : innerAt(pl.path);
161
+ const def = defAt.get(bodyKey(pl.path)) ?? (inner?.artifacts?.some((a) => a.format === "glb" && a.role === "body") ? inner : undefined);
162
+ if (!def) continue;
163
+ const glb = (def.artifacts ?? []).find((a) => a.format === "glb" && a.role === "body" && (pl.body ? a.id === pl.body || a.provenance?.generatedFrom === pl.body : true));
164
+ const file = glb && art(def.id, glb.id);
165
+ if (file?.resolved && existsSync(file.resolved)) bodies.push({ key: pl.key, glb: file.resolved, matrix: pl.matrix });
166
+ else problems.push(`${pl.key}: no GLB body to route around (${glb?.filePath ?? "none declared"})`);
167
+ }
168
+ for (const hw of asm.hardware) {
169
+ const glb = (hw.def.artifacts ?? []).find((a) => a.format === "glb" && a.role === "body");
170
+ const file = glb && art(hw.def.id, glb.id);
171
+ if (file?.resolved && existsSync(file.resolved)) bodies.push({ key: `${hw.harness}/${hw.child}`, glb: file.resolved, matrix: hw.matrix });
172
+ }
173
+
174
+ const links = allLinks(p.rootDef!, p.lookup);
175
+ const harnesses: JobHarness[] = [];
176
+ for (const inst of wireHarnesses(asm)) {
177
+ const def = inst.def;
178
+ const build = cableBuild(def)!;
179
+ const length = cableLength(def);
180
+ const pathKey = inst.path.join("/");
181
+ if (!length) {
182
+ problems.push(`${pathKey} (${def.id}): no cable length (performance trait { kind: "cable", length_mm })`);
183
+ continue;
184
+ }
185
+ const parent = inst.path.slice(0, -1).join("/");
186
+ const childId = inst.path[inst.path.length - 1];
187
+ const ends: JobEnd[] = [];
188
+ for (const { owner, prefix, link } of links) {
189
+ if (prefix.join("/") !== parent) continue;
190
+ for (const [mine, theirs] of [[link.a, link.b], [link.b, link.a]] as const) {
191
+ if (!("child" in mine) || mine.child !== childId) continue;
192
+ const own = def.interfaces.find((i) => i.id === mine.interfaceId);
193
+ if (!own || !isConnector(own)) continue;
194
+ const other = linkEndFrames(owner, theirs, p.lookup, prefix, placed);
195
+ const linkId = [...prefix, link.id].join("/");
196
+ if (!other || !other.frames.length || !other.combined) {
197
+ problems.push(`${pathKey}.${own.id}: ${other?.label ?? "its counterpart"} has no geometry frame (link ${linkId})`);
198
+ continue;
199
+ }
200
+ if (!other.placed) {
201
+ problems.push(`${pathKey}.${own.id}: ${other.label} is not placed (no mate and no display placement)`);
202
+ continue;
203
+ }
204
+ const positions = (own.slots ?? []).map((s) => s.id);
205
+ const t = termination(connectorType(own), positions.length);
206
+ const c = other.combined;
207
+ let pins: JobEnd["pins"];
208
+ if (other.frames.length === positions.length) {
209
+ // one frame per position: a composed end (pads in slot order) or a parent port's leaves
210
+ pins = positions.map((pos, k) => ({ position: pos, at: other.frames[k].frame.origin as Vec3, out: other.frames[k].frame.normal as Vec3 }));
211
+ } else if (other.frames.length === 1 && c.xAxis) {
212
+ // a socket: positions along its xAxis at the connector's pitch
213
+ const pitch = t.pitch ?? 1;
214
+ pins = positions.map((pos, k) => ({ position: pos, at: add(c.origin as Vec3, c.xAxis as Vec3, (k - (positions.length - 1) / 2) * pitch), out: c.normal as Vec3 }));
215
+ } else {
216
+ problems.push(`${pathKey}.${own.id}: ${other.frames.length} frame(s) on ${other.label} for ${positions.length} position(s)`);
217
+ continue;
218
+ }
219
+ ends.push({
220
+ interfaceId: own.id,
221
+ linkId,
222
+ counterpart: other.label,
223
+ connector: connectorType(own),
224
+ termination: t,
225
+ pins,
226
+ counterpartFrame: c,
227
+ frame: { origin: c.origin, normal: neg(c.normal as Vec3), ...(c.xAxis ? { xAxis: c.xAxis } : {}) },
228
+ });
229
+ }
230
+ }
231
+ if (ends.length !== 2) {
232
+ problems.push(`${pathKey} (${def.id}): ${ends.length} routable end(s); the router handles point-to-point cables (2)`);
233
+ continue;
234
+ }
235
+ // conductor pairing from the harness's internal link (straight through unless childLinks say otherwise)
236
+ const wires = (def.links ?? []).find((l) => "self" in l.a && "self" in l.b);
237
+ const [ea, eb] = "self" in (wires?.a ?? {}) && wires!.a.interfaceId === ends[1].interfaceId ? [ends[1], ends[0]] : [ends[0], ends[1]];
238
+ const pairs = ea.pins.map((pin, k) => {
239
+ const cl = wires?.childLinks?.find((x) => x.a === pin.position);
240
+ const target = cl?.b ?? pin.position;
241
+ const idx = eb.pins.findIndex((q) => q.position === target);
242
+ return idx < 0 ? k : idx;
243
+ });
244
+ const awg = cableAwg(def);
245
+ const od = build.od_mm ?? (awg ? SILICONE_OD_MM[awg] : undefined);
246
+ if (!od) {
247
+ problems.push(`${pathKey} (${def.id}): no conductor diameter (wire_gauge_awg or cable_build.od_mm)`);
248
+ continue;
249
+ }
250
+ if (build.colors.length !== ea.pins.length) problems.push(`${pathKey}: ${build.colors.length} colour(s) for ${ea.pins.length} conductor(s)`);
251
+ harnesses.push({
252
+ id: def.id,
253
+ path: pathKey,
254
+ name: def.name ?? def.id,
255
+ length_mm: length,
256
+ awg,
257
+ conductors: ea.pins.map((pin, k) => ({ position: pin.position, color: WIRE_COLORS[build.colors[k]] ?? build.colors[k] ?? "#888888", od, bare: !!build.bare })),
258
+ pairs,
259
+ build: { ...build, bundle: build.bundle ?? "parallel" },
260
+ ends: [ea, eb],
261
+ });
262
+ }
263
+ // thickest cables first: they have the fewest ways through
264
+ harnesses.sort((a, b) => b.conductors[0].od * b.conductors.length - a.conductors[0].od * a.conductors.length);
265
+ return { schema: JOB_SCHEMA, bodies, harnesses, problems };
266
+ }
267
+
268
+ /** The harness end frames a route manifest records, as UHD frames. */
269
+ export function endFrames(h: JobHarness): Record<string, GeometryFrame> {
270
+ return Object.fromEntries(h.ends.map((e) => [e.interfaceId, e.frame]));
271
+ }
272
+
273
+ export { combineFrames };
package/lib/route.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Routed wire harnesses as a `cad` provider (docs/specs/tool-integrations.md
3
+ * §2–§4, PB-822).
4
+ *
5
+ * generate the assembled model → native design <dir>/harness-cad/route.json:
6
+ * conductor paths, centreline, length fit, bend limits, clearance
7
+ * export route.json → out/body.glb, body.step, body.manifest.json
8
+ * extract the end frames the route was generated for, plus the artifacts
9
+ * check model vs route (ends, conductors, length changed), route vs
10
+ * out/ (an export older than the route), and the route report:
11
+ * length fit, the generator's notes, ends that no longer meet
12
+ * their parts in the assembly
13
+ *
14
+ * Routing order is a property of the system (rigid ends first, then
15
+ * thickest), and each cable is routed around the ones before it. Generating
16
+ * one harness routes the harnesses before it again, in memory, so its route
17
+ * is the one a run over the whole system gives, whatever was generated
18
+ * before.
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import type { ArtifactDef, GeometryFrame, ModuleDef } from "@deltarobotics/uhd";
25
+ import { sha256, stableStringify, type CapabilityBinding, type OperationContext, type Project, type ProjectDiagnostic } from "@protoboardai/core";
26
+ import { HARNESS_CAD_TOOL, harnessCadArtifacts } from "./artifacts.ts";
27
+ import { buildJob, cableLength, projectAssembly, wireHarnesses, type JobHarness } from "./job.ts";
28
+
29
+ export const GENERATOR = fileURLToPath(new URL("../py/harness_cad.py", import.meta.url));
30
+ export const python = () => process.env.PROTOBOARD_PYTHON || "python3";
31
+ export const PROVIDER = "@protoboardai/ext-harness-cad";
32
+ const TOOL = HARNESS_CAD_TOOL;
33
+
34
+ /** What generate and export need from their context (a capability context, or the harness-cad alias's). */
35
+ export type Writer = Pick<OperationContext, "project" | "writeOutput" | "log">;
36
+
37
+ /** A harness definition and its provider directory. */
38
+ export interface HarnessTarget {
39
+ id: string;
40
+ binding: Pick<CapabilityBinding, "dir" | "native" | "out">;
41
+ }
42
+
43
+ export interface RouteResult {
44
+ id: string;
45
+ path: string;
46
+ status: "ok" | "warning" | "failed";
47
+ notes: string[];
48
+ [k: string]: unknown;
49
+ }
50
+
51
+ /** The route file the router writes (py/harness_cad.py, ROUTE_SCHEMA). */
52
+ export interface RouteFile {
53
+ schema: string;
54
+ harness: JobHarness;
55
+ notes: string[];
56
+ }
57
+
58
+ type Outcome<T> = { ok: true; value: T } | { ok: false; summary: string; problems?: string[] };
59
+
60
+ export const harnessFiles = (b: Pick<CapabilityBinding, "native" | "out">) => ({ route: `${b.native}/route.json`, manifest: `${b.out}/body.manifest.json` });
61
+
62
+ /** The harness's cad binding to this provider (its `cad.config.ts`), when it has a usable one. */
63
+ export function harnessBinding(p: Project, id: string): CapabilityBinding | undefined {
64
+ return p.capabilities.find((b) => b.capability === "cad" && b.module === id && b.provider === PROVIDER && !b.problem && b.native);
65
+ }
66
+
67
+ function runPython(args: string[]): Outcome<{ harnesses: RouteResult[]; context?: RouteResult[]; map?: unknown }> {
68
+ try {
69
+ const stdout = execFileSync(python(), [GENERATOR, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 1800000, maxBuffer: 64 << 20, env: { ...process.env, PYTHONHASHSEED: "0" } });
70
+ return { ok: true, value: JSON.parse(stdout.trim().split("\n").pop()!) };
71
+ } catch (e) {
72
+ const err = e as { stderr?: string; message: string };
73
+ return { ok: false, summary: `generator failed: ${(err.stderr ?? err.message).trim().split("\n").slice(-3).join(" | ")}` };
74
+ }
75
+ }
76
+
77
+ /** generate: route the system's harnesses up to the last target and write each target's route.json. */
78
+ export function routeHarnesses(ctx: Writer, targets: HarnessTarget[]) {
79
+ const p = ctx.project;
80
+ if (!p.rootDef) return { ok: false as const, summary: "the project has no root system" };
81
+ const job = buildJob(p);
82
+ for (const t of targets) {
83
+ const entries = job.harnesses.filter((h) => h.id === t.id);
84
+ if (!entries.length) {
85
+ const why = job.problems.filter((x) => x.includes(`(${t.id})`) || x.includes(t.id));
86
+ return { ok: false as const, summary: `${t.id} cannot be routed: ${why.join("; ") || "it is not a placed wire harness with connector ends and a cable_build trait"}`, problems: job.problems };
87
+ }
88
+ if (entries.length > 1) return { ok: false as const, summary: `${t.id} is used ${entries.length} times (${entries.map((h) => h.path).join(", ")}); routes are written per definition, so give each cable its own definition` };
89
+ }
90
+ job.targets = targets.map((t) => t.id);
91
+ const scratch = join(p.root, ".protoboard", "cache", "harness-cad", "route");
92
+ rmSync(scratch, { recursive: true, force: true });
93
+ mkdirSync(scratch, { recursive: true });
94
+ writeFileSync(join(scratch, "job.json"), stableStringify(job));
95
+ const run = runPython(["route", join(scratch, "job.json"), join(scratch, "routes")]);
96
+ if (!run.ok) return run;
97
+ const summary = run.value;
98
+ for (const r of summary.harnesses) {
99
+ const t = targets.find((x) => x.id === r.id)!;
100
+ if (r.status !== "failed") ctx.writeOutput(harnessFiles(t.binding).route, readFileSync(join(scratch, "routes", `${r.id}.route.json`)), `${r.path}: route (native design)`);
101
+ ctx.log(`${r.path}: ${r.status}${r.notes?.length ? ` — ${r.notes.join("; ")}` : ""}`);
102
+ }
103
+ if (summary.context?.length) ctx.log(`routed first, in memory: ${summary.context.map((r) => r.path).join(", ")}`);
104
+ rmSync(scratch, { recursive: true, force: true });
105
+ return { ok: true as const, value: { ...summary, problems: job.problems } };
106
+ }
107
+
108
+ /** export: build each target's bodies from its route.json and write the standard outputs. */
109
+ export function exportHarnesses(ctx: Writer, targets: HarnessTarget[]) {
110
+ const p = ctx.project;
111
+ const missing = targets.filter((t) => !existsSync(join(p.root, harnessFiles(t.binding).route)));
112
+ if (missing.length) return { ok: false as const, summary: `${missing.map((t) => t.id).join(", ")}: no route (${harnessFiles(missing[0].binding).route}); run \`protoboard run cad:generate --module ${missing[0].id}\` first` };
113
+ const scratch = join(p.root, ".protoboard", "cache", "harness-cad", "export");
114
+ rmSync(scratch, { recursive: true, force: true });
115
+ mkdirSync(scratch, { recursive: true });
116
+ // one process per harness: OpenCascade numbers STEP assembly occurrences with a
117
+ // process-wide counter, so this keeps each body.step the same as its own cad:export gives
118
+ const results: RouteResult[] = [];
119
+ for (const t of targets) {
120
+ const run = runPython(["export", scratch, join(p.root, harnessFiles(t.binding).route)]);
121
+ if (!run.ok) return run;
122
+ results.push(...run.value.harnesses);
123
+ }
124
+ for (const r of results) {
125
+ if (r.status === "failed") continue;
126
+ const t = targets.find((x) => x.id === r.id)!;
127
+ const files = harnessFiles(t.binding);
128
+ const out = t.binding.out;
129
+ ctx.writeOutput(`${out}/body.glb`, readFileSync(join(scratch, `${r.id}.glb`)), `${r.path}: viewer mesh`);
130
+ if (existsSync(join(scratch, `${r.id}.step`))) ctx.writeOutput(`${out}/body.step`, readFileSync(join(scratch, `${r.id}.step`)), `${r.path}: swept solids`);
131
+ // the generator's manifest, named for out/: it describes body.*, and records the route it was exported from
132
+ const manifest = JSON.parse(readFileSync(join(scratch, `${r.id}.manifest.json`), "utf8")) as Record<string, unknown>;
133
+ const standard = { ...manifest, artifact: "body", exportedFrom: { file: files.route, digest: sha256(readFileSync(join(p.root, files.route))) } };
134
+ ctx.writeOutput(files.manifest, JSON.stringify(standard, null, 2) + "\n", `${r.path}: route manifest (end frames, lengths, bends, clearance)`);
135
+ ctx.log(`${r.path}: exported to ${out}${r.step !== "ok" ? ` (STEP ${r.step})` : ""}`);
136
+ }
137
+ rmSync(scratch, { recursive: true, force: true });
138
+ return { ok: true as const, value: { harnesses: results } };
139
+ }
140
+
141
+ const readJson = <T>(file: string): T | undefined => (existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as T) : undefined);
142
+
143
+ /** extract: the frames the route gives the harness's end interfaces, and the artifacts that exist. */
144
+ export function extractHarness(root: string, t: HarnessTarget) {
145
+ const route = readJson<RouteFile>(join(root, harnessFiles(t.binding).route));
146
+ if (!route) return { ok: false as const, summary: `${t.id} has no route (${harnessFiles(t.binding).route}); run \`protoboard run cad:generate --module ${t.id}\` first` };
147
+ const frames: Record<string, GeometryFrame> = Object.fromEntries(route.harness.ends.map((e) => [e.interfaceId, e.frame]));
148
+ const artifacts: ArtifactDef[] = harnessCadArtifacts({ dir: t.binding.dir, name: t.id, tool: TOOL }).filter((a) => a.filePath && existsSync(join(root, a.filePath)));
149
+ return { ok: true as const, value: { frames, artifacts } };
150
+ }
151
+
152
+ interface RouteManifest {
153
+ length_mm: number;
154
+ route_length_mm: number;
155
+ status: string;
156
+ notes: string[];
157
+ exportedFrom?: { digest?: string };
158
+ }
159
+
160
+ /** check: the harness's route against the model, its export against the route, and the route report. */
161
+ export function harnessDiagnostics(p: Project, def: ModuleDef, t: HarnessTarget): Omit<ProjectDiagnostic, "validator">[] {
162
+ const out: Omit<ProjectDiagnostic, "validator">[] = [];
163
+ const source = p.definitions.get(def.id)?.origin.source;
164
+ if (!p.rootDef) return out;
165
+ const asm = projectAssembly(p);
166
+ const inst = wireHarnesses(asm).find((i) => i.def.id === def.id);
167
+ if (!inst) {
168
+ out.push({ code: "HARNESS_NOT_ROUTABLE", severity: "error", entity: { kind: "definition", id: def.id }, message: `${def.id} is not a wire harness ext-harness-cad can route (kind harness, connector ends, a cable_build trait, used in the system)`, source });
169
+ return out;
170
+ }
171
+ const key = inst.path.join("/");
172
+ const entity = { kind: "instance" as const, id: key };
173
+ const files = harnessFiles(t.binding);
174
+ const regenerate = `protoboard run cad:generate --module ${def.id}, then cad:export`;
175
+ const routeBytes = existsSync(join(p.root, files.route)) ? readFileSync(join(p.root, files.route)) : undefined;
176
+ if (!routeBytes) {
177
+ out.push({ code: "HARNESS_NOT_ROUTED", severity: "info", entity, message: `${key} (${def.id}) has no routed geometry; run \`${regenerate}\``, source });
178
+ return out;
179
+ }
180
+ const route = JSON.parse(routeBytes.toString("utf8")) as RouteFile;
181
+ const job = buildJob(p);
182
+ const now = job.harnesses.find((h) => h.id === def.id);
183
+ if (!now) {
184
+ out.push({ code: "HARNESS_ROUTE_STALE", severity: "warning", entity, message: `${key}: no longer routable (${job.problems.filter((x) => x.includes(def.id)).join("; ") || "see the model"}); its route was generated for an earlier model`, source });
185
+ } else if (stableStringify(now) !== stableStringify(route.harness)) {
186
+ const changed = (Object.keys(now) as (keyof JobHarness)[]).filter((k) => stableStringify(now[k]) !== stableStringify(route.harness[k]));
187
+ out.push({ code: "HARNESS_ROUTE_STALE", severity: "warning", entity, message: `${key}: the route was generated for a different ${changed.join(", ")}; regenerate (${regenerate})`, source });
188
+ }
189
+
190
+ const m = readJson<RouteManifest>(join(p.root, files.manifest));
191
+ if (!m) return out; // never exported: the standard-outputs check says so
192
+ if (m.exportedFrom?.digest !== sha256(routeBytes)) {
193
+ out.push({ code: "HARNESS_EXPORT_STALE", severity: "warning", entity, message: `${key}: ${t.binding.out}/ was exported from an earlier route than ${files.route}; run \`protoboard run cad:export --module ${def.id}\``, source });
194
+ }
195
+ const length = cableLength(def);
196
+ if (length !== undefined && Math.abs(length - m.length_mm) > 0.5) {
197
+ out.push({ code: "HARNESS_ROUTE_LENGTH", severity: "warning", entity, message: `${key}: modelled length ${length} mm, routed at ${m.length_mm} mm; regenerate (${regenerate})`, source });
198
+ }
199
+ if (Math.abs(m.route_length_mm - m.length_mm) > 1.5) {
200
+ out.push({ code: "HARNESS_ROUTE_LENGTH", severity: "warning", entity, message: `${key}: the routed cable is ${m.route_length_mm} mm, not its ${m.length_mm} mm (${m.route_length_mm > m.length_mm ? "too short to reach" : "the slack could not be laid clear"})`, source });
201
+ }
202
+ for (const note of m.notes ?? []) out.push({ code: "HARNESS_ROUTE_WARNING", severity: "warning", entity, message: `${key}: ${note}`, source });
203
+ const placed = asm.harnesses.find((h) => h.key === key);
204
+ if (!placed) {
205
+ out.push({ code: "HARNESS_ROUTE_STALE", severity: "warning", entity, message: `${key}: routed, but no end lands on a placed part`, source });
206
+ return out;
207
+ }
208
+ for (const e of placed.ends) {
209
+ if (e.status === "stale") {
210
+ out.push({ code: "HARNESS_ROUTE_STALE", severity: "warning", entity, message: `${key}.${e.interfaceId} is ${e.offsetMm} mm / ${e.angleDeg}° from ${e.counterpart}: regenerate (${regenerate})`, source });
211
+ } else if (e.status === "unplaced" || e.status === "no_geometry") {
212
+ out.push({ code: "HARNESS_ROUTE_STALE", severity: "info", entity, message: `${key}.${e.interfaceId}: ${e.counterpart} ${e.status === "unplaced" ? "is not placed" : "has no frame"}, so this end is not checked`, source });
213
+ }
214
+ }
215
+ return out;
216
+ }
package/ops/cad.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The `cad` capability for routed wire harnesses (tool-integrations §2–§4,
3
+ * PB-822): `protoboard run cad:<generate|export|extract|check> --module <id>`
4
+ * on a harness whose `cad.config.ts` names @protoboardai/ext-harness-cad. The
5
+ * work is in lib/route.ts, shared with the `harness-cad` alias.
6
+ */
7
+ import type { CapabilityContext, CapabilityOperationFn, ExtractedFacts } from "@protoboardai/core";
8
+ import { exportHarnesses, extractHarness, harnessDiagnostics, routeHarnesses, type HarnessTarget } from "../lib/route.ts";
9
+
10
+ const target = (ctx: CapabilityContext): HarnessTarget => ({ id: ctx.module.id, binding: { dir: ctx.module.dir, native: ctx.module.native, out: ctx.module.out } });
11
+
12
+ export const generate: CapabilityOperationFn = (ctx) => {
13
+ const r = routeHarnesses(ctx, [target(ctx)]);
14
+ if (!r.ok) return { status: "failed", summary: r.summary, ...(r.problems ? { data: { problems: r.problems } } : {}) };
15
+ const h = r.value.harnesses[0];
16
+ const before = r.value.context ?? [];
17
+ return {
18
+ status: h.status === "failed" ? "failed" : "succeeded",
19
+ summary: `${h.path}: ${h.status}${h.notes.length ? ` (${h.notes.join("; ")})` : ""}; routed around the parts${before.length ? ` and ${before.length} cable(s) routed before it` : ""}`,
20
+ data: { generator: "@protoboardai/ext-harness-cad/py/harness_cad.py", ...r.value },
21
+ };
22
+ };
23
+
24
+ export const exportOutputs: CapabilityOperationFn = (ctx) => {
25
+ const r = exportHarnesses(ctx, [target(ctx)]);
26
+ if (!r.ok) return { status: "failed", summary: r.summary };
27
+ const h = r.value.harnesses[0];
28
+ return {
29
+ status: h.status === "failed" ? "failed" : "succeeded",
30
+ summary: h.status === "failed" ? `${ctx.module.id}: ${h.notes.join("; ")}` : `${h.path}: body.glb, body.step${h.step === "ok" ? "" : ` (${h.step})`} and body.manifest.json in ${ctx.module.out}`,
31
+ data: h,
32
+ };
33
+ };
34
+
35
+ export const extract: CapabilityOperationFn = (ctx) => {
36
+ const r = extractHarness(ctx.project.root, target(ctx));
37
+ if (!r.ok) return { status: "failed", summary: r.summary };
38
+ const facts: ExtractedFacts = r.value;
39
+ return { status: "succeeded", summary: `${Object.keys(facts.frames!).length} end frames, ${facts.artifacts!.length} artifacts`, data: facts };
40
+ };
41
+
42
+ export const check: CapabilityOperationFn = (ctx) => {
43
+ const diagnostics = harnessDiagnostics(ctx.project, ctx.module.definition, target(ctx));
44
+ return { status: "succeeded", summary: `${diagnostics.length} finding(s)`, diagnostics };
45
+ };
@@ -0,0 +1,91 @@
1
+ import { loadProject, operationRecordFile, runCapabilityOperation, runCapabilityStep, type OperationFn, type OperationRecord } from "@protoboardai/core";
2
+ import { projectAssembly, wireHarnesses } from "../lib/job.ts";
3
+ import { PROVIDER, harnessBinding, harnessFiles, routeHarnesses, type HarnessTarget, type RouteResult } from "../lib/route.ts";
4
+
5
+ const GENERATOR_ID = "@protoboardai/ext-harness-cad/py/harness_cad.py";
6
+
7
+ /** One harness's step, as the alias reports it. */
8
+ interface AliasStep {
9
+ module: string;
10
+ operation: string;
11
+ status: OperationRecord["status"];
12
+ summary: string;
13
+ /** The step's operation record (project-relative). */
14
+ record: string;
15
+ outputs: string[];
16
+ }
17
+ const aliasStep = (module: string, r: OperationRecord): AliasStep => ({ module, operation: r.operation, status: r.status, summary: r.summary, record: operationRecordFile(r), outputs: r.outputs.map((o) => o.path) });
18
+
19
+ export { GENERATOR, python } from "../lib/route.ts";
20
+
21
+ /**
22
+ * harness-cad: the `cad` capability's generate and export for every wire
23
+ * harness (or the named ones), in one routing run. Each harness needs a
24
+ * `cad.config.ts` naming @protoboardai/ext-harness-cad; its route goes to
25
+ * `<dir>/harness-cad/route.json` and its bodies to `<dir>/out/`, exactly as
26
+ * `protoboard run cad:generate` and `cad:export --module <id>` write them,
27
+ * and each is recorded as that harness's own `cad:generate` and `cad:export`
28
+ * run, so the release traces every cable's `out/` (the alias's own record
29
+ * writes nothing and lists them). The routes are written first; the project
30
+ * is then loaded anew, so each export records the harness as its new route
31
+ * makes it.
32
+ * The `routes` check and each harness's `cad:check` then confirm that every
33
+ * routed end still meets its part.
34
+ */
35
+ export const harnessCad: OperationFn = async (ctx) => {
36
+ const p = ctx.project;
37
+ if (!p.rootDef) return { status: "failed", summary: "the project has no root system" };
38
+ const only = ctx.inputs.harness?.split(",").map((s) => s.trim()).filter(Boolean);
39
+ const found = wireHarnesses(projectAssembly(p), only);
40
+ if (!found.length) return { status: "failed", summary: only?.length ? `no wire harness ${only.join(", ")} (definition id or instance path) with a cable_build trait` : "no wire harness with a cable_build trait to route" };
41
+ const targets: HarnessTarget[] = [];
42
+ const unbound: string[] = [];
43
+ for (const inst of found) {
44
+ if (targets.some((t) => t.id === inst.def.id)) continue;
45
+ const binding = harnessBinding(p, inst.def.id);
46
+ if (binding) targets.push({ id: inst.def.id, binding });
47
+ else unbound.push(`${inst.path.join("/")} (${inst.def.id}) has no cad.config.ts naming ${PROVIDER}; add one in its provider directory, e.g. src/harnesses/${inst.def.id}/cad.config.ts: export default defineCapabilityConfig({ provider: "${PROVIDER}", module: "${inst.def.id}" })`);
48
+ }
49
+ if (!targets.length) return { status: "failed", summary: unbound[0], data: { problems: unbound } };
50
+
51
+ // one routing run over every target, held in memory; then each target's route is written by its own cad:generate step
52
+ const held = new Map<string, { content: string | Buffer; description?: string }>();
53
+ const routed = routeHarnesses({ project: p, log: ctx.log, writeOutput: (path, content, description) => held.set(path, { content, description }) }, targets);
54
+ if (!routed.ok) return { status: "failed", summary: routed.summary, data: { problems: [...(routed.problems ?? []), ...unbound] } };
55
+ const steps: AliasStep[] = [];
56
+ const generated = new Set<string>();
57
+ for (const r of routed.value.harnesses) {
58
+ const t = targets.find((x) => x.id === r.id)!;
59
+ const rec = await runCapabilityStep(p, "cad", "generate", t.id, (sctx) => {
60
+ const route = harnessFiles(t.binding).route;
61
+ const file = held.get(route);
62
+ if (r.status === "failed" || !file) return { status: "failed", summary: `${r.path}: failed${r.notes.length ? ` (${r.notes.join("; ")})` : ""}`, data: { generator: GENERATOR_ID, harnesses: [r] } };
63
+ sctx.writeOutput(route, file.content, file.description);
64
+ return { status: "succeeded", summary: `${r.path}: ${r.status}${r.notes.length ? ` (${r.notes.join("; ")})` : ""}; routed in one run with ${targets.length - 1} other cable(s) (run harness-cad)`, data: { generator: GENERATOR_ID, harnesses: [r], problems: routed.value.problems } };
65
+ });
66
+ steps.push(aliasStep(t.id, rec));
67
+ if (rec.status === "succeeded") generated.add(t.id);
68
+ }
69
+ // the routes just written are what each harness definition reads (routedHarness): export from the project as it is now
70
+ const now = generated.size ? await loadProject(p.root, { checks: false, fresh: true }) : p;
71
+ const exported = new Map<string, RouteResult>();
72
+ for (const t of targets.filter((x) => generated.has(x.id))) {
73
+ const rec = await runCapabilityOperation(now, "cad", "export", t.id);
74
+ steps.push(aliasStep(t.id, rec));
75
+ if (rec.status === "succeeded") exported.set(t.id, rec.data as RouteResult);
76
+ }
77
+
78
+ const results = routed.value.harnesses.map((r) => {
79
+ const e = exported.get(r.id);
80
+ if (e) return { ...e, search_s: r.search_s };
81
+ return generated.has(r.id) ? { ...r, status: "failed" as const, notes: [...r.notes, "export failed (see its cad:export record)"] } : r;
82
+ });
83
+ const failed = results.filter((h) => h.status === "failed");
84
+ const warned = results.filter((h) => h.status === "warning");
85
+ const problems = [...routed.value.problems, ...unbound];
86
+ return {
87
+ status: failed.length === results.length ? "failed" : "succeeded",
88
+ summary: `${results.length - failed.length}/${results.length} harness(es) routed and exported, each with its cad:generate and cad:export record${warned.length ? `; ${warned.length} with warnings (${warned.map((h) => h.path).join(", ")})` : ""}${failed.length ? `; failed: ${failed.map((h) => h.path).join(", ")}` : ""}${problems.length ? `; ${problems.length} problem(s) in the model` : ""}`,
89
+ data: { generator: GENERATOR_ID, problems, map: routed.value.map, harnesses: results, outputs: Object.fromEntries(targets.map((t) => [t.id, t.binding.dir])), steps },
90
+ };
91
+ };