feat(breadboard): one-hole-one-wire selection rules + jumper colors

Fixes the reported breadboard wiring UX ("requerimos un vocabulario"):

Vocabulary implemented (breadboardOccupancy.ts, pure + unit-tested):
  - 1 hole = 1 wire. A hole already holding a visible wire can't start a
    new one — clicking it SELECTS that wire. This is the core fix: wires
    running hole-to-hole across the board were impossible to select
    because the pin overlays swallowed every click and silently started a
    new wire (so the top horizontal rail wire was un-deletable).
  - Same 5-hole strip / rail = one net. When a new wire end lands in an
    occupied hole (a seated leg or another wire), it shifts to the
    NEAREST FREE hole of the same group — electrically identical, the
    real-world "bridge to the next hole in the row". Never crosses strips.

Two selection bugs behind the symptom:
  - Click on a wire lying over the breadboard BODY now selects the wire
    instead of opening the breadboard's 830-hole property dialog (that
    list popping over everything was the "se sobrepone la lista de todos
    los puntos" report). Guarded so the bubbled canvas click doesn't
    re-toggle the fresh selection.
  - Click on a hole occupied by a wire selects the wire (handlePinClick),
    so wires anchored in holes are reachable at all.

Jumper colors (like a real kit — a board of identical green wires is
unreadable, "se ven todos verdes"):
  - Power-rail holes mandate red (tp./bp. = +) / black (tn./bn. = −).
  - Other breadboard holes get a random jumper-palette color on manual
    draw; red and black are reserved for rails.
  - jumperColorForId gives agent/deterministic callers a stable per-wire
    color across reloads.

Tests: breadboard-occupancy.test.ts (12) — findWireAtHole (skips seating
wires, topmost wins), resolveFreeHole (same-strip shift, no cross-strip,
rail shift, passthrough), color policy (rails, palette determinism,
red/black reserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-07-21 05:49:23 +02:00
parent 701042fa22
commit d726139946
4 changed files with 346 additions and 3 deletions

View File

@ -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');
});
});

View File

@ -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<AlignmentGuide[]>([]);
/** 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.

View File

@ -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;
}

View File

@ -25,6 +25,48 @@ export const WIRE_KEY_COLORS: Record<string, string> = {
/** 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.