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:
parent
701042fa22
commit
d726139946
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue