@inweb/viewer-three
Version:
JavaScript library for rendering CAD and BIM files in a browser using Three.js
286 lines (234 loc) • 9.94 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.
///////////////////////////////////////////////////////////////////////////////
// ===================== AI-CODE-FILE ======================
// Source: Claude Sonnet 4.5
// Date: 2025-10-07
// Reviewer: vitaly.ivanov@opendesign.com
// Issue: CLOUD-5851
// Notes: Originally AI-generated, modified manually
// =========================================================
import { Camera, Clock, Controls, Vector2, Vector3, Raycaster, Object3D, MathUtils } from "three";
interface JoyStickControlsEventMap {
change: { type: "change" };
}
export class JoyStickControls extends Controls<JoyStickControlsEventMap> {
readonly EYE_HEIGHT = 1.7;
readonly FAILING_DISTANCE = 2;
readonly GROUND_FOLLOWING_SPEED = 0.05;
readonly WALK_SPEED_DELIMITER = 4;
public movementSpeed = 0.1;
public multiplier = 3;
private raycaster: Raycaster;
private groundObjects: Object3D[];
private canvas: HTMLCanvasElement;
private overlayElement: HTMLDivElement;
private joyStickCanvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private moveClock: Clock;
private camera: Camera;
private joyStickPosition: Vector2;
private isActive: boolean = false;
private readonly MAX_JOYSTICK_DISTANCE = 100;
private readonly INTERNAL_RADIUS = 35;
private readonly MAX_MOVE_STICK = 40;
private readonly EXTERNAL_RADIUS = 65;
private readonly CANVAS_SIZE = 200;
private centerX: number;
private centerY: number;
private pressed: boolean = false;
constructor(camera: Camera, domElement: HTMLElement, canvasElement: HTMLCanvasElement, groundObjects: Object3D[]) {
super(camera, domElement);
this.camera = camera;
this.canvas = canvasElement;
this.moveClock = new Clock(false);
this.joyStickPosition = new Vector2(0, 0);
this.groundObjects = groundObjects;
this.raycaster = new Raycaster();
this.raycaster.near = 0;
this.raycaster.far = this.EYE_HEIGHT + this.FAILING_DISTANCE;
this.centerX = this.CANVAS_SIZE / 2;
this.centerY = this.CANVAS_SIZE / 2;
this.overlayElement = document.createElement("div");
this.overlayElement.id = "joyStickDiv";
this.overlayElement.style.background = "rgba(0,0,0,0)";
this.overlayElement.style.position = "fixed";
this.overlayElement.style.zIndex = "0";
this.overlayElement.style.touchAction = "none";
canvasElement.parentElement.appendChild(this.overlayElement);
this.joyStickCanvas = document.createElement("canvas");
this.joyStickCanvas.id = "joyStickCanvas";
this.joyStickCanvas.width = this.CANVAS_SIZE;
this.joyStickCanvas.height = this.CANVAS_SIZE;
this.overlayElement.appendChild(this.joyStickCanvas);
this.context = this.joyStickCanvas.getContext("2d")!;
this.joyStickCanvas.addEventListener("pointerdown", this.onPointerDown);
document.addEventListener("pointermove", this.onPointerMove);
document.addEventListener("pointerup", this.onPointerUp);
window.addEventListener("resize", this.onResize);
document.addEventListener("fullscreenchange", this.onResize);
this.updateVisibility();
this.updatePosition();
this.draw();
}
override dispose() {
this.joyStickCanvas.removeEventListener("pointerdown", this.onPointerDown);
document.removeEventListener("pointermove", this.onPointerMove);
document.removeEventListener("pointerup", this.onPointerUp);
window.removeEventListener("resize", this.onResize);
document.removeEventListener("fullscreenchange", this.onResize);
this.overlayElement.remove();
super.dispose();
}
private onPointerDown = (event: PointerEvent) => {
event.preventDefault();
this.pressed = true;
};
private onPointerMove = (event: PointerEvent) => {
event.preventDefault();
if (!this.pressed) return;
let movedX = event.pageX;
let movedY = event.pageY;
if (
this.joyStickCanvas.offsetParent &&
(this.joyStickCanvas.offsetParent as HTMLElement).tagName.toUpperCase() === "BODY"
) {
movedX -= this.joyStickCanvas.offsetLeft;
movedY -= this.joyStickCanvas.offsetTop;
} else if (this.joyStickCanvas.offsetParent) {
movedX -= (this.joyStickCanvas.offsetParent as HTMLElement).offsetLeft;
movedY -= (this.joyStickCanvas.offsetParent as HTMLElement).offsetTop;
}
const x = 100 * ((movedX - this.centerX) / this.MAX_MOVE_STICK);
const y = 100 * ((movedY - this.centerY) / this.MAX_MOVE_STICK) * -1;
const distance = Math.sqrt(x * x + y * y);
if (distance > 20) {
this.joyStickPosition.set(x, y);
this.isActive = true;
} else {
this.joyStickPosition.set(0, 0);
this.isActive = false;
}
this.draw();
this.moveClock.start();
this.update();
};
private onPointerUp = (event: PointerEvent) => {
event.preventDefault();
this.pressed = false;
this.joyStickPosition.set(0, 0);
this.isActive = false;
this.moveClock.stop();
this.draw();
};
private onResize = () => {
this.updateVisibility();
this.updatePosition();
};
private updateVisibility() {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const isNarrowScreen = window.innerWidth < 1024;
if (isMobile || isNarrowScreen) {
this.overlayElement.style.display = "block";
} else {
this.overlayElement.style.display = "none";
}
}
private updatePosition() {
const rect = this.canvas.getBoundingClientRect();
this.overlayElement.style.top = `${rect.height - this.CANVAS_SIZE}px`;
this.overlayElement.style.left = `${rect.left}px`;
this.overlayElement.style.width = `${this.CANVAS_SIZE}px`;
this.overlayElement.style.height = `${this.CANVAS_SIZE}px`;
}
private draw() {
this.context.clearRect(0, 0, this.CANVAS_SIZE, this.CANVAS_SIZE);
// Draw external circle
this.context.beginPath();
this.context.arc(this.centerX, this.centerY, this.EXTERNAL_RADIUS, 0, 2 * Math.PI, false);
this.context.lineWidth = 2;
this.context.strokeStyle = "#35436E";
this.context.globalAlpha = 0.5;
this.context.stroke();
// Draw internal circle
let movedX = this.centerX + (this.joyStickPosition.x * this.MAX_MOVE_STICK) / 100;
let movedY = this.centerY - (this.joyStickPosition.y * this.MAX_MOVE_STICK) / 100;
movedX = Math.max(this.MAX_MOVE_STICK, Math.min(this.CANVAS_SIZE - this.MAX_MOVE_STICK, movedX));
movedY = Math.max(this.MAX_MOVE_STICK, Math.min(this.CANVAS_SIZE - this.MAX_MOVE_STICK, movedY));
this.context.beginPath();
this.context.arc(movedX, movedY, this.INTERNAL_RADIUS, 0, 2 * Math.PI, false);
this.context.fillStyle = "#35436E";
this.context.lineWidth = 2;
this.context.strokeStyle = "#35436E";
this.context.globalAlpha = 0.5;
this.context.fill();
this.context.stroke();
}
private updateGroundFollowing() {
const up = new Vector3().copy(this.camera.up);
this.raycaster.set(this.camera.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.camera.position.y = MathUtils.lerp(this.camera.position.y, targetY, this.GROUND_FOLLOWING_SPEED);
}
}
override update() {
if (!this.isActive) return;
const forwardInput = this.joyStickPosition.y / this.MAX_JOYSTICK_DISTANCE;
const rightInput = this.joyStickPosition.x / this.MAX_JOYSTICK_DISTANCE;
const timeDelta = this.moveClock.getDelta();
const moveDelta = (timeDelta * this.multiplier * this.movementSpeed) / this.WALK_SPEED_DELIMITER;
const forward = new Vector3();
const sideways = new Vector3();
this.camera.getWorldDirection(forward);
if (this.groundObjects.length > 0) {
forward.y = 0;
}
forward.normalize();
sideways.setFromMatrixColumn(this.camera.matrix, 0);
if (this.groundObjects.length > 0) {
sideways.y = 0;
}
sideways.normalize();
if (forwardInput !== 0) {
this.camera.position.addScaledVector(forward, moveDelta * forwardInput);
}
if (rightInput !== 0) {
this.camera.position.addScaledVector(sideways, moveDelta * rightInput);
}
if (forwardInput !== 0 || rightInput !== 0) {
if (this.groundObjects.length > 0) this.updateGroundFollowing();
this.dispatchEvent({ type: "change" });
}
}
}