feat(breadboard): Wokwi-style parts-on-breadboard — hole snapping + invisible seating wires

Parts now plug INTO the breadboard instead of using it as a junction box:

- Drag magnetism: while dragging, the part's anchor pin snaps to the
  nearest hole center (9 px range, 9.6 px grid) so parts land perfectly
  aligned, like Wokwi.
- Seating: every pin within 4 px of a hole gets an invisible zero-length
  wire (Wire.bb) from pin to hole — the exact model Wokwi persists as
  ["r1:1","bb1:6t.b","",["$bb"]]. Electrically they are ordinary
  wires, so the netlist builder, digital trace and SPICE need zero
  changes; they are simply not rendered and not hit-testable. Seating
  re-computes on every move/rotation (updateComponent), and moving the
  breadboard carries its seated parts along.
- Resistors auto-rotate to vertical when dragged over a breadboard
  (their 58.8 px pin span bridges the center trench rows b-f exactly).
- Seat tolerance 4 px: absorbs the worst element pin-spacing residual
  (~1.6 px) while staying under half the hole pitch, so a pin is never
  ambiguous between holes.

Wokwi interchange fixes that fell out of the diagram.json research:
- import maps the top-level rotate attr onto properties.rotation
  (previously every rotated part imported flat) and export emits it
  back as rotate instead of leaking it into attrs;
- $bb / empty-color connections import as bb seating wires and export
  back as ["$bb"] entries, so parts-on-breadboard projects round-trip;
- wokwi-breadboard-half aliases to the full breadboard (hole names are
  a strict superset, so every connection stays valid).

Breadboard elements now export their pure hole grids and import cleanly
without a DOM (node tests); geometry + store seating covered by
breadboard-snap.test.ts and breadboard-seating.test.ts.
This commit is contained in:
David Montero 2026-07-18 08:47:25 +02:00
parent 8e33088752
commit f98c3268fa
11 changed files with 557 additions and 20 deletions

View File

@ -0,0 +1,81 @@
// @vitest-environment jsdom
/**
* Store-level integration of parts-on-breadboard seating: moving a part so
* its pins land on holes creates invisible bb wires, moving it away removes
* them, and moving the breadboard carries seated parts along.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { BREADBOARD_PINS } from '../velxio-elements/breadboard-element';
const RES_PIN_INFO = [
{ name: '1', x: 0, y: 5.65, signals: [] },
{ name: '2', x: 58.8, y: 5.65, signals: [] },
];
const INSET = 6; // DynamicComponent wrapper border+padding
function mountFakeElement(id: string, pinInfo: unknown): void {
document.getElementById(id)?.remove();
const el = document.createElement('div');
el.id = id;
(el as unknown as { pinInfo: unknown }).pinInfo = pinInfo;
document.body.appendChild(el);
}
/** Component x/y that puts resistor pin 1 exactly on the given hole. */
function resistorPosFor(holeName: string, bbX: number, bbY: number) {
const hole = BREADBOARD_PINS.find((h) => h.name === holeName)!;
return {
x: bbX + INSET + hole.x - INSET - RES_PIN_INFO[0].x,
y: bbY + INSET + hole.y - INSET - RES_PIN_INFO[0].y,
};
}
describe('breadboard seating via updateComponent', () => {
beforeEach(() => {
const s = useSimulatorStore.getState();
s.setComponents([
{ id: 'bb1', metadataId: 'breadboard', x: 0, y: 0, properties: {} },
{ id: 'res1', metadataId: 'resistor', x: 900, y: 900, properties: {} },
] as never);
s.setWires([]);
mountFakeElement('res1', RES_PIN_INFO);
});
it('creates one invisible bb wire per pin when the part lands on holes', () => {
const pos = resistorPosFor('5t.a', 0, 0);
useSimulatorStore.getState().updateComponent('res1', pos);
const bbWires = useSimulatorStore.getState().wires.filter((w) => w.bb);
expect(bbWires).toHaveLength(2);
const holes = bbWires.map((w) => w.end.pinName).sort();
// Pin 1 on 5t.a; pin 2 is 58.8 px away = 6 pitches + 1.2 px residual,
// absorbed by the seat tolerance onto column 11.
expect(holes).toEqual(['11t.a', '5t.a']);
expect(bbWires.every((w) => w.end.componentId === 'bb1')).toBe(true);
});
it('removes the seating when the part moves off the board', () => {
useSimulatorStore.getState().updateComponent('res1', resistorPosFor('5t.a', 0, 0));
expect(useSimulatorStore.getState().wires.filter((w) => w.bb)).toHaveLength(2);
useSimulatorStore.getState().updateComponent('res1', { x: 1500, y: 1500 });
expect(useSimulatorStore.getState().wires.filter((w) => w.bb)).toHaveLength(0);
});
it('carries seated parts when the breadboard moves', () => {
useSimulatorStore.getState().updateComponent('res1', resistorPosFor('5t.a', 0, 0));
const before = useSimulatorStore.getState().components.find((c) => c.id === 'res1')!;
useSimulatorStore.getState().updateComponent('bb1', { x: 120, y: 40 });
const after = useSimulatorStore.getState().components.find((c) => c.id === 'res1')!;
expect(after.x).toBeCloseTo(before.x + 120, 5);
expect(after.y).toBeCloseTo(before.y + 40, 5);
const holes = useSimulatorStore.getState().wires
.filter((w) => w.bb)
.map((w) => w.end.pinName)
.sort();
expect(holes).toEqual(['11t.a', '5t.a']); // same holes, new location
});
});

View File

@ -0,0 +1,68 @@
/**
* Parts-on-breadboard geometry: hole grids, nearest-hole lookup and the
* wire flag plumbing that keeps seating wires invisible.
*/
import { describe, it, expect } from 'vitest';
import { breadboardHoles, nearestHole, SEAT_TOLERANCE } from '../utils/breadboardSnap';
import { BREADBOARD_PINS } from '../velxio-elements/breadboard-element';
import { BREADBOARD_MINI_PINS } from '../velxio-elements/breadboard-mini-element';
const WRAPPER_INSET = 6;
describe('breadboardHoles', () => {
it('exposes the full-size grid (830 holes) and the mini grid (170)', () => {
expect(breadboardHoles('breadboard')).toHaveLength(830);
expect(breadboardHoles('breadboard-mini')).toHaveLength(170);
expect(breadboardHoles('led')).toBeNull();
});
it('uses the wokwi 9.6 px pitch on both axes', () => {
const holes = BREADBOARD_PINS;
const c1a = holes.find((h) => h.name === '1t.a')!;
const c2a = holes.find((h) => h.name === '2t.a')!;
const c1b = holes.find((h) => h.name === '1t.b')!;
expect(c2a.x - c1a.x).toBeCloseTo(9.6, 5);
expect(c1b.y - c1a.y).toBeCloseTo(9.6, 5);
});
it('spans exactly 6 pitches from row b to row f (resistor trench bridge)', () => {
const b = BREADBOARD_PINS.find((h) => h.name === '10t.b')!;
const f = BREADBOARD_PINS.find((h) => h.name === '10b.f')!;
expect(f.y - b.y).toBeCloseTo(6 * 9.6, 5);
expect(f.x).toBeCloseTo(b.x, 5);
});
it('mini board has no rails', () => {
expect(BREADBOARD_MINI_PINS.some((h) => h.name.includes('p.'))).toBe(false);
});
});
describe('nearestHole', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const hole = BREADBOARD_PINS.find((h) => h.name === '5t.c')!;
const world = { x: 100 + WRAPPER_INSET + hole.x, y: 200 + WRAPPER_INSET + hole.y };
it('finds the exact hole under a world point (wrapper inset accounted)', () => {
const found = nearestHole(bb, world, SEAT_TOLERANCE);
expect(found?.name).toBe('5t.c');
expect(found?.dist).toBeCloseTo(0, 5);
});
it('absorbs the resistor 1.2 px pitch residual but rejects beyond tolerance', () => {
expect(nearestHole(bb, { x: world.x + 1.2, y: world.y }, SEAT_TOLERANCE)?.name).toBe('5t.c');
// Center of the e-f trench: 14.4 px from the nearest rows — no hole.
const rowE = BREADBOARD_PINS.find((h) => h.name === '5t.e')!;
const trench = { x: 100 + WRAPPER_INSET + rowE.x, y: 200 + WRAPPER_INSET + rowE.y + 14.4 };
expect(nearestHole(bb, trench, SEAT_TOLERANCE)).toBeNull();
});
it('tolerance stays below half the hole pitch (no ambiguous seating)', () => {
expect(SEAT_TOLERANCE).toBeLessThan(9.6 / 2);
});
it('refuses rotated breadboards', () => {
const rotated = { ...bb, properties: { rotation: 90 } };
expect(nearestHole(rotated, world, SEAT_TOLERANCE)).toBeNull();
});
});

View File

@ -29,6 +29,11 @@ import { PinOverlay } from './PinOverlay';
import { calculatePinPosition } from '../../utils/pinPositionCalculator';
import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping';
import { autoWireColor, WIRE_KEY_COLORS, expandOrthogonalPoints } from '../../utils/wireUtils';
import {
AUTO_VERTICAL_PARTS,
isOverBreadboard,
snapPositionToBreadboard,
} from '../../utils/breadboardSnap';
import {
findWireNearPoint,
findSegmentNearPoint,
@ -819,9 +824,35 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
y: world.y - touchDragOffsetRef.current.y,
});
} else {
let nx = world.x - touchDragOffsetRef.current.x;
let ny = world.y - touchDragOffsetRef.current.y;
const simState = useSimulatorStore.getState();
const dragged = simState.components.find(
(c) => c.id === touchDraggedComponentIdRef.current,
);
if (dragged) {
if (
AUTO_VERTICAL_PARTS.has(dragged.metadataId) &&
(Number(dragged.properties?.rotation) || 0) === 0 &&
isOverBreadboard(dragged, nx, ny, simState.components)
) {
updateComponent(touchDraggedComponentIdRef.current!, {
properties: { ...dragged.properties, rotation: 90 },
} as any);
}
const cur = useSimulatorStore
.getState()
.components.find((c) => c.id === touchDraggedComponentIdRef.current);
const snapped =
cur && snapPositionToBreadboard(cur, nx, ny, simState.components);
if (snapped) {
nx = snapped.x;
ny = snapped.y;
}
}
updateComponent(touchDraggedComponentIdRef.current!, {
x: world.x - touchDragOffsetRef.current.x,
y: world.y - touchDragOffsetRef.current.y,
x: nx,
y: ny,
} as any);
}
}
@ -1385,10 +1416,33 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
// legacy fallback
setBoardPosition({ x: world.x - dragOffset.x, y: world.y - dragOffset.y });
} else {
updateComponent(draggedComponentId, {
x: world.x - dragOffset.x,
y: world.y - dragOffset.y,
} as any);
let nx = world.x - dragOffset.x;
let ny = world.y - dragOffset.y;
const simState = useSimulatorStore.getState();
const dragged = simState.components.find((c) => c.id === draggedComponentId);
if (dragged) {
// Axial parts entering a breadboard flip to vertical so they
// bridge the trench columns like on a real board.
if (
AUTO_VERTICAL_PARTS.has(dragged.metadataId) &&
(Number(dragged.properties?.rotation) || 0) === 0 &&
isOverBreadboard(dragged, nx, ny, simState.components)
) {
updateComponent(draggedComponentId, {
properties: { ...dragged.properties, rotation: 90 },
} as any);
}
const cur = useSimulatorStore
.getState()
.components.find((c) => c.id === draggedComponentId);
const snapped =
cur && snapPositionToBreadboard(cur, nx, ny, simState.components);
if (snapped) {
nx = snapped.x;
ny = snapped.y;
}
}
updateComponent(draggedComponentId, { x: nx, y: ny } as any);
}
}

View File

@ -24,6 +24,10 @@ export const WireRenderer: React.FC<WireRendererProps> = ({
previewWaypoints,
overridePath,
}) => {
// Breadboard seating wires are pure connectivity — the part visually
// sits in the holes, so there is nothing to draw.
if (wire.bb) return null;
const waypoints = previewWaypoints ?? wire.waypoints;
const path = overridePath ?? generateOrthogonalPath(wire.start, waypoints, wire.end);

View File

@ -37,6 +37,8 @@ import {
previewElbow,
} from '../utils/wireUtils';
import { routeAroundObstacles, collectComponentObstacles } from '../utils/wireAutoRoute';
import { isBreadboard } from '../utils/breadboardNets';
import { computeSeating } from '../utils/breadboardSnap';
import { createSerialBatcher } from './serialBatcher';
import {
bindBoard as icBindBoard,
@ -894,6 +896,8 @@ interface SimulatorState {
addComponent: (component: Component) => void;
removeComponent: (id: string) => void;
updateComponent: (id: string, updates: Partial<Component>) => void;
/** Recompute the breadboard seating wires (bb: true) of one component. */
reseatComponentOnBreadboard: (id: string) => void;
updateComponentState: (id: string, state: boolean) => void;
handleComponentEvent: (componentId: string, eventName: string, data?: unknown) => void;
setComponents: (components: Component[]) => void;
@ -2457,6 +2461,13 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
})),
updateComponent: (id, updates) => {
const before = get().components.find((c) => c.id === id);
const isBbMove =
!!before && isBreadboard(before.metadataId) &&
(updates.x !== undefined || updates.y !== undefined);
const dx = isBbMove ? (updates.x ?? before.x) - before.x : 0;
const dy = isBbMove ? (updates.y ?? before.y) - before.y : 0;
set((state) => ({
components: state.components.map((c) => (c.id === id ? { ...c, ...updates } : c)),
}));
@ -2468,7 +2479,49 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
updates.properties && 'rotation' in updates.properties;
if (updates.x !== undefined || updates.y !== undefined || rotationChanged) {
get().updateWirePositions(id);
get().reseatComponentOnBreadboard(id);
}
// A moving breadboard carries its seated parts: shift every part
// plugged into it (bb wires) by the same delta AFTER the board has
// moved, so each rider's own reseat re-lands on the same holes at
// their new location regardless of how fast the drag is.
if (isBbMove && (dx !== 0 || dy !== 0)) {
const riders = new Set<string>();
for (const w of get().wires) {
if (!w.bb) continue;
if (w.start.componentId === id) riders.add(w.end.componentId);
if (w.end.componentId === id) riders.add(w.start.componentId);
}
riders.delete(id);
for (const rid of riders) {
const rc = get().components.find((c) => c.id === rid);
if (rc) get().updateComponent(rid, { x: rc.x + dx, y: rc.y + dy });
}
}
},
reseatComponentOnBreadboard: (id) => {
const comp = get().components.find((c) => c.id === id);
if (!comp || isBreadboard(comp.metadataId)) return;
const seating = computeSeating(comp, get().components);
// null = geometry unmeasurable (unmounted DOM) — keep whatever
// seating exists rather than tearing out live connections.
if (seating === null) return;
set((state) => {
const kept = state.wires.filter(
(w) => !(w.bb && (w.start.componentId === id || w.end.componentId === id)),
);
const seated: Wire[] = seating.map((s, i) => ({
id: `bbwire-${id}-${i}-${s.holeName}`,
start: { componentId: id, pinName: s.pinName, x: s.pinX, y: s.pinY },
end: { componentId: s.bbId, pinName: s.holeName, x: s.holeX, y: s.holeY },
waypoints: [],
color: '#000000',
bb: true,
}));
return { wires: [...kept, ...seated] };
});
},
updateComponentState: (id, state) => {

View File

@ -36,6 +36,13 @@ export interface Wire {
* resolved against board pin metadata.
*/
signalType?: WireSignalType;
/**
* Breadboard seating wire: auto-created zero-length connection from a
* part pin to the hole it is plugged into (Wokwi's `["$bb"]` entries).
* Electrically a normal wire; never rendered, never hit-testable, and
* re-generated whenever the part moves.
*/
bb?: boolean;
}
export interface WireInProgress {

View File

@ -0,0 +1,222 @@
/**
* Parts-on-breadboard support (Wokwi-style).
*
* Model (mirrors what Wokwi persists as `["r1:1","bb1:6t.b","",["$bb"]]`):
* a part seated on a breadboard is connected by one INVISIBLE zero-length
* wire per pin, from the part pin to the hole under it. Those wires are
* ordinary `Wire` objects flagged `bb: true`, so the netlist builder, the
* digital trace and SPICE treat them exactly like hand-drawn wires — the
* only special-casing is cosmetic (not rendered, not hit-testable).
*
* Two building blocks live here:
* - pure geometry over the exported hole grids (testable without DOM), and
* - a DOM adapter that reads the dragged part's pinInfo to compute the
* anchor-pin snap and the per-pin seating.
*/
import {
BREADBOARD_PINS,
} from '../velxio-elements/breadboard-element';
import {
BREADBOARD_MINI_PINS,
} from '../velxio-elements/breadboard-mini-element';
import { isBreadboard } from './breadboardNets';
import { calculatePinPosition } from './pinPositionCalculator';
export interface Hole {
name: string;
x: number; // element-space CSS px (relative to the breadboard inner element)
y: number;
}
interface Pt {
x: number;
y: number;
}
export interface ComponentLike {
id: string;
metadataId: string;
x: number;
y: number;
properties?: Record<string, unknown>;
}
/** Magnet range for the anchor pin while dragging (px, world units). */
export const SNAP_TOLERANCE = 9;
/**
* A pin within this of a hole center counts as plugged in. Must absorb the
* worst element-geometry residual (~1.6 px — pin spacings are not exact
* 9.6 multiples) while staying below half the hole pitch (4.8 px) so a
* pin can never be ambiguous between two holes.
*/
export const SEAT_TOLERANCE = 4;
/** DynamicComponent wrapper inset: border 2 + padding 4 on every side. */
const WRAPPER_INSET = 6;
export function breadboardHoles(metadataId: string): readonly Hole[] | null {
if (metadataId === 'breadboard') return BREADBOARD_PINS;
if (metadataId === 'breadboard-mini') return BREADBOARD_MINI_PINS;
return null;
}
/**
* Nearest hole of one breadboard to a world point. Pure — hole world
* position is bb top-left + wrapper inset + element-space hole coords.
* Rotated breadboards are not supported (returns null).
*/
export function nearestHole(
bb: ComponentLike,
world: Pt,
maxDistance: number,
): { name: string; x: number; y: number; dist: number } | null {
if (Number(bb.properties?.rotation) || 0) return null;
const holes = breadboardHoles(bb.metadataId);
if (!holes) return null;
const ox = bb.x + WRAPPER_INSET;
const oy = bb.y + WRAPPER_INSET;
let best: { name: string; x: number; y: number; dist: number } | null = null;
for (const h of holes) {
const hx = ox + h.x;
const hy = oy + h.y;
const dist = Math.hypot(hx - world.x, hy - world.y);
if (dist <= maxDistance && (!best || dist < best.dist)) {
best = { name: h.name, x: hx, y: hy, dist };
}
}
return best;
}
function breadboardsOf(components: ComponentLike[]): ComponentLike[] {
return components.filter((c) => isBreadboard(c.metadataId));
}
/** Pin names of a mounted component, from its DOM element's pinInfo. */
function pinNames(componentId: string): string[] | null {
const el = document.getElementById(componentId);
const pinInfo = el && (el as { pinInfo?: Array<{ name: string }> }).pinInfo;
if (!pinInfo || !Array.isArray(pinInfo) || pinInfo.length === 0) return null;
return pinInfo.map((p) => p.name);
}
/**
* Snap a dragged component's tentative position so its ANCHOR pin (first
* pinInfo entry) lands exactly on the nearest hole center. Returns the
* adjusted {x, y} or null when no hole is within SNAP_TOLERANCE (or the
* geometry cannot be measured).
*/
export function snapPositionToBreadboard(
comp: ComponentLike,
tentativeX: number,
tentativeY: number,
components: ComponentLike[],
): Pt | null {
if (isBreadboard(comp.metadataId)) return null;
const bbs = breadboardsOf(components);
if (bbs.length === 0) return null;
const names = pinNames(comp.id);
if (!names) return null;
const rotation = Number(comp.properties?.rotation) || 0;
const anchor = calculatePinPosition(
comp.id, names[0], tentativeX + WRAPPER_INSET, tentativeY + WRAPPER_INSET, rotation,
);
if (!anchor) return null;
let best: { hole: { x: number; y: number; dist: number }; } | null = null;
for (const bb of bbs) {
const hole = nearestHole(bb, anchor, SNAP_TOLERANCE);
if (hole && (!best || hole.dist < best.hole.dist)) best = { hole };
}
if (!best) return null;
return {
x: tentativeX + (best.hole.x - anchor.x),
y: tentativeY + (best.hole.y - anchor.y),
};
}
export interface Seat {
pinName: string;
pinX: number;
pinY: number;
bbId: string;
holeName: string;
holeX: number;
holeY: number;
}
/**
* Compute the per-pin seating of a component at its CURRENT position:
* every pin within SEAT_TOLERANCE of a hole. Returns null when geometry
* cannot be measured (unmounted DOM) — callers must then leave any
* existing seating untouched; an empty array means "measured, not seated".
*/
export function computeSeating(
comp: ComponentLike,
components: ComponentLike[],
): Seat[] | null {
if (isBreadboard(comp.metadataId)) return [];
const bbs = breadboardsOf(components);
if (bbs.length === 0) return [];
const names = pinNames(comp.id);
if (!names) return null;
const rotation = Number(comp.properties?.rotation) || 0;
const seats: Seat[] = [];
for (const name of names) {
const pin = calculatePinPosition(
comp.id, name, comp.x + WRAPPER_INSET, comp.y + WRAPPER_INSET, rotation,
);
if (!pin) continue;
let best: { bbId: string; hole: { name: string; x: number; y: number; dist: number } } | null =
null;
for (const bb of bbs) {
const hole = nearestHole(bb, pin, SEAT_TOLERANCE);
if (hole && (!best || hole.dist < best.hole.dist)) best = { bbId: bb.id, hole };
}
if (best) {
seats.push({
pinName: name,
pinX: pin.x,
pinY: pin.y,
bbId: best.bbId,
holeName: best.hole.name,
holeX: best.hole.x,
holeY: best.hole.y,
});
}
}
return seats;
}
/**
* True when the component's bounding-box center is over a breadboard's
* body — used to auto-rotate axial parts (resistors) to vertical so they
* bridge the center trench like on a real board.
*/
export function isOverBreadboard(
comp: ComponentLike,
tentativeX: number,
tentativeY: number,
components: ComponentLike[],
): boolean {
const el = document.getElementById(comp.id);
const w = el ? (el as HTMLElement).offsetWidth : 40;
const h = el ? (el as HTMLElement).offsetHeight : 40;
const cx = tentativeX + WRAPPER_INSET + w / 2;
const cy = tentativeY + WRAPPER_INSET + h / 2;
for (const bb of breadboardsOf(components)) {
const bbEl = document.getElementById(bb.id);
if (!bbEl) continue;
const bw = (bbEl as HTMLElement).offsetWidth;
const bh = (bbEl as HTMLElement).offsetHeight;
if (
cx >= bb.x + WRAPPER_INSET && cx <= bb.x + WRAPPER_INSET + bw &&
cy >= bb.y + WRAPPER_INSET && cy <= bb.y + WRAPPER_INSET + bh
) {
return true;
}
}
return false;
}
/** Axial 2-pin parts that read better vertical on a breadboard. */
export const AUTO_VERTICAL_PARTS = new Set(['resistor', 'wokwi-resistor']);

View File

@ -101,6 +101,7 @@ export function findWireNearPoint(
): Wire | null {
for (let i = wires.length - 1; i >= 0; i--) {
const wire = wires[i];
if (wire.bb) continue; // breadboard seating wires are not interactive
const segments = getRenderedSegments(wire);
for (const seg of segments) {
if (distToSegment(wx, wy, seg.x1, seg.y1, seg.x2, seg.y2) <= threshold) {

View File

@ -150,6 +150,10 @@ function hexToColorName(hex: string): string {
// ── Type conversion ───────────────────────────────────────────────────────────
function wokwiTypeToMetadataId(type: string): string {
// Velxio has no half-size breadboard element; the full board is a strict
// superset of its hole names (columns 1-30 exist on both), so every
// connection stays valid — the part just renders longer.
if (type === 'wokwi-breadboard-half') return 'breadboard';
if (type.startsWith('wokwi-')) return type.slice(6);
if (type.startsWith('board-')) return type.slice(6);
return type;
@ -196,13 +200,19 @@ export async function exportToWokwiZip(
// Subtract boardPosition so coords are relative to the board
const parts: WokwiPart[] = [
{ type: boardWokwiType, id: boardId, top: 0, left: 0, attrs: {} },
...components.map((c) => ({
type: metadataIdToWokwiType(c.metadataId),
id: c.id,
top: Math.round(c.y - boardPosition.y),
left: Math.round(c.x - boardPosition.x),
attrs: c.properties as Record<string, unknown>,
})),
...components.map((c) => {
// Rotation travels as Wokwi's top-level `rotate`, never as an attr.
const { rotation, ...attrs } = (c.properties ?? {}) as Record<string, unknown>;
const rotate = Number(rotation) || 0;
return {
type: metadataIdToWokwiType(c.metadataId),
id: c.id,
top: Math.round(c.y - boardPosition.y),
left: Math.round(c.x - boardPosition.x),
...(rotate ? { rotate } : {}),
attrs,
};
}),
];
// Build connections
@ -217,6 +227,10 @@ export async function exportToWokwiZip(
w.end.componentId === 'nano-rp2040';
const startId = isBoardStart ? boardId : w.start.componentId;
const endId = isBoardEnd ? boardId : w.end.componentId;
// Breadboard seating wires round-trip as Wokwi's hidden `["$bb"]` entries.
if (w.bb) {
return [`${startId}:${w.start.pinName}`, `${endId}:${w.end.pinName}`, '', ['$bb']];
}
return [
`${startId}:${w.start.pinName}`,
`${endId}:${w.end.pinName}`,
@ -301,12 +315,23 @@ export async function importFromWokwiZip(file: File): Promise<ImportResult> {
metadataId: wokwiTypeToMetadataId(p.type),
x: p.left + offsetX,
y: p.top + offsetY,
properties: { ...p.attrs },
// Wokwi stores rotation as a top-level `rotate` (degrees), not an
// attr — map it onto properties.rotation or every rotated part
// imports lying flat.
properties: {
...p.attrs,
...(p.rotate ? { rotation: p.rotate } : {}),
},
}));
// Convert connections to Velxio wires
const wires: Wire[] = diagram.connections.map((conn, i) => {
const [startStr, endStr, color] = conn;
const [startStr, endStr, color, path] = conn;
// Wokwi persists parts seated ON a breadboard as one hidden connection
// per pin: empty color + the special "$bb" token in the path slot.
// Import them as velxio breadboard-seating wires (bb: true) so they
// stay invisible and re-seat when the part moves.
const isSeating = (Array.isArray(path) && path[0] === '$bb') || color === '';
const colonA = startStr.indexOf(':');
const colonB = endStr.indexOf(':');
const startCompRaw = colonA >= 0 ? startStr.slice(0, colonA) : startStr;
@ -330,7 +355,8 @@ export async function importFromWokwiZip(file: File): Promise<ImportResult> {
start: { componentId: startId, pinName: normalizedStartPin, x: 0, y: 0 },
end: { componentId: endId, pinName: normalizedEndPin, x: 0, y: 0 },
waypoints: [],
color: colorToHex(color),
color: isSeating ? '#000000' : colorToHex(color),
...(isSeating ? { bb: true as const } : {}),
};
});

View File

@ -86,6 +86,10 @@ function buildPins(): BreadboardPin[] {
const PINS: readonly BreadboardPin[] = buildPins();
/** Pure hole-grid export — lets snapping/seating logic and tests use the
* exact same geometry as the rendered element without touching the DOM. */
export const BREADBOARD_PINS: readonly BreadboardPin[] = PINS;
/* Shared, module-level fetch of the Fritzing artwork (one request no matter
* how many breadboards are on the canvas). Resolves to the raw <svg> markup
* or null when unavailable (missing asset, offline dev edge cases). */
@ -117,7 +121,15 @@ function buildFallbackSvg(): string {
return parts.join('');
}
export class BreadboardElement extends HTMLElement {
// DOM-free import guard: the hole-grid exports above are consumed by pure
// geometry code (breadboardSnap) and node-side tests, where HTMLElement
// does not exist. The stub is never instantiated outside a browser.
const HTMLElementBase: typeof HTMLElement =
typeof HTMLElement !== 'undefined'
? HTMLElement
: (class {} as unknown as typeof HTMLElement);
export class BreadboardElement extends HTMLElementBase {
constructor() {
super();
this.attachShadow({ mode: 'open' });
@ -145,6 +157,6 @@ export class BreadboardElement extends HTMLElement {
}
}
if (!customElements.get('velxio-breadboard')) {
if (typeof customElements !== 'undefined' && !customElements.get('velxio-breadboard')) {
customElements.define('velxio-breadboard', BreadboardElement);
}

View File

@ -58,6 +58,9 @@ function buildPins(): BreadboardMiniPin[] {
const PINS: readonly BreadboardMiniPin[] = buildPins();
/** Pure hole-grid export — same geometry as the rendered element, DOM-free. */
export const BREADBOARD_MINI_PINS: readonly BreadboardMiniPin[] = PINS;
let artPromise: Promise<string | null> | null = null;
function fetchArt(): Promise<string | null> {
if (!artPromise) {
@ -85,7 +88,13 @@ function buildFallbackSvg(): string {
return parts.join('');
}
export class BreadboardMiniElement extends HTMLElement {
// DOM-free import guard — see breadboard-element.ts.
const HTMLElementBase: typeof HTMLElement =
typeof HTMLElement !== 'undefined'
? HTMLElement
: (class {} as unknown as typeof HTMLElement);
export class BreadboardMiniElement extends HTMLElementBase {
constructor() {
super();
this.attachShadow({ mode: 'open' });
@ -111,6 +120,6 @@ export class BreadboardMiniElement extends HTMLElement {
}
}
if (!customElements.get('velxio-breadboard-mini')) {
if (typeof customElements !== 'undefined' && !customElements.get('velxio-breadboard-mini')) {
customElements.define('velxio-breadboard-mini', BreadboardMiniElement);
}