diff --git a/frontend/src/__tests__/breadboard-occupancy.test.ts b/frontend/src/__tests__/breadboard-occupancy.test.ts new file mode 100644 index 00000000..8b7c48ce --- /dev/null +++ b/frontend/src/__tests__/breadboard-occupancy.test.ts @@ -0,0 +1,121 @@ +/** + * Breadboard hole-occupancy rules ("1 pin = 1 cable"): + * - clicking an occupied hole selects the occupying wire (findWireAtHole); + * - a new wire end landing in an occupied hole shifts to the nearest free + * hole of the same 5-hole strip / power rail (resolveFreeHole); + * - seated component legs (invisible bb wires) count as occupancy but are + * never selectable. + * Plus the jumper color policy: rails are red/black, strips draw from the + * jumper palette deterministically per wire id. + */ +import { describe, it, expect } from 'vitest'; +import { + findWireAtHole, + holeIsOccupied, + resolveFreeHole, +} from '../utils/breadboardOccupancy'; +import { + railWireColor, + jumperColorForId, + WIRE_JUMPER_PALETTE, +} from '../utils/wireUtils'; +import type { Wire } from '../types/wire'; + +const wire = ( + id: string, + s: [string, string], + e: [string, string], + bb = false, +): Wire => + ({ + id, + start: { componentId: s[0], pinName: s[1], x: 0, y: 0 }, + end: { componentId: e[0], pinName: e[1], x: 0, y: 0 }, + waypoints: [], + color: '#22c55e', + ...(bb ? { bb: true } : {}), + }) as unknown as Wire; + +// Column 21, top bank: holes a-e — one strip. +const STRIP = ['21t.a', '21t.b', '21t.c', '21t.d', '21t.e']; +const ALL = [...STRIP, '22t.a', '22t.b', 'bp.1', 'bp.2', 'bn.1']; + +describe('findWireAtHole', () => { + it('returns the visible wire whose end sits in the hole', () => { + const w = wire('w1', ['bb1', '21t.a'], ['bb1', '33t.b']); + expect(findWireAtHole([w], 'bb1', '21t.a')?.id).toBe('w1'); + expect(findWireAtHole([w], 'bb1', '33t.b')?.id).toBe('w1'); + expect(findWireAtHole([w], 'bb1', '21t.b')).toBeNull(); + }); + + it('never returns invisible seating wires', () => { + const seat = wire('s1', ['comp1', 'A'], ['bb1', '21t.a'], true); + expect(findWireAtHole([seat], 'bb1', '21t.a')).toBeNull(); + }); + + it('topmost wire wins when stacked (legacy stacked circuits)', () => { + const w1 = wire('w1', ['bb1', '21t.a'], ['bb1', '30t.a']); + const w2 = wire('w2', ['bb1', '21t.a'], ['bb1', '31t.a']); + expect(findWireAtHole([w1, w2], 'bb1', '21t.a')?.id).toBe('w2'); + }); +}); + +describe('holeIsOccupied', () => { + it('counts seated legs (bb wires) as occupancy', () => { + const seat = wire('s1', ['comp1', 'A'], ['bb1', '21t.a'], true); + expect(holeIsOccupied([seat], 'bb1', '21t.a')).toBe(true); + expect(holeIsOccupied([seat], 'bb1', '21t.b')).toBe(false); + }); +}); + +describe('resolveFreeHole', () => { + it('keeps a free hole as-is', () => { + expect(resolveFreeHole('breadboard', 'bb1', '21t.c', [], ALL)).toBe('21t.c'); + }); + + it('shifts to the nearest free hole in the same strip', () => { + const seat = wire('s1', ['comp1', 'A'], ['bb1', '21t.a'], true); + expect(resolveFreeHole('breadboard', 'bb1', '21t.a', [seat], ALL)).toBe('21t.b'); + }); + + it('never shifts across strips', () => { + // Whole 21t strip occupied → falls back to the clicked hole, NOT 22t. + const wires = STRIP.map((p, i) => wire(`w${i}`, ['bb1', p], ['bb1', '40t.a'])); + expect(resolveFreeHole('breadboard', 'bb1', '21t.a', wires, ALL)).toBe('21t.a'); + }); + + it('shifts within a power rail too', () => { + const w = wire('w1', ['bb1', 'bp.1'], ['esp32_1', '3V3']); + expect(resolveFreeHole('breadboard', 'bb1', 'bp.1', [w], ALL)).toBe('bp.2'); + }); + + it('non-breadboard pins pass through', () => { + const w = wire('w1', ['led1', 'A'], ['bb1', '21t.a']); + expect(resolveFreeHole('wokwi-led', 'led1', 'A', [w], ['A', 'C'])).toBe('A'); + }); +}); + +describe('wire color policy', () => { + it('rails are red (+) / black (−)', () => { + expect(railWireColor('tp.5')).toBe('#cc0000'); + expect(railWireColor('bp.12')).toBe('#cc0000'); + expect(railWireColor('tn.5')).toBe('#000000'); + expect(railWireColor('bn.1')).toBe('#000000'); + expect(railWireColor('21t.a')).toBeNull(); + expect(railWireColor('GND')).toBeNull(); + }); + + it('jumper colors are deterministic per id and inside the palette', () => { + const c = jumperColorForId('wire_42'); + expect(jumperColorForId('wire_42')).toBe(c); + expect(WIRE_JUMPER_PALETTE).toContain(c); + // Different ids spread — at least two distinct colors among a handful. + const set = new Set(['a', 'b', 'c', 'd', 'e', 'f'].map(jumperColorForId)); + expect(set.size).toBeGreaterThan(1); + }); + + it('palette reserves red and black for rails', () => { + expect(WIRE_JUMPER_PALETTE).not.toContain('#cc0000'); + expect(WIRE_JUMPER_PALETTE).not.toContain('#000000'); + }); +}); diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 026da3f3..414b50b7 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -30,7 +30,14 @@ import { SeatedPinMarkers } from './SeatedPinMarkers'; import { calculatePinPosition } from '../../utils/pinPositionCalculator'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; import { isBreadboard } from '../../utils/breadboardNets'; -import { autoWireColor, WIRE_KEY_COLORS, expandOrthogonalPoints } from '../../utils/wireUtils'; +import { + autoWireColor, + railWireColor, + WIRE_JUMPER_PALETTE, + WIRE_KEY_COLORS, + expandOrthogonalPoints, +} from '../../utils/wireUtils'; +import { findWireAtHole, resolveFreeHole } from '../../utils/breadboardOccupancy'; import { isAutoVerticalPart, isOverBreadboard, @@ -358,6 +365,10 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const [alignmentGuides, setAlignmentGuides] = useState([]); /** Set to true during mouseup if a segment/waypoint drag committed, so onClick can skip selection. */ const segmentDragJustCommittedRef = useRef(false); + // Set when the mouse-up handler already resolved this click into a wire + // selection (click on the breadboard body over a wire) — the bubbled + // canvas onClick must not re-toggle that selection. + const wireSelectJustHandledRef = useRef(false); const wiresRef = useRef(wires); wiresRef.current = wires; @@ -1686,6 +1697,32 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { } else if (component.metadataId === 'custom-chip') { // Custom Chips have their own designer (C editor + chip.json + Compile). setCustomChipComponentId(draggedComponentId); + } else if ( + isBreadboard(component.metadataId) && + (() => { + // A wire crossing the breadboard body sits visually ON TOP of + // it — clicking the wire must select the wire, not bury it + // under the breadboard's full-pin-list property dialog + // (reported: bb→bb wires were unselectable, the hole list + // popped over everything instead). + const world = toWorld(e.clientX, e.clientY); + const nearWire = findWireNearPoint( + wiresRef.current, + world.x, + world.y, + 8 / zoomRef.current, + ); + if (nearWire) { + setSelectedWire(nearWire.id); + // The subsequent canvas onClick would re-hit the same wire + // and toggle it back OFF — suppress that one click. + wireSelectJustHandledRef.current = true; + return true; + } + return false; + })() + ) { + // handled — wire selected instead of opening the dialog } else { setPropertyDialogComponentId(draggedComponentId); setPropertyDialogPosition({ @@ -1880,6 +1917,46 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { setShowPropertyDialog(false); } + // ── Breadboard hole rules: one hole, one wire ────────────────────── + // A hole already holding a visible wire end can't start another wire — + // clicking it SELECTS that wire instead. (Without this, a wire whose + // endpoints sit in holes is impossible to select: the hole overlays + // swallow every click and silently start a new wire.) When a new end + // does land in an occupied hole (seated leg or another wire), it + // shifts to the nearest free hole of the same 5-hole strip / rail — + // electrically identical, visually untangled. + const bbComp = componentsRef.current.find((c) => c.id === componentId); + const isBBHole = !!bbComp && isBreadboard(bbComp.metadataId); + if (isBBHole) { + if (!wireInProgress) { + const occupying = findWireAtHole(wiresRef.current, componentId, pinName); + if (occupying) { + setSelectedWire(occupying.id); + return; + } + } + const el = document.getElementById(componentId) as + | (HTMLElement & { pinInfo?: Array<{ name: string }> }) + | null; + const allNames = (el?.pinInfo ?? []).map((p) => p.name); + const free = resolveFreeHole( + bbComp.metadataId, + componentId, + pinName, + wiresRef.current, + allNames, + ); + if (free !== pinName) { + const rot = Number(bbComp.properties?.rotation) || 0; + const pos = calculatePinPosition(componentId, free, bbComp.x + 6, bbComp.y + 6, rot); + if (pos) { + pinName = free; + x = pos.x; + y = pos.y; + } + } + } + if (wireInProgress) { // Finish wire: the store atomically appends the new wire and clears // `wireInProgress`. Once that's done, we look up the wire it just @@ -1900,8 +1977,15 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { ); } } else { - // Start wire: auto-detect color from pin name - startWireCreation({ componentId, pinName, x, y }, autoWireColor(pinName)); + // Start wire: rails mandate red (+) / black (−); other breadboard + // holes pick a random jumper color (like a real jumper kit — a board + // full of identical green wires is unreadable); component pins keep + // the name-based auto color (GND → black, VCC → red, else green). + const color = isBBHole + ? (railWireColor(pinName) ?? + WIRE_JUMPER_PALETTE[Math.floor(Math.random() * WIRE_JUMPER_PALETTE.length)]) + : autoWireColor(pinName); + startWireCreation({ componentId, pinName, x, y }, color); } }; @@ -2637,6 +2721,12 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { segmentDragJustCommittedRef.current = false; return; } + // Mouse-up already selected a wire under this click (breadboard + // body case) — don't let this bubbled click toggle it back off. + if (wireSelectJustHandledRef.current) { + wireSelectJustHandledRef.current = false; + return; + } // While the simulation runs the canvas is interact-only: a click on // a button must press it (its own handler), not select the wire // underneath it for editing. diff --git a/frontend/src/utils/breadboardOccupancy.ts b/frontend/src/utils/breadboardOccupancy.ts new file mode 100644 index 00000000..6d986ec9 --- /dev/null +++ b/frontend/src/utils/breadboardOccupancy.ts @@ -0,0 +1,90 @@ +/** + * Breadboard hole-occupancy rules ("vocabulario"): + * + * - each HOLE (pin) holds at most ONE wire end — a seated component leg + * counts as occupying its hole; + * - a terminal-strip ROW (5 holes) is one net, so two wires landing in + * different holes of the same row are connected — that's the legal way + * to fan out; + * - clicking a hole occupied by a (visible) wire selects that wire + * instead of starting a new one — otherwise wires whose whole length + * lies over hole overlays are impossible to select. + * + * These helpers are pure over the wires list + pin names so they're + * unit-testable without a DOM. + */ + +import type { Wire } from '../types/wire'; +import { breadboardGroupKey } from './breadboardNets'; + +/** + * The topmost VISIBLE wire with an endpoint exactly on (componentId, pinName), + * or null. Invisible `bb` seating wires are never returned — they're not + * user-interactive — but see `holeIsOccupied` for occupancy checks. + */ +export function findWireAtHole( + wires: Wire[], + componentId: string, + pinName: string, +): Wire | null { + for (let i = wires.length - 1; i >= 0; i--) { + const w = wires[i]; + if (w.bb) continue; + if ( + (w.start.componentId === componentId && w.start.pinName === pinName) || + (w.end.componentId === componentId && w.end.pinName === pinName) + ) { + return w; + } + } + return null; +} + +/** + * True when any wire end — including an invisible seating wire (a seated + * component leg) — already lives in this hole. + */ +export function holeIsOccupied(wires: Wire[], componentId: string, pinName: string): boolean { + return wires.some( + (w) => + (w.start.componentId === componentId && w.start.pinName === pinName) || + (w.end.componentId === componentId && w.end.pinName === pinName), + ); +} + +/** + * Resolve the hole a NEW wire end should land in, honouring one-wire-per-hole: + * returns `pinName` itself when free, else the nearest FREE hole in the same + * internal group (5-hole strip / power rail) — electrically identical, so the + * connection intent is preserved. Falls back to the original hole when the + * whole group is occupied (stacking is still electrically traced). + * + * `allPinNames` is the breadboard element's full pin list (from pinInfo); + * "nearest" is by index distance within the group, which matches physical + * adjacency for both strips and rails. + */ +export function resolveFreeHole( + metadataId: string, + componentId: string, + pinName: string, + wires: Wire[], + allPinNames: string[], +): string { + if (!holeIsOccupied(wires, componentId, pinName)) return pinName; + const group = breadboardGroupKey(metadataId, pinName); + if (!group) return pinName; + const clickedIdx = allPinNames.indexOf(pinName); + const candidates = allPinNames + .filter( + (name) => + name !== pinName && + breadboardGroupKey(metadataId, name) === group && + !holeIsOccupied(wires, componentId, name), + ) + .sort( + (a, b) => + Math.abs(allPinNames.indexOf(a) - clickedIdx) - + Math.abs(allPinNames.indexOf(b) - clickedIdx), + ); + return candidates[0] ?? pinName; +} diff --git a/frontend/src/utils/wireUtils.ts b/frontend/src/utils/wireUtils.ts index b3b4db5b..82c2093f 100644 --- a/frontend/src/utils/wireUtils.ts +++ b/frontend/src/utils/wireUtils.ts @@ -25,6 +25,48 @@ export const WIRE_KEY_COLORS: Record = { /** Default wire color when no specific signal is detected */ export const DEFAULT_WIRE_COLOR = '#22c55e'; +/** + * Jumper palette for breadboard wires — like a real jumper kit, neighbouring + * wires get visibly different colors instead of a wall of green. Red and + * black are deliberately absent: they're reserved for power-rail wires. + */ +export const WIRE_JUMPER_PALETTE = [ + '#22c55e', // green + '#0000cc', // blue + '#FF8C00', // orange + '#8B00FF', // violet + '#FFD700', // gold + '#00FFFF', // cyan + '#FF00FF', // magenta + '#8B4513', // brown + '#808080', // gray + '#32CD32', // limegreen +] as const; + +/** + * Power-rail hole → mandated wire color: positive rails (tp./bp.) are red, + * negative rails (tn./bn.) are black, like the stripes on a real breadboard. + * Returns null for anything that isn't a rail hole. + */ +export function railWireColor(pinName: string): string | null { + if (/^[tb]p\.\d+$/.test(pinName)) return '#cc0000'; + if (/^[tb]n\.\d+$/.test(pinName)) return '#000000'; + return null; +} + +/** + * Deterministic palette pick for a wire id — stable across reloads so a + * saved project keeps its colors, and different ids spread across the + * palette so adjacent jumpers rarely collide. + */ +export function jumperColorForId(wireId: string): string { + let h = 0; + for (let i = 0; i < wireId.length; i++) { + h = (h * 31 + wireId.charCodeAt(i)) >>> 0; + } + return WIRE_JUMPER_PALETTE[h % WIRE_JUMPER_PALETTE.length]; +} + /** * Automatically determine wire color from the starting pin name. * GND → black, VCC/5V/3.3V/VBUS/VIN → red, everything else → green.