@protoboardai/ext-tscircuit 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.
Files changed (43) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +59 -0
  3. package/README.md +493 -2
  4. package/evals/announce-pcb-fallback/case.ts +67 -0
  5. package/evals/catch-swapped-nets/case.ts +69 -0
  6. package/evals/generate-imu-board/case.ts +60 -0
  7. package/evals/locked-board-fix-upstream/case.ts +70 -0
  8. package/lib/checks.ts +464 -0
  9. package/lib/config.ts +384 -0
  10. package/lib/fingerprint.ts +144 -0
  11. package/lib/footprints.ts +370 -0
  12. package/lib/kicad/cli.ts +93 -0
  13. package/lib/kicad/drc.ts +95 -0
  14. package/lib/kicad/fields.ts +65 -0
  15. package/lib/kicad/handoff.ts +112 -0
  16. package/lib/kicad/netlists.ts +311 -0
  17. package/lib/kicad/probe.cjs +21 -0
  18. package/lib/kicad/project.ts +594 -0
  19. package/lib/kicad/refdes.ts +130 -0
  20. package/lib/kicad/schematic.ts +624 -0
  21. package/lib/kicad/sexpr.ts +117 -0
  22. package/lib/land-geometry.ts +177 -0
  23. package/lib/land-patterns.ts +706 -0
  24. package/lib/layout.ts +236 -0
  25. package/lib/models.ts +284 -0
  26. package/lib/native.ts +199 -0
  27. package/lib/outputs.ts +383 -0
  28. package/lib/paste.ts +69 -0
  29. package/lib/probe.cjs +31 -0
  30. package/lib/runtime.ts +185 -0
  31. package/lib/silk.ts +141 -0
  32. package/lib/source.ts +472 -0
  33. package/lib/stepfile.ts +210 -0
  34. package/lib/suppliers.ts +39 -0
  35. package/ops/circuit.ts +270 -0
  36. package/ops/kicad.ts +636 -0
  37. package/ops/package.ts +156 -0
  38. package/package.json +81 -4
  39. package/protoboard-extension.json +313 -0
  40. package/skills/circuit-board/SKILL.md +494 -0
  41. package/skills/circuit-board/references/pcb-workflow.md +87 -0
  42. package/skills/kicad-cli/SKILL.md +210 -0
  43. package/skills/kicad-layout-review/SKILL.md +275 -0
package/lib/layout.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Reading a built board (circuit JSON) back in UHD's terms: where each
3
+ * component instance sits, the frames of its mating faces, the outline and
4
+ * holes, and the nets as the copper actually joins them.
5
+ *
6
+ * Board frame throughout: mm, +X right and +Y up in the top view (tscircuit's
7
+ * PCB coordinates), +Z out of the top copper, z = 0 on the board's mid-plane
8
+ * (tscircuit's own 3D convention), so the top surface is z = +thickness/2.
9
+ */
10
+ import type { GeometryFrame } from "@deltarobotics/uhd";
11
+ import { getFullConnectivityMapFromCircuitJson } from "circuit-json-to-connectivity-map";
12
+ import type { Side, Vec2 } from "./config.ts";
13
+ import type { CircuitElement } from "./runtime.ts";
14
+ import type { NameMap } from "./source.ts";
15
+
16
+ type Vec3 = [number, number, number];
17
+ const r4 = (v: number) => Math.round(v * 1e4) / 1e4;
18
+ const v3 = (x: number, y: number, z: number): Vec3 => [r4(x), r4(y), r4(z)];
19
+
20
+ /** tscircuit insertion directions, already rotated into the board frame. */
21
+ const DIRECTION: Record<string, Vec3> = {
22
+ from_left: [-1, 0, 0],
23
+ from_right: [1, 0, 0],
24
+ from_top: [0, 1, 0],
25
+ from_bottom: [0, -1, 0],
26
+ from_above: [0, 0, 1],
27
+ from_below: [0, 0, -1],
28
+ };
29
+
30
+ export interface PlacedComponent {
31
+ instance: string;
32
+ /** tscircuit component name. */
33
+ name: string;
34
+ part: string;
35
+ /** Footprint origin as placed (pcbX/pcbY). */
36
+ origin: Vec2;
37
+ /** Pad and courtyard bounding-box centre (what pick-and-place machines take). */
38
+ center: Vec2;
39
+ rotation_deg: number;
40
+ side: Side;
41
+ /** Bounding box of pads and courtyard, rotated (board-aligned). */
42
+ size: Vec2;
43
+ /** The face that meets a mating part: the connector mouth, else the top (or bottom) of the body at its centre. */
44
+ frame: GeometryFrame & { source: "insertion_direction" | "inferred_from_cable_insertion_center" | "body_top" };
45
+ }
46
+
47
+ export interface BoardGeometry {
48
+ thickness_mm: number;
49
+ layers: number;
50
+ outline: Vec2[];
51
+ holes: { name: string; at: Vec2; diameter_mm: number }[];
52
+ keepouts: { center: Vec2; shape: string; width?: number; height?: number; radius?: number; layers: string[] }[];
53
+ components: PlacedComponent[];
54
+ routing: { traces: number; vias: number; wire_mm: number };
55
+ toolVersion?: string;
56
+ }
57
+
58
+ export const ofType = (cj: CircuitElement[], t: string) => cj.filter((e) => e.type === t);
59
+
60
+ /** Placement, frames, outline and holes of a built board. */
61
+ export function boardGeometry(cj: CircuitElement[], map: NameMap): BoardGeometry {
62
+ const board = ofType(cj, "pcb_board")[0];
63
+ if (!board) throw new Error("circuit JSON has no pcb_board");
64
+ const half = board.thickness / 2;
65
+ const sources = new Map(ofType(cj, "source_component").map((s) => [s.source_component_id, s]));
66
+ const outline: Vec2[] = board.outline
67
+ ? board.outline.map((p: { x: number; y: number }) => [r4(p.x), r4(p.y)] as Vec2)
68
+ : [
69
+ [board.center.x - board.width / 2, board.center.y - board.height / 2],
70
+ [board.center.x + board.width / 2, board.center.y - board.height / 2],
71
+ [board.center.x + board.width / 2, board.center.y + board.height / 2],
72
+ [board.center.x - board.width / 2, board.center.y + board.height / 2],
73
+ ];
74
+ const components: PlacedComponent[] = [];
75
+ for (const pc of ofType(cj, "pcb_component")) {
76
+ const name = sources.get(pc.source_component_id)?.name as string | undefined;
77
+ const m = name ? map.components[name] : undefined;
78
+ if (!name || !m) continue;
79
+ const side: Side = pc.layer === "bottom" ? "bottom" : "top";
80
+ const z = side === "bottom" ? -half : half;
81
+ const rot = ((pc.rotation ?? 0) * Math.PI) / 180;
82
+ const xAxis = v3(Math.cos(rot) * (side === "bottom" ? -1 : 1), Math.sin(rot), 0);
83
+ let frame: PlacedComponent["frame"];
84
+ const mouth = pc.cable_insertion_center as { x: number; y: number } | undefined;
85
+ if (mouth && pc.insertion_direction && DIRECTION[pc.insertion_direction]) {
86
+ frame = { origin: v3(mouth.x, mouth.y, z), normal: DIRECTION[pc.insertion_direction], xAxis, source: "insertion_direction" };
87
+ } else if (mouth && Math.hypot(mouth.x - pc.center.x, mouth.y - pc.center.y) > 1e-6) {
88
+ const d = Math.hypot(mouth.x - pc.center.x, mouth.y - pc.center.y);
89
+ frame = { origin: v3(mouth.x, mouth.y, z), normal: v3((mouth.x - pc.center.x) / d, (mouth.y - pc.center.y) / d, 0), xAxis, source: "inferred_from_cable_insertion_center" };
90
+ } else frame = { origin: v3(pc.center.x, pc.center.y, z), normal: [0, 0, side === "bottom" ? -1 : 1], xAxis, source: "body_top" };
91
+ components.push({
92
+ instance: m.instance,
93
+ name,
94
+ part: m.part,
95
+ origin: [r4(pc.display_offset_x ?? pc.center.x), r4(pc.display_offset_y ?? pc.center.y)],
96
+ center: [r4(pc.center.x), r4(pc.center.y)],
97
+ rotation_deg: r4((((pc.rotation ?? 0) % 360) + 360) % 360),
98
+ side,
99
+ size: [r4(pc.width), r4(pc.height)],
100
+ frame,
101
+ });
102
+ }
103
+ let wire = 0;
104
+ for (const t of ofType(cj, "pcb_trace")) {
105
+ for (let i = 1; i < (t.route ?? []).length; i++) {
106
+ const a = t.route[i - 1];
107
+ const b = t.route[i];
108
+ if (a.route_type === "wire" && b.route_type === "wire" && a.layer === b.layer) wire += Math.hypot(b.x - a.x, b.y - a.y);
109
+ }
110
+ }
111
+ return {
112
+ thickness_mm: board.thickness,
113
+ layers: board.num_layers,
114
+ outline,
115
+ holes: ofType(cj, "pcb_hole")
116
+ .filter((h) => !h.pcb_component_id)
117
+ .map((h, i) => ({ name: (h.name as string | undefined) ?? `hole_${i + 1}`, at: [r4(h.x), r4(h.y)] as Vec2, diameter_mm: h.hole_diameter })),
118
+ keepouts: ofType(cj, "pcb_keepout").map((k) => ({ center: [k.center.x, k.center.y] as Vec2, shape: k.shape, width: k.width, height: k.height, radius: k.radius, layers: k.layers })),
119
+ components,
120
+ routing: { traces: ofType(cj, "pcb_trace").length, vias: ofType(cj, "pcb_via").length, wire_mm: r4(wire) },
121
+ toolVersion: ofType(cj, "source_project_metadata")[0]?.software_used_string,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * UHD pin (`<instance>:<interface>`) of every pcb_port that carries one, via
127
+ * the name map: component name → instance, pad number → interface.
128
+ */
129
+ export function portPins(cj: CircuitElement[], map: NameMap): Map<string, string> {
130
+ const sources = new Map(ofType(cj, "source_component").map((s) => [s.source_component_id, s]));
131
+ const sourcePorts = new Map(ofType(cj, "source_port").map((s) => [s.source_port_id, s]));
132
+ const out = new Map<string, string>();
133
+ for (const p of ofType(cj, "pcb_port")) {
134
+ const sp = sourcePorts.get(p.source_port_id);
135
+ const sc = sp && sources.get(sp.source_component_id);
136
+ const m = sc && map.components[sc.name];
137
+ if (!m || sp.pin_number === undefined) continue;
138
+ const iface = m.ports[String(sp.pin_number)];
139
+ if (iface) out.set(p.pcb_port_id, `${m.instance}:${iface}`);
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * Traces written by hand (board.tsx, or an edited board.gen.tsx) that join pads of one pin
146
+ * (PB-1027, seen on a large field board): generate joins a pin's pads itself since 0.1.7 (padJoins), so such a trace
147
+ * duplicates its joins, and the converter writes it into KiCad as copper on no net. A trace is
148
+ * the generator's when the generated source (`generated`, board.gen.tsx as generate wrote it) has
149
+ * a `<trace from=… to=…>` of the same two selectors; each one there accounts for one in the build.
150
+ */
151
+ export function handPadJoins(cj: CircuitElement[], map: NameMap, generated: string): { trace: string; instance: string; pin: string; ports: string[] }[] {
152
+ const sources = new Map(ofType(cj, "source_component").map((s) => [s.source_component_id, s.name as string]));
153
+ const sourcePorts = new Map(ofType(cj, "source_port").map((s) => [s.source_port_id, s]));
154
+ const pinOf = (id: string): { comp: string; pin: string } | undefined => {
155
+ const sp = sourcePorts.get(id);
156
+ const comp = sp && sources.get(sp.source_component_id);
157
+ if (!comp) return undefined;
158
+ const names = [String(sp.name ?? ""), ...((sp.port_hints as string[] | undefined) ?? [])];
159
+ const internal = names.map((n) => /^pin(\d+)_internal_\d+$/.exec(n)?.[1]).find(Boolean);
160
+ const pin = internal ?? (sp.pin_number !== undefined ? String(sp.pin_number) : names.map((n) => /^pin(\d+)$/.exec(n)?.[1]).find(Boolean));
161
+ return pin ? { comp, pin } : undefined;
162
+ };
163
+ // the generator's own traces, by "<from> to <to>" (tscircuit's display name of a trace)
164
+ const ours = new Map<string, number>();
165
+ for (const m of generated.matchAll(/<trace\s+from=("(?:[^"\\]|\\.)*")\s+to=("(?:[^"\\]|\\.)*")/g)) {
166
+ const key = `${JSON.parse(m[1])} to ${JSON.parse(m[2])}`;
167
+ ours.set(key, (ours.get(key) ?? 0) + 1);
168
+ }
169
+ const out: { trace: string; instance: string; pin: string; ports: string[] }[] = [];
170
+ for (const t of ofType(cj, "source_trace")) {
171
+ const ids = (t.connected_source_port_ids as string[] | undefined) ?? [];
172
+ if (ids.length < 2 || ((t.connected_source_net_ids as string[] | undefined) ?? []).length) continue;
173
+ const pins = ids.map(pinOf);
174
+ if (pins.some((p) => !p) || new Set(pins.map((p) => `${p!.comp}/${p!.pin}`)).size !== 1) continue;
175
+ const name = String(t.display_name ?? t.source_trace_id);
176
+ const left = ours.get(name) ?? 0;
177
+ if (left > 0) {
178
+ ours.set(name, left - 1);
179
+ continue;
180
+ }
181
+ const m = map.components[pins[0]!.comp];
182
+ out.push({ trace: name, instance: m?.instance ?? pins[0]!.comp, pin: m?.ports[pins[0]!.pin] ?? `pin${pins[0]!.pin}`, ports: ids.map((id) => String(sourcePorts.get(id)?.name ?? id)) });
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * Pins whose pads the copper does not join: a pin with several pads (a module's exposed ground
189
+ * pads, a thermal pad split into windows) is one tscircuit port on its first pad and an internal
190
+ * port (`pinN_internal_k`) on each other pad. Generate traces every internal port to the pin's net
191
+ * (source.ts); this finds, among `pins` (`<instance>:<interface>`, the pins on a UHD net), each
192
+ * whose pads lie in more than one copper piece.
193
+ */
194
+ export function unjoinedPads(cj: CircuitElement[], map: NameMap, pins: Set<string>): { pin: string; pads: number; pieces: number; ports: string[] }[] {
195
+ const sources = new Map(ofType(cj, "source_component").map((s) => [s.source_component_id, s]));
196
+ const sourcePorts = new Map(ofType(cj, "source_port").map((s) => [s.source_port_id, s]));
197
+ const byPin = new Map<string, string[]>();
198
+ // SMD pads only: a pin's plated holes (a receptacle's shell stakes) are joined by the part's own metal
199
+ const smd = new Set(ofType(cj, "pcb_smtpad").map((p) => p.pcb_port_id));
200
+ for (const p of ofType(cj, "pcb_port")) {
201
+ if (!smd.has(p.pcb_port_id)) continue;
202
+ const sp = sourcePorts.get(p.source_port_id);
203
+ const m = sp && map.components[sources.get(sp.source_component_id)?.name];
204
+ if (!m) continue;
205
+ const n = sp.pin_number !== undefined ? String(sp.pin_number) : /^pin(\d+)_internal_\d+$/.exec(String(sp.name ?? ""))?.[1];
206
+ const iface = n !== undefined ? m.ports[n] : undefined;
207
+ if (!iface || !pins.has(`${m.instance}:${iface}`)) continue;
208
+ byPin.set(`${m.instance}:${iface}`, [...(byPin.get(`${m.instance}:${iface}`) ?? []), p.pcb_port_id]);
209
+ }
210
+ const multi = [...byPin].filter(([, ports]) => ports.length > 1);
211
+ if (!multi.length) return [];
212
+ const connectivity = getFullConnectivityMapFromCircuitJson(cj.filter((e) => e.type.startsWith("pcb_")) as never);
213
+ const out: { pin: string; pads: number; pieces: number; ports: string[] }[] = [];
214
+ for (const [pin, ports] of multi) {
215
+ const pieces = new Set(ports.map((id) => connectivity.getNetConnectedToId(id) ?? `alone:${id}`)).size;
216
+ if (pieces > 1) out.push({ pin, pads: ports.length, pieces, ports });
217
+ }
218
+ return out.sort((a, b) => a.pin.localeCompare(b.pin));
219
+ }
220
+
221
+ /**
222
+ * The nets as the copper joins them: groups of UHD pins connected by traces,
223
+ * vias, pads and pours. Built from pcb_* elements only: the connectivity
224
+ * map's full mode also merges the source intent (source_trace, source_net),
225
+ * which would make an unrouted net look connected (spike §4).
226
+ */
227
+ export function copperNets(cj: CircuitElement[], map: NameMap): { nets: { pins: string[] }[]; present: Set<string> } {
228
+ const pins = portPins(cj, map);
229
+ const connectivity = getFullConnectivityMapFromCircuitJson(cj.filter((e) => e.type.startsWith("pcb_")) as never);
230
+ const groups = new Map<string, string[]>();
231
+ for (const [portId, pin] of pins) {
232
+ const net = connectivity.getNetConnectedToId(portId) ?? `alone:${portId}`;
233
+ groups.set(net, [...(groups.get(net) ?? []), pin]);
234
+ }
235
+ return { nets: [...groups.values()].filter((g) => g.length > 1).map((g) => ({ pins: [...new Set(g)].sort() })), present: new Set(pins.values()) };
236
+ }
package/lib/models.ts ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Component bodies for board.glb and board.step. In order of preference:
3
+ *
4
+ * 1. the part's own UHD body artifact (STEP), placed on its footprint by the
5
+ * part's mounting frame (the face that meets the board), without the
6
+ * interface feature shapes UHD bodies carry (see stepfile.ts)
7
+ * 2. a box from the part's package body facts (UHD `dimensions_mm`),
8
+ * written here as a small STEP solid
9
+ * 3. nothing from us: tscircuit's own generated body for built-in
10
+ * footprints, or no body (reported)
11
+ *
12
+ * The models are set on the built circuit JSON (cad_component) just before
13
+ * the 3D exporters run, so the native source never carries model paths.
14
+ */
15
+ import { join, relative, sep } from "node:path";
16
+ import type { BoardComponent, Project } from "@protoboardai/core";
17
+ import type { CircuitElement } from "./runtime.ts";
18
+ import type { NameMap } from "./source.ts";
19
+
20
+ export type BodySource = "part" | "box" | "tscircuit" | "none";
21
+
22
+ export interface ComponentBody {
23
+ instance: string;
24
+ source: BodySource;
25
+ /** Absolute path of the STEP model used. */
26
+ step?: string;
27
+ /** Machine-independent name of the model, for outputs: `<part>#<artifact>`, or the box's project-relative path. */
28
+ label?: string;
29
+ /** Artifact id when the part's own body was used. */
30
+ artifact?: string;
31
+ /** Project-relative path of a generated box model, when one is needed. */
32
+ box?: { path: string; size: [number, number, number] };
33
+ /** Named sub-shapes to strip from the part's STEP: the part's interface ids (UHD feature shapes). */
34
+ strip?: string[];
35
+ /** Project-relative path of the stripped copy, when stripping removes anything. */
36
+ stripped?: string;
37
+ /** Mount origin offset in the model's own frame (mm), applied on placement. */
38
+ mountOrigin?: [number, number, number];
39
+ /**
40
+ * A mesh body (GLB or OBJ) for a part with no STEP artifact: board.glb uses it, board.step
41
+ * the package box when the part states its size. `units` is the file's ("m" for UHD GLBs,
42
+ * which are glTF metres, +Y up).
43
+ */
44
+ mesh?: { format: "glb" | "obj"; path: string; label: string; units?: string; mount?: [number, number, number] };
45
+ }
46
+
47
+ /** Choose the body for each component. Box models and stripped copies are named here; writing them is the caller's. */
48
+ export function chooseBodies(p: Project, components: BoardComponent[], nativeDir: string): ComponentBody[] {
49
+ return components.map((c) => {
50
+ for (const b of c.bodies) {
51
+ if (b.format !== "step") continue;
52
+ const art = p.artifacts.find((a) => a.definition === c.part && a.id === b.artifact);
53
+ if (art?.resolved && art.present === true) {
54
+ const strip = (p.lookup(c.part)?.interfaces ?? []).map((i) => i.id);
55
+ return { instance: c.instance, source: "part", step: art.resolved, label: `${c.part}#${b.artifact}`, artifact: b.artifact, strip, stripped: `${nativeDir}/models/${c.part}-${b.artifact}.step`, ...(b.mount ? { mountOrigin: b.mount.origin } : {}) };
56
+ }
57
+ }
58
+ // no STEP: a GLB or OBJ body for board.glb (board.step falls back to the package box)
59
+ let mesh: ComponentBody["mesh"];
60
+ for (const b of c.bodies) {
61
+ if (b.format !== "glb" && b.format !== "obj") continue;
62
+ const art = p.artifacts.find((a) => a.definition === c.part && a.id === b.artifact);
63
+ if (art?.resolved && art.present === true) {
64
+ mesh = { format: b.format, path: art.resolved, label: `${c.part}#${b.artifact}`, ...(b.units ? { units: b.units } : {}), ...(b.mount ? { mount: b.mount.origin } : {}) };
65
+ break;
66
+ }
67
+ }
68
+ const s = c.package?.size_mm;
69
+ if (s?.length && s.width && s.height) {
70
+ const size: [number, number, number] = [s.length, s.width, s.height];
71
+ const path = `${nativeDir}/models/box-${size.join("x")}.step`;
72
+ return { instance: c.instance, source: mesh ? "part" : "box", label: path, box: { path, size }, ...(mesh ? { mesh, artifact: mesh.label.split("#")[1] } : {}) };
73
+ }
74
+ return mesh ? { instance: c.instance, source: "part", mesh, artifact: mesh.label.split("#")[1] } : { instance: c.instance, source: "none" };
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Point each component's cad_component at its chosen model. Returns the
80
+ * patched circuit JSON (a copy), per instance what was used, and the label
81
+ * of each model file (absolute path → label) for outputs that name models.
82
+ *
83
+ * A cad_component's position is where the model's own origin goes: the
84
+ * footprint origin on the board surface (z = ±thickness/2), moved by the
85
+ * part's mount origin when it has one.
86
+ */
87
+ export function applyBodies(cj: CircuitElement[], map: NameMap, bodies: ComponentBody[], root: string): { circuitJson: CircuitElement[]; used: Record<string, BodySource>; labels: Record<string, string> } {
88
+ const out = cj.map((e) => ({ ...e }));
89
+ const byInstance = new Map(bodies.map((b) => [b.instance, b]));
90
+ const sources = new Map(out.filter((e) => e.type === "source_component").map((s) => [s.source_component_id, s]));
91
+ const pcbOf = new Map(out.filter((e) => e.type === "pcb_component").map((c) => [c.pcb_component_id, c]));
92
+ const used: Record<string, BodySource> = {};
93
+ const labels: Record<string, string> = {};
94
+ for (const cad of out.filter((e) => e.type === "cad_component")) {
95
+ const name = sources.get(cad.source_component_id)?.name as string | undefined;
96
+ const m = name ? map.components[name] : undefined;
97
+ if (!m) continue;
98
+ const body = byInstance.get(m.instance);
99
+ const step = body?.step ?? (body?.box ? `${root}/${body.box.path}` : undefined);
100
+ if (!body || (!step && !body.mesh)) {
101
+ used[m.instance] = cad.model_jscad || cad.footprinter_string || cad.model_step_url || cad.model_glb_url ? "tscircuit" : "none";
102
+ continue;
103
+ }
104
+ for (const k of ["model_obj_url", "model_stl_url", "model_glb_url", "model_gltf_url", "model_wrl_url", "model_jscad", "footprinter_string", "model_step_url"]) delete cad[k];
105
+ // a mesh with no size to box: board.glb draws the mesh (withMeshes), board.step has no body for it
106
+ if (step) {
107
+ cad.model_step_url = step;
108
+ labels[step] = body.label ?? body.box?.path ?? body.instance;
109
+ }
110
+ cad.show_as_bounding_box = false;
111
+ // tscircuit puts a cad_component at the pads' bounding-box centre and infers the model's origin from
112
+ // its contact faces; both differ from the footprint origin for a part whose pads are not centred on
113
+ // it (a module with its antenna end bare, a USB-C receptacle). The model's own origin (its mounting
114
+ // point, or the box's bottom centre) goes to the footprint origin instead, stated explicitly: where
115
+ // tscircuit placed it (pcbX/pcbY), else the pads' centre less their offset from the origin.
116
+ cad.model_origin_position = { x: 0, y: 0, z: 0 };
117
+ const pcb = pcbOf.get(cad.pcb_component_id);
118
+ if (pcb && typeof pcb.display_offset_x === "number" && typeof pcb.display_offset_y === "number") cad.position = { ...cad.position, x: pcb.display_offset_x, y: pcb.display_offset_y };
119
+ else if (m.padCentre) {
120
+ const a = ((cad.rotation?.z ?? 0) * Math.PI) / 180;
121
+ const flip = (cad.position?.z ?? 0) < 0 ? -1 : 1;
122
+ const [cx, cy] = [m.padCentre[0] * flip, m.padCentre[1]];
123
+ cad.position = { ...cad.position, x: cad.position.x - (cx * Math.cos(a) - cy * Math.sin(a)), y: cad.position.y - (cx * Math.sin(a) + cy * Math.cos(a)) };
124
+ }
125
+ const o = body.step ? body.mountOrigin : undefined;
126
+ if (o && (o[0] || o[1] || o[2])) {
127
+ // the model's mount point goes to the footprint origin on the board surface
128
+ const a = ((cad.rotation?.z ?? 0) * Math.PI) / 180;
129
+ const flip = (cad.position?.z ?? 0) < 0 ? -1 : 1;
130
+ cad.position = { x: cad.position.x - (o[0] * Math.cos(a) - o[1] * Math.sin(a)) * flip, y: cad.position.y - (o[0] * Math.sin(a) + o[1] * Math.cos(a)), z: cad.position.z - o[2] * flip };
131
+ }
132
+ used[m.instance] = body.source;
133
+ }
134
+ return { circuitJson: out, used, labels };
135
+ }
136
+
137
+ /**
138
+ * A rectangular body as an ISO 10303-21 (AP214) solid: length along X, width
139
+ * along Y, height up +Z from z = 0, centred on the origin in X and Y (the
140
+ * face at z = 0 is the one that sits on the board).
141
+ */
142
+ export function boxStep(name: string, [l, w, h]: [number, number, number]): string {
143
+ const lines: string[] = [];
144
+ let id = 0;
145
+ const add = (s: string) => {
146
+ lines.push(`#${++id}=${s};`);
147
+ return `#${id}`;
148
+ };
149
+ const f = (v: number) => {
150
+ const s = (Math.round(v * 1e6) / 1e6).toString();
151
+ return s.includes(".") || s.includes("e") ? s.toUpperCase() : `${s}.`;
152
+ };
153
+ const pt = (p: number[]) => add(`CARTESIAN_POINT('',(${p.map(f).join(",")}))`);
154
+ const dir = (d: number[]) => add(`DIRECTION('',(${d.map(f).join(",")}))`);
155
+
156
+ const ctx = add("APPLICATION_CONTEXT('automotive design')");
157
+ add(`APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,${ctx})`);
158
+ const pctx = add(`PRODUCT_CONTEXT('',${ctx},'mechanical')`);
159
+ const product = add(`PRODUCT('${name}','${name}','',(${pctx}))`);
160
+ const formation = add(`PRODUCT_DEFINITION_FORMATION('','',${product})`);
161
+ const dctx = add(`PRODUCT_DEFINITION_CONTEXT('part definition',${ctx},'design')`);
162
+ const pdef = add(`PRODUCT_DEFINITION('design','',${formation},${dctx})`);
163
+ const shape = add(`PRODUCT_DEFINITION_SHAPE('','',${pdef})`);
164
+ const len = add("(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.))");
165
+ const ang = add("(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.))");
166
+ const sol = add("(NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT())");
167
+ const unc = add(`UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),${len},'distance_accuracy_value','confusion accuracy')`);
168
+ const gctx = add(`(GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((${unc})) GLOBAL_UNIT_ASSIGNED_CONTEXT((${len},${ang},${sol})) REPRESENTATION_CONTEXT('Context #1','3D Context with UNIT and UNCERTAINTY'))`);
169
+
170
+ // vertices: index = x + 2y + 4z over the box corners
171
+ const corner = (i: number) => [(i & 1 ? 0.5 : -0.5) * l, (i & 2 ? 0.5 : -0.5) * w, i & 4 ? h : 0];
172
+ const vertex = Array.from({ length: 8 }, (_, i) => add(`VERTEX_POINT('',${pt(corner(i))})`));
173
+ const edges = new Map<string, string>();
174
+ const edge = (a: number, b: number): { ref: string; forward: boolean } => {
175
+ const [s, e] = a < b ? [a, b] : [b, a];
176
+ const key = `${s}-${e}`;
177
+ if (!edges.has(key)) {
178
+ const ps = corner(s);
179
+ const pe = corner(e);
180
+ const d = pe.map((v, k) => v - ps[k]);
181
+ const n = Math.hypot(...d);
182
+ const line = add(`LINE('',${pt(ps)},${add(`VECTOR('',${dir(d.map((v) => v / n))},${f(n)})`)})`);
183
+ edges.set(key, add(`EDGE_CURVE('',${vertex[s]},${vertex[e]},${line},.T.)`));
184
+ }
185
+ return { ref: edges.get(key)!, forward: a < b };
186
+ };
187
+ // faces: corners counter-clockwise seen from outside, with the outward normal
188
+ const faces: [number[], number[], number[]][] = [
189
+ [[0, 2, 3, 1], [0, 0, -1], [1, 0, 0]],
190
+ [[4, 5, 7, 6], [0, 0, 1], [1, 0, 0]],
191
+ [[0, 1, 5, 4], [0, -1, 0], [1, 0, 0]],
192
+ [[2, 6, 7, 3], [0, 1, 0], [-1, 0, 0]],
193
+ [[0, 4, 6, 2], [-1, 0, 0], [0, 1, 0]],
194
+ [[1, 3, 7, 5], [1, 0, 0], [0, -1, 0]],
195
+ ];
196
+ const faceRefs = faces.map(([loop, normal, ref]) => {
197
+ const oriented = loop.map((a, i) => {
198
+ const e = edge(a, loop[(i + 1) % 4]);
199
+ return add(`ORIENTED_EDGE('',*,*,${e.ref},${e.forward ? ".T." : ".F."})`);
200
+ });
201
+ const bound = add(`FACE_OUTER_BOUND('',${add(`EDGE_LOOP('',(${oriented.join(",")}))`)},.T.)`);
202
+ const plane = add(`PLANE('',${add(`AXIS2_PLACEMENT_3D('',${pt(corner(loop[0]))},${dir(normal)},${dir(ref)})`)})`);
203
+ return add(`ADVANCED_FACE('',(${bound}),${plane},.T.)`);
204
+ });
205
+ const solid = add(`MANIFOLD_SOLID_BREP('${name}',${add(`CLOSED_SHELL('',(${faceRefs.join(",")}))`)})`);
206
+ const axis = add(`AXIS2_PLACEMENT_3D('',${pt([0, 0, 0])},${dir([0, 0, 1])},${dir([1, 0, 0])})`);
207
+ const rep = add(`ADVANCED_BREP_SHAPE_REPRESENTATION('${name}',(${axis},${solid}),${gctx})`);
208
+ add(`SHAPE_DEFINITION_REPRESENTATION(${shape},${rep})`);
209
+
210
+ return [
211
+ "ISO-10303-21;",
212
+ "HEADER;",
213
+ `FILE_DESCRIPTION(('${name}: box from package body facts'),'2;1');`,
214
+ `FILE_NAME('${name}.step','1970-01-01T00:00:00',(''),(''),'@protoboardai/ext-tscircuit','','');`,
215
+ "FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));",
216
+ "ENDSEC;",
217
+ "DATA;",
218
+ ...lines,
219
+ "ENDSEC;",
220
+ "END-ISO-10303-21;",
221
+ "",
222
+ ].join("\n");
223
+ }
224
+
225
+ /**
226
+ * board.glb's copy of the bodied circuit JSON: a component whose body is a mesh (a part with a GLB
227
+ * or OBJ body and no STEP) gets the mesh instead of its package box. A UHD GLB is glTF: metres,
228
+ * +Y up, so it is scaled to millimetres and its +Y is the board normal; the part's mount origin
229
+ * (mm, part frame) goes to the footprint origin as for a STEP body.
230
+ */
231
+ export function withMeshes(cj: CircuitElement[], map: NameMap, bodies: ComponentBody[]): CircuitElement[] {
232
+ const byInstance = new Map(bodies.map((b) => [b.instance, b]));
233
+ const sources = new Map(cj.filter((e) => e.type === "source_component").map((s) => [s.source_component_id, s.name as string]));
234
+ return cj.map((e) => {
235
+ if (e.type !== "cad_component") return e;
236
+ const mesh = byInstance.get(map.components[sources.get(e.source_component_id) ?? ""]?.instance ?? "")?.mesh;
237
+ if (!mesh) return e;
238
+ const { model_step_url: _step, ...rest } = e;
239
+ const metres = mesh.units === "m" || (mesh.format === "glb" && mesh.units === undefined);
240
+ const out: CircuitElement = { ...rest, [mesh.format === "glb" ? "model_glb_url" : "model_obj_url"]: mesh.path, model_unit_to_mm_scale_factor: metres ? 1000 : 1, ...(mesh.format === "glb" ? { model_board_normal_direction: "y+" } : {}), model_origin_position: { x: 0, y: 0, z: 0 }, show_as_bounding_box: false };
241
+ const o = mesh.mount;
242
+ if (o && (o[0] || o[1] || o[2])) {
243
+ const a = ((e.rotation?.z ?? 0) * Math.PI) / 180;
244
+ const flip = (e.position?.z ?? 0) < 0 ? -1 : 1;
245
+ out.position = { x: e.position.x - (o[0] * Math.cos(a) - o[1] * Math.sin(a)) * flip, y: e.position.y - (o[0] * Math.sin(a) + o[1] * Math.cos(a)), z: e.position.z - o[2] * flip };
246
+ }
247
+ return out;
248
+ });
249
+ }
250
+
251
+ /** A tscircuit `cadModel` prop: the model file relative to the board's source folder, and how it sits on the board. */
252
+ export interface CadModelProp {
253
+ stepUrl?: string;
254
+ glbUrl?: string;
255
+ objUrl?: string;
256
+ modelOriginPosition?: { x: number; y: number; z: number };
257
+ modelUnitToMmScale?: number;
258
+ modelBoardNormalDirection?: "y+";
259
+ }
260
+
261
+ /**
262
+ * The `cadModel` generate writes for each component whose part has a body artifact: the STEP
263
+ * (the stripped copy in models/ when stripping removed anything), else the GLB or OBJ. Paths are
264
+ * relative to the native folder with forward slashes, so they mean the same on every OS and on
265
+ * every machine; the provider's exports resolve them against that folder (and set absolute paths
266
+ * on a copy of the circuit JSON, applyBodies). Package boxes are export's own and not written here.
267
+ */
268
+ export function cadModels(root: string, nativeDir: string, bodies: ComponentBody[]): Record<string, CadModelProp> {
269
+ const rel = (abs: string) => {
270
+ const r = relative(join(root, nativeDir), abs).split(sep).join("/");
271
+ return r.startsWith(".") ? r : `./${r}`;
272
+ };
273
+ const origin = (o?: [number, number, number]) => (o && (o[0] || o[1] || o[2]) ? { modelOriginPosition: { x: o[0], y: o[1], z: o[2] } } : {});
274
+ const out: Record<string, CadModelProp> = {};
275
+ for (const b of bodies) {
276
+ if (b.source !== "part") continue;
277
+ if (b.step) out[b.instance] = { stepUrl: rel(b.step), ...origin(b.mountOrigin) };
278
+ else if (b.mesh) {
279
+ const metres = b.mesh.units === "m" || (b.mesh.format === "glb" && b.mesh.units === undefined);
280
+ out[b.instance] = { [b.mesh.format === "glb" ? "glbUrl" : "objUrl"]: rel(b.mesh.path), ...origin(b.mesh.mount), ...(metres ? { modelUnitToMmScale: 1000 } : {}), ...(b.mesh.format === "glb" ? { modelBoardNormalDirection: "y+" as const } : {}) };
281
+ }
282
+ }
283
+ return out;
284
+ }