Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions src/input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import * as THREE from 'three';
import { state, setSelectedBoard, setHoverTarget } from './state.js';
import * as tools from './tools.js';
import * as sketch from './sketch.js';

const RAY_LENGTH = 2;
const RAY_THICKNESS = 0.003;
const TRIGGER_THRESHOLD = 0.2;

const raycaster = new THREE.Raycaster();
const forward = new THREE.Vector3(0, 0, -1);
const tempDirection = new THREE.Vector3();
const tempTipPosition = new THREE.Vector3();

let previousTriggerPressed = false;
let hoveredButtonId = null;

function ensureRightRay(scene) {
if (state.ray.mesh) {
return;
}

const geometry = new THREE.CylinderGeometry(RAY_THICKNESS, RAY_THICKNESS, RAY_LENGTH, 16, 1, true);
geometry.rotateX(Math.PI / 2);
const material = new THREE.MeshBasicMaterial({
color: 0x00ffff,
transparent: true,
opacity: 0.6,
side: THREE.DoubleSide,
});

const mesh = new THREE.Mesh(geometry, material);
mesh.name = 'right-controller-ray';
mesh.visible = false;
scene.add(mesh);
state.ray.mesh = mesh;
}

function getRightController() {
return state.controllers.right?.controller || state.controllers.right || null;
}

function getRightGamepad() {
const right = state.controllers.right;
if (!right) return null;
if (right.inputSource && right.inputSource.gamepad) {
return right.inputSource.gamepad;
}
if (right.gamepad) {
return right.gamepad;
}
return null;
}

function updateRightRayTransform(rightController) {
if (!state.ray.mesh) return;

if (!rightController) {
state.ray.mesh.visible = false;
return;
}

state.ray.mesh.visible = true;
state.ray.mesh.position.copy(rightController.position);
state.ray.mesh.quaternion.copy(rightController.quaternion);
state.ray.mesh.updateMatrixWorld(true);
state.ray.mesh.translateZ(-RAY_LENGTH / 2);
}

function computeIntersections(rightController) {
if (!rightController) {
setHoverTarget(null);
tools.clearHoverState();
hoveredButtonId = null;
return null;
}

tempDirection.copy(forward).applyQuaternion(rightController.quaternion).normalize();
raycaster.set(rightController.position, tempDirection);

const boardMeshes = state.boards
.map((board) => board?.mesh || board)
.filter((mesh) => mesh instanceof THREE.Object3D);
const buttonMeshes = state.wristButtons.map((entry) => entry.mesh);

const intersections = raycaster.intersectObjects([...boardMeshes, ...buttonMeshes], false);
if (!intersections.length) {
if (hoveredButtonId) {
tools.setButtonHover(hoveredButtonId, false);
hoveredButtonId = null;
}
setHoverTarget(null);
return null;
}

const { object } = intersections[0];
const buttonEntry = state.wristButtons.find((entry) => entry.mesh === object);
if (buttonEntry) {
if (hoveredButtonId !== buttonEntry.id) {
if (hoveredButtonId) {
tools.setButtonHover(hoveredButtonId, false);
}
hoveredButtonId = buttonEntry.id;
tools.setButtonHover(buttonEntry.id, true);
}
const target = { type: 'button', id: buttonEntry.id };
setHoverTarget(target);
return target;
}

if (hoveredButtonId) {
tools.setButtonHover(hoveredButtonId, false);
hoveredButtonId = null;
}

const boardEntry = state.boards.find((entry) => entry.mesh === object || entry === object);
if (boardEntry) {
const target = { type: 'board', boardRef: boardEntry };
setHoverTarget(target);
return target;
}

setHoverTarget(null);
return null;
}

function isTriggerPressed(gamepad) {
if (!gamepad || !gamepad.buttons?.length) return false;
const button = gamepad.buttons[0];
return (button.value ?? button.pressed ? 1 : 0) > TRIGGER_THRESHOLD;
Comment on lines +128 to +130
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Trigger threshold logic always evaluates as pressed

The nullish/ternary expression button.value ?? button.pressed ? 1 : 0 is parsed as (button.value ?? button.pressed) ? 1 : 0, so any defined analog value (even 0) evaluates as truthy and the function returns true on every frame a gamepad exposes button.value. This means clicking tools or drawing strokes triggers immediately even when the trigger is not pulled past TRIGGER_THRESHOLD. The condition should compare the analog value (or a button.pressed fallback) to the threshold before converting to boolean.

Useful? React with 👍 / 👎.

}

function handleTriggerInteraction(rightController, hoverTarget) {
const gamepad = getRightGamepad();
const pressed = isTriggerPressed(gamepad);

if (state.sketchMode) {
if (!rightController) {
if (previousTriggerPressed && !pressed) {
sketch.endStroke();
}
previousTriggerPressed = pressed;
return;
}
if (pressed) {
tempDirection.copy(forward).applyQuaternion(rightController.quaternion).normalize();
tempTipPosition.copy(rightController.position).addScaledVector(tempDirection, RAY_LENGTH);
sketch.extendStroke(tempTipPosition);
} else if (previousTriggerPressed) {
sketch.endStroke();
}
} else if (pressed && !previousTriggerPressed && hoverTarget) {
if (hoverTarget.type === 'button') {
tools.handleToolClick(hoverTarget.id);
} else if (hoverTarget.type === 'board') {
setSelectedBoard(hoverTarget.boardRef);
}
}

previousTriggerPressed = pressed;
}

export function updateRaycast() {
if (!state.scene) return;

ensureRightRay(state.scene);

const rightController = getRightController();
updateRightRayTransform(rightController);
const hoverTarget = computeIntersections(rightController);
handleTriggerInteraction(rightController, hoverTarget);
}
54 changes: 54 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as THREE from 'three';
import { state, registerScene, registerControllers } from './state.js';
import * as input from './input.js';
import { buildWristMenuButtons } from './tools.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.xr.enabled = true;
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 1000);
const clock = new THREE.Clock();

registerScene(scene);

const wristMenu = new THREE.Group();
wristMenu.name = 'wrist-menu';
scene.add(wristMenu);
state.wristMenuGroup = wristMenu;
buildWristMenuButtons(wristMenu);

const leftController = new THREE.Group();
leftController.name = 'left-controller';
scene.add(leftController);

const rightController = new THREE.Group();
rightController.name = 'right-controller';
scene.add(rightController);

registerControllers({ left: { controller: leftController }, right: { controller: rightController } });

function updateWristMenuPose() {
const left = state.controllers.left?.controller;
if (!left || !state.wristMenuGroup) return;

const group = state.wristMenuGroup;
group.position.copy(left.position);
group.quaternion.copy(left.quaternion);
group.translateY(0.05);
group.translateX(0.05);
}

function renderLoop() {
const delta = clock.getDelta();
void delta;

updateWristMenuPose();
input.updateRaycast();

renderer.render(scene, camera);
}

renderer.setAnimationLoop(renderLoop);
14 changes: 14 additions & 0 deletions src/sketch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as THREE from 'three';

const workingStroke = {
points: [],
};

export function extendStroke(position) {
if (!position) return;
workingStroke.points.push(position.clone ? position.clone() : new THREE.Vector3().copy(position));
}

export function endStroke() {
workingStroke.points.length = 0;
}
44 changes: 44 additions & 0 deletions src/state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as THREE from 'three';

export const state = {
scene: null,
wristMenuGroup: null,
wristButtons: [],
boards: [],
hoverTarget: null,
sketchMode: false,
selectedBoard: null,
controllers: {
left: null,
right: null,
},
ray: {
mesh: null,
},
};

export function registerScene(scene) {
state.scene = scene;
}

export function registerControllers({ left, right }) {
state.controllers.left = left;
state.controllers.right = right;
}

export function addBoard(board) {
if (!board) return;
state.boards.push(board);
}

export function removeBoard(board) {
state.boards = state.boards.filter((entry) => entry !== board);
}

export function setSelectedBoard(board) {
state.selectedBoard = board;
}

export function setHoverTarget(target) {
state.hoverTarget = target;
}
Loading