@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.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +21 -2
- package/package.json +46 -4
- package/src/applies.ts +54 -0
- package/src/builtins.tsx +89 -0
- package/src/drawing/BoardDrawingView.tsx +515 -0
- package/src/drawing/CircuitPcbView.tsx +494 -0
- package/src/drawing/DrawingControls.tsx +39 -0
- package/src/drawing/boards.ts +82 -0
- package/src/drawing/circuit.ts +413 -0
- package/src/drawing/kicadSchematic.ts +277 -0
- package/src/drawing/palettes.ts +150 -0
- package/src/drawing/svg.ts +445 -0
- package/src/drawing/usePanZoom.ts +400 -0
- package/src/icons.tsx +69 -0
- package/src/index.ts +33 -0
- package/src/inspector/DrcPanel.tsx +302 -0
- package/src/inspector/SelectionInspector.tsx +392 -0
- package/src/inspector/index.ts +9 -0
- package/src/inspector/model.ts +224 -0
- package/src/pair/PairView.tsx +314 -0
- package/src/pair/model.ts +206 -0
- package/src/parts/BomEditor.tsx +564 -0
- package/src/parts/CardPages.tsx +317 -0
- package/src/parts/CategoryList.tsx +72 -0
- package/src/parts/CloudPanel.tsx +160 -0
- package/src/parts/GlbPreview.tsx +116 -0
- package/src/parts/LibrariesManager.tsx +251 -0
- package/src/parts/LibraryBrowser.tsx +191 -0
- package/src/parts/LibraryPage.tsx +168 -0
- package/src/parts/PartCard.tsx +274 -0
- package/src/parts/PartDetail.tsx +613 -0
- package/src/parts/PartStatus.tsx +54 -0
- package/src/parts/Prices.tsx +241 -0
- package/src/parts/ProjectParts.tsx +174 -0
- package/src/parts/ProjectPartsPage.tsx +264 -0
- package/src/parts/SettingsPage.tsx +268 -0
- package/src/parts/Surface.tsx +51 -0
- package/src/parts/Windowed.tsx +70 -0
- package/src/parts/carousel.tsx +344 -0
- package/src/parts/index.ts +32 -0
- package/src/parts/library-data.ts +726 -0
- package/src/parts/matte.ts +69 -0
- package/src/parts/model.ts +590 -0
- package/src/parts/part-pages.ts +379 -0
- package/src/parts/part-status.ts +88 -0
- package/src/parts/revision.ts +11 -0
- package/src/parts/three.d.ts +5 -0
- package/src/parts/ui.tsx +335 -0
- package/src/parts/views.tsx +433 -0
- package/src/registry.ts +107 -0
- package/src/tabs.ts +118 -0
- package/src/theme.ts +225 -0
- package/src/types.ts +93 -0
- package/src/ui/Select.tsx +99 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schematic and PCB views (PB-898): a board's own drawings, as its
|
|
3
|
+
* circuit tool exported them (schematic.svg, pcb-top.svg, pcb-bottom.svg),
|
|
4
|
+
* themed by role so they follow the shell's theme, high contrast included,
|
|
5
|
+
* with the system's knowledge on top: a component names its module, a net
|
|
6
|
+
* lights up end to end, a click selects the module (the host's inspector
|
|
7
|
+
* follows) and its source is a button away. Viewing only: the design is
|
|
8
|
+
* edited in the circuit tool.
|
|
9
|
+
*
|
|
10
|
+
* Pan and zoom are the canvas's (usePanZoom). A board KiCad lays out (a
|
|
11
|
+
* locked board) is drawn from KiCad's export, its layout of record; the
|
|
12
|
+
* circuit provider's routed build it was locked from is a switch away. KiCad
|
|
13
|
+
* writes no nets or parts into its drawings: they are read from the
|
|
14
|
+
* schematic's geometry (kicadSchematic.ts), and the parts are UHD instances
|
|
15
|
+
* through the board's reference map (refdes.json) when it carries one.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
|
18
|
+
import type { ModuleDef } from "@deltarobotics/uhd";
|
|
19
|
+
import { EmptyState, Icon, IconButton, Legend, LegendItem, ToggleGroup, Toolbar, ToolbarSeparator, cx } from "@deltarobotics/uhd-viewer/components";
|
|
20
|
+
import { SCHEMATIC_ICON, ViewIcon } from "../icons.js";
|
|
21
|
+
import { Select } from "../ui/Select.js";
|
|
22
|
+
import { definition } from "../applies.js";
|
|
23
|
+
import type { ViewProps } from "../types.js";
|
|
24
|
+
import { PCB_FILES, ROUTED_FILE, SCHEMATIC_FILES, artifactHref, artifactNamed, boardsWith, circuitSource, instanceAt, kicadLayout, type BoardAt } from "./boards.js";
|
|
25
|
+
import { CircuitPcbView } from "./CircuitPcbView.js";
|
|
26
|
+
import { DrawingTools, DrawingViewButtons, SelectionBox, toolCursor } from "./DrawingControls.js";
|
|
27
|
+
import { indexKicadSchematic, partAt, type SchematicIndex, type SchematicPart } from "./kicadSchematic.js";
|
|
28
|
+
import { DRAWING_CSS, PCB_PALETTE, SCHEMATIC_PALETTE } from "./palettes.js";
|
|
29
|
+
import { scopedRules, themeSvg, type ThemedSvg } from "./svg.js";
|
|
30
|
+
import { usePanZoom, type Box } from "./usePanZoom.js";
|
|
31
|
+
|
|
32
|
+
type Kind = "schematic" | "pcb";
|
|
33
|
+
type Side = "top" | "bottom";
|
|
34
|
+
/** Where a PCB view draws the board from: the exported drawings, or the provider's routed build. */
|
|
35
|
+
type Source = "export" | "routed";
|
|
36
|
+
|
|
37
|
+
/** What the pointer is over or what is selected: a component (by its instance id on the board or its reference), a net, a pad. */
|
|
38
|
+
interface Hot {
|
|
39
|
+
part?: string;
|
|
40
|
+
/** A schematic net (the tool's connectivity key, or a KiCad net's name) or a PCB pad's net name. */
|
|
41
|
+
net?: string;
|
|
42
|
+
pad?: string;
|
|
43
|
+
/** A KiCad part's value (`523k`). */
|
|
44
|
+
value?: string;
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** KiCad wrote it (its schematic or board export): it says so in the file's description. */
|
|
50
|
+
const fromKicad = (text: string) => /Eeschema|Pcbnew|PCBNEW|KiCad/.test(text.slice(0, 4000));
|
|
51
|
+
|
|
52
|
+
function useDrawing(url: string | undefined, kind: Kind, prefix: string, mirror = false) {
|
|
53
|
+
const [state, setState] = useState<{ svg?: ThemedSvg; error?: string; loading: boolean }>({ loading: true });
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!url) {
|
|
56
|
+
setState({ loading: false, error: "This board has no such drawing yet. Build the project to export it." });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
setState({ loading: true });
|
|
61
|
+
fetch(url)
|
|
62
|
+
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(`${r.status} ${r.statusText}`))))
|
|
63
|
+
.then((text) => {
|
|
64
|
+
if (cancelled) return;
|
|
65
|
+
// KiCad exports the bottom side already mirrored (seen from below): mirrored again it would read as the top
|
|
66
|
+
const svg = themeSvg(text, { palette: kind === "schematic" ? SCHEMATIC_PALETTE : PCB_PALETTE, idPrefix: `${prefix}-`, mirror: mirror && !fromKicad(text) });
|
|
67
|
+
setState({ loading: false, svg });
|
|
68
|
+
})
|
|
69
|
+
.catch((e: Error) => !cancelled && setState({ loading: false, error: `The drawing could not be read (${e.message}).` }));
|
|
70
|
+
return () => {
|
|
71
|
+
cancelled = true;
|
|
72
|
+
};
|
|
73
|
+
}, [url, kind, prefix, mirror]);
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A board's reference map (`refdes.json`, ext-tscircuit: UHD instance → KiCad reference), when it carries one: reference → instance. */
|
|
78
|
+
function useReferenceMap(url: string | undefined) {
|
|
79
|
+
const [map, setMap] = useState<Map<string, string> | undefined>(undefined);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!url) return setMap(undefined);
|
|
82
|
+
let cancelled = false;
|
|
83
|
+
fetch(url)
|
|
84
|
+
.then((r) => (r.ok ? r.json() : undefined))
|
|
85
|
+
.then((j: { assigned?: Record<string, string> } | undefined) => !cancelled && setMap(j?.assigned ? new Map(Object.entries(j.assigned).map(([inst, ref]) => [ref, inst])) : undefined))
|
|
86
|
+
.catch(() => !cancelled && setMap(undefined));
|
|
87
|
+
return () => {
|
|
88
|
+
cancelled = true;
|
|
89
|
+
};
|
|
90
|
+
}, [url]);
|
|
91
|
+
return map;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The instance a schematic component group draws: the text in it that names a child of the board (tscircuit's schematic). */
|
|
95
|
+
function schematicParts(root: Element, children: Set<string>): Map<Element, string> {
|
|
96
|
+
const out = new Map<Element, string>();
|
|
97
|
+
for (const g of root.querySelectorAll("[data-schematic-component-id]")) {
|
|
98
|
+
for (const t of g.querySelectorAll("text")) {
|
|
99
|
+
const id = t.textContent?.trim() ?? "";
|
|
100
|
+
if (children.has(id)) {
|
|
101
|
+
out.set(g, id);
|
|
102
|
+
g.setAttribute("data-pb-hot", "part");
|
|
103
|
+
g.setAttribute("data-pb-part", id);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The box of what is drawn: everything but the page behind it (the tool's sheet or background), with a little room. */
|
|
112
|
+
function drawnBox(root: SVGSVGElement): Box | undefined {
|
|
113
|
+
const hidden = [...root.querySelectorAll<SVGElement>("[data-pb-page], .boundary, [data-type='pcb_background']")];
|
|
114
|
+
const before = hidden.map((e) => e.style.display);
|
|
115
|
+
for (const e of hidden) e.style.display = "none";
|
|
116
|
+
let b: DOMRect | undefined;
|
|
117
|
+
try {
|
|
118
|
+
b = root.getBBox();
|
|
119
|
+
} catch {
|
|
120
|
+
b = undefined;
|
|
121
|
+
}
|
|
122
|
+
hidden.forEach((e, i) => (e.style.display = before[i]));
|
|
123
|
+
if (!b || (!b.width && !b.height)) return undefined;
|
|
124
|
+
const m = Math.max(b.width, b.height) * 0.02;
|
|
125
|
+
return [b.x - m, b.y - m, b.width + 2 * m, b.height + 2 * m];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A PCB pad's component and pin: `u1.GND` → ["u1", "GND"]. */
|
|
129
|
+
const padParts = (name: string | null): [string, string] | undefined => {
|
|
130
|
+
if (!name) return undefined;
|
|
131
|
+
const dot = name.indexOf(".");
|
|
132
|
+
return dot > 0 ? [name.slice(0, dot), name.slice(dot + 1)] : undefined;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const PART_CLASSES = ["pb-net-on", "pb-pad-on", "pb-part-hover", "pb-part-on", "pb-pad-part", "pb-kicad-net", "pb-kicad-part", "pb-kicad-part-hover"];
|
|
136
|
+
|
|
137
|
+
export function BoardDrawingView(props: ViewProps & { kind: Kind }) {
|
|
138
|
+
const { module, bundle, path, host, kind, params } = props;
|
|
139
|
+
const files = kind === "schematic" ? SCHEMATIC_FILES : [PCB_FILES.top, PCB_FILES.bottom, ROUTED_FILE];
|
|
140
|
+
const boards = useMemo(() => boardsWith(module, bundle, path, files), [module, bundle, path, kind]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
141
|
+
const [boardPath, setBoardPath] = useState<string | undefined>(params?.board);
|
|
142
|
+
const board: BoardAt | undefined = boards.find((b) => b.path === boardPath) ?? boards[0];
|
|
143
|
+
const sides: Side[] = kind === "pcb" && board ? (["top", "bottom"] as const).filter((s) => artifactNamed(board.def, PCB_FILES[s])) : [];
|
|
144
|
+
const [side, setSide] = useState<Side>((params?.side as Side) ?? "top");
|
|
145
|
+
const shownSide: Side | undefined = kind === "pcb" ? (sides.includes(side) ? side : sides[0]) : undefined;
|
|
146
|
+
const file = board ? artifactNamed(board.def, kind === "schematic" ? "schematic.svg" : PCB_FILES[shownSide ?? "top"]) : undefined;
|
|
147
|
+
// KiCad is the layout of record of a locked board: its export is drawn, the routed build is the other choice
|
|
148
|
+
const kicad = board ? kicadLayout(board.def) : undefined;
|
|
149
|
+
const hasRouted = kind === "pcb" && !!board && !!artifactNamed(board.def, ROUTED_FILE);
|
|
150
|
+
const hasExport = kind === "pcb" && sides.length > 0;
|
|
151
|
+
const [sourceChoice, setSource] = useState<Source | undefined>(params?.source === "routed" || params?.source === "export" ? (params.source as Source) : undefined);
|
|
152
|
+
const source: Source = !hasRouted ? "export" : !hasExport ? "routed" : (sourceChoice ?? (kicad ? "export" : "routed"));
|
|
153
|
+
const routed = kind === "pcb" && source === "routed";
|
|
154
|
+
const url = board && !routed ? artifactHref(host, board.def, file) : undefined;
|
|
155
|
+
const prefix = `pbd${useId().replace(/[^\w]/g, "")}`;
|
|
156
|
+
const drawing = useDrawing(url, kind, prefix, shownSide === "bottom");
|
|
157
|
+
const kicadDrawn = drawing.svg?.generator === "kicad";
|
|
158
|
+
const refMap = useReferenceMap(board && kind === "schematic" ? artifactHref(host, board.def, artifactNamed(board.def, "refdes.json")) : undefined);
|
|
159
|
+
|
|
160
|
+
const canvas = useRef<HTMLDivElement>(null);
|
|
161
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
162
|
+
const cardRef = useRef<HTMLDivElement>(null);
|
|
163
|
+
const svgEl = () => canvas.current?.querySelector("svg") ?? null;
|
|
164
|
+
const [contentBox, setContentBox] = useState<Box | undefined>(undefined);
|
|
165
|
+
const [hover, setHover] = useState<Hot | null>(null);
|
|
166
|
+
const [heldNet, setHeldNet] = useState<string | undefined>(undefined);
|
|
167
|
+
/** Picked parts: instance ids (a tscircuit schematic) or references (KiCad's drawings); the first is the one the card shows. */
|
|
168
|
+
const [picked, setPicked] = useState<string[]>(params?.select ? [params.select] : []);
|
|
169
|
+
const selected = picked[0];
|
|
170
|
+
const children = useMemo(() => new Set((board?.def.children ?? []).map((c) => c.id)), [board]);
|
|
171
|
+
const parts = useRef(new Map<Element, string>());
|
|
172
|
+
const index = useRef<SchematicIndex | undefined>(undefined);
|
|
173
|
+
|
|
174
|
+
const pz = usePanZoom(svgEl, {
|
|
175
|
+
box: contentBox ?? drawing.svg?.viewBox,
|
|
176
|
+
insets: () => ({ top: 60, bottom: 64 }),
|
|
177
|
+
onBox: (b, additive) => {
|
|
178
|
+
const inBox = (x: number, y: number) => x >= b[0] && x <= b[0] + b[2] && y >= b[1] && y <= b[1] + b[3];
|
|
179
|
+
let refs: string[] = [];
|
|
180
|
+
if (index.current) refs = index.current.parts.filter((p) => inBox(p.at[0], p.at[1])).map((p) => p.ref);
|
|
181
|
+
else {
|
|
182
|
+
const root = svgEl();
|
|
183
|
+
if (root && kind === "schematic") {
|
|
184
|
+
const toBox = (g: Element) => {
|
|
185
|
+
try {
|
|
186
|
+
const r = (g as SVGGraphicsElement).getBBox();
|
|
187
|
+
return inBox(r.x + r.width / 2, r.y + r.height / 2);
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
refs = [...parts.current].filter(([g]) => toBox(g)).map(([, id]) => id);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
pick(additive ? [...new Set([...picked, ...refs])] : refs);
|
|
196
|
+
},
|
|
197
|
+
onEscape: () => {
|
|
198
|
+
setHeldNet(undefined);
|
|
199
|
+
pick([]);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// the themed markup in place (never through React: it is a large static tree), and its parts and nets found once
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const el = canvas.current;
|
|
206
|
+
if (!el) return;
|
|
207
|
+
el.innerHTML = drawing.svg?.markup ?? "";
|
|
208
|
+
const root = el.querySelector("svg");
|
|
209
|
+
index.current = undefined;
|
|
210
|
+
parts.current = new Map();
|
|
211
|
+
if (root) {
|
|
212
|
+
root.removeAttribute("style");
|
|
213
|
+
root.setAttribute("data-pb-viewport", "");
|
|
214
|
+
if (kind === "schematic") {
|
|
215
|
+
if (drawing.svg?.generator === "kicad") index.current = indexKicadSchematic(root);
|
|
216
|
+
else parts.current = schematicParts(root, children);
|
|
217
|
+
}
|
|
218
|
+
for (const p of root.querySelectorAll("[data-pad-name]")) p.setAttribute("data-pb-hot", "pad");
|
|
219
|
+
}
|
|
220
|
+
setContentBox(root ? drawnBox(root) : undefined);
|
|
221
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
222
|
+
}, [drawing.svg, children, kind]);
|
|
223
|
+
|
|
224
|
+
// a selection made elsewhere (the Inspector, the hierarchy): an instance, or its reference in KiCad's drawings
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
const want = params?.select;
|
|
227
|
+
if (!want) return;
|
|
228
|
+
if (kicadDrawn && refMap) {
|
|
229
|
+
const ref = [...refMap].find(([, inst]) => inst === want || inst.endsWith(`/${want}`))?.[0];
|
|
230
|
+
if (ref) setPicked([ref]);
|
|
231
|
+
} else setPicked([want]);
|
|
232
|
+
}, [params?.select, kicadDrawn, refMap]);
|
|
233
|
+
|
|
234
|
+
// what is lit: the hovered (or held) net, the hovered and the picked parts
|
|
235
|
+
const litNet = hover?.net ?? heldNet;
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
const root = canvas.current?.querySelector("svg");
|
|
238
|
+
if (!root) return;
|
|
239
|
+
for (const c of PART_CLASSES) for (const e of root.querySelectorAll(`.${c}`)) e.classList.remove(c);
|
|
240
|
+
const ix = index.current;
|
|
241
|
+
if (ix) {
|
|
242
|
+
if (litNet) for (const e of ix.nets.get(litNet) ?? []) e.classList.add("pb-kicad-net");
|
|
243
|
+
const byRef = new Map(ix.parts.map((p) => [p.ref, p]));
|
|
244
|
+
if (hover?.part) for (const e of byRef.get(hover.part)?.els ?? []) e.classList.add("pb-kicad-part-hover");
|
|
245
|
+
for (const r of picked) for (const e of byRef.get(r)?.els ?? []) e.classList.add("pb-kicad-part");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (litNet) {
|
|
249
|
+
if (kind === "schematic") for (const e of root.querySelectorAll(`[data-subcircuit-connectivity-map-key="${CSS.escape(litNet)}"]`)) e.classList.add("pb-net-on");
|
|
250
|
+
else for (const e of root.querySelectorAll(`[data-pad-net-name="${CSS.escape(litNet)}"]`)) e.classList.add("pb-pad-on");
|
|
251
|
+
}
|
|
252
|
+
if (hover?.part && kind === "schematic") root.querySelector(`[data-pb-part="${CSS.escape(hover.part)}"]`)?.classList.add("pb-part-hover");
|
|
253
|
+
for (const s of picked) {
|
|
254
|
+
if (kind === "schematic") root.querySelector(`[data-pb-part="${CSS.escape(s)}"]`)?.classList.add("pb-part-on");
|
|
255
|
+
else for (const e of root.querySelectorAll(`[data-pad-name^="${CSS.escape(s)}."]`)) e.classList.add("pb-pad-part");
|
|
256
|
+
}
|
|
257
|
+
}, [hover, litNet, picked, kind, drawing.svg]);
|
|
258
|
+
|
|
259
|
+
const hotAt = (target: EventTarget | null, x: number, y: number): Hot | null => {
|
|
260
|
+
const el = target instanceof Element ? target : null;
|
|
261
|
+
if (!el) return null;
|
|
262
|
+
const ix = index.current;
|
|
263
|
+
if (ix) {
|
|
264
|
+
const netEl = [el, el.closest("g.stroked-text")].find((e): e is Element => !!e && ix.netOf.has(e));
|
|
265
|
+
const at = pz.toDrawing(x, y);
|
|
266
|
+
// a wire or a label within a few pixels of the pointer (its strokes are a fraction of a pixel when zoomed out)
|
|
267
|
+
const px = pz.view ? 1 / pz.view.s : 0;
|
|
268
|
+
const net = netEl ? ix.netOf.get(netEl) : at && px ? ix.netAt(at, 5 * px) : undefined;
|
|
269
|
+
// over a wire or a label the net is what the pointer means; elsewhere the part under it
|
|
270
|
+
const part: SchematicPart | undefined = !net && at ? partAt(ix.parts, at) : undefined;
|
|
271
|
+
return net || part ? { net, part: part?.ref, value: part?.value, x, y } : null;
|
|
272
|
+
}
|
|
273
|
+
if (kind === "schematic") {
|
|
274
|
+
const part = el.closest("[data-pb-part]")?.getAttribute("data-pb-part") ?? undefined;
|
|
275
|
+
const net = el.closest("[data-subcircuit-connectivity-map-key]")?.getAttribute("data-subcircuit-connectivity-map-key") ?? undefined;
|
|
276
|
+
return part || net ? { part, net, x, y } : null;
|
|
277
|
+
}
|
|
278
|
+
const pad = el.closest("[data-pad-name]");
|
|
279
|
+
const pp = padParts(pad?.getAttribute("data-pad-name") ?? null);
|
|
280
|
+
if (!pad || !pp) return null;
|
|
281
|
+
return { part: pp[0], pad: pp[1], net: pad.getAttribute("data-pad-net-name") ?? undefined, x, y };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/** The UHD instance a picked part is (a KiCad reference through the board's map), relative to the board. */
|
|
285
|
+
const instanceOf = (part: string | undefined): string | undefined => {
|
|
286
|
+
if (!part) return undefined;
|
|
287
|
+
if (index.current) return refMap?.get(part);
|
|
288
|
+
return part;
|
|
289
|
+
};
|
|
290
|
+
function pick(next: string[]) {
|
|
291
|
+
setPicked(next);
|
|
292
|
+
if (!board) return;
|
|
293
|
+
const inst = instanceOf(next[0]);
|
|
294
|
+
const ref = inst ? instanceAt(bundle, board.def, inst) : undefined;
|
|
295
|
+
host.selectionChanged?.(inst && ref ? { kind: "node", id: board.path ? `${board.path}/${inst}` : inst, defId: ref.moduleDefId } : null);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!boards.length || !board) {
|
|
299
|
+
return (
|
|
300
|
+
<div className="grid size-full place-items-center bg-canvas">
|
|
301
|
+
<EmptyState icon={<ViewIcon name={props.view.icon} size={28} strokeWidth={1.25} />} title={props.view.title}>
|
|
302
|
+
{module.name} has no {kind === "schematic" ? "schematic" : "board layout"} drawing. A board built by a circuit tool exports one.
|
|
303
|
+
</EmptyState>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const partDef = (inst?: string): ModuleDef | undefined => {
|
|
309
|
+
const r = inst ? instanceAt(bundle, board.def, inst) : undefined;
|
|
310
|
+
return r ? definition(bundle, r.moduleDefId) : undefined;
|
|
311
|
+
};
|
|
312
|
+
const selInst = instanceOf(selected);
|
|
313
|
+
const sel = selInst ? instanceAt(bundle, board.def, selInst) : undefined;
|
|
314
|
+
const selDef = partDef(selInst);
|
|
315
|
+
const selPart = selected && index.current ? index.current.parts.find((p) => p.ref === selected) : undefined;
|
|
316
|
+
const hoverInst = instanceOf(hover?.part);
|
|
317
|
+
const hoverRef = hoverInst ? instanceAt(bundle, board.def, hoverInst) : undefined;
|
|
318
|
+
const circuit = circuitSource(board.def);
|
|
319
|
+
const sourceFile = kind === "pcb" && source === "export" && kicad ? kicad : (kicadDrawn && kicad ? kicad : circuit);
|
|
320
|
+
const netName = (n?: string) => (n && !n.startsWith("~") ? n : undefined);
|
|
321
|
+
|
|
322
|
+
// which board the drawing is of (when the level holds several), and what it is drawn from
|
|
323
|
+
const header = (extra?: ReactNode) => (
|
|
324
|
+
<div className="absolute top-[calc(var(--uhd-inset-top,0px)+12px)] left-3 flex max-w-[calc(100%-24px)] items-center gap-2 rounded-panel border border-line bg-surface py-1.5 pr-1.5 pl-3 shadow-panel outline outline-1 -outline-offset-1 outline-contrast">
|
|
325
|
+
<ViewIcon name={kind === "schematic" ? SCHEMATIC_ICON : "circuit-board"} size={15} className="shrink-0 text-accent" aria-hidden />
|
|
326
|
+
<div className="min-w-0">
|
|
327
|
+
<div className="truncate font-label text-[12px] tracking-[0.06em] uppercase">{kind === "schematic" ? "Schematic" : routed ? "PCB" : `PCB · ${shownSide === "bottom" ? "bottom, seen from below" : "top"}`}</div>
|
|
328
|
+
<div className="truncate text-[11px] text-fg-muted">
|
|
329
|
+
{board.label}
|
|
330
|
+
{board.label !== board.def.name ? ` · ${board.def.name}` : ""}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
{/* (the routed view's badge says what the switch says, when there is a switch) */}
|
|
334
|
+
{!(kind === "pcb" && hasRouted && hasExport) && extra}
|
|
335
|
+
{kind === "pcb" && hasRouted && hasExport && (
|
|
336
|
+
<Select
|
|
337
|
+
label="Drawn from"
|
|
338
|
+
tooltip={kicad ? "KiCad is this board's layout of record (it is locked): its export is the board as it is; the routed build is what it was locked from" : "Drawn from the exported drawings or from the routed build"}
|
|
339
|
+
value={source}
|
|
340
|
+
onChange={(v) => (setSource(v as Source), pick([]))}
|
|
341
|
+
options={[
|
|
342
|
+
{ value: "export", label: kicad ? "KiCad · layout of record" : "Exported drawing" },
|
|
343
|
+
{ value: "routed", label: kicad ? "tscircuit build · before lock" : "Routed build" },
|
|
344
|
+
]}
|
|
345
|
+
className="ml-1 max-w-56 shrink-0"
|
|
346
|
+
/>
|
|
347
|
+
)}
|
|
348
|
+
{kind === "schematic" && kicadDrawn && (
|
|
349
|
+
<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="KiCad's schematic export: the layout of record of a locked board">
|
|
350
|
+
KiCad
|
|
351
|
+
</span>
|
|
352
|
+
)}
|
|
353
|
+
{boards.length > 1 && (
|
|
354
|
+
<Select label="Board" tooltip="Board" value={board.path} onChange={(v) => (setBoardPath(v), pick([]))} options={boards.map((b) => ({ value: b.path, label: b.label }))} className="ml-1 max-w-48 shrink-0" />
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
if (routed) return <CircuitPcbView {...props} board={board} boards={boards} onBoard={(v) => (setBoardPath(v), pick([]))} header={header} />;
|
|
360
|
+
|
|
361
|
+
const lit = netName(litNet);
|
|
362
|
+
return (
|
|
363
|
+
<div ref={rootRef} className={cx("pb-board-drawing relative size-full min-h-[240px] min-w-[300px] overflow-hidden bg-canvas text-fg", kicadDrawn && (kind === "schematic" ? "pb-kicad" : "pb-kicad-pcb"))}>
|
|
364
|
+
<style>{DRAWING_CSS}</style>
|
|
365
|
+
{drawing.svg && <style>{scopedRules(drawing.svg.rules, `[data-pb-drawing="${prefix}"]`)}</style>}
|
|
366
|
+
<div
|
|
367
|
+
ref={pz.ref}
|
|
368
|
+
data-pb-drawing={prefix}
|
|
369
|
+
data-pb-tool={pz.panning ? "pan" : "select"}
|
|
370
|
+
className={cx("pb-drawing absolute inset-0 touch-none select-none", toolCursor(pz))}
|
|
371
|
+
onPointerMove={(e) => {
|
|
372
|
+
if (e.buttons) return;
|
|
373
|
+
setHover(hotAt(e.target, e.clientX, e.clientY));
|
|
374
|
+
}}
|
|
375
|
+
onPointerLeave={() => setHover(null)}
|
|
376
|
+
onClick={(e) => {
|
|
377
|
+
if (pz.dragged()) return;
|
|
378
|
+
const hot = hotAt(e.target, e.clientX, e.clientY);
|
|
379
|
+
setHeldNet(hot?.net);
|
|
380
|
+
const part = hot?.part && (index.current || children.has(hot.part)) ? hot.part : undefined;
|
|
381
|
+
if (part && (e.shiftKey || e.ctrlKey || e.metaKey)) pick(picked.includes(part) ? picked.filter((p) => p !== part) : [...picked, part]);
|
|
382
|
+
else pick(part ? [part] : []);
|
|
383
|
+
}}
|
|
384
|
+
onDoubleClick={() => pz.fit()}
|
|
385
|
+
>
|
|
386
|
+
<div ref={canvas} className="size-full" />
|
|
387
|
+
<SelectionBox pz={pz} />
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
{header()}
|
|
391
|
+
|
|
392
|
+
{/* the picked component: what it is, and where to go from here */}
|
|
393
|
+
{selected && (sel || selPart) && (
|
|
394
|
+
<div ref={cardRef} className="absolute top-[calc(var(--uhd-inset-top,0px)+12px)] 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}>
|
|
395
|
+
<div className="flex items-center gap-2 border-b border-line py-1.5 pr-1.5 pl-3">
|
|
396
|
+
<span className="font-code text-[12px] text-accent">{selected.toUpperCase()}</span>
|
|
397
|
+
<span className="min-w-0 flex-1 truncate font-label text-[12px] tracking-[0.04em] uppercase">{sel?.name ?? selDef?.name ?? selPart?.value ?? sel?.id}</span>
|
|
398
|
+
{host.openSource && sel && selInst && (
|
|
399
|
+
<IconButton icon="file-code" size="sm" label="Open the module's source" onClick={() => host.openSource!({ defId: board.def.id, instancePath: board.path ? `${board.path}/${selInst}` : selInst })} />
|
|
400
|
+
)}
|
|
401
|
+
{host.openView && sel && selInst && !selInst.includes("/") && <IconButton icon="workflow" size="sm" label="Show the board's diagram" onClick={() => host.openView!("diagram", board.path, { select: selInst })} />}
|
|
402
|
+
<IconButton icon="x" size="sm" label="Clear the selection" onClick={() => pick([])} />
|
|
403
|
+
</div>
|
|
404
|
+
<div className="px-3 py-2 text-[11px]">
|
|
405
|
+
{selPart && <Row k="Reference" v={selPart.ref} mono />}
|
|
406
|
+
{selPart?.value && <Row k="Value" v={selPart.value} mono />}
|
|
407
|
+
{selInst && index.current && <Row k="Instance" v={selInst} mono />}
|
|
408
|
+
{selDef && <Row k="Part" v={selDef.name} />}
|
|
409
|
+
<Row k="Definition" v={sel?.moduleDefId} mono />
|
|
410
|
+
{selDef && <Row k="Interfaces" v={String(selDef.interfaces.length)} />}
|
|
411
|
+
{selDef?.categories?.[0] && <Row k="Category" v={selDef.categories[0]} mono />}
|
|
412
|
+
{selPart && !sel && <div className="pt-1 text-fg-muted">The board carries no reference map (refdes.json), so this reference is not tied to its UHD instance here.</div>}
|
|
413
|
+
</div>
|
|
414
|
+
{picked.length > 1 && (
|
|
415
|
+
<div className="flex flex-wrap gap-1 border-t border-line px-3 py-2 text-[11px]" data-pb-picked={picked.length}>
|
|
416
|
+
<span className="mr-1 text-fg-muted">{picked.length} picked</span>
|
|
417
|
+
{picked.map((r) => (
|
|
418
|
+
<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)])}>
|
|
419
|
+
{r.toUpperCase()}
|
|
420
|
+
</button>
|
|
421
|
+
))}
|
|
422
|
+
</div>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
426
|
+
|
|
427
|
+
{/* the lit net, by name */}
|
|
428
|
+
{lit && (
|
|
429
|
+
<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={lit}>
|
|
430
|
+
<Icon name="waypoints" size={13} className="text-accent" />
|
|
431
|
+
<span className="font-code">{lit}</span>
|
|
432
|
+
{heldNet && <IconButton icon="x" size="sm" label="Let the net go" onClick={() => setHeldNet(undefined)} />}
|
|
433
|
+
</div>
|
|
434
|
+
)}
|
|
435
|
+
|
|
436
|
+
{/* under the pointer */}
|
|
437
|
+
{hover && (hover.part || hover.net) && (
|
|
438
|
+
<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 }}>
|
|
439
|
+
{hover.part && (
|
|
440
|
+
<div className="truncate">
|
|
441
|
+
<span className="text-accent">{hover.part.toUpperCase()}</span>
|
|
442
|
+
{hover.pad && <span className="text-fg-muted"> · {hover.pad}</span>}
|
|
443
|
+
{hover.value && <span className="text-fg-muted"> · {hover.value}</span>}
|
|
444
|
+
{hoverRef && <span className="text-fg-muted"> · {hoverRef.name ?? partDef(hoverInst)?.name ?? hoverInst}</span>}
|
|
445
|
+
</div>
|
|
446
|
+
)}
|
|
447
|
+
{hover.net && <div className="truncate text-fg-muted">{netName(hover.net) ? `net ${hover.net}` : kind === "pcb" ? `net ${hover.net}` : "an unnamed net"}</div>}
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
{drawing.loading || drawing.error ? (
|
|
452
|
+
<div className="pointer-events-none absolute inset-0 grid place-items-center">
|
|
453
|
+
<EmptyState icon={<Icon name={drawing.error ? "file-x" : "loader"} size={24} strokeWidth={1.25} />} title={drawing.error ? "No drawing" : "Loading the drawing"}>
|
|
454
|
+
{drawing.error}
|
|
455
|
+
</EmptyState>
|
|
456
|
+
</div>
|
|
457
|
+
) : null}
|
|
458
|
+
|
|
459
|
+
{kind === "pcb" && (
|
|
460
|
+
<Legend title="Layers" className="absolute bottom-[68px] left-3 max-[520px]:hidden">
|
|
461
|
+
<LegendItem color={shownSide === "bottom" ? "var(--pb-d-copper-bottom)" : "var(--pb-d-copper-top)"} label={`Copper · ${shownSide ?? "top"}`} />
|
|
462
|
+
{kicadDrawn && <LegendItem color="var(--pb-d-pad)" label="Pads (mask open)" />}
|
|
463
|
+
<LegendItem color="var(--pb-d-silk)" label="Silkscreen" />
|
|
464
|
+
{!kicadDrawn && <LegendItem color="var(--pb-d-drill)" label="Drill" shape="dot" />}
|
|
465
|
+
<LegendItem color="var(--pb-d-edge)" label="Board edge" />
|
|
466
|
+
{!kicadDrawn && <LegendItem color="var(--pb-d-hover)" label="Net under the pointer" shape="dot" />}
|
|
467
|
+
</Legend>
|
|
468
|
+
)}
|
|
469
|
+
|
|
470
|
+
<div className="absolute right-0 bottom-3 left-0 flex justify-center px-3">
|
|
471
|
+
<Toolbar label={`${props.view.title} tools`} className="max-w-full overflow-x-auto [scrollbar-width:none]">
|
|
472
|
+
<DrawingTools pz={pz} />
|
|
473
|
+
<ToolbarSeparator />
|
|
474
|
+
<DrawingViewButtons pz={pz} />
|
|
475
|
+
{sides.length > 1 && (
|
|
476
|
+
<>
|
|
477
|
+
<ToolbarSeparator />
|
|
478
|
+
<ToggleGroup
|
|
479
|
+
label="Side"
|
|
480
|
+
tooltipSide="top"
|
|
481
|
+
options={[
|
|
482
|
+
{ id: "top", icon: "panel-top", label: "Top side" },
|
|
483
|
+
{ id: "bottom", icon: "panel-bottom", label: "Bottom side, seen from below" },
|
|
484
|
+
]}
|
|
485
|
+
value={shownSide ?? "top"}
|
|
486
|
+
onChange={(s) => setSide(s)}
|
|
487
|
+
/>
|
|
488
|
+
</>
|
|
489
|
+
)}
|
|
490
|
+
{(host.openArtifact || host.openSource) && <ToolbarSeparator />}
|
|
491
|
+
{host.openArtifact && file && (
|
|
492
|
+
<IconButton icon="file-image" label={`Open ${file.filePath?.split("/").pop() ?? "the drawing"}`} tooltipSide="top" onClick={() => host.openArtifact!({ defId: board.def.id, artifactId: file.id })} />
|
|
493
|
+
)}
|
|
494
|
+
{host.openArtifact && sourceFile && (
|
|
495
|
+
<IconButton icon="code-xml" label={sourceFile === kicad ? "Open the KiCad project" : "Open the circuit source"} tooltipSide="top" onClick={() => host.openArtifact!({ defId: board.def.id, artifactId: sourceFile.id })} />
|
|
496
|
+
)}
|
|
497
|
+
{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 })} />}
|
|
498
|
+
</Toolbar>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function Row({ k, v, mono }: { k: string; v?: string; mono?: boolean }) {
|
|
505
|
+
if (!v) return null;
|
|
506
|
+
return (
|
|
507
|
+
<div className="flex items-baseline justify-between gap-3 py-0.5">
|
|
508
|
+
<span className="shrink-0 text-fg-muted">{k}</span>
|
|
509
|
+
<span className={cx("min-w-0 truncate text-right", mono && "font-code")}>{v}</span>
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export const SchematicView = (props: ViewProps) => <BoardDrawingView {...props} kind="schematic" />;
|
|
515
|
+
export const PcbView = (props: ViewProps) => <BoardDrawingView {...props} kind="pcb" />;
|