// Pick & Place — TOP-DOWN VIEW
// Single perspective: looking straight down on the machine.
// Gantry rails appear as horizontal bars; head is a square carriage.
// The nozzle, viewed from above, is a circle (down the barrel) with crosshairs.
// Component "lifting" is shown by a soft shadow offset + slight scale, not perspective.
const ORANGE = '#FF3D00';
const ORANGE_DIM = '#A82800';
const WHITE = '#FFFFFF';
// Component colors (physical objects stay dark)
const INK_900 = '#0B0B0B';
const INK_800 = '#141414';
const INK_700 = '#1F1F1F';
const INK_600 = '#2B2B2B';
const INK_500 = '#3D3D3D';
const INK_400 = '#5C5C5C';
const INK_300 = '#8A8A8A';
const INK_200 = '#C4C4C4';
// Dark-theme machine surface colors
const M_BG = '#080808';
const M_1 = '#111111';
const M_2 = '#1A1A1A';
const M_3 = '#242424';
const M_4 = '#2E2E2E';
const M_5 = '#404040';
// Text on dark background
const T_PRI = '#EFEFEF';
const T_SEC = '#888888';
const T_TER = '#555555';
const W = 1920, H = 1080;
// MACHINE BED (the work area visible from above) — fills most of the canvas
const BED = { x: 120, y: 120, w: 1680, h: 840 };
// PCB — center-left of bed
const PCB = { x: 280, y: 280, w: 820, h: 540 };
// FEEDER — right side; the reel itself is a circle, tape extends toward pick point
const FEEDER = {
reel: { cx: 1560, cy: 720, r: 130 },
tape: { x: 1380, y: 220, w: 36, h: 520 }, // vertical tape going up to pick point
pick: { x: 1398, y: 250 } // where the head grabs from the tape
};
// Component slots on PCB (pcb-local coords)
const SLOTS = [
{ x: 80, y: 90, w: 160, h: 80, type: 'ic', label: 'U1' },
{ x: 280, y: 90, w: 70, h: 35, type: 'res', label: 'R1' },
{ x: 380, y: 90, w: 70, h: 35, type: 'res', label: 'R2' },
{ x: 490, y: 90, w: 100, h: 100, type: 'cap', label: 'C1' },
{ x: 80, y: 240, w: 130, h: 130, type: 'qfn', label: 'U2' },
{ x: 260, y: 260, w: 80, h: 80, type: 'led', label: 'D1' },
{ x: 370, y: 260, w: 80, h: 80, type: 'led', label: 'D2' },
{ x: 490, y: 240, w: 70, h: 35, type: 'res', label: 'R3' },
{ x: 600, y: 240, w: 70, h: 35, type: 'res', label: 'R4' },
{ x: 80, y: 420, w: 100, h: 50, type: 'cap', label: 'C2' },
{ x: 220, y: 420, w: 100, h: 50, type: 'cap', label: 'C3' },
{ x: 360, y: 420, w: 240, h: 60, type: 'conn', label: 'J1' },
];
// t0 = when nozzle starts descending at PICK point.
// Each cycle needs: prev place-up (~0.18) + travel-to-pick (~0.7) gap before the next t0.
// Spacing minimum ~2.0s per step to avoid teleporting.
const SEQUENCE = [
{ slot: 0, t0: 1.4 },
{ slot: 4, t0: 4.0 },
{ slot: 11, t0: 6.6 },
{ slot: 3, t0: 9.2 },
{ slot: 5, t0: 11.4 },
{ slot: 6, t0: 13.4 },
{ slot: 1, t0: 15.4 },
{ slot: 2, t0: 17.4 },
{ slot: 7, t0: 19.4 },
{ slot: 8, t0: 21.4 },
{ slot: 9, t0: 23.4 },
{ slot: 10, t0: 25.4 },
];
// ─────────────────────────────────────────────────────────────────────
// Component (top-down flat shape)
// ─────────────────────────────────────────────────────────────────────
function CompTop({ type, w, h, label }) {
if (type === 'res') {
return (
10K
);
}
if (type === 'cap') {
return (
);
}
if (type === 'led') {
return (
);
}
if (type === 'ic') {
return (
{Array.from({length: 10}).map((_,i) => (
))}
{label}
);
}
if (type === 'qfn') {
return (
{Array.from({length: 7}).map((_,i) => {
const step = w*0.84/7;
const o = w*0.08 + i*step + step*0.18;
const sz = step*0.65;
return (
);
})}
{label}
);
}
if (type === 'conn') {
return (
{Array.from({length: 12}).map((_,i) => (
))}
);
}
return ;
}
// Solder pad outline
function SolderPad({ slot, highlight }) {
return (
);
}
// ─────────────────────────────────────────────────────────────────────
// PCB (top view) — flat rectangle with copper traces visible
// ─────────────────────────────────────────────────────────────────────
function PCB_Board({ placedSet, activeSlot, placeFlash, targetSlot }) {
return (
{/* mounting holes */}
{[[24,24],[PCB.w-24,24],[24,PCB.h-24],[PCB.w-24,PCB.h-24]].map(([cx,cy],i) => (
))}
{/* copper traces */}
{/* drill grid */}
{Array.from({length: 14}).map((_,i) =>
Array.from({length: 9}).map((_,j) => (
))
)}
{/* solder pads (unplaced) */}
{SLOTS.map((s, i) => !placedSet.has(i) ? (
) : null)}
{/* placed components */}
{SLOTS.map((s, i) => {
if (!placedSet.has(i)) return null;
const flash = i === activeSlot ? placeFlash : 0;
return (
{flash > 0 && (
)}
);
})}
{/* silkscreen */}
APW-PCB-001 REV.A
APWAY
);
}
// ─────────────────────────────────────────────────────────────────────
// Feeder (top view) — circular reel + vertical tape with components
// ─────────────────────────────────────────────────────────────────────
function FeederTop({ time, tapeOffset }) {
const r = FEEDER.reel;
const t = FEEDER.tape;
const rotation = -time * 22;
return (
{/* feeder housing */}
{/* tape (vertical) */}
{/* tape sprocket holes */}
{Array.from({length: 18}).map((_,i) => (
))}
{Array.from({length: 18}).map((_,i) => (
))}
{/* component pockets visible on tape — small dark squares */}
{Array.from({length: 16}).map((_,i) => {
const py = t.y + 28 + i*30 + (tapeOffset%30);
if (py < t.y + 4 || py > t.y + t.h - 16) return null;
return (
);
})}
{/* reel */}
{/* spokes */}
{Array.from({length: 8}).map((_,i) => (
))}
{/* outer perforations */}
{Array.from({length: 32}).map((_,i) => (
))}
{/* feeder label (top) */}
FEEDER · 08
● LIVE
{/* pickup point indicator */}
);
}
// ─────────────────────────────────────────────────────────────────────
// Gantry (top view)
// X-rail = horizontal bar across top of bed
// Y-rail = vertical bar perpendicular, sliding along X-rail
// Head/carriage = square that slides along Y-rail
// Nozzle = circle with crosshair, looking down the barrel
// "lift" of nozzle from work surface = scale of nozzle circle (bigger when lifted, smaller when down)
// ─────────────────────────────────────────────────────────────────────
function Gantry({ headX, headY, nozzleZ, holding, time, showLaser, gantryColor }) {
const railW = BED.w;
const xRailY = BED.y; // top of bed - X rail spans bed top
const xRailH = 60;
// Y-rail is a vertical bar that follows headX, spans bed height
const yRailW = 60;
// Head sits at intersection of Y-rail and current Y position
// nozzleZ: 0 = up (high), 1 = down (touching surface)
// Visually: when up, the nozzle circle has a wider outer ring (perspective of barrel further)
// when down, it shrinks to the actual contact circle.
const nozzleOuterR = 28 - nozzleZ * 8; // outer ring (barrel) shrinks as we descend
const nozzleInnerR = 10 + nozzleZ * 6; // contact tip grows as we touch
const nozzleBlur = (1 - nozzleZ) * 0.4; // when up, slight motion blur halo
return (
{/* X-rail (horizontal, at top of bed) */}
{/* tick marks */}
{Array.from({length: Math.floor(railW/40) + 1}).map((_,i) => (
))}
{/* end caps */}
{/* Y-rail — perpendicular bar that rides on X-rail at headX */}
{/* X-carriage block (where Y-rail meets X-rail) */}
{/* ID stripe */}
{/* Y-rail bar going down across bed */}
{/* Y-rail tick marks down its center */}
{Array.from({length: Math.floor((BED.h)/40)}).map((_,i) => (
))}
{/* HEAD CARRIAGE — slides along Y-rail at headY */}
{/* carriage outer body */}
{/* top stripe */}
{/* status lamps */}
{/* head label */}
HEAD·01
{/* corner bolts */}
{[[-62,-42],[62,-42],[-62,42],[62,42]].map(([cx,cy],i) => (
))}
{/* mount plate around nozzle */}
{/* NOZZLE (top view — concentric circles) */}
{/* outer barrel ring */}
{/* mid ring */}
{/* held component (tiny, centered on nozzle, slightly visible from above) */}
{holding && (
)}
{/* inner contact tip */}
0.4 ? ORANGE : M_3}
stroke={ORANGE} strokeWidth={1}
opacity={nozzleZ > 0.4 ? 0.85 : 1}/>
{/* center pinhole */}
{/* crosshair */}
{/* halo when lifted (suggests barrel sticking up) */}
{nozzleBlur > 0 && (
)}
);
}
// ─────────────────────────────────────────────────────────────────────
// Component shadow (drops on bed when component is held - shows it's lifted)
// ─────────────────────────────────────────────────────────────────────
function HeldShadow({ headX, headY, holding, nozzleZ }) {
if (!holding || nozzleZ > 0.5) return null;
// shadow offset proportional to lift (1 - nozzleZ): higher = bigger offset
const lift = 1 - nozzleZ;
const offset = lift * 6;
return (
);
}
// ─────────────────────────────────────────────────────────────────────
// Telemetry HUD (top view — overlay corners)
// ─────────────────────────────────────────────────────────────────────
function Telemetry({ time, placedCount, total, headX, headY, currentLabel, phase }) {
return (
{/* Top-left brand */}
PICK · PLACE
AP-PNP · TOP VIEW · LINE 04
{/* Top-right coords */}
GANTRY POSITION
X {String(Math.round(headX)).padStart(4,'0')}
Y {String(Math.round(headY)).padStart(4,'0')}
● {phase}
{/* Bottom-left stats */}
PLACED
{String(placedCount).padStart(2,'0')}
/{String(total).padStart(2,'0')}
T+
{time.toFixed(1)}s
CURRENT
{currentLabel || '—'}
{/* Bottom-right logo */}
APWAY
APWAY · ASSEMBLY
);
}
// ─────────────────────────────────────────────────────────────────────
// MAIN SCENE — computes head state + renders
// ─────────────────────────────────────────────────────────────────────
function PickPlaceScene({ tweaks }) {
const rawTime = useTime();
const time = rawTime * tweaks.speed;
const HEAD_PARK = { x: 960, y: 540 };
let headX = HEAD_PARK.x;
let headY = HEAD_PARK.y;
let nozzleZ = 0;
let holding = null;
let activeSlot = null;
let placeFlash = 0;
let targetSlot = null;
let phase = 'IDLE';
const placedSet = new Set();
let currentLabel = '';
if (time < 0.6) {
const t = Easing.easeOutCubic(clamp(time / 0.6, 0, 1));
headX = -200 + (HEAD_PARK.x + 200) * t;
headY = HEAD_PARK.y;
phase = 'INIT';
}
for (let i = 0; i < SEQUENCE.length; i++) {
const step = SEQUENCE[i];
const slot = SLOTS[step.slot];
const slotWorld = {
x: PCB.x + slot.x + slot.w/2,
y: PCB.y + slot.y + slot.h/2,
};
const T_PICK_DOWN = 0.18;
const T_PICK_HOLD = 0.10;
const T_PICK_UP = 0.18;
const T_TRAVEL_TO_PLACE = 0.55;
const T_PLACE_DOWN = 0.20;
const T_PLACE_HOLD = 0.12;
const T_PLACE_UP = 0.18;
const tPickStart = step.t0;
const tPickDownEnd = tPickStart + T_PICK_DOWN;
const tPickHoldEnd = tPickDownEnd + T_PICK_HOLD;
const tPickUpEnd = tPickHoldEnd + T_PICK_UP;
const tTravelEnd = tPickUpEnd + T_TRAVEL_TO_PLACE;
const tPlaceDownEnd = tTravelEnd + T_PLACE_DOWN;
const tPlaceHoldEnd = tPlaceDownEnd + T_PLACE_HOLD;
const tPlaceUpEnd = tPlaceHoldEnd + T_PLACE_UP;
// travel-to-pick starts exactly when previous place-up ends (or at 0 for first step)
let tTravelToPickStart;
if (i === 0) {
tTravelToPickStart = Math.max(0, tPickStart - 0.8);
} else {
const prev = SEQUENCE[i-1];
tTravelToPickStart = prev.t0 + T_PICK_DOWN + T_PICK_HOLD + T_PICK_UP +
T_TRAVEL_TO_PLACE + T_PLACE_DOWN + T_PLACE_HOLD + T_PLACE_UP;
}
const T_TRAVEL_TO_PICK = tPickStart - tTravelToPickStart;
// mark earlier as placed
for (let j = 0; j < i; j++) {
const prev = SEQUENCE[j];
const prevPlaceUpEnd = prev.t0 + T_PICK_DOWN + T_PICK_HOLD + T_PICK_UP +
T_TRAVEL_TO_PLACE + T_PLACE_DOWN + T_PLACE_HOLD;
if (time >= prevPlaceUpEnd - 0.05) placedSet.add(prev.slot);
}
if (time >= tTravelToPickStart && time < tPickStart) {
const t = Easing.easeInOutCubic(clamp((time - tTravelToPickStart) / T_TRAVEL_TO_PICK, 0, 1));
let startX = HEAD_PARK.x, startY = HEAD_PARK.y;
if (i > 0) {
const prev = SLOTS[SEQUENCE[i-1].slot];
startX = PCB.x + prev.x + prev.w/2;
startY = PCB.y + prev.y + prev.h/2;
}
headX = startX + (FEEDER.pick.x - startX) * t;
headY = startY + (FEEDER.pick.y - startY) * t;
nozzleZ = 0;
currentLabel = slot.label;
targetSlot = step.slot;
phase = 'TRAVEL → PICK';
break;
} else if (time >= tPickStart && time < tPickDownEnd) {
const t = Easing.easeOutCubic(clamp((time - tPickStart) / T_PICK_DOWN, 0, 1));
headX = FEEDER.pick.x; headY = FEEDER.pick.y;
nozzleZ = t;
currentLabel = slot.label;
targetSlot = step.slot;
phase = 'PICKING';
break;
} else if (time >= tPickDownEnd && time < tPickHoldEnd) {
headX = FEEDER.pick.x; headY = FEEDER.pick.y;
nozzleZ = 1;
holding = { ...slot };
currentLabel = slot.label;
targetSlot = step.slot;
phase = 'VACUUM ON';
break;
} else if (time >= tPickHoldEnd && time < tPickUpEnd) {
const t = Easing.easeOutCubic(clamp((time - tPickHoldEnd) / T_PICK_UP, 0, 1));
headX = FEEDER.pick.x; headY = FEEDER.pick.y;
nozzleZ = 1 - t;
holding = { ...slot };
currentLabel = slot.label;
targetSlot = step.slot;
phase = 'LIFTING';
break;
} else if (time >= tPickUpEnd && time < tTravelEnd) {
const t = Easing.easeInOutCubic(clamp((time - tPickUpEnd) / T_TRAVEL_TO_PLACE, 0, 1));
headX = FEEDER.pick.x + (slotWorld.x - FEEDER.pick.x) * t;
headY = FEEDER.pick.y + (slotWorld.y - FEEDER.pick.y) * t;
nozzleZ = 0;
holding = { ...slot };
currentLabel = slot.label;
targetSlot = step.slot;
phase = 'TRAVEL → PLACE';
break;
} else if (time >= tTravelEnd && time < tPlaceDownEnd) {
const t = Easing.easeOutCubic(clamp((time - tTravelEnd) / T_PLACE_DOWN, 0, 1));
headX = slotWorld.x; headY = slotWorld.y;
nozzleZ = t;
holding = { ...slot };
activeSlot = step.slot;
placeFlash = 0;
currentLabel = slot.label;
targetSlot = step.slot;
phase = 'PLACING';
break;
} else if (time >= tPlaceDownEnd && time < tPlaceHoldEnd) {
headX = slotWorld.x; headY = slotWorld.y;
nozzleZ = 1;
holding = null;
placedSet.add(step.slot);
activeSlot = step.slot;
placeFlash = 1 - (time - tPlaceDownEnd) / T_PLACE_HOLD;
currentLabel = slot.label;
phase = 'PLACED';
break;
} else if (time >= tPlaceHoldEnd && time < tPlaceUpEnd) {
const t = Easing.easeOutCubic(clamp((time - tPlaceHoldEnd) / T_PLACE_UP, 0, 1));
headX = slotWorld.x; headY = slotWorld.y;
nozzleZ = 1 - t;
holding = null;
placedSet.add(step.slot);
activeSlot = step.slot;
placeFlash = (1 - t) * 0.4;
currentLabel = slot.label;
phase = 'RETRACT';
break;
} else if (time >= tPlaceUpEnd) {
placedSet.add(step.slot);
headX = slotWorld.x; headY = slotWorld.y;
nozzleZ = 0;
holding = null;
currentLabel = slot.label;
phase = 'IDLE';
}
}
// tape moves down (reel feeds tape) when picking
const tapeOffset = -time * 18;
return (
{/* PCB sits on bed, beneath gantry */}
{/* laser sight from head down to surface (only visible when nozzleZ < 0.6) */}
{tweaks.showLaser && nozzleZ < 0.6 && (
)}
{/* shadow of held component on bed below nozzle when lifted */}
{/* gantry on top */}
{tweaks.showHud && (
)}
);
}
// ─────────────────────────────────────────────────────────────────────
// Background — black bed with technical grid
// ─────────────────────────────────────────────────────────────────────
function Background() {
return (
{/* subtle dot grid only — no solid fill, no frame */}
{/* bed grid */}
{/* bed corner markers */}
{[[BED.x, BED.y],[BED.x + BED.w, BED.y],[BED.x, BED.y+BED.h],[BED.x+BED.w, BED.y+BED.h]].map(([cx,cy],i) => (
))}
);
}
// ─────────────────────────────────────────────────────────────────────
// Time labeller — sets data-screen-label per second on root
// ─────────────────────────────────────────────────────────────────────
function TimeLabeler() {
const time = useTime();
const sec = Math.floor(time);
React.useEffect(() => {
const root = document.querySelector('[data-screen-label]');
if (root) root.setAttribute('data-screen-label', `t=${sec}s`);
}, [sec]);
return null;
}
const TWEAK_DEFAULTS = {
"speed": 1.0,
"showHud": true,
"showLaser": true,
"gantryColor": "#1A1A1A",
"bedScale": 1.0
};
// Export scene components for website.jsx
Object.assign(window, {
PickPlaceScene, Background, W, H, BED, FEEDER, PCB_Board,
FeederTop, Gantry, Telemetry, HeldShadow, CompTop,
SLOTS, SEQUENCE, TWEAK_DEFAULTS, TimeLabeler, SolderPad,
M_BG, M_1, M_2, M_3, M_4, M_5, T_PRI, T_SEC, T_TER,
ORANGE, ORANGE_DIM,
});
// Only mount standalone App if website.jsx hasn't claimed the root
window.__SCENE_APP = function() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
return (
);
};
// Auto-mount only if website.jsx hasn't set a flag
setTimeout(() => {
if (!window.__WEBSITE_MOUNTED) {
const App = window.__SCENE_APP;
ReactDOM.createRoot(document.getElementById('root')).render();
}
}, 0);