From f98c3268fabced64594e6c6e63342d2af0dfa404 Mon Sep 17 00:00:00 2001 From: David Montero Date: Sat, 18 Jul 2026 08:47:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(breadboard):=20Wokwi-style=20parts-on-brea?= =?UTF-8?q?dboard=20=E2=80=94=20hole=20snapping=20+=20invisible=20seating?= =?UTF-8?q?=20wires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/__tests__/breadboard-seating.test.ts | 81 +++++++ .../src/__tests__/breadboard-snap.test.ts | 68 ++++++ .../components/simulator/SimulatorCanvas.tsx | 66 +++++- .../src/components/simulator/WireRenderer.tsx | 4 + frontend/src/store/useSimulatorStore.ts | 53 +++++ frontend/src/types/wire.ts | 7 + frontend/src/utils/breadboardSnap.ts | 222 ++++++++++++++++++ frontend/src/utils/wireHitDetection.ts | 1 + frontend/src/utils/wokwiZip.ts | 46 +++- .../src/velxio-elements/breadboard-element.ts | 16 +- .../breadboard-mini-element.ts | 13 +- 11 files changed, 557 insertions(+), 20 deletions(-) create mode 100644 frontend/src/__tests__/breadboard-seating.test.ts create mode 100644 frontend/src/__tests__/breadboard-snap.test.ts create mode 100644 frontend/src/utils/breadboardSnap.ts diff --git a/frontend/src/__tests__/breadboard-seating.test.ts b/frontend/src/__tests__/breadboard-seating.test.ts new file mode 100644 index 00000000..a10bee32 --- /dev/null +++ b/frontend/src/__tests__/breadboard-seating.test.ts @@ -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 + }); +}); diff --git a/frontend/src/__tests__/breadboard-snap.test.ts b/frontend/src/__tests__/breadboard-snap.test.ts new file mode 100644 index 00000000..ef6fa85a --- /dev/null +++ b/frontend/src/__tests__/breadboard-snap.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 8b39ad9c..ed96b9a5 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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); } } diff --git a/frontend/src/components/simulator/WireRenderer.tsx b/frontend/src/components/simulator/WireRenderer.tsx index c65c5105..d87a1136 100644 --- a/frontend/src/components/simulator/WireRenderer.tsx +++ b/frontend/src/components/simulator/WireRenderer.tsx @@ -24,6 +24,10 @@ export const WireRenderer: React.FC = ({ 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); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index ce23309e..6d5b0dc5 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -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) => 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((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((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(); + 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) => { diff --git a/frontend/src/types/wire.ts b/frontend/src/types/wire.ts index 9ee5497e..48d3ba39 100644 --- a/frontend/src/types/wire.ts +++ b/frontend/src/types/wire.ts @@ -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 { diff --git a/frontend/src/utils/breadboardSnap.ts b/frontend/src/utils/breadboardSnap.ts new file mode 100644 index 00000000..a3ec7d83 --- /dev/null +++ b/frontend/src/utils/breadboardSnap.ts @@ -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; +} + +/** 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']); diff --git a/frontend/src/utils/wireHitDetection.ts b/frontend/src/utils/wireHitDetection.ts index 82978999..b26fe77e 100644 --- a/frontend/src/utils/wireHitDetection.ts +++ b/frontend/src/utils/wireHitDetection.ts @@ -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) { diff --git a/frontend/src/utils/wokwiZip.ts b/frontend/src/utils/wokwiZip.ts index 66c06c5a..eefbcbb8 100644 --- a/frontend/src/utils/wokwiZip.ts +++ b/frontend/src/utils/wokwiZip.ts @@ -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, - })), + ...components.map((c) => { + // Rotation travels as Wokwi's top-level `rotate`, never as an attr. + const { rotation, ...attrs } = (c.properties ?? {}) as Record; + 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 { 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 { 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 } : {}), }; }); diff --git a/frontend/src/velxio-elements/breadboard-element.ts b/frontend/src/velxio-elements/breadboard-element.ts index 08fd1900..d7ba52a5 100644 --- a/frontend/src/velxio-elements/breadboard-element.ts +++ b/frontend/src/velxio-elements/breadboard-element.ts @@ -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 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); } diff --git a/frontend/src/velxio-elements/breadboard-mini-element.ts b/frontend/src/velxio-elements/breadboard-mini-element.ts index 1b847193..06129d73 100644 --- a/frontend/src/velxio-elements/breadboard-mini-element.ts +++ b/frontend/src/velxio-elements/breadboard-mini-element.ts @@ -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 | null = null; function fetchArt(): Promise { 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); }