870 lines
30 KiB
JavaScript
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);
|
|
|