@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,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A part's pages (PB-918) from one revision of it: what the cards' text
|
|
3
|
+
* carousel and the part detail show beyond the overview. Specifications,
|
|
4
|
+
* dimensions and interfaces come from the definition; pictures, the CAD
|
|
5
|
+
* summary and the artifact list from the definition's artifacts and the
|
|
6
|
+
* revision's files. Plain functions with no host API and no React, so the VS
|
|
7
|
+
* Code extension and `protoboard dev` compute the same pages and the unit
|
|
8
|
+
* tests run them directly.
|
|
9
|
+
*
|
|
10
|
+
* Only what the definition states is shown: a page with nothing to show is
|
|
11
|
+
* left out by the views (`availablePages`), never drawn empty.
|
|
12
|
+
*/
|
|
13
|
+
import type { PartRevisionEnvelope } from "@protoboardai/cloud/client";
|
|
14
|
+
import { CARD_PAGES, type CardPageId, type PartArtifactGroup, type PartArtifactItem, type PartCadInfo, type PartFact, type PartImage, type PartOutline, type PartPages } from "./model.js";
|
|
15
|
+
|
|
16
|
+
type Def = Record<string, any>;
|
|
17
|
+
type Envelope = Pick<PartRevisionEnvelope, "artifacts" | "definition" | "revision"> & { evidence?: PartRevisionEnvelope["evidence"] };
|
|
18
|
+
type EnvFile = Envelope["artifacts"][number];
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------- facts from a definition's own keys
|
|
21
|
+
|
|
22
|
+
/** A unit a key ends in (`max_power_W`, `hole_diameter_mm`), as it is written. Case matters: `_A` is amperes, `_a` is not. */
|
|
23
|
+
const UNITS: Record<string, string> = {
|
|
24
|
+
mm: "mm", cm: "cm", m: "m", g: "g", kg: "kg",
|
|
25
|
+
V: "V", mV: "mV", A: "A", mA: "mA", uA: "µA", nA: "nA", W: "W", mW: "mW",
|
|
26
|
+
C: "°C", s: "s", ms: "ms", us: "µs", h: "h",
|
|
27
|
+
Hz: "Hz", kHz: "kHz", MHz: "MHz", GHz: "GHz",
|
|
28
|
+
ohm: "Ω", mohm: "mΩ", kohm: "kΩ",
|
|
29
|
+
rpm: "rpm", mAh: "mAh", Wh: "Wh", N: "N", Nm: "N·m", deg: "°", dBm: "dBm", dB: "dB",
|
|
30
|
+
hPa: "hPa", kPa: "kPa", bar: "bar", psi: "psi", pct: "%", percent: "%",
|
|
31
|
+
uF: "µF", nF: "nF", pF: "pF", uH: "µH", mH: "mH",
|
|
32
|
+
MB: "MB", GB: "GB", kbps: "kbit/s", Mbps: "Mbit/s", lm: "lm", cd: "cd", nm: "nm",
|
|
33
|
+
};
|
|
34
|
+
/** A qualifier after the unit (`idle_current_A_max`): it goes after the name ("Idle current, max"). */
|
|
35
|
+
const QUALIFIERS = new Set(["min", "max", "typ", "nom", "nominal", "avg", "peak", "rms"]);
|
|
36
|
+
/** Words a label writes its own way. */
|
|
37
|
+
const WORDS: Record<string, string> = {
|
|
38
|
+
soc: "SoC", io: "I/O", kv: "KV", i2c: "I2C", i3c: "I3C", spi: "SPI", usb: "USB", led: "LED", pcb: "PCB", gpio: "GPIO", wifi: "Wi-Fi", cad: "CAD", mcu: "MCU",
|
|
39
|
+
uart: "UART", pwm: "PWM", adc: "ADC", dac: "DAC", id: "ID", rtc: "RTC", ble: "BLE", cpu: "CPU", gpu: "GPU", ram: "RAM", esc: "ESC", imu: "IMU", fov: "FoV", dc: "DC", ac: "AC",
|
|
40
|
+
};
|
|
41
|
+
/** Keys that say where a figure came from or remark on it: not a specification. */
|
|
42
|
+
const SKIPPED = /^(sources?|notes?|cad|description|comment|comments|references?|url|urls|datasheet)$/;
|
|
43
|
+
const NOTE = /(^|_)note$/;
|
|
44
|
+
|
|
45
|
+
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
46
|
+
|
|
47
|
+
/** `sensor_average_current_mA` → { label: "Sensor average current", unit: "mA" }; `idle_current_A_max` → "Idle current, max", "A". */
|
|
48
|
+
export function keyLabel(key: string): { label: string; unit?: string } {
|
|
49
|
+
const tokens = key.split("_").filter(Boolean);
|
|
50
|
+
let qualifier: string | undefined;
|
|
51
|
+
if (tokens.length > 1 && QUALIFIERS.has(tokens[tokens.length - 1])) qualifier = tokens.pop();
|
|
52
|
+
let unit: string | undefined;
|
|
53
|
+
if (tokens.length > 1 && UNITS[tokens[tokens.length - 1]]) unit = UNITS[tokens.pop()!];
|
|
54
|
+
// a qualifier before the unit too: `diameter_tolerance_mm` keeps its words, `current_max_A` moves "max" after the name
|
|
55
|
+
if (!qualifier && tokens.length > 1 && QUALIFIERS.has(tokens[tokens.length - 1])) qualifier = tokens.pop();
|
|
56
|
+
const words = tokens.map((t) => WORDS[t.toLowerCase()] ?? t);
|
|
57
|
+
const label = cap(words.join(" ")) + (qualifier ? `, ${qualifier}` : "");
|
|
58
|
+
return { label, ...(unit ? { unit } : {}) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const num = (n: number) => String(Math.round(n * 1000) / 1000);
|
|
62
|
+
|
|
63
|
+
/** A value as a fact's words: a number with its unit, a two-number range, a short text, yes or no. Anything else has no short form. */
|
|
64
|
+
function valueText(v: unknown, unit: string | undefined): string | undefined {
|
|
65
|
+
const u = unit ? (unit === "°" || unit === "%" ? unit : ` ${unit}`) : "";
|
|
66
|
+
if (typeof v === "number" && Number.isFinite(v)) return `${num(v)}${u}`;
|
|
67
|
+
if (typeof v === "boolean") return v ? "yes" : "no";
|
|
68
|
+
if (typeof v === "string") return v.trim() && v.length <= 72 && !/^https?:\/\//.test(v) ? `${v.trim()}${u}` : undefined;
|
|
69
|
+
if (Array.isArray(v) && v.length >= 2 && v.length <= 3 && v.every((x) => typeof x === "number")) {
|
|
70
|
+
// two numbers are a range; a key ending in a length with three is a size
|
|
71
|
+
return v.length === 2 ? (v[0] === v[1] ? `${num(v[0])}${u}` : `${num(v[0])} to ${num(v[1])}${u}`) : `${v.map(num).join(" × ")}${u}`;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The facts a domain's `metadata` states, in the order it states them: one
|
|
78
|
+
* per value with a short form; a map of numbers gives one per entry
|
|
79
|
+
* ("Sensor average current, continuous"). Remarks (`*_note`) go to `notes`.
|
|
80
|
+
*/
|
|
81
|
+
export function metadataFacts(metadata: unknown, notes: string[] = []): PartFact[] {
|
|
82
|
+
const out: PartFact[] = [];
|
|
83
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return out;
|
|
84
|
+
for (const [key, v] of Object.entries(metadata as Record<string, unknown>)) {
|
|
85
|
+
if (NOTE.test(key)) {
|
|
86
|
+
if (typeof v === "string" && v.trim()) notes.push(v.trim());
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (SKIPPED.test(key) || v === null || v === undefined) continue;
|
|
90
|
+
const { label, unit } = keyLabel(key);
|
|
91
|
+
const text = valueText(v, unit);
|
|
92
|
+
if (text !== undefined) {
|
|
93
|
+
out.push({ label, value: text });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (typeof v === "object" && !Array.isArray(v)) {
|
|
97
|
+
for (const [sub, x] of Object.entries(v as Record<string, unknown>)) {
|
|
98
|
+
const t = valueText(x, unit);
|
|
99
|
+
if (t !== undefined) out.push({ label: `${label}, ${keyLabel(sub).label.toLowerCase()}`, value: t });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const domainsOf = (def: Def): Def[] => (Array.isArray(def.domains) ? def.domains.filter((d: unknown) => d && typeof d === "object") : []);
|
|
107
|
+
const range = (v: unknown, unit: string) => valueText(v, unit);
|
|
108
|
+
|
|
109
|
+
/** The specifications a definition states: its supply, power domains and operating range first, then every domain's own figures but the mechanical ones (those are the dimensions). */
|
|
110
|
+
export function specFacts(def: Def): { facts: PartFact[]; notes: string[] } {
|
|
111
|
+
const facts: PartFact[] = [];
|
|
112
|
+
const notes: string[] = [];
|
|
113
|
+
const add = (f: PartFact | undefined) => {
|
|
114
|
+
if (f && !facts.some((x) => x.label.toLowerCase() === f.label.toLowerCase())) facts.push(f);
|
|
115
|
+
};
|
|
116
|
+
const domains = domainsOf(def);
|
|
117
|
+
const elec = domains.find((d) => d.domain === "electrical");
|
|
118
|
+
const therm = domains.find((d) => d.domain === "thermal");
|
|
119
|
+
const supply = range(elec?.metadata?.supply_voltage_V ?? elec?.supply_voltage_V, "V");
|
|
120
|
+
if (supply) add({ label: "Supply", value: supply });
|
|
121
|
+
for (const pd of Array.isArray(elec?.power_domains) ? elec.power_domains.slice(0, 4) : []) {
|
|
122
|
+
const v = range(pd?.voltage_range_V, "V") ?? range(pd?.nominal_voltage_V, "V");
|
|
123
|
+
if (v && typeof pd.name === "string") add({ label: pd.name.replace(/\s*\([^)]*\)\s*$/, ""), value: v, icon: "zap" });
|
|
124
|
+
}
|
|
125
|
+
const temp = range(therm?.operating_temperature_C, "°C");
|
|
126
|
+
if (temp) add({ label: "Operating temperature", value: temp });
|
|
127
|
+
for (const d of domains) {
|
|
128
|
+
if (d.domain === "mechanical") continue;
|
|
129
|
+
for (const f of metadataFacts(d.metadata, notes)) {
|
|
130
|
+
// the supply was read above; a mounting or package key of a non-mechanical domain is still a specification
|
|
131
|
+
if (/^supply voltage$/i.test(f.label)) continue;
|
|
132
|
+
add(f);
|
|
133
|
+
}
|
|
134
|
+
// a domain's own top-level figures (`thermal.max_junction_C`), other than its structure
|
|
135
|
+
const { domain: _d, metadata: _m, power_domains: _p, operating_temperature_C: _o, supply_voltage_V: _s, ...rest } = d;
|
|
136
|
+
for (const f of metadataFacts(rest, notes)) add(f);
|
|
137
|
+
}
|
|
138
|
+
return { facts, notes };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The part's outline and the mechanical figures the definition states. */
|
|
142
|
+
export function dimensionFacts(def: Def): { outline?: PartOutline; rows: PartFact[] } | undefined {
|
|
143
|
+
const mech = domainsOf(def).find((d) => d.domain === "mechanical");
|
|
144
|
+
if (!mech) return undefined;
|
|
145
|
+
const rows: PartFact[] = [];
|
|
146
|
+
const add = (f: PartFact) => {
|
|
147
|
+
if (!rows.some((x) => x.label.toLowerCase() === f.label.toLowerCase())) rows.push(f);
|
|
148
|
+
};
|
|
149
|
+
const dims = mech.dimensions_mm && typeof mech.dimensions_mm === "object" ? (mech.dimensions_mm as Record<string, unknown>) : {};
|
|
150
|
+
const n = (v: unknown) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
151
|
+
const diameter = n(dims.diameter) ?? n(mech.metadata?.diameter_mm);
|
|
152
|
+
const outline: PartOutline = {
|
|
153
|
+
...(n(dims.length) !== undefined ? { length: n(dims.length)! } : {}),
|
|
154
|
+
...(n(dims.width) !== undefined ? { width: n(dims.width)! } : {}),
|
|
155
|
+
...(n(dims.height) !== undefined ? { height: n(dims.height)! } : {}),
|
|
156
|
+
...(diameter !== undefined ? { diameter } : {}),
|
|
157
|
+
};
|
|
158
|
+
const size = [outline.length, outline.width, outline.height].filter((x): x is number => x !== undefined);
|
|
159
|
+
if (size.length) add({ label: "Size", value: `${size.map(num).join(" × ")} mm`, icon: "ruler" });
|
|
160
|
+
const mass = n(mech.weight_g) ?? n(mech.mass_g) ?? n(mech.metadata?.mass_g) ?? n(mech.metadata?.weight_g) ?? n(def.mass_g);
|
|
161
|
+
if (mass !== undefined) add({ label: "Mass", value: `${num(mass)} g`, icon: "weight" });
|
|
162
|
+
for (const f of metadataFacts(mech.metadata)) if (!/^(mass|weight)$/i.test(f.label)) add(f);
|
|
163
|
+
const { domain: _d, metadata: _m, dimensions_mm: _x, weight_g: _w, mass_g: _g, ...rest } = mech;
|
|
164
|
+
for (const f of metadataFacts(rest)) add(f);
|
|
165
|
+
if (!rows.length) return undefined;
|
|
166
|
+
return { ...(Object.keys(outline).length ? { outline } : {}), rows };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------- interfaces
|
|
170
|
+
|
|
171
|
+
const BUS_LABEL: Record<string, string> = { i2c: "I2C", i3c: "I3C", spi: "SPI", qspi: "QSPI", uart: "UART", usb: "USB", can: "CAN", canfd: "CAN FD", i2s: "I2S", pwm: "PWM", crsf: "CRSF", dshot: "DShot", ethernet: "Ethernet", gpio: "GPIO", adc: "ADC", hdmi: "HDMI", pcie: "PCIe", sdio: "SDIO", swd: "SWD", jtag: "JTAG" };
|
|
172
|
+
const busLabel = (p: string) => BUS_LABEL[p.toLowerCase()] ?? cap(p.replace(/_/g, " "));
|
|
173
|
+
const plainName = (s: string) => s.replace(/\s*\([^)]*\)/g, "").trim();
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The part's interfaces as chips, the ones that tell parts apart first:
|
|
177
|
+
* buses and links by their protocol, connectors and mechanical interfaces by
|
|
178
|
+
* name, power pins by name, then plain signal pins folded into one chip.
|
|
179
|
+
* The same chip twice is written once with its count ("STEMMA QT socket ×2").
|
|
180
|
+
*/
|
|
181
|
+
export function interfaceChips(def: Def): { chips: string[]; count: number } {
|
|
182
|
+
const list: Def[] = Array.isArray(def.interfaces) ? def.interfaces.filter((i: unknown) => i && typeof i === "object") : [];
|
|
183
|
+
const ranked: { rank: number; label: string }[] = [];
|
|
184
|
+
for (const i of list) {
|
|
185
|
+
const type = String((Array.isArray(i.protocols) ? i.protocols[0]?.type : undefined) ?? "").toLowerCase();
|
|
186
|
+
const name = plainName(String(i.name ?? i.id ?? ""));
|
|
187
|
+
if (!name && !type) continue;
|
|
188
|
+
if (type === "power" || type === "ground") ranked.push({ rank: 2, label: name || "Power" });
|
|
189
|
+
else if (type === "digital" || type === "analog" || type === "signal") ranked.push({ rank: 4, label: type === "analog" ? "Analog pins" : "Signal pins" });
|
|
190
|
+
else if (type === "connector" || i.domain === "mechanical" || !type) ranked.push({ rank: i.domain === "mechanical" ? 3 : 1, label: name || busLabel(type) });
|
|
191
|
+
else ranked.push({ rank: 0, label: busLabel(type) });
|
|
192
|
+
}
|
|
193
|
+
const counts = new Map<string, { rank: number; n: number; at: number }>();
|
|
194
|
+
ranked.forEach((r, at) => {
|
|
195
|
+
const c = counts.get(r.label);
|
|
196
|
+
if (c) c.n++;
|
|
197
|
+
else counts.set(r.label, { rank: r.rank, n: 1, at });
|
|
198
|
+
});
|
|
199
|
+
const chips = [...counts]
|
|
200
|
+
.sort((a, b) => a[1].rank - b[1].rank || a[1].at - b[1].at)
|
|
201
|
+
.map(([label, c]) => (c.n > 1 ? `${label} ×${c.n}` : label));
|
|
202
|
+
return { chips, count: list.length };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------- files: pictures, CAD, artifacts
|
|
206
|
+
|
|
207
|
+
const fileName = (path: string) => path.split("/").pop() ?? path;
|
|
208
|
+
const extOf = (path: string) => (/\.([a-z0-9]+)$/i.exec(path.split(/[?#]/)[0])?.[1] ?? "").toLowerCase();
|
|
209
|
+
|
|
210
|
+
/** "0.85 MB", "13.3 MB", "995 B". */
|
|
211
|
+
export function fileSize(bytes: number): string {
|
|
212
|
+
if (bytes < 1000) return `${bytes} B`;
|
|
213
|
+
if (bytes < 1e5) return `${Math.round(bytes / 100) / 10} kB`;
|
|
214
|
+
const mb = bytes / 1e6;
|
|
215
|
+
return `${mb < 10 ? Math.round(mb * 100) / 100 : Math.round(mb * 10) / 10} MB`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A licence as a card writes it: its SPDX id, or "library terms" for the library's own. */
|
|
219
|
+
export const licenseLabel = (license: string | undefined) => (!license ? undefined : /^LicenseRef-/i.test(license) ? "library terms" : license);
|
|
220
|
+
|
|
221
|
+
const isImage = (a: EnvFile) => a.mediaType.startsWith("image/");
|
|
222
|
+
const isGlb = (a: EnvFile) => a.mediaType === "model/gltf-binary" || extOf(a.path) === "glb";
|
|
223
|
+
const isStep = (a: EnvFile) => a.mediaType === "model/step" || /^(step|stp)$/.test(extOf(a.path));
|
|
224
|
+
|
|
225
|
+
/** The revision's pictures, the catalog picture first: the files a host fetches before it builds the pages. */
|
|
226
|
+
export function pageImages(env: Pick<Envelope, "artifacts">): { sha256: string; size: number; mediaType: string; label: string }[] {
|
|
227
|
+
const images = env.artifacts.filter(isImage);
|
|
228
|
+
const first = images.filter((a) => a.role === "thumbnail");
|
|
229
|
+
const rest = images.filter((a) => a.role !== "thumbnail");
|
|
230
|
+
const label = (a: EnvFile) => (a.role === "thumbnail" ? "Catalog picture" : cap(fileName(a.path).replace(/\.[a-z0-9]+$/i, "").replace(/[-_]+/g, " ")));
|
|
231
|
+
const seen = new Set<string>();
|
|
232
|
+
return [...first, ...rest].filter((a) => !seen.has(a.sha256) && !!seen.add(a.sha256)).map((a) => ({ sha256: a.sha256, size: a.size, mediaType: a.mediaType, label: label(a) }));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The CAD a revision carries, in a line or two: its STEP, where it came from, its named mate features, and whether there is a mesh to orbit. */
|
|
236
|
+
export function cadInfo(env: Pick<Envelope, "artifacts" | "definition">, preview?: string): PartCadInfo | undefined {
|
|
237
|
+
const defArts: Def[] = Array.isArray((env.definition as Def).artifacts) ? (env.definition as Def).artifacts : [];
|
|
238
|
+
const roleOf = (a: EnvFile) => a.role ?? defArts.find((d) => d.id === a.artifactId)?.role;
|
|
239
|
+
const glbs = env.artifacts.filter(isGlb);
|
|
240
|
+
const body = glbs.find((a) => roleOf(a) === "body") ?? glbs.find((a) => roleOf(a) !== "interface");
|
|
241
|
+
const steps = env.artifacts.filter(isStep);
|
|
242
|
+
const step = steps.find((a) => a.role === "body") ?? steps[0];
|
|
243
|
+
const features = glbs.filter((a) => roleOf(a) === "interface").length;
|
|
244
|
+
const main = step ?? body;
|
|
245
|
+
if (!main) return undefined;
|
|
246
|
+
const vendor = /vendor/i.test(`${main.artifactId ?? ""} ${main.path}`);
|
|
247
|
+
const kcl = env.artifacts.some((a) => extOf(a.path) === "kcl");
|
|
248
|
+
const tool = defArts.find((d) => d.id === main.artifactId)?.provenance?.tool;
|
|
249
|
+
const second = vendor ? licenseLabel(main.terms?.license) : kcl ? "KCL" : typeof tool === "string" ? tool.split(/[\s(]/)[0] : licenseLabel(main.terms?.license);
|
|
250
|
+
return {
|
|
251
|
+
...(preview ? { preview } : {}),
|
|
252
|
+
file: `${step ? "STEP" : "GLB"} · ${fileSize(main.size)}`,
|
|
253
|
+
origin: [vendor ? "Vendor CAD" : "Generated", second].filter(Boolean).join(" · "),
|
|
254
|
+
features,
|
|
255
|
+
...(body ? { mesh: body.size } : {}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const LINK_KINDS: Record<string, { label: string; icon: string }> = {
|
|
260
|
+
datasheet: { label: "Datasheets", icon: "file-text" },
|
|
261
|
+
documentation: { label: "Documentation", icon: "book-open" },
|
|
262
|
+
schematic: { label: "Schematics", icon: "circuit-board" },
|
|
263
|
+
pcb: { label: "PCB", icon: "circuit-board" },
|
|
264
|
+
firmware: { label: "Firmware", icon: "binary" },
|
|
265
|
+
simulation: { label: "Simulation", icon: "chart-line" },
|
|
266
|
+
cad: { label: "Drawings", icon: "pencil-ruler" },
|
|
267
|
+
"3d_model": { label: "3D models", icon: "box" },
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const linkNote = (url: string) => {
|
|
271
|
+
const e = extOf(url);
|
|
272
|
+
return e === "pdf" ? "PDF" : /^(png|jpe?g|webp|gif|svg)$/.test(e) ? "image" : /^(zip|gz|7z)$/.test(e) ? "archive" : "web";
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** The kind a carried file is listed under. */
|
|
276
|
+
function fileKind(a: EnvFile): { kind: string; label: string; icon: string } {
|
|
277
|
+
const e = extOf(a.path);
|
|
278
|
+
if (a.role === "license") return { kind: "license", label: "Licences", icon: "scale" };
|
|
279
|
+
if (isImage(a)) return { kind: "image", label: "Pictures", icon: "image" };
|
|
280
|
+
if (isGlb(a)) return a.role === "interface" ? { kind: "interface-geometry", label: "Interface geometry", icon: "magnet" } : { kind: "mesh", label: "Viewer mesh", icon: "box" };
|
|
281
|
+
if (isStep(a) || /^(stl|3mf|f3d|iges|igs|sldprt|fcstd)$/.test(e) || a.mediaType === "model/stl" || a.mediaType === "model/3mf") return { kind: "cad-model", label: "CAD models", icon: "box" };
|
|
282
|
+
if (/^(py|kcl|scad|ts)$/.test(e) || a.mediaType === "text/x-python") return { kind: "cad-source", label: "CAD source", icon: "file-code" };
|
|
283
|
+
if (a.mediaType === "application/json" && /manifest/i.test(a.path)) return { kind: "manifest", label: "Feature manifests", icon: "list-tree" };
|
|
284
|
+
if (a.mediaType === "application/pdf") return { kind: "document", label: "Documents", icon: "file-text" };
|
|
285
|
+
if (a.mediaType === "application/zip") return { kind: "archive", label: "Archives", icon: "file-archive" };
|
|
286
|
+
return { kind: "file", label: "Other files", icon: "file" };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const KIND_ORDER = ["datasheet", "documentation", "schematic", "pcb", "cad", "cad-model", "3d_model", "mesh", "cad-source", "interface-geometry", "manifest", "firmware", "simulation", "document", "image", "archive", "file", "license", "evidence"];
|
|
290
|
+
|
|
291
|
+
/** The part's artifacts by kind, with counts: the definition's links (datasheets, documentation, schematics), the files the revision carries (CAD models, sources, meshes, licences) and its evidence. */
|
|
292
|
+
export function artifactGroups(env: Envelope): PartArtifactGroup[] {
|
|
293
|
+
const groups = new Map<string, { label: string; icon: string; items: PartArtifactItem[]; formats: string[]; unit: string }>();
|
|
294
|
+
const push = (k: { kind: string; label: string; icon: string }, item: PartArtifactItem, format: string, unit: string) => {
|
|
295
|
+
const g = groups.get(k.kind) ?? { label: k.label, icon: k.icon, items: [], formats: [], unit };
|
|
296
|
+
g.items.push(item);
|
|
297
|
+
if (format && !g.formats.includes(format)) g.formats.push(format);
|
|
298
|
+
groups.set(k.kind, g);
|
|
299
|
+
};
|
|
300
|
+
const defArts: Def[] = Array.isArray((env.definition as Def).artifacts) ? (env.definition as Def).artifacts : [];
|
|
301
|
+
for (const a of defArts) {
|
|
302
|
+
if (typeof a?.url !== "string" || !/^https?:\/\//.test(a.url)) continue;
|
|
303
|
+
const type = String(a.type ?? "documentation");
|
|
304
|
+
const k = LINK_KINDS[type] ?? { label: cap(type.replace(/_/g, " ")), icon: "link" };
|
|
305
|
+
push({ kind: type, ...k }, { name: String(a.name ?? a.id ?? a.url), note: linkNote(a.url), url: a.url }, "", "links");
|
|
306
|
+
}
|
|
307
|
+
for (const a of env.artifacts) {
|
|
308
|
+
if (a.role === "thumbnail") continue;
|
|
309
|
+
const k = fileKind(a);
|
|
310
|
+
const license = licenseLabel(a.terms?.license);
|
|
311
|
+
push(k, { name: fileName(a.path), note: fileSize(a.size), ...(license ? { license } : {}) }, extOf(a.path).toUpperCase(), "files");
|
|
312
|
+
}
|
|
313
|
+
for (const e of env.evidence ?? []) push({ kind: "evidence", label: "Evidence", icon: "shield-check" }, { name: e.title ?? cap(e.kind), note: e.kind, ...(e.url ? { url: e.url } : {}) }, "", "records");
|
|
314
|
+
const rank = (kind: string) => (KIND_ORDER.indexOf(kind) + 1 || KIND_ORDER.length) - 1;
|
|
315
|
+
return [...groups]
|
|
316
|
+
.sort((a, b) => rank(a[0]) - rank(b[0]))
|
|
317
|
+
.map(([kind, g]) => {
|
|
318
|
+
const one = g.items.length === 1 ? g.items[0] : undefined;
|
|
319
|
+
const formats = g.formats.length > 0 && g.formats.length <= 3 ? g.formats.join(" · ") : "";
|
|
320
|
+
return {
|
|
321
|
+
kind,
|
|
322
|
+
label: g.label,
|
|
323
|
+
icon: g.icon,
|
|
324
|
+
items: g.items,
|
|
325
|
+
summary: one ? one.name : formats ? `${g.label} · ${formats}` : g.label,
|
|
326
|
+
note: one ? (one.note ?? "") : `${g.items.length} ${g.unit}`,
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------- the pages
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A revision's pages. `urls` are the pictures the host fetched, by blob
|
|
335
|
+
* address (`pageImages` lists them); a picture without a URL is left out.
|
|
336
|
+
*/
|
|
337
|
+
export function partPages(env: Envelope, o: { urls?: Record<string, string> } = {}): PartPages {
|
|
338
|
+
const def = env.definition as Def;
|
|
339
|
+
const images: PartImage[] = pageImages(env)
|
|
340
|
+
.filter((i) => o.urls?.[i.sha256])
|
|
341
|
+
.map((i) => ({ url: o.urls![i.sha256], label: i.label }));
|
|
342
|
+
const { facts, notes } = specFacts(def);
|
|
343
|
+
const dimensions = dimensionFacts(def);
|
|
344
|
+
const cad = cadInfo(env, images[0]?.url);
|
|
345
|
+
return {
|
|
346
|
+
revision: env.revision,
|
|
347
|
+
images,
|
|
348
|
+
specs: facts,
|
|
349
|
+
specNotes: notes,
|
|
350
|
+
...(dimensions ? { dimensions } : {}),
|
|
351
|
+
interfaces: interfaceChips(def),
|
|
352
|
+
...(cad ? { cad } : {}),
|
|
353
|
+
artifacts: artifactGroups(env),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The pages a part has data for, in order; the overview always is. Before the pages are known, every page is offered. */
|
|
358
|
+
export function availablePages(pages: PartPages | undefined): CardPageId[] {
|
|
359
|
+
if (!pages) return [...CARD_PAGES];
|
|
360
|
+
return CARD_PAGES.filter((p) => {
|
|
361
|
+
switch (p) {
|
|
362
|
+
case "overview":
|
|
363
|
+
return true;
|
|
364
|
+
case "specs":
|
|
365
|
+
return pages.specs.length > 0;
|
|
366
|
+
case "dimensions":
|
|
367
|
+
return !!pages.dimensions;
|
|
368
|
+
case "interfaces":
|
|
369
|
+
return pages.interfaces.chips.length > 0;
|
|
370
|
+
case "cad":
|
|
371
|
+
return !!pages.cad;
|
|
372
|
+
case "artifacts":
|
|
373
|
+
return pages.artifacts.length > 0;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export const PAGE_LABEL: Record<CardPageId, string> = { overview: "Overview", specs: "Specs", dimensions: "Dimensions", interfaces: "Interfaces", cad: "CAD", artifacts: "Artifacts" };
|
|
379
|
+
export const PAGE_ICON: Record<CardPageId, string> = { overview: "info", specs: "gauge", dimensions: "ruler", interfaces: "plug", cad: "box", artifacts: "paperclip" };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The part status colours (PB-932), once, for every view: the dot on a module
|
|
3
|
+
* in the diagram, the Project Parts list and page, and the parts Inspector.
|
|
4
|
+
* The states and what they mean are core's (`@protoboardai/core/part-trust`,
|
|
5
|
+
* where the mapping from a library's data is documented); this file gives
|
|
6
|
+
* each a colour per colour scheme and the ways a shell applies it:
|
|
7
|
+
*
|
|
8
|
+
* - React views draw `PartStatusDot` (PartStatus.tsx) inside a themed root;
|
|
9
|
+
* `PART_STATUS_CSS` gives the root its `--pb-status-*` variables by the
|
|
10
|
+
* scheme the theme provider writes on it (`data-uhd-scheme`), and by VS
|
|
11
|
+
* Code's body classes for the extension's plain pages (the Inspector).
|
|
12
|
+
* - The diagram (@deltarobotics/uhd-viewer, which knows nothing of parts)
|
|
13
|
+
* takes generic marks: `nodeMarks` maps a state to a tone, and the same
|
|
14
|
+
* stylesheet points the viewer's `--uhd-mark-<tone>` at these colours.
|
|
15
|
+
*
|
|
16
|
+
* Colour alone never carries the state: every dot has a tooltip that says
|
|
17
|
+
* the state and what it means, lists write the state's name beside it, and
|
|
18
|
+
* high contrast adds a ring. No React here: the extension's Node side
|
|
19
|
+
* imports it too.
|
|
20
|
+
*/
|
|
21
|
+
import { BLANK_BLOCK_TEXT, PART_TRUST_STATES, PART_TRUST_TEXT, partTrustTooltip, type PartTrust } from "@protoboardai/core/part-trust";
|
|
22
|
+
|
|
23
|
+
export { BLANK_BLOCK_TEXT, PART_TRUST_STATES, PART_TRUST_TEXT, partTrustTooltip, type PartTrust };
|
|
24
|
+
|
|
25
|
+
export type PartStatusScheme = "dark" | "light" | "high-contrast" | "high-contrast-light";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* gray local, yellow unverified, green verified, blue official. The dark
|
|
29
|
+
* values are bright enough on a dark card; the light ones are the deeper
|
|
30
|
+
* shade of the same hue (each at least 4.5:1 on white, so the state's name
|
|
31
|
+
* can be written in it); high contrast uses saturated values on black and
|
|
32
|
+
* the darkest on white.
|
|
33
|
+
*/
|
|
34
|
+
export const PART_STATUS_COLORS: Record<PartStatusScheme, Record<PartTrust, string>> = {
|
|
35
|
+
dark: { local: "#8b949e", unverified: "#e3b341", verified: "#3fb950", official: "#58a6ff" },
|
|
36
|
+
light: { local: "#6e7781", unverified: "#9a6700", verified: "#1a7f37", official: "#0969da" },
|
|
37
|
+
"high-contrast": { local: "#bdbdbd", unverified: "#ffd60a", verified: "#3ff23f", official: "#6fc3ff" },
|
|
38
|
+
"high-contrast-light": { local: "#5a5a5a", unverified: "#7a5200", verified: "#0b6b1f", official: "#0550ae" },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The CSS variable of a state's colour. */
|
|
42
|
+
export const partStatusVar = (trust: PartTrust): string => `--pb-status-${trust}`;
|
|
43
|
+
|
|
44
|
+
/** The viewer's generic mark tone for each state (`ViewerOptions.nodeMarks`). */
|
|
45
|
+
export const PART_STATUS_TONE: Record<PartTrust, "neutral" | "warning" | "ok" | "info"> = { local: "neutral", unverified: "warning", verified: "ok", official: "info" };
|
|
46
|
+
|
|
47
|
+
const declarations = (scheme: PartStatusScheme) =>
|
|
48
|
+
[
|
|
49
|
+
...PART_TRUST_STATES.map((t) => `${partStatusVar(t)}: ${PART_STATUS_COLORS[scheme][t]};`),
|
|
50
|
+
...PART_TRUST_STATES.map((t) => `--uhd-mark-${PART_STATUS_TONE[t]}: var(${partStatusVar(t)});`),
|
|
51
|
+
`--pb-status-ring: ${scheme.startsWith("high-contrast") ? "currentColor" : "transparent"};`,
|
|
52
|
+
].join(" ");
|
|
53
|
+
|
|
54
|
+
/** VS Code's body class for each scheme (`vscode-high-contrast-light` also carries `vscode-high-contrast`, so it comes last). */
|
|
55
|
+
const VSCODE_CLASS: [PartStatusScheme, string][] = [
|
|
56
|
+
["dark", "body.vscode-dark"],
|
|
57
|
+
["light", "body.vscode-light"],
|
|
58
|
+
["high-contrast", "body.vscode-high-contrast"],
|
|
59
|
+
["high-contrast-light", "body.vscode-high-contrast-light"],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The stylesheet every shell includes once: the variables on a themed root
|
|
64
|
+
* (any theme: VS Code's, Protoboard's, the neutral ones) and on a VS Code
|
|
65
|
+
* webview's body, and the dot itself for pages drawn without React.
|
|
66
|
+
*/
|
|
67
|
+
export const PART_STATUS_CSS = [
|
|
68
|
+
`:root, .uhd-root { ${declarations("dark")} }`,
|
|
69
|
+
...(["dark", "light", "high-contrast", "high-contrast-light"] as const).map((s) => `[data-uhd-scheme="${s}"] { ${declarations(s)} }`),
|
|
70
|
+
...VSCODE_CLASS.map(([s, sel]) => `${sel} { ${declarations(s)} }`),
|
|
71
|
+
`.pb-status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--pb-status-local); outline: 1px solid var(--pb-status-ring); outline-offset: 1px; }`,
|
|
72
|
+
...PART_TRUST_STATES.map((t) => `.pb-status-dot[data-status="${t}"] { background: var(${partStatusVar(t)}); }`),
|
|
73
|
+
// a blank block (no part chosen): the local gray as a dashed ring, not a fifth colour
|
|
74
|
+
`.pb-status-dot[data-blank] { background: transparent; box-shadow: none; border: 1.5px dashed var(--pb-status-local); }`,
|
|
75
|
+
].join("\n");
|
|
76
|
+
|
|
77
|
+
/** A mark on a module in the diagram: the status's tone, its tooltip, and a ring instead of a dot for a blank block. */
|
|
78
|
+
export type PartMark = { tone: "neutral" | "warning" | "ok" | "info"; label: string; shape?: "ring" };
|
|
79
|
+
|
|
80
|
+
/** A part's status as a mark on its modules in the diagram. A blank block (no part chosen) is the local gray as a ring. */
|
|
81
|
+
export function nodeMarks(status: Record<string, { trust: PartTrust; basis?: string; blank?: boolean }> | undefined): Record<string, PartMark> {
|
|
82
|
+
const out: Record<string, PartMark> = {};
|
|
83
|
+
for (const [id, s] of Object.entries(status ?? {})) {
|
|
84
|
+
if (!PART_TRUST_STATES.includes(s.trust)) continue;
|
|
85
|
+
out[id] = { tone: PART_STATUS_TONE[s.trust], label: partTrustTooltip(s.trust, s.basis, s.blank === true), ...(s.blank === true ? { shape: "ring" as const } : {}) };
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a part's revision is written in every view (beta test 1, decision D2):
|
|
3
|
+
* "v4". Buttons ("Update to v4"), chips, lists, tooltips and the part
|
|
4
|
+
* detail's Revisions tab all use it. The CLI's `id@rev` argument form
|
|
5
|
+
* (`protoboard parts add bme688@4`) and the file formats are not views and
|
|
6
|
+
* keep theirs.
|
|
7
|
+
*/
|
|
8
|
+
export const rev = (revision: number | string | undefined): string => (revision === undefined || revision === "" ? "v?" : `v${revision}`);
|
|
9
|
+
|
|
10
|
+
/** "v2 → v4": a pinned revision and the one it would move to. */
|
|
11
|
+
export const revChange = (from: number | string | undefined, to: number | string | undefined): string => `${rev(from)} → ${rev(to)}`;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// three ships no types and the stack does not install @types/three here; the
|
|
2
|
+
// model preview uses a handful of its classes, typed loosely.
|
|
3
|
+
declare module "three";
|
|
4
|
+
declare module "three/examples/jsm/loaders/GLTFLoader.js";
|
|
5
|
+
declare module "three/examples/jsm/controls/OrbitControls.js";
|