@protoboardai/views 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.
Files changed (56) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +21 -2
  4. package/package.json +46 -4
  5. package/src/applies.ts +54 -0
  6. package/src/builtins.tsx +89 -0
  7. package/src/drawing/BoardDrawingView.tsx +515 -0
  8. package/src/drawing/CircuitPcbView.tsx +494 -0
  9. package/src/drawing/DrawingControls.tsx +39 -0
  10. package/src/drawing/boards.ts +82 -0
  11. package/src/drawing/circuit.ts +413 -0
  12. package/src/drawing/kicadSchematic.ts +277 -0
  13. package/src/drawing/palettes.ts +150 -0
  14. package/src/drawing/svg.ts +445 -0
  15. package/src/drawing/usePanZoom.ts +400 -0
  16. package/src/icons.tsx +69 -0
  17. package/src/index.ts +33 -0
  18. package/src/inspector/DrcPanel.tsx +302 -0
  19. package/src/inspector/SelectionInspector.tsx +392 -0
  20. package/src/inspector/index.ts +9 -0
  21. package/src/inspector/model.ts +224 -0
  22. package/src/pair/PairView.tsx +314 -0
  23. package/src/pair/model.ts +206 -0
  24. package/src/parts/BomEditor.tsx +564 -0
  25. package/src/parts/CardPages.tsx +317 -0
  26. package/src/parts/CategoryList.tsx +72 -0
  27. package/src/parts/CloudPanel.tsx +160 -0
  28. package/src/parts/GlbPreview.tsx +116 -0
  29. package/src/parts/LibrariesManager.tsx +251 -0
  30. package/src/parts/LibraryBrowser.tsx +191 -0
  31. package/src/parts/LibraryPage.tsx +168 -0
  32. package/src/parts/PartCard.tsx +274 -0
  33. package/src/parts/PartDetail.tsx +613 -0
  34. package/src/parts/PartStatus.tsx +54 -0
  35. package/src/parts/Prices.tsx +241 -0
  36. package/src/parts/ProjectParts.tsx +174 -0
  37. package/src/parts/ProjectPartsPage.tsx +264 -0
  38. package/src/parts/SettingsPage.tsx +268 -0
  39. package/src/parts/Surface.tsx +51 -0
  40. package/src/parts/Windowed.tsx +70 -0
  41. package/src/parts/carousel.tsx +344 -0
  42. package/src/parts/index.ts +32 -0
  43. package/src/parts/library-data.ts +726 -0
  44. package/src/parts/matte.ts +69 -0
  45. package/src/parts/model.ts +590 -0
  46. package/src/parts/part-pages.ts +379 -0
  47. package/src/parts/part-status.ts +88 -0
  48. package/src/parts/revision.ts +11 -0
  49. package/src/parts/three.d.ts +5 -0
  50. package/src/parts/ui.tsx +335 -0
  51. package/src/parts/views.tsx +433 -0
  52. package/src/registry.ts +107 -0
  53. package/src/tabs.ts +118 -0
  54. package/src/theme.ts +225 -0
  55. package/src/types.ts +93 -0
  56. package/src/ui/Select.tsx +99 -0
@@ -0,0 +1,494 @@
1
+ /**
2
+ * The PCB view from the circuit provider's routed board (`circuit.json`): the
3
+ * board as `circuit:generate` built and routed it, drawn here, never routed
4
+ * again. What a person needs instead of the tool's own viewer:
5
+ *
6
+ * - every copper layer on and off by itself (top, the inner ones, bottom),
7
+ * and silkscreen, courtyard and fabrication on each side, drills, the edge
8
+ * and keep-outs;
9
+ * - zoom and pan as on the canvas (usePanZoom: the Select and Pan tools, a
10
+ * middle-button or Space drag, the wheel about the pointer, F, +, -, 0,
11
+ * Fit), a box drawn with Select picks the components in it, and the board
12
+ * seen from below;
13
+ * - a net lit across the board, by its name, under the pointer or held by a
14
+ * click;
15
+ * - a component picked: its reference, part and footprint here, and the
16
+ * host's Inspector follows the selection;
17
+ * - the board check's findings at their place, listed and found by a click.
18
+ */
19
+ import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
20
+ import type { ModuleDef } from "@deltarobotics/uhd";
21
+ import { EmptyState, Icon, IconButton, ScrollArea, ToggleGroup, Toolbar, ToolbarSeparator, cx } from "@deltarobotics/uhd-viewer/components";
22
+ import { definition } from "../applies.js";
23
+ import type { ViewProps } from "../types.js";
24
+ import { artifactHref, artifactNamed, circuitSource, type BoardAt } from "./boards.js";
25
+ import { drawOrder, findingsLabel, locate, readCircuit, type CircuitElement, type Layer, type LayerId, type PcbModel, type Piece, type Shape } from "./circuit.js";
26
+ import { DrawingTools, DrawingViewButtons, SelectionBox, toolCursor } from "./DrawingControls.js";
27
+ import { DRAWING_CSS } from "./palettes.js";
28
+ import { usePanZoom, type Box } from "./usePanZoom.js";
29
+
30
+ /** What the provider's name map says of a component (build.json `map.components`): its UHD instance, part and footprint. */
31
+ interface MapEntry {
32
+ instance?: string;
33
+ part?: string;
34
+ footprint?: string;
35
+ }
36
+
37
+ interface Finding {
38
+ severity: "error" | "warning" | "info";
39
+ code: string;
40
+ message: string;
41
+ at?: [number, number];
42
+ }
43
+
44
+ const SEVERITY_RANK = { error: 0, warning: 1, info: 2 } as const;
45
+
46
+ /** Below this width the board check's card starts folded (B11): open, it lies over the board's right half. */
47
+ const FINDINGS_OPEN_MIN_WIDTH = 1280;
48
+
49
+ /** The board's files: its routed result and the provider's name map, fetched once per board. */
50
+ function useBoard(url: string | undefined, mapUrl: string | undefined) {
51
+ const [state, setState] = useState<{ cj?: CircuitElement[]; map?: Record<string, MapEntry>; error?: string; loading: boolean }>({ loading: true });
52
+ useEffect(() => {
53
+ if (!url) return setState({ loading: false, error: "This board has no routed result yet. Run circuit:generate to build and route it." });
54
+ let cancelled = false;
55
+ setState({ loading: true });
56
+ const get = (u: string) => fetch(u).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status} ${r.statusText}`))));
57
+ Promise.all([get(url), mapUrl ? get(mapUrl).catch(() => undefined) : Promise.resolve(undefined)])
58
+ .then(([cj, build]) => {
59
+ if (cancelled) return;
60
+ if (!Array.isArray(cj)) throw new Error("not Circuit JSON");
61
+ setState({ loading: false, cj: cj as CircuitElement[], map: (build as { map?: { components?: Record<string, MapEntry> } } | undefined)?.map?.components });
62
+ })
63
+ .catch((e: Error) => !cancelled && setState({ loading: false, error: `The routed board could not be read (${e.message}).` }));
64
+ return () => {
65
+ cancelled = true;
66
+ };
67
+ }, [url, mapUrl]);
68
+ return state;
69
+ }
70
+
71
+ /** One piece as SVG. Pads and pours are filled; drawings (silkscreen, courtyard, fabrication) are stroked. */
72
+ export function shapeEl(p: Piece, key: number, flip: boolean, extra: Record<string, string | undefined>): ReactNode {
73
+ const s: Shape = p.shape;
74
+ const outline = p.stroke !== undefined;
75
+ const paint = outline ? { fill: "none", stroke: "currentColor", strokeWidth: p.stroke } : { fill: "currentColor" };
76
+ switch (s.kind) {
77
+ case "rect":
78
+ return <rect key={key} x={s.x - s.w / 2} y={s.y - s.h / 2} width={s.w} height={s.h} rx={s.r || undefined} transform={s.rotate ? `rotate(${s.rotate} ${s.x} ${s.y})` : undefined} {...paint} {...extra} />;
79
+ case "circle":
80
+ return <circle key={key} cx={s.x} cy={s.y} r={s.r} {...paint} {...extra} />;
81
+ case "line":
82
+ return <polyline key={key} points={s.points.map((q) => q.join(",")).join(" ")} fill="none" stroke="currentColor" strokeWidth={s.width} strokeLinecap="round" strokeLinejoin="round" {...extra} />;
83
+ case "area":
84
+ return <path key={key} d={s.rings.map((r) => `M${r.map((q) => q.join(",")).join("L")}Z`).join("")} fillRule="evenodd" {...paint} {...extra} />;
85
+ case "text":
86
+ // text reads left to right whichever side the board is seen from. It is set at 100 times its size and scaled
87
+ // down: Chrome draws a font of a millimetre or less badly once the drawing is zoomed (doubled glyphs, R2 as "R2R")
88
+ return (
89
+ <text key={key} transform={`translate(${s.x} ${s.y})${flip ? " scale(-1 1)" : ""}${s.rotate ? ` rotate(${flip ? -s.rotate : s.rotate})` : ""} scale(${TEXT_SCALE})`} fontSize={s.size / TEXT_SCALE} textAnchor={s.anchor ?? "middle"} dominantBaseline="central" fill="currentColor" {...extra}>
90
+ {s.text}
91
+ </text>
92
+ );
93
+ }
94
+ }
95
+
96
+ /** Text is drawn at 1/TEXT_SCALE of its font size (see Piece's text case). */
97
+ const TEXT_SCALE = 0.01;
98
+
99
+ /** A layer's pieces, drawn once per model and side (the highlight is a style rule, not a redraw; a pan is the viewBox). */
100
+ const LayerGroup = memo(function LayerGroup({ layer, flip }: { layer: Layer; flip: boolean }) {
101
+ const pour = layer.id.startsWith("copper:");
102
+ return (
103
+ <g className={cx("pb-pcb-layer", pour && "pb-pcb-copper")} data-layer={layer.id} style={{ color: `var(--pb-d-${layer.role})` }}>
104
+ {layer.pieces.map((p, i) =>
105
+ shapeEl(p, i, flip, {
106
+ "data-net": p.net,
107
+ "data-ref": p.ref,
108
+ "data-pin": p.pin,
109
+ className: p.shape.kind === "area" && pour ? "pb-pour" : undefined,
110
+ }),
111
+ )}
112
+ </g>
113
+ );
114
+ });
115
+
116
+ /** Layers on by default: everything but the far side's drawings (for the side the board is seen from) and the courtyards. */
117
+ const defaultHidden = (m: PcbModel, flip = false): Set<LayerId> =>
118
+ new Set<LayerId>(["courtyard:top", "courtyard:bottom", flip ? "fab:top" : "fab:bottom", flip ? "silk:top" : "silk:bottom", "keepout", ...m.layers.filter((l) => !l.pieces.length).map((l) => l.id)]);
119
+
120
+ export function CircuitPcbView(props: ViewProps & { board: BoardAt; boards: BoardAt[]; onBoard: (path: string) => void; header: (extra?: ReactNode) => ReactNode }) {
121
+ const { bundle, host, board } = props;
122
+ const circuit = artifactNamed(board.def, "circuit.json");
123
+ const build = artifactNamed(board.def, "build.json");
124
+ const data = useBoard(artifactHref(host, board.def, circuit), artifactHref(host, board.def, build));
125
+ const model = useMemo(() => (data.cj ? readCircuit(data.cj) : undefined), [data.cj]);
126
+ // what the user turned off, per board; until they touch a layer, the defaults (known in the same render as the model,
127
+ // so the drawing's host is there when the view first fits it)
128
+ const [chosen, setHidden] = useState<{ model: PcbModel; hidden: Set<LayerId> } | undefined>(undefined);
129
+ const [flip, setFlip] = useState(props.params?.side === "bottom");
130
+ const hidden = model ? (chosen?.model === model ? chosen.hidden : defaultHidden(model, flip)) : undefined;
131
+ const [hover, setHover] = useState<{ net?: string; ref?: string; pin?: string; x: number; y: number } | null>(null);
132
+ const [heldNet, setHeldNet] = useState<string | undefined>(undefined);
133
+ /** The picked components, by reference: a click picks one, a box several (the first is the one the card and the host get). */
134
+ const [picked, setPicked] = useState<string[]>([]);
135
+ const selected = picked[0];
136
+ const [layersOpen, setLayersOpen] = useState(true);
137
+ // the board check's card starts folded where it would lie over the board (B11: a laptop-sized viewer); its marks are on the board
138
+ const [findingsOpen, setFindingsOpen] = useState(() => typeof window === "undefined" || window.innerWidth >= FINDINGS_OPEN_MIN_WIDTH);
139
+ const [findingsShown, setFindingsShown] = useState(true);
140
+
141
+ const svgRef = useRef<SVGSVGElement>(null);
142
+ const rootRef = useRef<HTMLDivElement>(null);
143
+ const layersRef = useRef<HTMLDivElement>(null);
144
+ const findingsRef = useRef<HTMLDivElement>(null);
145
+ const box = useMemo<Box | undefined>(() => {
146
+ if (!model) return undefined;
147
+ const [x, y, w, h] = model.box;
148
+ const m = Math.max(w, h) * 0.02;
149
+ return [(flip ? -x - w : x) - m, y - m, w + 2 * m, h + 2 * m];
150
+ }, [model, flip]);
151
+ const pz = usePanZoom(() => svgRef.current, {
152
+ box,
153
+ // the board fits between the floating panels (the layers on the left, the findings on the right)
154
+ insets: () => {
155
+ const root = rootRef.current?.getBoundingClientRect();
156
+ if (!root) return {};
157
+ const l = layersRef.current?.getBoundingClientRect();
158
+ const f = findingsRef.current?.getBoundingClientRect();
159
+ return { left: l && l.width ? l.right - root.left + 8 : 0, right: f && f.width ? root.right - f.left + 8 : 0, top: 64, bottom: 64 };
160
+ },
161
+ onBox: (b, additive) => {
162
+ if (!model) return;
163
+ // the components whose centre is in the box (the drawing is mirrored when seen from below)
164
+ const inside = [...model.components.values()].filter((c) => {
165
+ const x = flip ? -c.x : c.x;
166
+ return x >= b[0] && x <= b[0] + b[2] && c.y >= b[1] && c.y <= b[1] + b[3];
167
+ });
168
+ const refs = inside.map((c) => c.ref);
169
+ pick(additive ? [...new Set([...picked, ...refs])] : refs);
170
+ },
171
+ onEscape: () => {
172
+ setHeldNet(undefined);
173
+ pick([]);
174
+ },
175
+ });
176
+
177
+ // a reference → the UHD instance it is (the provider's name map; else the reference is the instance's id)
178
+ const instanceOf = (ref: string) => data.map?.[ref]?.instance ?? ref;
179
+ const children = useMemo(() => new Map((board.def.children ?? []).map((c) => [c.id, c])), [board]);
180
+ const refOfInstance = useMemo(() => new Map(Object.entries(data.map ?? {}).map(([ref, m]) => [m.instance ?? ref, ref])), [data.map]);
181
+ // a selection made elsewhere (the Inspector, the hierarchy) by `params.select`
182
+ useEffect(() => {
183
+ const want = props.params?.select;
184
+ const ref = want && model ? (refOfInstance.get(want) ?? (model.components.has(want) ? want : undefined)) : undefined;
185
+ if (ref) setPicked([ref]);
186
+ }, [props.params?.select, model, refOfInstance]);
187
+
188
+ const findings = useMemo<Finding[]>(() => {
189
+ if (!model) return [];
190
+ const out: Finding[] = [];
191
+ const seen = new Set<string>();
192
+ for (const d of bundle.diagnostics ?? []) {
193
+ if (d.entity?.id !== board.def.id || !d.code.startsWith("CIRCUIT_") || d.code === "CIRCUIT_NOT_BUILT") continue;
194
+ seen.add(d.message);
195
+ const at = locate(d.message, model, data.cj);
196
+ out.push({ severity: d.severity, code: d.code, message: d.message, ...(at ? { at } : {}) });
197
+ }
198
+ // what the provider recorded and the check did not report (a warning, or a build the check has not read yet)
199
+ for (const r of model.recorded) {
200
+ const message = `${r.type}: ${r.message}`;
201
+ if (seen.has(message)) continue;
202
+ seen.add(message);
203
+ out.push({ severity: /_error$/.test(r.type) ? "error" : "warning", code: r.type, message: r.message, ...(r.at ? { at: r.at } : {}) });
204
+ }
205
+ return out.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || (a.at ? 0 : 1) - (b.at ? 0 : 1));
206
+ }, [model, bundle, board, data.cj]);
207
+
208
+ const select = (ref: string | undefined) => pick(ref ? [ref] : []);
209
+ function pick(refs: string[]) {
210
+ setPicked(refs);
211
+ const ref = refs[0];
212
+ const inst = ref ? instanceOf(ref) : undefined;
213
+ const child = inst ? children.get(inst) : undefined;
214
+ host.selectionChanged?.(inst && child ? { kind: "node", id: board.path ? `${board.path}/${inst}` : inst, defId: child.moduleDefId } : null);
215
+ };
216
+
217
+ const hit = (t: EventTarget | null) => {
218
+ const el = t instanceof Element ? t.closest("[data-net],[data-ref]") : null;
219
+ return el ? { net: el.getAttribute("data-net") ?? undefined, ref: el.getAttribute("data-ref") ?? undefined, pin: el.getAttribute("data-pin") ?? undefined } : undefined;
220
+ };
221
+
222
+ if (data.loading || data.error || !model || !hidden) {
223
+ return (
224
+ <div className="pb-board-drawing relative grid size-full min-h-[240px] place-items-center bg-canvas text-fg">
225
+ <style>{DRAWING_CSS}</style>
226
+ {props.header()}
227
+ <EmptyState icon={<Icon name={data.error ? "file-x" : "loader"} size={24} strokeWidth={1.25} />} title={data.error ? "No routed board" : "Loading the routed board"}>
228
+ {data.error}
229
+ </EmptyState>
230
+ </div>
231
+ );
232
+ }
233
+
234
+ const litNet = hover?.net ?? heldNet;
235
+ const net = litNet ? model.nets.get(litNet) : undefined;
236
+ const comp = selected ? model.components.get(selected) : undefined;
237
+ const entry = selected ? data.map?.[selected] : undefined;
238
+ const inst = selected ? instanceOf(selected) : undefined;
239
+ const ref = inst ? children.get(inst) : undefined;
240
+ const partDef: ModuleDef | undefined = ref ? definition(bundle, ref.moduleDefId) : undefined;
241
+ // drawn bottom-most first for the side it is seen from; listed nearest first
242
+ const layers = drawOrder(model, flip ? "bottom" : "top");
243
+ const shown = layers.filter((l) => !hidden.has(l.id));
244
+ const toggle = (id: LayerId) => {
245
+ const next = new Set(hidden);
246
+ if (next.has(id)) next.delete(id);
247
+ else next.add(id);
248
+ setHidden({ model, hidden: next });
249
+ };
250
+ const source = circuitSource(board.def);
251
+ const esc = (v: string) => v.replace(/["\\]/g, "\\$&");
252
+ const located = findings.filter((f) => f.at).length;
253
+
254
+ return (
255
+ <div ref={rootRef} className="pb-board-drawing pb-pcb relative size-full min-h-[240px] min-w-[300px] overflow-hidden bg-canvas text-fg" data-pb-pcb={board.def.id}>
256
+ <style>{DRAWING_CSS}</style>
257
+ {/* the net that is lit and the components that are picked: rules, not a redraw of the board */}
258
+ <style>
259
+ {[
260
+ litNet ? `.pb-pcb [data-net="${esc(litNet)}"] { color: var(--pb-d-hover) !important; } .pb-pcb .pb-pcb-copper [data-net="${esc(litNet)}"] { opacity: 1; }` : "",
261
+ litNet ? `.pb-pcb .pb-pcb-copper > :not([data-net="${esc(litNet)}"]) { opacity: 0.35; }` : "",
262
+ // a lit pour stays a wash: the pads and traces of the net are what stands out
263
+ litNet ? `.pb-pcb .pb-pcb-copper > .pb-pour[data-net="${esc(litNet)}"] { opacity: 0.3; }` : "",
264
+ picked.length ? `${picked.map((r) => `.pb-pcb [data-ref="${esc(r)}"]`).join(", ")} { color: var(--pb-d-select) !important; }` : "",
265
+ ].join("\n")}
266
+ </style>
267
+ <div
268
+ ref={pz.ref}
269
+ data-pb-tool={pz.panning ? "pan" : "select"}
270
+ className={cx("pb-drawing absolute inset-0 touch-none select-none", toolCursor(pz))}
271
+ onPointerMove={(e) => {
272
+ if (e.buttons) return;
273
+ const h = hit(e.target);
274
+ setHover(h && (h.net || h.ref) ? { ...h, x: e.clientX, y: e.clientY } : null);
275
+ }}
276
+ onPointerLeave={() => setHover(null)}
277
+ onClick={(e) => {
278
+ if (pz.dragged()) return;
279
+ const h = hit(e.target);
280
+ setHeldNet(h?.net);
281
+ const ref = h?.ref && model.components.has(h.ref) ? h.ref : undefined;
282
+ // Shift (or Ctrl, Cmd) adds a component to the picked ones, or takes it out
283
+ if (ref && (e.shiftKey || e.ctrlKey || e.metaKey)) pick(picked.includes(ref) ? picked.filter((r) => r !== ref) : [...picked, ref]);
284
+ else select(ref);
285
+ }}
286
+ onDoubleClick={() => pz.fit()}
287
+ >
288
+ <svg ref={svgRef} className="size-full" role="img" aria-label={`Routed board ${board.label}`}>
289
+ <g data-pb-viewport transform={flip ? "scale(-1 1)" : undefined}>
290
+ {shown.map((l) => (
291
+ <LayerGroup key={`${l.id}:${flip}`} layer={l} flip={flip} />
292
+ ))}
293
+ {/* findings at their place: marks of a fixed size on screen, whatever the zoom */}
294
+ {findingsShown && (
295
+ <g className="pb-pcb-findings">
296
+ {findings.map((f, i) =>
297
+ f.at ? (
298
+ <g key={i} data-finding={i} data-severity={f.severity} style={{ color: `var(--uhd-severity-${f.severity})` }}>
299
+ <circle cx={f.at[0]} cy={f.at[1]} fill="none" stroke="currentColor" strokeWidth={1.5} vectorEffect="non-scaling-stroke" className="pb-finding-ring" />
300
+ <circle cx={f.at[0]} cy={f.at[1]} fill="currentColor" className="pb-finding-dot" />
301
+ </g>
302
+ ) : null,
303
+ )}
304
+ </g>
305
+ )}
306
+ </g>
307
+ </svg>
308
+ <SelectionBox pz={pz} />
309
+ </div>
310
+
311
+ {props.header(
312
+ <span className="ml-1 shrink-0 rounded-sm bg-raised px-1.5 py-0.5 font-code text-[10px] text-fg-muted" data-tooltip="Drawn from circuit.json, the routed result of circuit:generate">
313
+ routed
314
+ </span>,
315
+ )}
316
+
317
+ {/* the layers, each on and off by itself */}
318
+ <div ref={layersRef} className="absolute top-[calc(var(--uhd-inset-top,0px)+64px)] left-3 flex max-h-[calc(100%-150px)] w-[188px] flex-col rounded-panel border border-line bg-surface shadow-panel outline outline-1 -outline-offset-1 outline-contrast max-[520px]:hidden">
319
+ <button type="button" className="flex items-center gap-2 px-3 py-1.5 text-left font-label text-[10px] tracking-[0.8px] text-fg-muted uppercase hover:text-fg" aria-expanded={layersOpen} onClick={() => setLayersOpen((o) => !o)} data-tooltip={layersOpen ? "Hide the layer list" : "Show the layer list"}>
320
+ <Icon name="layers" size={13} />
321
+ <span className="flex-1">Layers</span>
322
+ <Icon name={layersOpen ? "chevron-up" : "chevron-down"} size={13} />
323
+ </button>
324
+ {layersOpen && (
325
+ <ScrollArea className="min-h-0 border-t border-line" contentClassName="flex flex-col gap-0.5 px-1.5 py-1.5">
326
+ {layers
327
+ .slice()
328
+ .reverse()
329
+ .map((l) => {
330
+ const on = !hidden.has(l.id);
331
+ const empty = !l.pieces.length;
332
+ return (
333
+ <button
334
+ key={l.id}
335
+ type="button"
336
+ aria-pressed={on}
337
+ disabled={empty}
338
+ data-layer-toggle={l.id}
339
+ data-tooltip={empty ? `${l.label}: nothing on this layer` : `${on ? "Hide" : "Show"} ${l.label.toLowerCase()}`}
340
+ data-tooltip-side="right"
341
+ onClick={() => toggle(l.id)}
342
+ className={cx("flex min-h-[20px] items-center gap-2 rounded-sm px-1.5 py-0.5 text-left text-[11px]", empty ? "cursor-default opacity-40" : "cursor-pointer hover:bg-hover", on ? "text-fg" : "text-fg-muted")}
343
+ >
344
+ <span aria-hidden className={cx("h-2.5 w-2.5 shrink-0 rounded-[2px]", !on && "opacity-40")} style={{ background: `var(--pb-d-${l.role})` }} />
345
+ <span className="min-w-0 flex-1 truncate">{l.label}</span>
346
+ <Icon name={on ? "eye" : "eye-off"} size={12} className="shrink-0 opacity-70" />
347
+ </button>
348
+ );
349
+ })}
350
+ {located > 0 && (
351
+ <button
352
+ type="button"
353
+ aria-pressed={findingsShown}
354
+ data-layer-toggle="findings"
355
+ data-tooltip={`${findingsShown ? "Hide" : "Show"} the board check's marks${located < findings.length ? `: ${located} of its ${findings.length} findings have a place on the board` : ""}`}
356
+ data-tooltip-side="right"
357
+ onClick={() => setFindingsShown((v) => !v)}
358
+ className={cx("mt-1 flex min-h-[20px] cursor-pointer items-center gap-2 rounded-sm border-t border-line px-1.5 pt-1.5 pb-0.5 text-left text-[11px] hover:bg-hover", findingsShown ? "text-fg" : "text-fg-muted")}
359
+ >
360
+ <span aria-hidden className={cx("h-2.5 w-2.5 shrink-0 rounded-full border-[1.5px] border-[var(--uhd-severity-error)]", !findingsShown && "opacity-40")} />
361
+ <span className="min-w-0 flex-1 truncate">{findingsLabel(located, findings.length)}</span>
362
+ <Icon name={findingsShown ? "eye" : "eye-off"} size={12} className="shrink-0 opacity-70" />
363
+ </button>
364
+ )}
365
+ </ScrollArea>
366
+ )}
367
+ </div>
368
+
369
+ {/* the lit net, by name */}
370
+ {net && (
371
+ <div className="absolute bottom-[64px] left-1/2 flex max-w-[calc(100%-24px)] -translate-x-1/2 items-center gap-2 rounded-panel border border-line bg-surface py-1 pr-1 pl-3 text-[12px] shadow-panel outline outline-1 -outline-offset-1 outline-contrast" data-pb-net={net.key}>
372
+ <Icon name="waypoints" size={13} className="text-accent" />
373
+ <span className="font-code">{net.name}</span>
374
+ <span className="text-fg-muted">
375
+ {net.pads.length} pad{net.pads.length === 1 ? "" : "s"}
376
+ </span>
377
+ {heldNet && <IconButton icon="x" size="sm" label="Let the net go" onClick={() => setHeldNet(undefined)} />}
378
+ </div>
379
+ )}
380
+
381
+ {/* the picked component: reference, part, footprint */}
382
+ {selected && comp && (
383
+ <div className="absolute top-[calc(var(--uhd-inset-top,0px)+64px)] right-3 w-[min(300px,calc(100%-24px))] rounded-panel border border-line bg-surface shadow-panel outline outline-1 -outline-offset-1 outline-contrast" data-pb-component={selected}>
384
+ <div className="flex items-center gap-2 border-b border-line py-1.5 pr-1.5 pl-3">
385
+ <span className="font-code text-[12px] text-accent">{selected.toUpperCase()}</span>
386
+ <span className="min-w-0 flex-1 truncate font-label text-[12px] tracking-[0.04em] uppercase">{ref?.name ?? partDef?.name ?? comp.mpn ?? selected}</span>
387
+ {host.openSource && ref && <IconButton icon="file-code" size="sm" label="Open the module's source" onClick={() => host.openSource!({ defId: board.def.id, instancePath: board.path ? `${board.path}/${ref.id}` : ref.id })} />}
388
+ {host.openView && ref && <IconButton icon="workflow" size="sm" label="Show the board's diagram" onClick={() => host.openView!("diagram", board.path, { select: ref.id })} />}
389
+ <IconButton icon="x" size="sm" label="Clear the selection" onClick={() => select(undefined)} />
390
+ </div>
391
+ <div className="px-3 py-2 text-[11px]">
392
+ <Row k="Reference" v={selected.toUpperCase()} mono />
393
+ <Row k="Part" v={partDef?.name ?? entry?.part} />
394
+ <Row k="Definition" v={ref?.moduleDefId ?? entry?.part} mono />
395
+ <Row k="Footprint" v={entry?.footprint} mono />
396
+ <Row k="Part number" v={comp.mpn} mono />
397
+ <Row k="Side" v={comp.side} />
398
+ <Row k="Position" v={`${comp.x.toFixed(2)}, ${(-comp.y).toFixed(2)} mm · ${(-comp.rotate + 360) % 360}°`} mono />
399
+ </div>
400
+ {picked.length > 1 && (
401
+ <div className="flex flex-wrap gap-1 border-t border-line px-3 py-2 text-[11px]" data-pb-picked={picked.length}>
402
+ <span className="mr-1 text-fg-muted">{picked.length} picked</span>
403
+ {picked.map((r) => (
404
+ <button key={r} type="button" className={cx("rounded-sm px-1 font-code hover:bg-hover", r === selected ? "text-accent" : "text-fg")} onClick={() => pick([r, ...picked.filter((x) => x !== r)])} data-tooltip={`Show ${r.toUpperCase()} here and in the Inspector`}>
405
+ {r.toUpperCase()}
406
+ </button>
407
+ ))}
408
+ </div>
409
+ )}
410
+ </div>
411
+ )}
412
+
413
+ {/* the board check's findings */}
414
+ {findings.length > 0 && (
415
+ <div ref={findingsRef} className="absolute right-3 bottom-[64px] flex max-h-[40%] w-[min(340px,calc(100%-24px))] flex-col rounded-panel border border-line bg-surface shadow-panel outline outline-1 -outline-offset-1 outline-contrast" data-pb-findings={findings.length}>
416
+ <button type="button" className="flex items-center gap-2 px-3 py-1.5 text-left font-label text-[10px] tracking-[0.8px] text-fg-muted uppercase hover:text-fg" aria-expanded={findingsOpen} onClick={() => setFindingsOpen((o) => !o)} data-tooltip={findingsOpen ? "Hide the findings" : "Show the findings"}>
417
+ <Icon name="shield-alert" size={13} />
418
+ <span className="flex-1" data-tooltip={located < findings.length ? `${located} of them have a place on the board` : undefined}>
419
+ Board check · {findings.length}
420
+ </span>
421
+ <Icon name={findingsOpen ? "chevron-down" : "chevron-up"} size={13} />
422
+ </button>
423
+ {findingsOpen && (
424
+ <ScrollArea className="min-h-0 border-t border-line" contentClassName="flex flex-col py-1">
425
+ {findings.map((f, i) => (
426
+ <button
427
+ key={i}
428
+ type="button"
429
+ disabled={!f.at}
430
+ data-tooltip={f.at ? "Show it on the board" : "This finding names no place on the board"}
431
+ data-tooltip-side="left"
432
+ onClick={() => f.at && pz.centerOn(flip ? -f.at[0] : f.at[0], f.at[1])}
433
+ className={cx("flex items-start gap-2 px-3 py-1 text-left text-[11px]", f.at ? "cursor-pointer hover:bg-hover" : "cursor-default")}
434
+ >
435
+ <span aria-hidden className="mt-1 size-2 shrink-0 rounded-full" style={{ background: `var(--uhd-severity-${f.severity})` }} />
436
+ <span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
437
+ <span className="font-code text-fg-muted">{f.code}</span> {f.message}
438
+ </span>
439
+ {f.at && <Icon name="crosshair" size={12} className="mt-0.5 shrink-0 text-fg-muted" />}
440
+ </button>
441
+ ))}
442
+ </ScrollArea>
443
+ )}
444
+ </div>
445
+ )}
446
+
447
+ {/* under the pointer */}
448
+ {hover && (hover.ref || hover.net) && (
449
+ <div className="pointer-events-none fixed z-50 max-w-[280px] rounded-md border border-line bg-raised px-2 py-1 font-code text-[11px] text-fg shadow-float" style={{ left: hover.x + 14, top: hover.y + 14 }}>
450
+ {hover.ref && (
451
+ <div className="truncate">
452
+ <span className="text-accent">{hover.ref.toUpperCase()}</span>
453
+ {hover.pin && <span className="text-fg-muted"> · {hover.pin}</span>}
454
+ </div>
455
+ )}
456
+ {hover.net && <div className="truncate text-fg-muted">net {model.nets.get(hover.net)?.name ?? hover.net}</div>}
457
+ </div>
458
+ )}
459
+
460
+ <div className="absolute right-0 bottom-3 left-0 flex justify-center px-3">
461
+ <Toolbar label={`${props.view.title} tools`} className="max-w-full overflow-x-auto [scrollbar-width:none]">
462
+ <DrawingTools pz={pz} />
463
+ <ToolbarSeparator />
464
+ <DrawingViewButtons pz={pz} />
465
+ <ToolbarSeparator />
466
+ <ToggleGroup
467
+ label="Side"
468
+ tooltipSide="top"
469
+ options={[
470
+ { id: "top", icon: "panel-top", label: "Seen from the top" },
471
+ { id: "bottom", icon: "panel-bottom", label: "Seen from below" },
472
+ ]}
473
+ value={flip ? "bottom" : "top"}
474
+ onChange={(s) => setFlip(s === "bottom")}
475
+ />
476
+ {(host.openArtifact || host.openSource) && <ToolbarSeparator />}
477
+ {host.openArtifact && circuit && <IconButton icon="file-json" label="Open circuit.json" tooltipSide="top" onClick={() => host.openArtifact!({ defId: board.def.id, artifactId: circuit.id })} />}
478
+ {host.openArtifact && source && <IconButton icon="code-xml" label="Open the circuit source" tooltipSide="top" onClick={() => host.openArtifact!({ defId: board.def.id, artifactId: source.id })} />}
479
+ {host.openSource && <IconButton icon="braces" label="Open the board's UHD source" tooltipSide="top" onClick={() => host.openSource!({ defId: board.def.id, instancePath: board.path || undefined })} />}
480
+ </Toolbar>
481
+ </div>
482
+ </div>
483
+ );
484
+ }
485
+
486
+ function Row({ k, v, mono }: { k: string; v?: string; mono?: boolean }) {
487
+ if (!v) return null;
488
+ return (
489
+ <div className="flex items-baseline justify-between gap-3 py-0.5">
490
+ <span className="shrink-0 text-fg-muted">{k}</span>
491
+ <span className={cx("min-w-0 truncate text-right", mono && "font-code")}>{v}</span>
492
+ </div>
493
+ );
494
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * What the schematic and PCB views share with the diagram's canvas: the
3
+ * pointer tools (Select, Pan) and the view buttons (Fit, zoom) with their
4
+ * keys, and the selection box drawn while a Select drag is under way.
5
+ */
6
+ import { IconButton, TOOL_OPTIONS } from "@deltarobotics/uhd-viewer/components";
7
+ import type { PanZoom } from "./usePanZoom.js";
8
+
9
+ export function DrawingTools({ pz }: { pz: PanZoom }) {
10
+ return (
11
+ <>
12
+ <div role="group" aria-label="Pointer" className="flex items-center gap-0.5">
13
+ {TOOL_OPTIONS.map((o) => (
14
+ <IconButton key={o.id} icon={o.icon} label={o.label} shortcut={o.shortcut} pressed={o.id === pz.tool} tooltipSide="top" onClick={() => pz.setTool(o.id)} />
15
+ ))}
16
+ </div>
17
+ </>
18
+ );
19
+ }
20
+
21
+ export function DrawingViewButtons({ pz }: { pz: PanZoom }) {
22
+ return (
23
+ <>
24
+ <IconButton icon="maximize" label="Fit" shortcut="F" tooltipSide="top" onClick={() => pz.fit()} />
25
+ <IconButton icon="zoom-in" label="Zoom in" shortcut="+" tooltipSide="top" onClick={() => pz.zoomBy(1.4)} />
26
+ <IconButton icon="zoom-out" label="Zoom out" shortcut="-" tooltipSide="top" onClick={() => pz.zoomBy(1 / 1.4)} />
27
+ </>
28
+ );
29
+ }
30
+
31
+ /** The selection box, over the drawing, while a Select drag draws it. */
32
+ export function SelectionBox({ pz }: { pz: PanZoom }) {
33
+ const b = pz.selecting;
34
+ if (!b) return null;
35
+ return <div aria-hidden className="pointer-events-none absolute rounded-[2px] border border-accent bg-accent/10" data-pb-selecting style={{ left: b[0], top: b[1], width: b[2], height: b[3] }} />;
36
+ }
37
+
38
+ /** The host's cursor for the tool: a hand to pan (grabbing while it does), an arrow to pick and box-select. */
39
+ export const toolCursor = (pz: PanZoom) => (pz.panning ? "cursor-grab active:cursor-grabbing" : "cursor-default");
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Which modules have a drawing to show, where they are in the system, and
3
+ * the drawing file's URL. Found by standard-output file name, never by tool.
4
+ */
5
+ import type { ArtifactDef, ModuleDef, UhdBundle } from "@deltarobotics/uhd";
6
+ import { definition, fileName } from "../applies.js";
7
+ import type { HostAdapter } from "../types.js";
8
+
9
+ export const SCHEMATIC_FILES = ["schematic.svg"] as const;
10
+ export const PCB_FILES = { top: "pcb-top.svg", bottom: "pcb-bottom.svg" } as const;
11
+ /** The circuit provider's routed board (Circuit JSON): the PCB view draws it when a board has it. */
12
+ export const ROUTED_FILE = "circuit.json";
13
+
14
+ /** A module with drawings, at its instance path. */
15
+ export interface BoardAt {
16
+ def: ModuleDef;
17
+ /** Instance path from the root ("" is the root). */
18
+ path: string;
19
+ /** Its name where it is used (the child ref's name), else the definition's. */
20
+ label: string;
21
+ }
22
+
23
+ const join = (a: string, b: string) => (a ? `${a}/${b}` : b);
24
+
25
+ /**
26
+ * The module and every module below it that has one of `files`, each
27
+ * definition once (the first place it is used), in the order a reader meets
28
+ * them: the module itself, then depth first.
29
+ */
30
+ export function boardsWith(module: ModuleDef, bundle: UhdBundle, path: string, files: readonly string[]): BoardAt[] {
31
+ const out: BoardAt[] = [];
32
+ const seen = new Set<string>();
33
+ const has = (d: ModuleDef) => (d.artifacts ?? []).some((a) => files.includes(fileName(a)));
34
+ const walk = (d: ModuleDef, at: string, label: string) => {
35
+ if (seen.has(d.id)) return;
36
+ seen.add(d.id);
37
+ if (has(d)) out.push({ def: d, path: at, label });
38
+ for (const c of d.children ?? []) {
39
+ const child = definition(bundle, c.moduleDefId);
40
+ if (child) walk(child, join(at, c.id), c.name ?? child.name);
41
+ }
42
+ };
43
+ walk(module, path, module.name);
44
+ return out;
45
+ }
46
+
47
+ /** A module's artifact by file name. */
48
+ export const artifactNamed = (def: ModuleDef, name: string): ArtifactDef | undefined => (def.artifacts ?? []).find((a) => fileName(a) === name);
49
+
50
+ /** Where to fetch an artifact: the host's URL for it, else its own `url`. */
51
+ export function artifactHref(host: HostAdapter, def: ModuleDef, a: ArtifactDef | undefined): string | undefined {
52
+ if (!a) return undefined;
53
+ if (a.filePath) {
54
+ const u = host.artifactUrl?.(def.id, a.filePath);
55
+ if (u) return u;
56
+ }
57
+ return a.url && /^https?:\/\//.test(a.url) ? a.url : undefined;
58
+ }
59
+
60
+ /** The circuit tool's source for a board (an artifact in the `source` role that is not CAD). */
61
+ export const circuitSource = (def: ModuleDef): ArtifactDef | undefined =>
62
+ (def.artifacts ?? []).find((a) => a.role === "source" && !/^(py|kcl|build123d)$/i.test(a.format ?? "") && !/\.json$/i.test(a.filePath ?? ""));
63
+
64
+ /**
65
+ * A board's KiCad project when KiCad is its layout of record (a locked board:
66
+ * `circuit:lock`, ext-tscircuit). Its exported drawings (schematic.svg,
67
+ * pcb-top.svg, pcb-bottom.svg) are then the board as it is, and the circuit
68
+ * provider's routed result (circuit.json) is the build it was locked from.
69
+ */
70
+ export const kicadLayout = (def: ModuleDef): ArtifactDef | undefined => (def.artifacts ?? []).find((a) => a.role === "source" && (a.format === "kicad" || /\.kicad_(pro|pcb|sch)$/i.test(a.filePath ?? "")));
71
+
72
+ /** The definition of the instance at `path` below `def` (`charger/pwr_rt1`), walking the children. */
73
+ export function instanceAt(bundle: UhdBundle, def: ModuleDef, path: string): { id: string; moduleDefId: string; name?: string } | undefined {
74
+ let d: ModuleDef | undefined = def;
75
+ let ref: { id: string; moduleDefId: string; name?: string } | undefined;
76
+ for (const seg of path.split("/").filter(Boolean)) {
77
+ ref = d?.children?.find((c) => c.id === seg);
78
+ if (!ref) return undefined;
79
+ d = definition(bundle, ref.moduleDefId);
80
+ }
81
+ return ref;
82
+ }