Reflect the current runtime in docs/technical/hand-tracking.md:
- SVG visualizer is now the primary hand UI; the 3D glove is opt-in
via the Show Model debug toggle.
- Reorder the runtime flow to put HandTrackingVisualizer before
HandTrackingGlove and make explicit that grab, fist detection, SVG
and optional 3D glove are independent consumers of the same
landmark snapshot.
- New Fist Detection section showing how isFist() in
browserHandTracking.ts derives the flag from landmarks alone (palm
centroid + 4 fingertip distances), and confirming GrabbableObject
reads that flag directly - no glove involvement.
- Describe the SVG visualizer styling and the feMorphology outline
trick.
- Mark HandTrackingFallback and the gant_l/_pad assets as legacy in
the limitations list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrite the live hand visualizer as a light-blue silhouette with a
crisp dark-blue outline, suitable as the primary hand UI (replacing
the buggy 3D glove for the default flow):
- Palm polygon (landmarks 0,1,5,9,13,17) and five finger tubes merged
via an SVG feMorphology filter, so the outline is a single
continuous ring with no internal seams.
- Q curves bow out to two synthetic wrist corners (perpendicular to
the palm centerline) for a rounded heel of palm.
- Straight L edges between MCPs along the top - the filter dilation
rounds the corners visually, no creux.
- Each finger path starts half a stroke inside the palm so the round
base cap is hidden under the palm fill.
- Whole silhouette shrunk to 65% of the tracked hand size around the
centroid, with 0.8 group opacity, and a faint MediaPipe skeleton
overlay (lines + dots) on top.
Update the static fallback silhouettes (HandTrackingFallback) to a
matching curved-path look in a 100x120 viewBox.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new react-compiler-aware lint rules flag legitimate Three.js
external-system synchronizations (texture/uniform/AnimationAction
mutations) and a derived-state reset in PylonDownedPylon. None of
these are bugs — they're the canonical way to bridge React state
with imperative graphics objects — so they're annotated with
targeted eslint-disable comments and a small reorder.
- EbikeGPSMap: disable on uniform/texture sync effects
- EbikeSpeedmeter: disable around the canvas+texture useFrame sync
- PylonFarmerNPC: disable around playAnim (drei AnimationAction
fadeIn/fadeOut/setLoop/clampWhenFinished) and the effects/frame
callbacks that invoke it
- PylonDownedPylon: move showUpright/isPylonInteractive declarations
above the useFrame that reads them (fixes access-before-declared)
and disable set-state-in-effect on the per-step isRaised reset
Mechanical formatting cleanup carried over from the develop merge:
inline single-line tuples and break long lines per project prettier
config. No behavior change.
The debug control now reflects what it actually gates: the 3D hand
model rendering (used by World.tsx to decide whether to show the
hand-tracking gloves), not the legacy SVG visualizer.
- Debug.ts: rename showHandTrackingSvg → showHandTrackingModel
(state, GUI label "Show Model", getter/setter)
- World.tsx: gate showHandTrackingGloves on the new toggle and
drop the unused HandTrackingGloveHandedness import
Add lockInput option (default true) to animateCameraTransformTransition
so ebike mount/dismount can keep player input active during the 1s
camera tween instead of locking via setCinematicPlaying.
Also drop the unused camPointPos/dropPointPos debug vars and the
matching debugRestingPosition state — the consuming JSX has been
commented out for a while.
While the player is mounted on the e-bike and pressing a movement key,
the persistent 'Descendre du bike' prompt was visible on screen and
polluted the view during gameplay. The InteractableObject is now
unmounted as soon as window.ebikeDriveInputActive flips to true and
remounted the moment the bike comes to a stop.
The driving signal is read in a useFrame and only flips React state
on transitions, so this adds zero per-frame re-renders.
- Smaller boxes (36x36 key + 36px-tall label) instead of the previous
oversized white pills.
- Dark translucent background (rgba(10, 12, 20, 0.55)) with a 1px
white outline (rgba(255, 255, 255, 0.7)), no border-radius and
white text so the prompt blends with the dark UI instead of being a
bright blob over the 3D scene.
- Key cube now has a 3D keyboard-key effect (inset top highlight +
inset bottom darkening + small bottom drop) so it reads as a
physical key.
- Key and label are visually separated (gap: 8px) but share the same
height for alignment.
- InteractPrompt no longer renders the label box when focused.label is
empty/whitespace, so callers can show the key prompt alone.
- Add RepairFocusBubble + useRepairFocusStore to the main files table.
- New 'Focus Bubble' section documenting the shroud lifecycle, the
cocoon decor pass and the vegetation/zone-overlay hide hook.
- Update the 'Fragmented' section to describe the recursive descent in
ExplodedModel.createParts and the new modelRotation field used to
align the fragmented model with the world-space source.
- Drop the stale reference to useRepairMovementLocked (removed in a
prior commit).
When the repair focus bubble is active the vegetation system and zone
debug visuals are unmounted so trees and gizmos don't clip through the
dark sphere shroud. Terrain, water, sky, clouds and grass remain
visible behind the bubble per Option (a).
Adds a dark expanding sphere around the repair model when the player
enters the immersive repair phases (fragmented / scanning / repairing /
reassembling). The bubble grows from 0 to 10m using GSAP expo.out over
2.5s and reverses on focus end, visually isolating the player from the
surrounding map.
- New useRepairFocusStore tracks active state + world center.
- New RepairFocusBubble renders a BackSide sphere shell + a soft cocoon
decor pass (grid floor + directional light + ambient) inside.
- RepairGame drives setFocus from its lifecycle effect.
- Mounted in both GameStageContent and TestMap so behaviour matches in
the production scene and the physics test scene.
Also drops the now-unused EBIKE_CONFIG_KEY constant in
GameStageContent.tsx (leftover from a previous remount-key strategy).
- Larger label box and key cube on white-translucent backgrounds
(rgba(255, 255, 255, 0.92)) with black Inter 900 text and rounded
12px corners + soft drop shadow.
- Move from bottom: 30% to bottom: 12% so the prompt sits closer to
the visual center of attention near focused world objects.
- Key cube grown 24x24 -> 64x64 / font 13 -> 32, label padding 0 ->
16x24 / font 13 -> 22, both bold instead of regular.
- ExplodedModel.createParts now descends recursively through single
mesh-bearing wrapper nodes (e.g. Scene > Moto > Eclatement) until
reaching a node with multiple mesh-bearing children. Previously the
first wrapper was used as root, so models with extra Empty/group
parents fell back to flat leaf meshes lerping in local space.
- Add optional modelRotation field on RepairMissionConfig so fragmented
+ repairing models can match the world-space rotation of the source
inspection model (parked Ebike).
- Ebike mission now uses EBIKE_WORLD_ROTATION_Y/EBIKE_WORLD_SCALE
directly so the fragmented bike lines up with the parked bike.
Adds an opt-out 'snapToTerrain' prop on Ebike so the parked position
keeps the explicit Y supplied by callers instead of resolving against
the world terrain GLTF. TestMap passes snapToTerrain={false} since it
does not render the world terrain — without this the bike was being
positioned at the invisible terrain height, far above the test floor,
and looked missing.
Drops the useRepairMovementLocked hook, the RepairMovementLockIndicator
overlay, and all PlayerController gating tied to repair sub-states.
The repair flow no longer freezes player movement or shows a lock
banner; the player keeps full control while interacting with the case.
The Physique test scene now mounts the real Ebike component for the
ebike repair zone, mirroring GameStageContent so the bike model and
its interactions (mount/dismount, parked position tracking) are
available when testing the repair flow.
RepairGame derives its live world position from
window.ebikeParkedPosition once the ebike mission leaves the
locked/waiting phase, so the repair sequence happens wherever the
player parked the bike rather than at the static zone anchor.
Pylon-only mission steps (approaching/arrived/npc-return/narrator-outro)
no longer appear in the GameStateDebugPanel sub-state dropdown for the
ebike or farm missions, which use the shorter
locked/waiting/inspected/fragmented/scanning/repairing/reassembling/done
flow.
Render the exploded mission model during the repairing step so broken
nodes (e.g. ebike refroidisseur) stay visible in the world, and surface
their world positions to RepairRepairingStep so broken pieces spawn
from where they belong on the model rather than from a static offset.
- ExplodableModel: add hideNodeNames (mesh visibility off, restored on
unmount) and nodeAnchorNames + onNodeAnchorsChange (per-frame world
positions debounced via signature so React state updates only when
the values actually move).
- RepairGame: render ExplodableModel during 'repairing' with the broken
node names hidden + anchors forwarded; threads brokenAnchors state
to RepairRepairingStep.
- RepairScanSequence + RepairRepairingStep: propagate targetNodeName
through scanned and fallback broken-part lists.
- RepairRepairingStep: broken parts spawn at brokenAnchors[targetNodeName]
when set, falling back to legacy BROKEN_PART_START_OFFSETS otherwise.
- Update pylone path references from /models/pylone/model.gltf to .glb
(galleryModels, mapInstancingConfig, repairMissions). The new glb
exports a single cable node 'cable2' (no cable1).
- Pylon brokenParts: drop lampe and panneau2; the pylon design no longer
has detached broken pieces (the cable is just missing from the model).
- Pylon replacement cables target the pylon's 'cable2' node world position
via targetNodeName (used by the upcoming broken-anchor wiring).
- Ebike refroidisseur replacement and brokenPart both target the bike's
'refroidisseur' node so the broken piece spawns from its original
location and the replacement can install in the same slot.
When a replacement part with a caseLockGroup is grabbed, sibling parts
sharing the same group become non-interactable and ghosted (35% opacity)
until the held part is released. This implements the pylon cable choice
where the player picks either cable1 or cable2 (both valid) without
being able to grab both simultaneously.
- GrabbableObject: add disabled prop (skips interaction frame logic and
unmounts InteractableObject so it does not register with the manager)
and onGrabChange callback fired on press, release, hand grab, and hand
release. Force-releases when disabled becomes true mid-grab.
- SimpleModel: add opacity prop, traversed onto cloned mesh materials
(safe because cloneResources clones materials per instance).
- RepairObjectModel: forward ghosted prop as opacity 0.35.
- RepairRepairingStep: track heldPartByLockGroup and pass disabled +
ghosted to siblings of the currently held part.
- Ebike replacement parts: cooling core (correct, anchored at refroidisseur)
+ four distractors anchored at cabledroit/cablegauche/pucehaut/pucebas.
Removes the ad-hoc gant_l/talkie distractors in favor of consistent
case-anchored visuals.
- Pylon replacement parts: cable1 + cable2 (alternative correct, both with
caseLockGroup 'pylon-cable' for upcoming soft-lock) + refroidisseur and
two puce distractors anchored to packderelance.
- Farm replacement parts kept as-is (caseAnchor undefined falls back to
placeholder slot positions for backward compatibility).
- RepairGame threads anchors from RepairCaseModel through RepairMissionCase
to RepairRepairingStep; replacement-part initial position now resolves
to the anchor world position when caseAnchor is set, falling back to the
legacy slot index otherwise.
- RepairMissionConfig.requiredReplacementPartId (string) is replaced by
requiredReplacementPartIds (readonly string[]) so a mission can accept
several alternative correct parts (e.g. pylon will accept either cable).
- RepairMissionPartConfig gains optional caseAnchor (where the standalone
spawns inside packderelance), caseLockGroup (mutually exclusive parts),
and targetNodeName (snap onto a node of the broken model rather than a
placeholder slot in the case).
- RepairScannedBrokenPart gains targetNodeName so scan results can carry
this hint through to the repairing step.
- RepairRepairingStep validation logic (placed/wrong/feedback) now matches
any id in requiredReplacementPartIds. Existing data is migrated mechanically
(single-element arrays); part-level new fields are wired in subsequent
commits.
- Fix REPAIR_CASE_LID_NODE_NAME from "partiesup" to "partsup" (the actual
node name in packderelance.gltf), restoring the lid open/close animation
that was silently no-oping since introduction.
- Add REPAIR_CASE_PART_ANCHOR_NAMES (cabledroit, cablegauche, pucehaut,
pucebas, refroidisseur) and REPAIR_CASE_PART_ANCHOR_FALLBACKS for
case-local positions used when the GLTF lacks a node (refroidisseur).
- RepairCaseModel now resolves these anchor nodes on mount, hides existing
meshes underneath them, and creates lightweight Object3D placeholders for
missing names so the anchoring pipeline is uniform.
- Each frame, anchor world positions are converted to step-local space and
emitted via the new onAnchorsChange callback (debounced via signature like
placeholders). Consumers added in subsequent commits.
The browser MediaPipe model (hand_landmarker.task float16) was failing
to detect hands at 320x240 — MediaPipe ran for 10s straight without
ever returning a hand. Bumping the requested camera resolution to
640x480 (browser only — backend still ships 320x240 JPEGs over the
WebSocket) makes the model reliably pick hands up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(1) HandTrackingProvider always renders the same JSX root
(HandTrackingRuntime) so toggling `enabled` no longer remounts the
<Canvas> below — that remount was destroying the WebGL context every
time the player entered an interaction zone.
(2) Add HAND_TRACKING_LINGER_MS (2s) cooldown on `enabled` so brief
walk-throughs of a trigger zone don't tear down MediaPipe before it
has time to initialize the webcam + model + first frame (cold start
~800ms).
Resolves the WebGL context lost + respawn loop and restores visible
hand tracking in the backend runtime. Browser JS runtime detection
quality is a separate follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In dev, <StrictMode> intentionally mounts → unmounts → remounts each
effect to surface non-idempotent code. The hand tracking hooks were
calling getUserMedia and creating MediaPipe / WebSocket runtimes on
every mount, which in practice ran the full start/stop/start cycle
inside a few milliseconds and pushed WebGL over its limit on top
of the loaded scene → context lost.
Add HAND_TRACKING_RUNTIME_START_DELAY_MS (80ms) and delay the actual
start() call behind a setTimeout in both useBrowserHandTracking and
useRemoteHandTracking. The cleanup clears the timer, so a fast
mount/unmount never reaches start(). 80ms is invisible to the user
(<5 frames at 60fps) and also absorbs rapid `nearby` toggles at
trigger borders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Several mitigations against the WebGL context lost that fires when
hand tracking starts on a loaded scene:
- Canvas: fixed DPR [1,1], antialias off, scoped id="game-canvas",
context-lost handler releases MediaPipe and logs GPU memory counters
- optimizeGLTFScene: cap anisotropy at 2 and stop forcing mipmaps /
needsUpdate on every pass — avoids massive texture re-uploads
- MediaPipe: force CPU delegate (HAND_TRACKING_BROWSER_DELEGATE),
cache the landmarker instance, and expose releaseBrowserHandLandmarker
- useBrowserHandTracking / useRemoteHandTracking: idempotent cleanup
guarded by a cleanedUp flag, try/catch around the detect loop, and
release of the landmarker on stop
- World: mount HandTrackingGlove only when the matching hand is
actually present in the snapshot (status connected + hands.length > 0)
- HandTrackingGlove: drop the eager useGLTF.preload that was running
at startup whether or not hand tracking was used
Does not yet absorb the React StrictMode double-mount — that is the
follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Store the lil-gui Source controller so setHandTrackingSource() from
the settings menu can refresh its display, and log the transition
for traceability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PointerLockControls now targets #game-canvas and respects the
settings menu, and document.exitPointerLock() only runs when a
pointer lock is actually active. The terrain ground snap in
PlayerController is gated on sceneMode === "game" so it doesn't
fight the physics test scene's flat floor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lower grabbable spawn, expose GPS preview position/rotation as
constants, fix physics spawn to use PLAYER_EYE_HEIGHT, and silence
the console.log noise around waypoint loading in TestMap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Solid character mesh does not need transparency; BLEND combined with doubleSided
caused depth-sort issues rendering inside-faces over front-faces (visible as
triangular orange artifacts on the face). Default OPAQUE single-sided is correct.