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:
parent
8e33088752
commit
f98c3268fa
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue