diff --git a/frontend/src/__tests__/breadboard-seating.test.ts b/frontend/src/__tests__/breadboard-seating.test.ts index 58b48e7d..af89dae8 100644 --- a/frontend/src/__tests__/breadboard-seating.test.ts +++ b/frontend/src/__tests__/breadboard-seating.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useSimulatorStore } from '../store/useSimulatorStore'; import { BREADBOARD_PINS } from '../velxio-elements/breadboard-element'; -import { computeSeating, seatOnDrop } from '../utils/breadboardSnap'; +import { computeSeating, resolveSeatPosition, seatOnDrop } from '../utils/breadboardSnap'; const RES_PIN_INFO = [ { name: '1', x: 0, y: 5.65, signals: [] }, @@ -172,3 +172,89 @@ describe('seatOnDrop', () => { expect(placed!.holes).toHaveLength(2); }); }); + +/** + * Agent-path seating correction: resolveSeatPosition lands a named pin exactly + * on a named hole by pure translation. jsdom has no layout (offsetWidth = 0), + * so this covers the translation + wiring at rotation 0; the rotation-pivot + * case — the reason the resolver exists — is verified in the browser via + * Playwright, where the real wrapper (with its text label) exists. + */ +describe('resolveSeatPosition', () => { + const bbAt = (x: number, y: number) => ({ id: 'bb1', metadataId: 'breadboard', x, y, properties: {} }); + // Anchor target in breadboard-element space (what the solver sends). Here we + // use a hole centre directly, since a 0-rotation resistor is on-lattice. + const holeElem = (name: string) => { + const h = BREADBOARD_PINS.find((p) => p.name === name)!; + return { x: h.x, y: h.y }; + }; + + beforeEach(() => mountFakeElement('res1', RES_PIN_INFO)); + + it('lands the anchor pin on its solver target from a wrong position', () => { + // Simulate the backend delivering an approximate x/y: drop the resistor + // 27 px off from where pin 1 should sit on hole 10t.b. + const bb = bbAt(0, 0); + const a = holeElem('10t.b'); + const targetWorld = { x: bb.x + INSET + a.x, y: bb.y + INSET + a.y }; + const comp = { id: 'res1', metadataId: 'resistor', x: targetWorld.x - 27, y: targetWorld.y + 13, properties: {} }; + + const pos = resolveSeatPosition(comp as never, 'bb1', '1', a.x, a.y, [bb, comp] as never)!; + expect(pos).not.toBeNull(); + // Pin 1 (offset 0,5.65 at rotation 0) now sits on the anchor target. + expect(pos.x + INSET + RES_PIN_INFO[0].x).toBeCloseTo(targetWorld.x, 6); + expect(pos.y + INSET + RES_PIN_INFO[0].y).toBeCloseTo(targetWorld.y, 6); + }); + + it('honours a sub-pitch anchor offset instead of snapping to a hole centre', () => { + // The solver shifts off-lattice parts; the anchor target is deliberately + // 2.4 px off a hole. The resolver must reproduce that, not re-centre it. + const bb = bbAt(0, 0); + const a = holeElem('10t.b'); + const shifted = { x: a.x + 2.4, y: a.y }; + const comp = { id: 'res1', metadataId: 'resistor', x: 500, y: 500, properties: {} }; + const pos = resolveSeatPosition(comp as never, 'bb1', '1', shifted.x, shifted.y, [bb, comp] as never)!; + expect(pos.x + INSET + RES_PIN_INFO[0].x).toBeCloseTo(bb.x + INSET + shifted.x, 6); + }); + + it('is a no-op when the part is already at its solver position', () => { + const bb = bbAt(50, 60); + const a = holeElem('20t.c'); + const comp = { + id: 'res1', metadataId: 'resistor', + x: bb.x + a.x - RES_PIN_INFO[0].x, + y: bb.y + a.y - RES_PIN_INFO[0].y, + properties: {}, + }; + const pos = resolveSeatPosition(comp as never, 'bb1', '1', a.x, a.y, [bb, comp] as never)!; + expect(pos.x).toBeCloseTo(comp.x, 6); + expect(pos.y).toBeCloseTo(comp.y, 6); + }); + + it('returns null for a non-breadboard target', () => { + const bb = bbAt(0, 0); + const comp = { id: 'res1', metadataId: 'resistor', x: 0, y: 0, properties: {} }; + expect(resolveSeatPosition(comp as never, 'res1', '1', 0, 0, [bb, comp] as never)).toBeNull(); + }); + + it('correction then updateComponent produces the invisible bb wires', () => { + // End-to-end of the agent path (minus the real rotation pivot): place off, + // correct, apply — the store should then seat both pins. + 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([]); + const a = holeElem('5t.a'); + const pos = resolveSeatPosition( + useSimulatorStore.getState().components.find((c) => c.id === 'res1')! as never, + 'bb1', '1', a.x, a.y, + useSimulatorStore.getState().components as never, + )!; + useSimulatorStore.getState().updateComponent('res1', pos); + const bbWires = useSimulatorStore.getState().wires.filter((w) => w.bb); + expect(bbWires).toHaveLength(2); + expect(bbWires.map((w) => w.end.pinName).sort()).toEqual(['11t.a', '5t.a']); + }); +}); diff --git a/frontend/src/utils/breadboardSnap.ts b/frontend/src/utils/breadboardSnap.ts index 73ec1330..c30cf34d 100644 --- a/frontend/src/utils/breadboardSnap.ts +++ b/frontend/src/utils/breadboardSnap.ts @@ -521,3 +521,55 @@ export function seatOnDrop( } return best; } + +/** + * Correct an agent-placed component's position so its anchor pin lands where + * the server's solver decided it should. This is the seating the agent already + * solved server-side; the browser only re-does the final positioning. + * + * Why the browser has to redo it: the backend computes the exact anchor target + * in BREADBOARD-ELEMENT space (pivot-free, so rotation-safe) but only an + * APPROXIMATE canvas x/y, because the true rotation pivot is the DOM wrapper + * centre — and the wrapper includes a text label whose width the server cannot + * measure. Under rotation that leaves the part off by enough that + * `computeSeating` finds no holes and the part sits visibly disconnected. + * + * `anchorX/anchorY` are the anchor pin's position in the breadboard's element + * space, straight from the solver — and they already carry the sub-pitch + * "fine translation" the solver applies to off-lattice footprints (a diode + * spans 7.5 pitches, so its anchor is deliberately ~2.4 px off a hole centre + * to split the error). Targeting the hole CENTRE instead would leave the far + * pin 4.8 px out and half-seat the part — verified against real DOM geometry. + * + * The fix is a pure TRANSLATION, never a re-solve: read where the anchor pin + * actually is from the live DOM (real pivot), read where the solver put it, + * and shift the whole part by the difference. Every other pin follows, because + * pin-to-pin offsets do not depend on the pivot. It cannot slide the part to + * different holes, so the netlist the agent validated is preserved. + * + * Returns the corrected {x, y}, or null when the geometry cannot be measured + * yet (element not mounted) — callers keep the backend's hint and retry. + */ +export function resolveSeatPosition( + comp: ComponentLike, + bbId: string, + anchorPin: string, + anchorX: number, + anchorY: number, + components: ComponentLike[], +): Pt | null { + const bb = components.find((c) => c.id === bbId); + if (!bb || !isBreadboard(bb.metadataId)) return null; + const rotation = Number(comp.properties?.rotation) || 0; + const anchor = calculatePinPosition( + comp.id, + anchorPin, + comp.x + WRAPPER_INSET, + comp.y + WRAPPER_INSET, + rotation, + ); + if (!anchor) return null; // DOM not ready — caller retries on the next frame + const targetX = bb.x + WRAPPER_INSET + anchorX; + const targetY = bb.y + WRAPPER_INSET + anchorY; + return { x: comp.x + (targetX - anchor.x), y: comp.y + (targetY - anchor.y) }; +}