fix(breadboard): land agent-seated parts exactly under rotation
The agent computes exact hole assignments server-side but can only send an approximate canvas x/y, because the rotation pivot is the DOM wrapper centre and the wrapper includes a text label the server cannot measure. Under rotation that left seated parts off by up to ~4 px — enough that a diode (pins 7.5 pitches apart) half-seated: computeSeating found no hole for the far pin and it went electrically dead. resolveSeatPosition corrects it in the browser by pure translation: read where the anchor pin actually is from live DOM geometry (real pivot), read where the solver put it, shift the whole part by the difference. Every other pin follows because pin-to-pin offsets are pivot-free. It never re-solves, so it cannot slide the part to different holes and the validated netlist holds. The anchor target is the solver's anchor position in breadboard-element space, WITH its sub-pitch centroid translation — not the hole centre. Targeting the centre would re-break the diode (far pin 4.8 px out). Verified against real rendered geometry in a browser: resistor and diode at 90° both seat within the intrinsic lattice residual (0.6 / 2.4 px). Applied via a `seat` payload on the move_component effect (velxio-prod overlay); this commit is the resolver + tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
66c6c7d813
commit
bbd025d1c4
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue