Guided Placement
Turns a draggable R3F object into a guided, game-like pick → carry → snap → placed
experience. One small state machine drives a set of composable stages — a full default
choreography out of the box; drop or reorder any stage.
Built for React Three Fiber 8 + drei 9 + three 0.169. The teaching example is a generic
object → destination; the policy system maps it onto bins, robots, or anything else.
Sibling skills this composes with:
interactive-3d-object — general hover/click/label/fly-in. This skill re-teaches only the
glow bit so it stands alone.
r3f-hero-camera — the camera rig. Stage 5 (framing) delegates to it when present; a
CameraControls.fitToBox fallback is included.
Object-aware placement policies
What the grabbed object IS decides where it may land. The hook takes a policy, not a
bare target:
| Policy |
Meaning |
Example |
fixedTarget([x,y,z]) |
One slot; drop commits within snapRadius of it |
a bin that belongs in one rack cell |
freePlacement(resolve) |
resolve(worldPoint) → { point, valid, ...meta } | null; the candidate slot follows the pointer |
a robot that may park on any valid grid cell |
resolve is where per-object constraints live: quantize to a grid cell, check bounds /
occupancy / forbidden zones, return valid: false to show the slot as rejected (ring turns
red, drop reverts). The choreography is identical either way — only the candidate source
differs. Different object types = different resolve functions, nothing else changes.
The stages → the state machine
Phases are discrete (React state → which stages render). Position, lift, scale, magnet
weight are refs stepped in useFrame — dragging never re-renders.
idle ─pointerdown─▶ selected ─move─▶ dragging ─drop on valid candidate─▶ snapping ─▶ placed ─settle─▶ idle
grab dip, │ │ pop + ripple + check, rehome
camera frame └── glow + lift ──┘ drop invalid/far ─▶ reverting ─▶ idle (descends home)
| # |
Stage |
Active in |
Built from |
| 1 |
Glow outline |
selected → snapping |
drei <Outlines> |
| 1b |
Anticipation dip |
grab (first ~120ms) |
scale squash 1→0.95→1 before the lift |
| 2 |
Soft lift |
after the dip → drop |
damped y offset (drops during revert too) |
| 3 |
Flowing path |
dragging · snapping |
bezier arc + travelling beads (home→candidate or object→candidate) |
| 4 |
Holo candidate ring |
selected → snapping |
rotating torus at the candidate; green = valid, red = invalid; pulse rate + brightness scale with magnet proximity |
| 5 |
Camera framing |
on grab (pointer down) |
delegate to r3f-hero-camera / fitToBox — see pitfalls |
| 6 |
Magnetic snap |
dragging (blend pull) · snapping (commit) |
proximity-weighted blend with hysteresis |
| 7 |
Pop + ripple + check |
placed |
asymmetric pop + expanding green ring + checkmark that fades out |
Workflow (checklist)
- Copy the core —
useGuidedPlacement + policies from REFERENCE.md.
No drag-plane mesh: the object itself is pointer-captured and pointer moves intersect
e.ray with a math plane at lift height.
- Pick the policy —
fixedTarget(...) or freePlacement(resolve) with your object's rules.
- Wrap your object with
<GuidedPlacement> (or spread bind yourself) — all stages wire up.
- External position owner? If a sim/fleet/physics system writes the object's transform
every frame, it must yield during the gesture — copy the
placementDrive pattern
(REFERENCE §7). Skipping this makes the two writers fight and the object teleports.
- Camera — pass
onGrab(box); delegate to your rig. Skip it entirely in an ortho
top-down editor view — the user already has the framing they chose.
- Tune the feel —
liftHeight, snapRadius (magnet engage), releaseRadius (magnet
let-go, default 1.35× — the hysteresis gap), magnetStrength, pop/settle durations.
Core principles
- Phases in state, motion in refs. setState only on phase edges; everything per-frame is refs.
- Capture on the object, raycast a math plane. Pointer capture routes all events to the
captured object — a separate plane mesh goes deaf the moment capture succeeds. So the
object handles its own
onPointerMove and intersects e.ray with a THREE.Plane.
- The magnet is felt AND seen. Blend weight grows nonlinearly toward the slot center
(gravity-well, not constant tug), engages/releases at different radii (hysteresis — no
boundary flicker), and the same weight drives the ring's pulse rate/brightness so the user
sees "you're in the zone" before releasing.
- Anticipation before action. ~120ms squash on grab, then lift. That beat is what makes
it feel picked up instead of switched on.
- Endings resolve, don't cut. Asymmetric pop (fast attack, slow settle), ripple, check
pops in after the pop peak and fades out before the settle — no dead air, no hard unmount.
- The user's pointer always wins. Magnet blends the intent, never overrides it; camera
never moves while the pointer is down and dragging.
- Rehome on success. The slot becomes the new
home so a later grab-and-miss reverts to
where the object now lives, not where it was born.
Pitfalls
- Pointer capture + separate drag-plane mesh = dead drag. Captured events go only to the
capture target. Use the math-plane pattern (REFERENCE §1); never a raycastable plane mesh.
- Revert must drop the lift. If lift stays up during
reverting, a 3D distance-to-home
check never passes (it includes the lift!) and the FSM wedges. Descend during revert, check
XZ distance + lift ≈ 0.
- Never move the camera mid-drag. It shifts the pointer→world mapping under a stationary
pointer — the object slides on its own. Frame on grab (pointer down), or not at all.
- Scene camera controls ride along with the drag. OrbitControls/CameraControls listen on
the canvas DOM — R3F
stopPropagation cannot stop a left-drag pan/orbit from ALSO firing
(runaway feedback: the world shifts under the pointer, the object chases it off-map). Set
controls.enabled = false on grab, restore on settle/revert/unmount.
- DOM overlays over the object swallow the grab. A drei
<Html> label/chip floating on
the object eats pointerdown before the canvas sees it — and it often appears only after
the first interaction (selection chip), so the SECOND grab mysteriously dies. Put
style={{ pointerEvents: 'none' }} on every display-only overlay.
- Two position writers fight. Any per-frame driver (fleet, physics) must check
placementDrive.activeId and yield; on commit, invalidate its cached runtime so it re-seeds
from the new location (no snap-back).
- Emissive rings wash out under tone mapping —
toneMapped={false}; and saturated ≠
glowing: for a real halo add selective bloom or an additive gradient sprite.
- Z-fighting on ground rings — lift
+0.015, depthWrite={false}, set renderOrder.
- Ring must read as a slot, not a decoration — size it from the object/cell footprint
(~1.1–1.3×), never a hardcoded radius.
Html checkmark is non-diegetic — it floats over the scene, ignores occlusion. Fine for
most apps; swap for a sprite in scenes with diegetic screens.
- Cursor + touch:
grab on hover, grabbing while carrying, restore on release; set
touchAction: 'none' on the canvas or touch drags scroll the page instead.
- Mid-gesture invalidation: if the object's owner state changes under the drag (a robot
gets dispatched a job), abort →
reverting. Check it in the hook's useFrame.
1---2name: guided-placement3description: Guided Placement4---56# Guided Placement78Turns a draggable R3F object into a guided, game-like **pick → carry → snap → placed**9experience. One small state machine drives a set of **composable stages** — a full default10choreography out of the box; drop or reorder any stage.1112Built for **React Three Fiber 8 + drei 9 + three 0.169**. The teaching example is a generic13*object → destination*; the policy system maps it onto bins, robots, or anything else.1415**Sibling skills this composes with:**16- `interactive-3d-object` — general hover/click/label/fly-in. This skill re-teaches only the17 glow bit so it stands alone.18- `r3f-hero-camera` — the camera rig. Stage 5 (framing) delegates to it when present; a19 `CameraControls.fitToBox` fallback is included.2021## Object-aware placement policies2223**What the grabbed object IS decides where it may land.** The hook takes a `policy`, not a24bare target:2526| Policy | Meaning | Example |27|---|---|---|28| `fixedTarget([x,y,z])` | One slot; drop commits within `snapRadius` of it | a bin that belongs in one rack cell |29| `freePlacement(resolve)` | `resolve(worldPoint) → { point, valid, ...meta } \| null`; the candidate slot follows the pointer | a robot that may park on **any valid grid cell** |3031`resolve` is where per-object constraints live: quantize to a grid cell, check bounds /32occupancy / forbidden zones, return `valid: false` to show the slot as rejected (ring turns33red, drop reverts). The choreography is identical either way — only the candidate source34differs. Different object types = different `resolve` functions, nothing else changes.3536## The stages → the state machine3738Phases are discrete (React state → which stages render). Position, lift, scale, magnet39weight are **refs** stepped in `useFrame` — dragging never re-renders.4041```42 idle ─pointerdown─▶ selected ─move─▶ dragging ─drop on valid candidate─▶ snapping ─▶ placed ─settle─▶ idle43 grab dip, │ │ pop + ripple + check, rehome44 camera frame └── glow + lift ──┘ drop invalid/far ─▶ reverting ─▶ idle (descends home)45```4647| # | Stage | Active in | Built from |48|---|-------|-----------|-----------|49| 1 | **Glow outline** | selected → snapping | drei `<Outlines>` |50| 1b | **Anticipation dip** | grab (first ~120ms) | scale squash 1→0.95→1 *before* the lift |51| 2 | **Soft lift** | after the dip → drop | damped `y` offset (drops during revert too) |52| 3 | **Flowing path** | dragging · snapping | bezier arc + travelling beads (`home→candidate` or `object→candidate`) |53| 4 | **Holo candidate ring** | selected → snapping | rotating torus at the candidate; **green = valid, red = invalid**; pulse rate + brightness scale with magnet proximity |54| 5 | **Camera framing** | on **grab** (pointer down) | delegate to `r3f-hero-camera` / `fitToBox` — see pitfalls |55| 6 | **Magnetic snap** | dragging (blend pull) · snapping (commit) | proximity-weighted blend **with hysteresis** |56| 7 | **Pop + ripple + check** | placed | asymmetric pop + expanding green ring + checkmark that fades out |5758## Workflow (checklist)59601. **Copy the core** — `useGuidedPlacement` + policies from [REFERENCE.md](REFERENCE.md).61 No drag-plane mesh: the object itself is pointer-captured and pointer moves intersect62 `e.ray` with a **math plane** at lift height.632. **Pick the policy** — `fixedTarget(...)` or `freePlacement(resolve)` with your object's rules.643. **Wrap your object** with `<GuidedPlacement>` (or spread `bind` yourself) — all stages wire up.654. **External position owner?** If a sim/fleet/physics system writes the object's transform66 every frame, it must **yield during the gesture** — copy the `placementDrive` pattern67 (REFERENCE §7). Skipping this makes the two writers fight and the object teleports.685. **Camera** — pass `onGrab(box)`; delegate to your rig. **Skip it entirely in an ortho69 top-down editor view** — the user already has the framing they chose.706. **Tune the feel** — `liftHeight`, `snapRadius` (magnet engage), `releaseRadius` (magnet71 let-go, default 1.35× — the hysteresis gap), `magnetStrength`, pop/settle durations.7273## Core principles74751. **Phases in state, motion in refs.** setState only on phase edges; everything per-frame is refs.762. **Capture on the object, raycast a math plane.** Pointer capture routes all events to the77 captured object — a separate plane *mesh* goes deaf the moment capture succeeds. So the78 object handles its own `onPointerMove` and intersects `e.ray` with a `THREE.Plane`.793. **The magnet is felt AND seen.** Blend weight grows nonlinearly toward the slot center80 (gravity-well, not constant tug), engages/releases at different radii (hysteresis — no81 boundary flicker), and the same weight drives the ring's pulse rate/brightness so the user82 *sees* "you're in the zone" before releasing.834. **Anticipation before action.** ~120ms squash on grab, then lift. That beat is what makes84 it feel picked up instead of switched on.855. **Endings resolve, don't cut.** Asymmetric pop (fast attack, slow settle), ripple, check86 pops in *after* the pop peak and fades out before the settle — no dead air, no hard unmount.876. **The user's pointer always wins.** Magnet blends the *intent*, never overrides it; camera88 never moves while the pointer is down and dragging.897. **Rehome on success.** The slot becomes the new `home` so a later grab-and-miss reverts to90 where the object now lives, not where it was born.9192## Pitfalls9394- **Pointer capture + separate drag-plane mesh = dead drag.** Captured events go only to the95 capture target. Use the math-plane pattern (REFERENCE §1); never a raycastable plane mesh.96- **Revert must drop the lift.** If lift stays up during `reverting`, a 3D distance-to-home97 check never passes (it includes the lift!) and the FSM wedges. Descend during revert, check98 XZ distance + lift ≈ 0.99- **Never move the camera mid-drag.** It shifts the pointer→world mapping under a stationary100 pointer — the object slides on its own. Frame on grab (pointer down), or not at all.101- **Scene camera controls ride along with the drag.** OrbitControls/CameraControls listen on102 the canvas DOM — R3F `stopPropagation` cannot stop a left-drag pan/orbit from ALSO firing103 (runaway feedback: the world shifts under the pointer, the object chases it off-map). Set104 `controls.enabled = false` on grab, restore on settle/revert/unmount.105- **DOM overlays over the object swallow the grab.** A drei `<Html>` label/chip floating on106 the object eats `pointerdown` before the canvas sees it — and it often appears only after107 the first interaction (selection chip), so the SECOND grab mysteriously dies. Put108 `style={{ pointerEvents: 'none' }}` on every display-only overlay.109- **Two position writers fight.** Any per-frame driver (fleet, physics) must check110 `placementDrive.activeId` and yield; on commit, invalidate its cached runtime so it re-seeds111 from the new location (no snap-back).112- **Emissive rings wash out under tone mapping** — `toneMapped={false}`; and saturated ≠113 glowing: for a real halo add selective bloom or an additive gradient sprite.114- **Z-fighting on ground rings** — lift `+0.015`, `depthWrite={false}`, set `renderOrder`.115- **Ring must read as a slot, not a decoration** — size it from the object/cell footprint116 (~1.1–1.3×), never a hardcoded radius.117- **`Html` checkmark is non-diegetic** — it floats over the scene, ignores occlusion. Fine for118 most apps; swap for a sprite in scenes with diegetic screens.119- **Cursor + touch**: `grab` on hover, `grabbing` while carrying, restore on release; set120 `touchAction: 'none'` on the canvas or touch drags scroll the page instead.121- **Mid-gesture invalidation**: if the object's owner state changes under the drag (a robot122 gets dispatched a job), abort → `reverting`. Check it in the hook's `useFrame`.