PURPOSE
Renders an absolute-positioned overlay layered on top of the shader preview canvas in the weapon workbench. For every UniformEntry that declares a handle kind (point, radius, angle), the component draws a circular grabber at the canvas-space pixel that corresponds to the uniform’s current value and lets the user drag the value live with pointer input. A bottom-left tooltip mirrors the in-flight value as a string. Implements the visual coordinate convention uniform (0,0) = canvas center, Y-up to match the shader convention.
OWNS
HANDLE_RADIUSconstant (6 px) — visual size of each grabber.HANDLE_RING_Rconstant (80 px) — orbit-ring radius used to map radius and angle uniforms into pixel space.Propsshape:canvasSize,uniforms: UniformEntry[],values: Record<string, number | number[]>,onChange(name, newValue).HandleDragStateshape: tracks the active drag’sname,kind,startMouseX,startMouseY,startValue.clamp,degToRad,radToDeglocal helpers.DraggableHandleOverlayexported function component.handlePixelPos(u)— pure mapping from uniform value to overlay pixel coordinates per handle kind.handlePointerDown/handlePointerMove/handlePointerUpcallbacks driving the live drag state machine.tooltiplocal state —{ name, value }displayed at the bottom-left of the overlay.- Refs:
overlayRef(overlay div) anddragRef(current drag state, kept in a ref so move handlers do not re-render per pointer event).
READS FROM
@engine/vfx-workbench/shader-templates/registry—UniformEntrytype, includingname,type(vec2/float),handle(point/radius/angle), and optionalhandleColor.Props.values— the live uniform value map provided by the parent (workbench shader state).Props.canvasSize— defines the center pixel used for the coordinate mapping.- The overlay DOM rect via
overlayRef.current?.getBoundingClientRect()to convert client coordinates into canvas-relative coordinates during pointer moves.
PUSHES TO
Props.onChange(name, newValue)— the only outward write path. Called on every pointer-move while dragging:pointhandles push[mx, my]vec2 values in canvas-centered, Y-up coordinates.radiushandles push a clamped scalar in[0, 1]derived from horizontal drag distance overHANDLE_RING_R.anglehandles push a scalar in[0, 360)degrees derived fromatan2(my, mx)and normalized.
- Local
tooltipstate — updated on hover, drag, and on leave.
DOES NOT
- Does not own the uniform values; it is a controlled component that delegates state to the parent via
onChange. - Does not render the shader, the canvas, or any uniform UI controls (sliders, number fields).
- Does not call any store, network, telemetry, or audio code.
- Does not read or write
localStorage, refs outside its own subtree, or window globals. - Does not enforce per-uniform min/max ranges beyond the radius clamp to
[0, 1]; the angle wraps via modulo and the point handle is unclamped. The code references a__uniformRangelookup but does not use it. - Does not handle keyboard input, touch gestures beyond what the Pointer Events API provides, or right-click.
- Does not animate; values flow directly from pointer position to
onChange.
Signals
data-overlay="true"on the root div — used by descendants to locate the overlay viaclosest('[data-overlay]').data-testid="draggable-handle-overlay"on the root div.data-testid="handle-${u.name}"on each handle grabber.data-handle-kindattribute on each grabber, set topoint,radius, orangle.data-testid="handle-tooltip"on the tooltip element.- Pointer-events are off on the overlay root (
pointerEvents: 'none') and on the tooltip; only handle grabbers opt back in withpointerEvents: 'auto'.
Entry points
- Default named export:
DraggableHandleOverlay({ canvasSize, uniforms, values, onChange })— invoked by the weapon-workbench screen above its shader preview canvas.
Pattern notes
- Coordinate convention is explicit and load-bearing: uniform
(x, y)maps to canvas pixel(center + x, center - y). Y is flipped at both the forward (handlePixelPos) and inverse (handlePointerMove) sides so the shader’s Y-up convention is preserved end-to-end. - Drag state is held in a
useRef, not React state, so high-frequency pointer-move events do not trigger re-renders. Only the tooltip and the controlled uniform values cause re-renders. - Pointer capture is acquired on
pointerdownviasetPointerCapture(pointerId), so move and up events continue to fire on the same element even if the cursor leaves the handle. - Handle layout per kind:
pointonvec2— pixel =(center + vx, center - vy).radiusonfloat— pixel =(center + v * HANDLE_RING_R, center); v is treated as normalized[0, 1].angleonfloat— pixel =(center + HANDLE_RING_R * cos(deg), center - HANDLE_RING_R * sin(deg)).
- Only uniforms whose
handlefield is set and whosehandlePixelPosreturns a non-null coordinate are rendered, so non-spatial uniforms are silently skipped. - Grabber visuals: 12 px circle with a 2 px
handleColorborder (default#00ffcc),${color}33translucent fill, additional${color}441 px box-shadow ring on point handles. - Tooltip formatting differs by source:
- Hover on a vec2 handle prints
[x.x, y.y]with one decimal per axis. - Hover on a scalar prints three decimals.
- During a drag, point handles print
[x.x, y.y], radius prints three decimals, angle prints${deg}°with one decimal.
- Hover on a vec2 handle prints
onPointerLeaveclears the tooltip only when no drag is active, so the tooltip stays visible mid-drag even if the pointer wanders off the grabber.- The component is purely presentational with respect to persistence: closing or remounting the workbench resets only the tooltip and drag refs; uniform state is the parent’s responsibility.