pcb-retrace/docs/schema/components.js

870 lines
30 KiB
JavaScript

// ═══════════════════════════════════════════════════════════════
// COMPONENT LIBRARY
//
// Each component type is a class that encapsulates everything
// specific to that type: geometry, pin layout, WireBender
// descriptor, and Canvas drawing. All external code talks only
// to the CompBase interface via createComp(stateObj).
//
// To add a new component type:
// 1. Subclass CompBase
// 2. Override color, label, _buildGeometry(), draw()
// 3. Register in ALL_TYPES at the bottom
//
// State object shape (S.components[i]):
// { id, ref, value, type, x, y, rotation, flipX, pins:[{name,net}], pcbX?, pcbY? }
// ═══════════════════════════════════════════════════════════════
import { MARGIN, getNetColor } from './state.js';
const PIN_PITCH = 20;
// ── WireBender PinDirection flags ─────────────────────────────
// Mirrors Module.PinDirection enum values.
export const PinDir = {
DirNone: 0,
DirUp: 1,
DirDown: 2,
DirLeft: 4,
DirRight: 8,
DirAll: 15,
};
// ── Base class ────────────────────────────────────────────────
export class CompBase {
constructor(state) {
// state is the plain object from S.components — we hold a reference,
// never copy, so mutations (x, y, rotation …) are always live.
this._s = state;
}
// ── Identity / appearance ─────────────────────────────────
get color() { return '#94a3b8'; } // override in subclass
get label() { return this._s.type; } // override in subclass
// ── Geometry ──────────────────────────────────────────────
// Returns { w, h, pins:[{name, side:'L'|'R'|'T'|'B', along}] }
// 'along' = signed offset from the face centre (px).
// Subclasses implement _buildGeometry(); consumers call geometry().
_buildGeometry() { throw new Error(`${this.constructor.name}._buildGeometry not implemented`); }
geometry() {
// Cache per instance — geometry only changes if the pin list changes,
// which only happens on netlist reload (new instance created).
if (!this._geo) this._geo = this._buildGeometry();
return this._geo;
}
// ── Pin local coordinates ─────────────────────────────────
// Returns pin positions in component-local space:
// origin = component centre, no rotation applied.
// Both pinPositions() and wbDescriptor() derive from this so
// they can never diverge.
//
// Returns [{gp, ldx, ldy, ex, ey}] where:
// ldx/ldy = offset from component centre
// ex/ey = unit outward direction of the stub
_pinLocalCoords() {
const geo = this.geometry();
return geo.pins.map(gp => {
let ldx, ldy, ex, ey;
switch (gp.side) {
case 'L': ldx = -geo.w / 2; ldy = gp.along; ex = -1; ey = 0; break;
case 'R': ldx = geo.w / 2; ldy = gp.along; ex = 1; ey = 0; break;
case 'T': ldx = gp.along; ldy = -geo.h / 2; ex = 0; ey = -1; break;
case 'B': ldx = gp.along; ldy = geo.h / 2; ex = 0; ey = 1; break;
default: ldx = 0; ldy = 0; ex = 0; ey = 0;
}
return { gp, ldx, ldy, ex, ey };
});
}
// ── Pin world-space positions ─────────────────────────────
// Returns the world-space position of each pin, derived purely
// from component geometry and rotation. This is the single source
// of truth for where pin dots and stubs are drawn.
//
// These positions match where WireBender places wire endpoints,
// because wbDescriptor() uses the same _pinLocalCoords() source.
// There is no need for a separate _wbX/_wbY back-annotation pass.
pinPositions() {
const s = this._s;
const rot = (s.rotation || 0) * Math.PI / 180;
const fx = s.flipX ? -1 : 1;
const cos = Math.cos(rot), sin = Math.sin(rot);
const rot2 = (dx, dy) => ({ x: dx * fx * cos - dy * sin, y: dx * fx * sin + dy * cos });
return this._pinLocalCoords().map(({ gp, ldx, ldy, ex, ey }, i) => {
const sp = s.pins[i];
const rp = rot2(ldx, ldy);
const re = rot2(ex, ey);
return {
name: gp.name,
pinName: sp?.name ?? gp.name,
x: s.x + rp.x,
y: s.y + rp.y,
ex: re.x,
ey: re.y,
net: sp?.net ?? null,
compId: s.id,
};
});
}
// ── Axis-aligned bounding box (with routing margin) ───────
bbox() {
const geo = this.geometry();
return {
x1: this._s.x - geo.w / 2 - MARGIN,
y1: this._s.y - geo.h / 2 - MARGIN,
x2: this._s.x + geo.w / 2 + MARGIN,
y2: this._s.y + geo.h / 2 + MARGIN,
};
}
// ── Pin stub length ───────────────────────────────────────
stubLen(_pinIdx) { return 16; } // matches WireBender padding default
// ── WireBender descriptor ─────────────────────────────────
// Returns the plain JS object consumed by wb.addComponent().
// NOTE: pins[] here is a plain array for our internal use;
// layout.js converts it to a VectorPinDescriptor before handing
// it to the WASM module.
//
wbDescriptor() {
const geo = this.geometry();
const s = this._s;
const lcs = this._pinLocalCoords();
const SIDE_DIR = {
L: PinDir.DirLeft,
R: PinDir.DirRight,
T: PinDir.DirUp,
B: PinDir.DirDown,
};
const pins = lcs.map(({ gp, ldx, ldy }, i) => {
const pinName = s.pins[i]?.name ?? gp.name;
return {
number: i + 1,
name: pinName,
x: ldx,
y: ldy,
directionFlags: SIDE_DIR[gp.side] ?? PinDir.DirAll,
};
});
return {
id: s.id,
width: geo.w,
height: geo.h,
padding: 16,
pins,
};
}
whTransformed() {
const geo = this.geometry();
const q = Math.round((this._s.rotation || 0) / 90) & 1;
return { w: q ? geo.h : geo.w, h: q ? geo.w : geo.h };
}
wbDescriptorTransformed() {
const geo = this.geometry();
const s = this._s;
const lcs = this._pinLocalCoords();
// Snap rotation to nearest 90° step (0, 1, 2, 3 quarter-turns)
const rot = s.rotation || 0;
const fx = s.flipX ? -1 : 1;
const steps = Math.round(rot / 90) & 3; // 0..3
const cosR = [1, 0, -1, 0][steps];
const sinR = [0, 1, 0, -1][steps];
// Rotated bounding box: w and h swap at 90°/270°
const rw = (steps & 1) ? geo.h : geo.w;
const rh = (steps & 1) ? geo.w : geo.h;
// Map pin side through rotation (CW on canvas, +Y down)
const ROTSIDE = { L: ['L','T','R','B'], T:['T','R','B','L'],
R: ['R','B','L','T'], B:['B','L','T','R'] };
const SIDE_DIR = {
L: PinDir.DirLeft,
R: PinDir.DirRight,
T: PinDir.DirUp,
B: PinDir.DirDown,
};
const pins = lcs.map(({ gp, ldx, ldy }, i) => {
let side = gp.side;
if (s.flipX) {
if (side === 'L') side = 'R';
else if (side === 'R') side = 'L';
}
// Rotate local coords by `steps` quarter-turns CW.
// Result is still centre-origin — exactly what WireBender v2 expects.
const px = cosR * (ldx * fx) - sinR * ldy;
const py = sinR * (ldx * fx) + cosR * ldy;
const rotatedSide = ROTSIDE[side]?.[steps] ?? side;
const pinName = s.pins[i]?.name ?? gp.name;
return {
number: i + 1,
name: pinName,
x: px,
y: py,
directionFlags: SIDE_DIR[rotatedSide] ?? PinDir.DirAll,
};
});
return {
id: s.id,
width: rw,
height: rh,
padding: 16,
pins,
};
}
// ── Drawing ───────────────────────────────────────────────
// labelHint — optional ComponentLabelHint from WireBender.
// When provided, pre-computed positions are used directly and the
// JS-side collision search is skipped entirely. Pass null/undefined
// to fall back to the self-contained JS placement logic.
draw(ctx, isSel, isHov, allComps, allWires, labelHint = null) {
const geo = this.geometry();
const col = this.color;
const s = this._s;
// ── Symbol body — rotated ─────────────────────────────
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate((s.rotation || 0) * Math.PI / 180);
if (s.flipX) ctx.scale(-1, 1);
if (isSel) {
ctx.shadowColor = col; ctx.shadowBlur = 14;
}
else if (isHov) { ctx.shadowColor = col; ctx.shadowBlur = 7; }
this._drawSymbol(ctx, geo, col, isSel);
ctx.shadowBlur = 0;
ctx.restore();
// ── Labels ────────────────────────────────────────────
this._drawLabels(ctx, geo, col, allComps, allWires, labelHint);
// ── Stubs and pin dots — world space ──────────────────
const STUB = 8;
const pins = this.pinPositions();
pins.forEach((pin) => {
const tipX = pin.x + pin.ex * STUB;
const tipY = pin.y + pin.ey * STUB;
// Stub: body edge → tip
ctx.strokeStyle = col + 'c0';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pin.x, pin.y);
ctx.lineTo(tipX, tipY);
ctx.stroke();
// Pin name label just outside body edge
ctx.fillStyle = col;
ctx.font = 'bold 8px Space Mono,monospace';
ctx.textBaseline = 'middle';
const lx = pin.x + pin.ex * 2, ly = pin.y + pin.ey * 2;
ctx.textAlign = pin.ex < -0.5 ? 'right' : pin.ex > 0.5 ? 'left' : 'center';
ctx.fillText(pin.pinName, lx, ly);
// Pin dot at stub tip (wire connection point)
ctx.beginPath();
ctx.arc(tipX, tipY, 3, 0, Math.PI * 2);
ctx.fillStyle = pin.net ? getNetColor(pin.net) : '#3d4552';
ctx.fill();
if (!pin.net) {
ctx.strokeStyle = '#3d4552'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(tipX - 4, tipY - 4); ctx.lineTo(tipX + 4, tipY + 4);
ctx.moveTo(tipX + 4, tipY - 4); ctx.lineTo(tipX - 4, tipY + 4);
ctx.stroke();
}
});
}
// labelHint is a ComponentLabelHint returned by WireBender (may be null).
// When present the pre-computed positions are used directly, skipping the
// JS-side candidate search entirely. The fallback path (null hint) keeps
// the original behaviour so the method works before routing has run.
_drawLabels(ctx, geo, col, allComps, allWires, labelHint = null) {
const s = this._s;
// ── Fast path: use WireBender-supplied positions ──────────────────────
if (labelHint) {
ctx.font = `bold 10px 'Space Mono',monospace`;
ctx.fillStyle = col;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(s.ref, labelHint.refPosition.x, labelHint.refPosition.y);
if (s.value) {
ctx.font = `9px 'Space Mono',monospace`;
ctx.fillStyle = 'rgba(230,237,243,0.6)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(s.value, labelHint.valuePosition.x, labelHint.valuePosition.y);
}
return;
}
// ── Fallback path: self-contained JS collision search ─────────────────
// Used before routeAll() has been called (e.g. during initial load or
// while the WireBender module is still initialising).
const isColliding = (rect) => {
const BBOX_INFLATE = 4;
// 1. Check components
for (const other of allComps) {
const otherGeo = createComp(other).geometry();
const otherRot = (other.rotation || 0) * Math.PI / 180;
const otherCos = Math.abs(Math.cos(otherRot));
const otherSin = Math.abs(Math.sin(otherRot));
const otherW = otherGeo.w * otherCos + otherGeo.h * otherSin;
const otherH = otherGeo.w * otherSin + otherGeo.h * otherCos;
const otherRect = {
x1: other.x - otherW / 2 - BBOX_INFLATE, y1: other.y - otherH / 2 - BBOX_INFLATE,
x2: other.x + otherW / 2 + BBOX_INFLATE, y2: other.y + otherH / 2 + BBOX_INFLATE,
};
if (rect.x1 < otherRect.x2 && rect.x2 > otherRect.x1 &&
rect.y1 < otherRect.y2 && rect.y2 > otherRect.y1) {
return true;
}
}
// 2. Check wires
for (const w of allWires) {
if (!w.points) continue;
for (let i = 0; i < w.points.length - 1; i++) {
const p1 = w.points[i], p2 = w.points[i + 1];
const minX = Math.min(p1.x, p2.x) - 2;
const maxX = Math.max(p1.x, p2.x) + 2;
const minY = Math.min(p1.y, p2.y) - 2;
const maxY = Math.max(p1.y, p2.y) + 2;
if (rect.x1 <= maxX && rect.x2 >= minX && rect.y1 <= maxY && rect.y2 >= minY) {
return true;
}
}
}
return false;
};
const findPosition = (text, textW, textH, candidates, reservedRect = null) => {
for (const candidate of candidates) {
const labelRect = {
x1: candidate.x - textW / 2, y1: candidate.y - textH / 2,
x2: candidate.x + textW / 2, y2: candidate.y + textH / 2
};
if (isColliding(labelRect)) continue;
if (reservedRect &&
labelRect.x1 < reservedRect.x2 && labelRect.x2 > reservedRect.x1 &&
labelRect.y1 < reservedRect.y2 && labelRect.y2 > reservedRect.y1) {
continue;
}
return candidate;
}
return candidates[0];
};
const rot = (s.rotation || 0) * Math.PI / 180;
const cosR = Math.abs(Math.cos(rot)), sinR = Math.abs(Math.sin(rot));
const worldW = geo.w * cosR + geo.h * sinR;
const worldH = geo.w * sinR + geo.h * cosR;
const PADDING = 6;
// --- REF label ---
ctx.font = `bold 10px 'Space Mono',monospace`;
const refW = ctx.measureText(s.ref).width;
const refH = 12;
const refCandidates = [
{ x: s.x, y: s.y - worldH / 2 - refH / 2 - PADDING },
{ x: s.x, y: s.y + worldH / 2 + refH / 2 + PADDING },
{ x: s.x + worldW / 2 + refW / 2 + PADDING, y: s.y },
{ x: s.x - worldW / 2 - refW / 2 - PADDING, y: s.y },
{ x: s.x + worldW / 2 + refW / 2 + PADDING, y: s.y - worldH / 2 },
{ x: s.x - worldW / 2 - refW / 2 - PADDING, y: s.y - worldH / 2 }
];
const refPos = findPosition(s.ref, refW, refH, refCandidates);
const refRect = { x1: refPos.x - refW / 2, y1: refPos.y - refH / 2, x2: refPos.x + refW / 2, y2: refPos.y + refH / 2 };
ctx.fillStyle = col;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(s.ref, refPos.x, refPos.y);
// --- VALUE label ---
if (s.value) {
ctx.font = `9px 'Space Mono',monospace`;
const valW = ctx.measureText(s.value).width;
const valH = 11;
const valCandidates = [
{ x: s.x, y: s.y + worldH / 2 + valH / 2 + PADDING },
{ x: s.x, y: s.y - worldH / 2 - valH / 2 - PADDING },
{ x: s.x + worldW / 2 + valW / 2 + PADDING, y: s.y },
{ x: s.x - worldW / 2 - valW / 2 - PADDING, y: s.y },
{ x: s.x + worldW / 2 + valW / 2 + PADDING, y: s.y + worldH / 2 },
{ x: s.x - worldW / 2 - valW / 2 - PADDING, y: s.y + worldH / 2 }
];
const valPos = findPosition(s.value, valW, valH, valCandidates, refRect);
ctx.fillStyle = 'rgba(230,237,243,0.6)';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(s.value, valPos.x, valPos.y);
}
}
_drawSymbol(_ctx, _geo, _col, _isSel) {
throw new Error(`${this.constructor.name}._drawSymbol not implemented`);
}
// ── Hit-test ─────────────────────────────────────────────
hitTest(wx, wy) {
const geo = this.geometry();
const s = this._s;
const hw = geo.w / 2 + 8, hh = geo.h / 2 + 8;
const rot = -(s.rotation || 0) * Math.Pi / 180;
const dx = wx - s.x, dy = wy - s.y;
const lx = dx * Math.cos(rot) - dy * Math.sin(rot);
const ly = dx * Math.sin(rot) + dy * Math.cos(rot);
return Math.abs(lx) <= hw && Math.abs(ly) <= hh;
}
}
// ── Resistor ──────────────────────────────────────────────────
export class ResistorComp extends CompBase {
static prefixes = ['R'];
static typeKey = 'R';
get color() { return '#4ade80'; }
get label() { return 'RES'; }
_buildGeometry() {
const cp = this._s.pins;
return {
w: 60, h: 24,
pins: [
{ name: cp[0]?.name ?? '1', side: 'L', along: 0 },
{ name: cp[1]?.name ?? '2', side: 'R', along: 0 },
],
};
}
_drawSymbol(ctx, g, col, sel) {
const bw = 28, bh = 12;
ctx.strokeStyle = col; ctx.lineWidth = sel ? 2 : 1.5;
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.beginPath();
ctx.moveTo(-g.w / 2, 0); ctx.lineTo(-bw / 2, 0);
ctx.moveTo(bw / 2, 0); ctx.lineTo(g.w / 2, 0);
ctx.stroke();
ctx.beginPath(); ctx.rect(-bw / 2, -bh / 2, bw, bh);
ctx.fill(); ctx.stroke();
ctx.fillStyle = col + '60';
ctx.fillRect(-4, -bh / 2, 3, bh);
}
}
// ── Capacitor ─────────────────────────────────────────────────
export class CapacitorComp extends CompBase {
static prefixes = ['C'];
static typeKey = 'C';
get color() { return '#60a5fa'; }
get label() { return 'CAP'; }
_buildGeometry() {
const cp = this._s.pins;
return {
w: 28, h: 40,
pins: [
{ name: cp[0]?.name ?? '+', side: 'T', along: 0 },
{ name: cp[1]?.name ?? '-', side: 'B', along: 0 },
],
};
}
_drawSymbol(ctx, g, col, sel) {
const gap = 5, pw = 12;
ctx.strokeStyle = col; ctx.lineWidth = sel ? 2 : 1.5;
ctx.beginPath();
ctx.moveTo(0, -g.h / 2); ctx.lineTo(0, -gap);
ctx.moveTo(0, gap); ctx.lineTo(0, g.h / 2);
ctx.stroke();
ctx.lineWidth = sel ? 3 : 2;
ctx.beginPath();
ctx.moveTo(-pw, -gap); ctx.lineTo(pw, -gap);
ctx.moveTo(-pw, gap); ctx.lineTo(pw, gap);
ctx.stroke();
}
}
// ── Inductor ──────────────────────────────────────────────────
export class InductorComp extends CompBase {
static prefixes = ['L'];
static typeKey = 'L';
get color() { return '#f472b6'; }
get label() { return 'IND'; }
_buildGeometry() {
const cp = this._s.pins;
return {
w: 60, h: 20,
pins: [
{ name: cp[0]?.name ?? '1', side: 'L', along: 0 },
{ name: cp[1]?.name ?? '2', side: 'R', along: 0 },
],
};
}
_drawSymbol(ctx, g, col, sel) {
ctx.strokeStyle = col; ctx.lineWidth = sel ? 2 : 1.5;
ctx.beginPath();
ctx.moveTo(-g.w / 2, 0); ctx.lineTo(-22, 0);
ctx.moveTo(22, 0); ctx.lineTo(g.w / 2, 0);
ctx.stroke();
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.arc(-16 + i * 11, 0, 6, Math.PI, 0, false);
ctx.stroke();
}
}
}
// ── Diode (base, shared by DiodeComp and ZenerComp) ──────────
class _DiodeBase extends CompBase {
get color() { return '#fb923c'; }
_buildGeometry() {
const cp = this._s.pins;
return {
w: 44, h: 24,
pins: [
{ name: cp[0]?.name ?? 'A', side: 'L', along: 0 },
{ name: cp[1]?.name ?? 'K', side: 'R', along: 0 },
],
};
}
_drawBody(ctx, g, col, sel, isZener) {
ctx.strokeStyle = col; ctx.lineWidth = sel ? 2 : 1.5;
ctx.fillStyle = col + '40';
ctx.beginPath();
ctx.moveTo(-g.w / 2, 0); ctx.lineTo(-10, 0);
ctx.moveTo(10, 0); ctx.lineTo(g.w / 2, 0);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-10, -10); ctx.lineTo(-10, 10); ctx.lineTo(10, 0);
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = sel ? 2.5 : 2;
ctx.beginPath();
if (isZener) {
ctx.moveTo(10, -10); ctx.lineTo(10, 10);
ctx.moveTo(10, -10); ctx.lineTo(14, -14);
ctx.moveTo(10, 10); ctx.lineTo(6, 14);
} else {
ctx.moveTo(10, -10); ctx.lineTo(10, 10);
}
ctx.stroke();
}
}
export class DiodeComp extends _DiodeBase {
static prefixes = ['D', 'LED'];
static typeKey = 'D';
get label() { return 'DIO'; }
_drawSymbol(ctx, g, col, sel) { this._drawBody(ctx, g, col, sel, false); }
}
export class ZenerComp extends _DiodeBase {
static prefixes = ['Z'];
static typeKey = 'Z';
get label() { return 'ZEN'; }
_drawSymbol(ctx, g, col, sel) { this._drawBody(ctx, g, col, sel, true); }
}
// ── Generic IC box ────────────────────────────────────────────
export class ICComp extends CompBase {
static prefixes = []; // catch-all — matched when no other class claims the prefix
static typeKey = 'IC';
get color() { return '#a78bfa'; }
get label() { return 'IC'; }
_buildGeometry() {
const cp = this._s.pins;
const pc = cp.length;
const pL = Math.ceil(pc / 2), pR = Math.floor(pc / 2);
const rows = Math.max(pL, pR);
// Force odd slot count so centre slot has along=0
const slots = rows % 2 === 0 ? rows + 1 : rows;
const midSlot = Math.floor(slots / 2);
const alongOf = i => (i - midSlot) * PIN_PITCH;
const h = Math.max(60, (slots + 1) * PIN_PITCH);
const w = 80;
const pins = [];
cp.forEach((p, i) => {
if (i < pL) {
pins.push({ name: p.name, side: 'L', along: alongOf(i) });
} else {
const j = i - pL;
pins.push({ name: p.name, side: 'R', along: alongOf(pL - 1 - j) });
}
});
return { w, h, pins };
}
_drawSymbol(ctx, g, col, sel) {
ctx.strokeStyle = col;
ctx.fillStyle = 'rgba(167,139,250,0.07)';
ctx.lineWidth = sel ? 2 : 1.5;
ctx.beginPath(); ctx.rect(-g.w / 2, -g.h / 2, g.w, g.h);
ctx.fill(); ctx.stroke();
// Orientation notch
ctx.beginPath(); ctx.arc(0, -g.h / 2, 5, 0, Math.PI, false);
ctx.strokeStyle = col + '50'; ctx.stroke();
// Pin labels inside box
ctx.lineWidth = 1;
g.pins.forEach((gp, i) => {
const name = this._s.pins[i]?.name ?? gp.name;
if (gp.side === 'L') {
const px = -g.w / 2, py = gp.along;
ctx.fillStyle = col + '90'; ctx.font = '7px Space Mono,monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(name, px + 3, py);
} else {
const px = g.w / 2, py = gp.along;
ctx.fillStyle = col + '90'; ctx.font = '7px Space Mono,monospace';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(name, px - 3, py);
}
});
}
}
// ── KiCad Imported Symbol ─────────────────────────────────────
const KICAD_SCALE = 20 / 2.54;
export class KiCadComp extends CompBase {
static prefixes = [];
static typeKey = 'KICAD';
get color() { return '#f0c040'; }
get label() { return this._s.kicadData?.name || 'KICAD'; }
_buildGeometry() {
let minX = 0, minY = 0, maxX = 0, maxY = 0;
const data = this._s.kicadData;
if (data && data.pins) {
data.pins.forEach(p => {
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
});
}
return { w: Math.max(40, maxX - minX + 20), h: Math.max(40, maxY - minY + 20), pins: [] };
}
_pinLocalCoords() {
if (!this._s.kicadData || !this._s.kicadData.pins) return [];
// Map over logical footprint pins, fetching the assigned physical KiCad pin geometry
return this._s.pins.map((sp, i) => {
const p = this._s.kicadData.pins.find(kp => kp.num === sp.name)
|| this._s.kicadData.pins.find(kp => kp.name === sp.name)
|| this._s.kicadData.pins[i]; // fallback
if (!p) return { gp: { name: sp.name, num: sp.name }, ldx: 0, ldy: 0, ex: 1, ey: 0 };
let ex = 0, ey = 0, a = p.angle || 0;
if (a === 0) { ex = -1; ey = 0; }
else if (a === 90) { ex = 0; ey = 1; }
else if (a === 180) { ex = 1; ey = 0; }
else if (a === 270) { ex = 0; ey = -1; }
return { gp: { name: p.name, num: p.num }, ldx: p.x, ldy: p.y, ex, ey };
});
}
wbDescriptor() {
const geo = this.geometry();
const s = this._s;
const lcs = this._pinLocalCoords();
const pins = lcs.map(({ gp, ldx, ldy, ex, ey }, i) => {
let dir = 15; // DirAll
if (ex < -0.5) dir = 4; else if (ex > 0.5) dir = 8;
else if (ey < -0.5) dir = 1; else if (ey > 0.5) dir = 2;
// Number must be 1-based index (i+1) to align perfectly with the footprint pins
return { number: i + 1, name: s.pins[i]?.name || gp.name || String(gp.num), x: ldx, y: ldy, directionFlags: dir };
});
return { id: s.id, width: geo.w, height: geo.h, padding: 16, pins };
}
wbDescriptorTransformed() {
const geo = this.geometry();
const s = this._s;
const lcs = this._pinLocalCoords();
const rot = s.rotation || 0;
const fx = s.flipX ? -1 : 1;
const steps = Math.round(rot / 90) & 3;
const cosR = [1, 0, -1, 0][steps];
const sinR = [0, 1, 0, -1][steps];
const rw = (steps & 1) ? geo.h : geo.w;
const rh = (steps & 1) ? geo.w : geo.h;
const pins = lcs.map(({ gp, ldx, ldy, ex, ey }, i) => {
const px = cosR * (ldx * fx) - sinR * ldy;
const py = sinR * (ldx * fx) + cosR * ldy;
const rex = cosR * (ex * fx) - sinR * ey;
const rey = sinR * (ex * fx) + cosR * ey;
let dir = 15; // DirAll
if (rex < -0.5) dir = 4; else if (rex > 0.5) dir = 8;
else if (rey < -0.5) dir = 1; else if (rey > 0.5) dir = 2;
// Number must be 1-based index (i+1) to align perfectly with the footprint pins
return { number: i + 1, name: s.pins[i]?.name || gp.name || String(gp.num), x: px, y: py, directionFlags: dir };
});
return { id: s.id, width: rw, height: rh, padding: 16, pins };
}
_drawSymbol(ctx, g, col, sel) {
const data = this._s.kicadData;
if (!data) return;
ctx.strokeStyle = col;
ctx.lineWidth = sel ? 2 : 1.5;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
(data.graphics || []).forEach(node => {
if (!Array.isArray(node)) return;
const tag = node[0];
const ptsNode = node.find(x => Array.isArray(x) && x[0] === 'pts');
const pts = [];
if (ptsNode) {
for (let i = 1; i < ptsNode.length; i++) {
if (ptsNode[i][0] === 'xy') pts.push({ x: parseFloat(ptsNode[i][1]) * KICAD_SCALE, y: -parseFloat(ptsNode[i][2]) * KICAD_SCALE });
}
}
ctx.beginPath();
if (tag === 'polyline' && pts.length > 0) {
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
} else if (tag === 'rectangle') {
const start = node.find(x => Array.isArray(x) && x[0] === 'start');
const end = node.find(x => Array.isArray(x) && x[0] === 'end');
if (start && end) {
const sx = parseFloat(start[1]) * KICAD_SCALE, sy = -parseFloat(start[2]) * KICAD_SCALE;
const ex = parseFloat(end[1]) * KICAD_SCALE, ey = -parseFloat(end[2]) * KICAD_SCALE;
ctx.rect(Math.min(sx, ex), Math.min(sy, ey), Math.abs(ex - sx), Math.abs(ey - sy));
ctx.stroke();
}
} else if (tag === 'circle') {
const center = node.find(x => Array.isArray(x) && x[0] === 'center');
const radius = node.find(x => Array.isArray(x) && x[0] === 'radius');
if (center && radius) {
ctx.arc(parseFloat(center[1]) * KICAD_SCALE, -parseFloat(center[2]) * KICAD_SCALE, parseFloat(radius[1]) * KICAD_SCALE, 0, Math.PI * 2);
ctx.stroke();
}
}
});
// KiCad pin lines
data.pins.forEach(p => {
let dx = 0, dy = 0;
if (p.angle === 0) dx = p.len;
else if (p.angle === 90) dy = -p.len;
else if (p.angle === 180) dx = -p.len;
else if (p.angle === 270) dy = p.len;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x + dx, p.y + dy); ctx.stroke();
});
}
draw(ctx, isSel, isHov, allComps, allWires, labelHint = null) {
const geo = this.geometry(); const col = this.color; const s = this._s;
ctx.save(); ctx.translate(s.x, s.y); ctx.rotate((s.rotation || 0) * Math.PI / 180);
if (s.flipX) ctx.scale(-1, 1);
if (isSel) { ctx.shadowColor = col; ctx.shadowBlur = 14; }
else if (isHov) { ctx.shadowColor = col; ctx.shadowBlur = 7; }
this._drawSymbol(ctx, geo, col, isSel);
ctx.shadowBlur = 0; ctx.restore();
this._drawLabels(ctx, geo, col, allComps, allWires, labelHint);
this.pinPositions().forEach(pin => {
ctx.fillStyle = col; ctx.font = 'bold 8px Space Mono,monospace'; ctx.textBaseline = 'middle';
ctx.textAlign = pin.ex < -0.5 ? 'right' : pin.ex > 0.5 ? 'left' : 'center';
ctx.fillText(pin.pinName, pin.x + pin.ex * 4, pin.y + pin.ey * 4);
ctx.beginPath(); ctx.arc(pin.x, pin.y, 3, 0, Math.PI * 2);
ctx.fillStyle = pin.net ? getNetColor(pin.net) : '#3d4552'; ctx.fill();
if (!pin.net) {
ctx.strokeStyle = '#3d4552'; ctx.lineWidth = 1; ctx.beginPath();
ctx.moveTo(pin.x - 4, pin.y - 4); ctx.lineTo(pin.x + 4, pin.y + 4);
ctx.moveTo(pin.x + 4, pin.y - 4); ctx.lineTo(pin.x - 4, pin.y + 4); ctx.stroke();
}
});
}
}
// ── Registry & factory ────────────────────────────────────────
// ALL_TYPES is the single authoritative list of component classes.
// To add a new type: define the class above with static
// prefixes/typeKey, then append it here.
const ALL_TYPES = [
ResistorComp,
CapacitorComp,
InductorComp,
DiodeComp,
ZenerComp,
KiCadComp,
ICComp, // must be last — it is the catch-all (prefixes = [])
];
// typeKey → class (e.g. 'R' → ResistorComp)
const _byType = new Map(ALL_TYPES.map(Cls => [Cls.typeKey, Cls]));
// Sorted prefix list: longest first so 'LED' matches before 'L'.
const _byPrefix = ALL_TYPES
.flatMap(Cls => Cls.prefixes.map(p => ({ prefix: p, typeKey: Cls.typeKey })))
.sort((a, b) => b.prefix.length - a.prefix.length);
/**
* createComp(stateObj) → CompBase instance
* Wraps a plain state object with the correct component class.
*/
export function createComp(state) {
const Cls = _byType.get(state.type) ?? ICComp;
return new Cls(state);
}
/**
* classifyComp(ref) → typeKey string
* Derives a component type from its reference designator.
*/
export function classifyComp(ref) {
const u = ref.toUpperCase();
for (const { prefix, typeKey } of _byPrefix)
if (u.startsWith(prefix)) return typeKey;
return ICComp.typeKey;
}
export const resolvePin = (comp, ndPin) => {
const pins = createComp(comp).pinPositions();
const idx = comp.pins.findIndex(p => p.name === ndPin || p.name === String(ndPin));
return { pin: pins[idx >= 0 ? idx : 0], idx: idx >= 0 ? idx : 0 };
};
export const compColor = type => createComp({ type, pins: [] }).color;
export const compLabel = type => createComp({ type, pins: [] }).label;
export const compGeometry = comp => createComp(comp).geometry();
export const getPinPositions = comp => createComp(comp).pinPositions();
export const compBBox = comp => createComp(comp).bbox();
export const pinStubLen = (comp, idx) => createComp(comp).stubLen(idx);