@inweb/viewer-three
Version:
JavaScript library for rendering CAD and BIM files in a browser using Three.js
283 lines (228 loc) • 9.47 kB
text/typescript
///////////////////////////////////////////////////////////////////////////////
// Copyright (C) 2002-2025, Open Design Alliance (the "Alliance").
// All rights reserved.
//
// This software and its documentation and related materials are owned by
// the Alliance. The software may only be incorporated into application
// programs owned by members of the Alliance, subject to a signed
// Membership Agreement and Supplemental Software License Agreement with the
// Alliance. The structure and organization of this software are the valuable
// trade secrets of the Alliance and its suppliers. The software is also
// protected by copyright law and international treaty provisions. Application
// programs incorporating this software must include the following statement
// with their copyright notices:
//
// This application incorporates Open Design Alliance software pursuant to a
// license agreement with Open Design Alliance.
// Open Design Alliance Copyright (C) 2002-2025 by Open Design Alliance.
// All rights reserved.
//
// By use of this software, its documentation or related materials, you
// acknowledge and accept the above terms.
///////////////////////////////////////////////////////////////////////////////
import { Clock, Camera, Controls, Quaternion, Vector2, Vector3, Raycaster, Object3D, MathUtils } from "three";
interface WalkControlsEventMap {
change: { type: "change" };
walkspeedchange: { type: "walkspeedchange"; data: number };
}
export class WalkControls extends Controls<WalkControlsEventMap> {
readonly EYE_HEIGHT = 1.7;
readonly FAILING_DISTANCE = 2;
readonly GROUND_FOLLOWING_SPEED = 0.05;
readonly LOOK_SPEED = 0.1;
readonly WALK_SPEED_DELIMITER = 4;
readonly WHEEL_SPEED_DELIMITER = 15000;
public movementSpeed = 0.1;
public multiplier = 3;
private raycaster: Raycaster;
private groundObjects: Object3D[];
private moveKeys: Set<string>;
private moveWheel = 0;
private moveClock: Clock;
private quaternion: Quaternion;
private downPosition: Vector2;
private mouseDragOn = false;
public rotateDelta: Vector2;
private camera: Camera;
constructor(camera: Camera, canvas: HTMLElement, groundObjects: Object3D[]) {
super(camera, canvas);
this.camera = camera;
this.groundObjects = groundObjects;
this.raycaster = new Raycaster();
this.raycaster.near = 0;
this.raycaster.far = this.EYE_HEIGHT + this.FAILING_DISTANCE;
this.moveKeys = new Set();
this.moveClock = new Clock();
this.quaternion = camera.quaternion.clone();
this.downPosition = new Vector2(0, 0);
this.rotateDelta = new Vector2(0, 0);
this.domElement.addEventListener("pointerdown", this.onPointerDown);
this.domElement.addEventListener("pointermove", this.onPointerMove);
this.domElement.addEventListener("pointerup", this.onPointerUp);
this.domElement.addEventListener("pointercancel", this.onPointerCancel);
this.domElement.addEventListener("wheel", this.onWheel);
window.addEventListener("keydown", this.onKeyDown);
window.addEventListener("keyup", this.onKeyUp);
}
override dispose() {
this.domElement.removeEventListener("pointerdown", this.onPointerDown);
this.domElement.removeEventListener("pointermove", this.onPointerMove);
this.domElement.removeEventListener("pointerup", this.onPointerUp);
this.domElement.removeEventListener("pointercancel", this.onPointerCancel);
this.domElement.removeEventListener("wheel", this.onWheel);
window.removeEventListener("keydown", this.onKeyDown);
window.removeEventListener("keyup", this.onKeyUp);
super.dispose();
}
onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
this.domElement.setPointerCapture(event.pointerId);
this.downPosition.set(event.clientX, event.clientY);
this.quaternion.copy(this.object.quaternion);
this.mouseDragOn = true;
};
onPointerMove = (event: PointerEvent) => {
if (!this.mouseDragOn) return;
const movePosition = new Vector2(event.clientX, event.clientY);
if (this.downPosition.distanceTo(movePosition) === 0) return;
this.rotateDelta.copy(this.downPosition).sub(movePosition);
this.rotateCamera(this.rotateDelta);
this.dispatchEvent({ type: "change" });
};
onPointerUp = (event: PointerEvent) => {
this.domElement.releasePointerCapture(event.pointerId);
this.mouseDragOn = false;
};
onPointerCancel = (event: PointerEvent) => {
this.domElement.dispatchEvent(new PointerEvent("pointerup", event));
};
onWheel = (event: WheelEvent) => {
this.moveWheel = event.deltaY;
this.update();
};
onKeyDown = (event: KeyboardEvent) => {
switch (event.code) {
case "NumpadSubtract":
case "Minus":
if (this.multiplier > 1) {
this.multiplier = this.multiplier - 1;
this.dispatchEvent({ type: "walkspeedchange", data: this.multiplier });
}
break;
case "NumpadAdd":
case "Equal":
if (this.multiplier < 10) {
this.multiplier = this.multiplier + 1;
this.dispatchEvent({ type: "walkspeedchange", data: this.multiplier });
}
break;
case "ArrowLeft":
case "ArrowRight":
case "ArrowUp":
case "ArrowDown":
case "KeyW":
case "KeyS":
case "KeyA":
case "KeyD":
case "KeyQ":
case "KeyE":
this.moveKeys.add(event.code);
this.update();
break;
}
};
onKeyUp = (event: KeyboardEvent) => {
if (this.moveKeys.delete(event.code)) this.update();
};
private updateGroundFollowing() {
const up = new Vector3().copy(this.camera.up);
this.raycaster.set(this.object.position, up.negate());
this.raycaster.params = this.raycaster.params = {
Mesh: {},
Line: { threshold: 0 },
Line2: { threshold: 0 },
LOD: { threshold: 0 },
Points: { threshold: 0 },
Sprite: { threshold: 0 },
};
const intersects = this.raycaster.intersectObjects(this.groundObjects, false);
if (intersects.length > 0) {
const groundY = intersects[0].point.y;
const targetY = groundY + this.EYE_HEIGHT;
// Smoothly interpolate the camera's y position to the target height
this.object.position.y = MathUtils.lerp(this.object.position.y, targetY, this.GROUND_FOLLOWING_SPEED);
}
}
override update() {
let moved = false;
let upgradeGroundFollowing = false;
const forward = new Vector3();
const sideways = new Vector3();
if (this.moveKeys.size > 0) {
upgradeGroundFollowing = true;
const timeDelta = this.moveClock.getDelta();
const moveDelta = (timeDelta * this.multiplier * this.movementSpeed) / this.WALK_SPEED_DELIMITER;
this.object.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
sideways.setFromMatrixColumn(this.object.matrix, 0);
sideways.y = 0;
sideways.normalize();
if (this.moveKeys.has("KeyW")) {
this.object.position.addScaledVector(forward, moveDelta);
}
if (this.moveKeys.has("KeyS")) {
this.object.position.addScaledVector(forward, -moveDelta);
}
if (this.moveKeys.has("KeyA")) {
this.object.position.addScaledVector(sideways, -moveDelta);
}
if (this.moveKeys.has("KeyD")) {
this.object.position.addScaledVector(sideways, moveDelta);
}
if (this.moveKeys.has("KeyQ")) {
this.object.translateY(moveDelta);
upgradeGroundFollowing = false;
}
if (this.moveKeys.has("KeyE")) {
this.object.translateY(-moveDelta);
upgradeGroundFollowing = false;
}
const lookDelta = this.LOOK_SPEED + (this.multiplier - 1);
if (this.moveKeys.has("ArrowUp")) this.rotateCamera(this.rotateDelta.add(new Vector2(0, -lookDelta / 2)));
if (this.moveKeys.has("ArrowDown")) this.rotateCamera(this.rotateDelta.add(new Vector2(0, lookDelta / 2)));
if (this.moveKeys.has("ArrowLeft")) this.rotateCamera(this.rotateDelta.add(new Vector2(lookDelta, 0)));
if (this.moveKeys.has("ArrowRight")) this.rotateCamera(this.rotateDelta.add(new Vector2(-lookDelta, 0)));
this.moveWheel = 0;
moved = true;
}
if (this.moveWheel !== 0) {
const moveDelta = (this.moveWheel * this.multiplier * this.movementSpeed) / this.WHEEL_SPEED_DELIMITER;
this.object.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
this.object.position.addScaledVector(forward, -moveDelta);
this.moveWheel += -1 * Math.sign(this.moveWheel);
moved = true;
}
if (upgradeGroundFollowing) this.updateGroundFollowing();
if (moved) {
this.dispatchEvent({ type: "change" });
}
if (this.moveKeys.size === 0 && this.moveWheel === 0) {
this.moveClock.stop();
this.moveClock.autoStart = true;
}
}
rotateCamera(delta: Vector2) {
const rotateX = (Math.PI * delta.x) / this.domElement.clientWidth;
const rotateY = (Math.PI * delta.y) / this.domElement.clientHeight;
const xRotation = new Quaternion();
xRotation.setFromAxisAngle(this.object.up, rotateX);
const yRotation = new Quaternion();
yRotation.setFromAxisAngle(new Vector3(1, 0, 0), rotateY);
const quaternion = this.quaternion.clone();
quaternion.premultiply(xRotation).multiply(yRotation).normalize();
this.object.setRotationFromQuaternion(quaternion);
}
}