3d-scene-creator
Version:
A simple utility to create and manage 3D scenes
933 lines (831 loc) • 31.5 kB
text/typescript
import * as THREE from "three";
import { Tween, Group, Easing } from "@tweenjs/tween.js";
// Type-only import: the runtime module is loaded lazily in enablePhysics(), so
// projects that never call it don't pull cannon-es into their bundle.
import type * as CANNON from "cannon-es";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import type {
LightingOptions,
AnimatedModel,
AnimatedModelOptions,
PlayAnimationOptions,
PhysicsOptions,
PhysicsBodyOptions,
} from "../types";
export class SceneCreator {
scene: THREE.Scene;
renderer: THREE.WebGLRenderer;
container: HTMLElement | undefined;
cWidth: number;
cHeight: number;
camera: THREE.PerspectiveCamera;
initialCamPos: THREE.Vector3;
initialTargetPos: THREE.Vector3;
prevCamPos: THREE.Vector3;
controls: OrbitControls | undefined;
additionalRenderFn: (() => void) | undefined;
stopLoop: boolean = false;
scale: number = 1;
animating: number = 0;
// Per-instance tween group (tween.js, MIT). Advanced once per frame in the
// render loop and cleared on dispose, so tweens never leak across instances.
private tweens: Group = new Group();
// Animation mixers advanced once per frame in the render loop. While at least
// one mixer is registered the loop keeps drawing, so animations play smoothly.
private mixers: THREE.AnimationMixer[] = [];
private lastFrameTime?: number;
// Optional cannon-es physics world. When present it's stepped every frame and
// each linked mesh is synced from its rigid body. Created by enablePhysics().
physicsWorld?: CANNON.World;
private physicsBodies: { mesh: THREE.Object3D; body: CANNON.Body }[] = [];
private physicsFixedStep = 1 / 60;
private physicsMaxSubSteps = 3;
// The cannon-es module, loaded on demand by enablePhysics().
private cannon?: typeof import("cannon-es");
// Picking/Raycasting properties
raycaster: THREE.Raycaster;
mouse: THREE.Vector2;
selectedObject: THREE.Object3D | null = null;
pickingEnabled: boolean = false;
onObjectClick: ((obj: THREE.Object3D) => void) | undefined;
onObjectHover: ((obj: THREE.Object3D | null) => void) | undefined;
onObjectContextMenu: ((obj: THREE.Object3D) => void) | undefined;
// Lazily-created GLTF loader + per-URL cache of loaded scenes.
private gltfLoader?: GLTFLoader;
private gltfCache: Map<string, Promise<THREE.Group>> = new Map();
// Click-vs-drag discrimination: a pointer gesture only counts as a click if
// it moves less than `clickDragThreshold` pixels between down and up.
clickDragThreshold: number = 6;
private pointerDownX: number = 0;
private pointerDownY: number = 0;
private pointerDragging: boolean = false;
/**
* Initialize a 3D scene with Three.js and tween.js
* @param container - Optional HTML element to attach the renderer to
* @param scale - Scale factor for the scene (default: 1)
* @param camPos - Initial camera position (default: 10, 10, 10)
* @param targetPos - Initial camera target (default: 0, 0, 0)
*/
constructor(container?: HTMLElement, scale?: number, camPos?: THREE.Vector3, targetPos?: THREE.Vector3) {
if (container && !(container instanceof HTMLElement)) {
throw new Error('Container must be a valid HTMLElement');
}
this.scene = new THREE.Scene();
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
// Initialize picking/raycasting
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
if (scale) {
this.scale = scale;
}
this.cWidth = 0
this.cHeight = 0
this.camera = new THREE.PerspectiveCamera(
50,
this.cWidth / this.cHeight,
0.1 * this.scale,
2000 * this.scale
);
if (camPos) {
this.initialCamPos = camPos
} else {
this.initialCamPos = new THREE.Vector3(10 * this.scale, 10 * this.scale, 10 * this.scale)
}
// to render at least first frame
this.prevCamPos = new THREE.Vector3(this.initialCamPos.x + 1, 1, 1)
if (targetPos) {
this.initialTargetPos = targetPos
} else {
this.initialTargetPos = new THREE.Vector3(0, 0, 0);
}
this.camera.position.set(this.initialCamPos.x, this.initialCamPos.y, this.initialCamPos.z);
var cameraTarget = this.initialTargetPos;
this.camera.lookAt(cameraTarget);
this.resizeListener();
if (container) {
this.attachRenderer(container);
}
}
/**
* Add orbit controls to the camera
* @param overrides - Optional configuration overrides
* @returns this for method chaining
*/
addControls(overrides = {}) {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
const defaults = {
enabled: true,
enableDamping: true,
dampingFactor: 0.25,
maxPolarAngle: Math.PI / 2,
maxDistance: 500 * this.scale,
minDistance: 0,
rotateSpeed: 1,
enableZoom: true,
enablePan: true
}
const values = Object.assign(defaults, overrides);
Object.assign(this.controls, values);
this.controls.target.set(
this.initialTargetPos.x,
this.initialTargetPos.y,
this.initialTargetPos.z
);
return this;
}
/**
* Set a callback function to be executed on each render frame
* @param fn - Callback function to run each frame
* @returns this for method chaining
*/
setAdditionalRenderFn(fn: () => void) {
this.additionalRenderFn = fn;
return this;
}
resetSizes() {
if (this.container) {
this.cWidth = this.container.clientWidth;
this.cHeight = this.container.clientHeight;
}
}
/**
* Tween numeric properties of a target object. Durations are given in seconds;
* the render loop keeps drawing while at least one tween is active, then stops once they complete.
* @param target - Object whose numeric props are animated
* @param props - Destination values
* @param duration - Duration in seconds
* @param onComplete - Optional callback fired when the tween finishes
* @returns the created Tween
*/
private tween<T extends Record<string, any>>(
target: T,
props: Partial<Record<keyof T, number>>,
duration: number,
onComplete?: () => void
) {
this.animating++;
return new Tween(target, this.tweens)
.to(props, duration * 1000)
.easing(Easing.Quadratic.Out)
.onComplete(() => {
this.animating--;
if (onComplete) onComplete();
})
.start();
}
/**
* Animate the color of a 3D model
* @param name - Name of the object in the scene
* @param color - Target color (hex, rgb, or color name)
* @param duration - Animation duration in seconds (default: 2)
* @returns this for method chaining
*/
animateModelColor(name: string, color: string | number, duration = 2) {
const obj = this.scene.getObjectByName(name);
if (!obj) {
console.warn(`Object with name "${name}" not found in scene`);
return this;
}
let rgbColor = new THREE.Color(color);
obj.traverse((mesh) => {
if (mesh instanceof THREE.Mesh) {
this.tween(mesh.material.color, { r: rgbColor.r, g: rgbColor.g, b: rgbColor.b }, duration);
}
});
return this;
}
/**
* Animate the opacity of a 3D model
* @param name - Name of the object in the scene
* @param value - Target opacity value (0-1)
* @param duration - Animation duration in seconds (default: 2)
* @returns this for method chaining
*/
animateModelOpacity(name: string, value: number, duration = 2) {
const obj = this.scene.getObjectByName(name)
if (!obj) {
console.warn(`Object with name "${name}" not found in scene`);
return this;
}
obj.traverse((mesh) => {
if (mesh instanceof THREE.Mesh) {
mesh.material.transparent = true;
mesh.material.needsUpdate = true;
this.tween(mesh.material, { opacity: value }, duration, () => {
mesh.material.needsUpdate = true;
});
}
});
return this;
}
/**
* Animate the position of a 3D model
* @param name - Name of the object in the scene
* @param newPosition - Target position vector
* @param duration - Animation duration in seconds (default: 2)
* @returns this for method chaining
*/
animateModelPosition(name: string, newPosition: THREE.Vector3, duration = 2) {
const obj = this.scene.getObjectByName(name)
if (!obj) {
console.warn(`Object with name "${name}" not found in scene`);
return this;
}
this.tween(obj.position, { x: newPosition.x, y: newPosition.y, z: newPosition.z }, duration);
return this;
}
/**
* Attach renderer to a DOM element and start rendering
* @param container - HTML element to attach the canvas to
* @returns this for method chaining
*/
attachRenderer(container: HTMLElement) {
this.container = container;
this.resetSizes();
this.camera.aspect = this.cWidth / this.cHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(this.cWidth, this.cHeight);
this.container.appendChild(this.renderer.domElement);
this.scene.updateMatrixWorld(true);
this.startRenderLoop();
return this;
}
/**
* Add a game-ready lighting rig: a hemisphere fill, a shadow-casting key
* light, and an opposite fill light, with optional ACES tone mapping.
* All parts are configurable; sensible defaults are used when omitted.
* @param options - Lighting configuration overrides
* @returns this for method chaining
*/
addLighting(options: LightingOptions = {}) {
const {
hemisphere = { sky: 0xbcd4ff, ground: 0x3a2c16, intensity: 1.4 },
key = {},
fill = { color: 0x9ec3ff, intensity: 0.6, position: new THREE.Vector3(-10, 8, -8) },
shadows = true,
shadowArea = 16,
shadowMapSize = 2048,
toneMapping = true,
exposure = 1.05,
} = options;
if (hemisphere) {
const hemi = new THREE.HemisphereLight(
hemisphere.sky ?? 0xbcd4ff,
hemisphere.ground ?? 0x3a2c16,
hemisphere.intensity ?? 1.4,
);
hemi.position.set(0, 20, 0);
this.scene.add(hemi);
}
const keyLight = new THREE.DirectionalLight(key.color ?? 0xfff2dd, key.intensity ?? 2.6);
keyLight.position.copy(key.position ?? new THREE.Vector3(8, 16, 10));
this.scene.add(keyLight);
if (fill) {
const fillLight = new THREE.DirectionalLight(fill.color ?? 0x9ec3ff, fill.intensity ?? 0.6);
fillLight.position.copy(fill.position ?? new THREE.Vector3(-10, 8, -8));
this.scene.add(fillLight);
}
if (shadows) {
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(shadowMapSize, shadowMapSize);
const cam = keyLight.shadow.camera;
cam.near = 1;
cam.far = 60;
cam.left = -shadowArea;
cam.right = shadowArea;
cam.top = shadowArea;
cam.bottom = -shadowArea;
cam.updateProjectionMatrix();
keyLight.shadow.bias = -0.0004;
}
if (toneMapping) {
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = exposure;
}
return this;
}
/**
* Enable shadow casting/receiving on every mesh currently in the scene.
* Call after adding your meshes (and after {@link addLighting} with shadows on).
* @param cast - Whether meshes cast shadows (default: true)
* @param receive - Whether meshes receive shadows (default: true)
* @returns this for method chaining
*/
applyShadows(cast: boolean = true, receive: boolean = true) {
this.scene.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
obj.castShadow = cast;
obj.receiveShadow = receive;
}
});
return this;
}
/**
* Add a skybox to the scene (360° background)
* @param url - Optional URL to a 360° image texture
* @param color - Fallback color if no texture URL provided
* @param name - Optional name for the skybox object
* @returns this for method chaining
*/
addSkybox(url?: string, color: THREE.ColorRepresentation = "#B2FFFF", name?: string) {
const sphereGeom = new THREE.SphereGeometry(1000 * this.scale, 60, 60);
sphereGeom.scale(-1, 1, 1)
let sphereMaterial;
if (url) {
THREE.TextureLoader.prototype.crossOrigin = 'anonymous';
sphereMaterial = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load(url)
});
} else {
sphereMaterial = new THREE.MeshStandardMaterial({ color });
}
const skybox = new THREE.Mesh(sphereGeom, sphereMaterial);
skybox.name = name ? name : 'skybox';
this.scene.add(skybox);
return this;
}
resizeListener() {
this.resetSizes();
window.addEventListener(
"resize",
() => {
this.resetSizes();
const camera = this.camera;
const renderer = this.renderer;
camera.aspect = this.cWidth / this.cHeight;
camera.updateProjectionMatrix();
renderer.setSize(this.cWidth * 1, this.cHeight * 1);
camera.position.x = camera.position.x + 0.001
},
false
);
}
stopRenderLoop() {
this.stopLoop = true;
return this
}
startRenderLoop() {
this.stopLoop = false;
this.renderLoop();
return this
}
renderLoop() {
if (this.stopLoop) return
requestAnimationFrame(this.renderLoop.bind(this));
// Per-frame delta time (seconds), shared by tweens and animation mixers.
const now = performance.now();
const delta = this.lastFrameTime === undefined ? 0 : (now - this.lastFrameTime) / 1000;
this.lastFrameTime = now;
// Advance any active tweens and animation mixers before deciding to draw.
this.tweens.update();
if (this.mixers.length) {
for (const mixer of this.mixers) mixer.update(delta);
}
// Step the physics world and copy each body's transform onto its mesh.
if (this.physicsWorld) {
this.physicsWorld.step(this.physicsFixedStep, delta, this.physicsMaxSubSteps);
for (const { mesh, body } of this.physicsBodies) {
mesh.position.set(body.position.x, body.position.y, body.position.z);
mesh.quaternion.set(
body.quaternion.x,
body.quaternion.y,
body.quaternion.z,
body.quaternion.w
);
}
}
const scene = this.scene;
const renderer = this.renderer;
const camera = this.camera;
if (
camera.position.x !== this.prevCamPos.x ||
camera.position.y !== this.prevCamPos.y ||
camera.position.z !== this.prevCamPos.z ||
this.animating ||
this.mixers.length ||
this.physicsWorld ||
this.additionalRenderFn) {
renderer.render(scene, camera);
}
this.prevCamPos = this.camera.position.clone();
if (this.additionalRenderFn) this.additionalRenderFn()
if (this.controls) this.controls.update();
}
/**
* Reset camera to initial position with animation
* @returns this for method chaining
*/
resetCameraPosition() {
this.moveCamera(this.initialCamPos, this.initialTargetPos)
return this;
}
/**
* Animate camera to a new position
* @param newPosCam - Target camera position
* @param newPosTarget - Optional target position for orbit controls
* @param callback - Optional callback on animation complete
* @returns this for method chaining
*/
moveCamera(newPosCam: THREE.Vector3, newPosTarget?: THREE.Vector3, callback?: () => void) {
const camera = this.camera;
let reEnable: boolean | undefined;
if (this.controls) {
reEnable = this.controls.enabled;
this.controls.enabled = false;
}
this.tween(camera.position, { x: newPosCam.x, y: newPosCam.y, z: newPosCam.z }, 3, () => {
if (this.controls && typeof reEnable === 'boolean') this.controls.enabled = reEnable;
if (typeof callback === 'function') callback();
});
if (newPosTarget && this.controls) {
this.tween(this.controls.target, { x: newPosTarget.x, y: newPosTarget.y, z: newPosTarget.z }, 3);
}
return this
}
/**
* Load a 3D model from a URL
* @param url - URL to the model file
* @param loader - Optional THREE.js loader (default: ObjectLoader)
* @returns Promise that resolves with the loaded object
*/
loadModel(url: string, loader?: THREE.Loader): Promise<THREE.Object3D> {
if (!url) {
return Promise.reject(new Error('URL is required'));
}
if (!loader) {
loader = new THREE.ObjectLoader();
}
return loader.loadAsync(url).then(((obj: unknown) => {
const object = obj as THREE.Object3D;
this.scene.add(object);
return object;
})).catch((error) => {
console.error(`Failed to load model from "${url}":`, error);
throw error;
});
}
/**
* Load a glTF/glb model and return a fresh clone of its scene (not added to
* the scene graph). Results are cached per URL, so loading the same model
* many times only fetches/parses it once. Ideal for instancing buildings,
* units, props, etc. in a game.
* @param url - URL to the .glb/.gltf file
* @returns Promise resolving to a cloneable Group ready to position/add
*/
loadGLTF(url: string): Promise<THREE.Group> {
if (!this.gltfLoader) {
this.gltfLoader = new GLTFLoader();
}
let pending = this.gltfCache.get(url);
if (!pending) {
pending = this.gltfLoader.loadAsync(url).then((gltf) => gltf.scene);
// Don't cache failures: drop the rejected promise so a later call can retry.
pending.catch(() => this.gltfCache.delete(url));
this.gltfCache.set(url, pending);
}
return pending.then((scene) => scene.clone(true));
}
/**
* Register an AnimationMixer so it's advanced automatically every frame by
* the render loop. The loop keeps drawing while any mixer is registered.
* @param mixer - The mixer to drive
* @returns this for method chaining
*/
addMixer(mixer: THREE.AnimationMixer) {
if (!this.mixers.includes(mixer)) this.mixers.push(mixer);
return this;
}
/**
* Stop driving a previously-registered AnimationMixer.
* @param mixer - The mixer to remove
* @returns this for method chaining
*/
removeMixer(mixer: THREE.AnimationMixer) {
const i = this.mixers.indexOf(mixer);
if (i !== -1) this.mixers.splice(i, 1);
return this;
}
/**
* Load a rigged glTF/glb model, add it to the scene, and wire its animation
* clips into the render loop. Unlike {@link loadGLTF} (which returns a clone
* for static instancing), this keeps the original object so its skeleton
* animates correctly, and returns a small handle to control playback.
* @param url - URL to the .glb/.gltf file
* @param options - Load options (add to scene, shadows, autoplay)
* @returns Promise resolving to an {@link AnimatedModel} handle
*/
async loadAnimatedModel(url: string, options: AnimatedModelOptions = {}): Promise<AnimatedModel> {
const { add = true, shadows = true, autoplay } = options;
if (!this.gltfLoader) {
this.gltfLoader = new GLTFLoader();
}
const gltf = await this.gltfLoader.loadAsync(url);
const model = gltf.scene;
if (shadows) {
model.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
obj.castShadow = true;
obj.receiveShadow = true;
}
});
}
if (add) this.scene.add(model);
const mixer = new THREE.AnimationMixer(model);
this.addMixer(mixer);
const actions: Record<string, THREE.AnimationAction> = {};
for (const clip of gltf.animations) {
actions[clip.name] = mixer.clipAction(clip);
}
let activeAction: THREE.AnimationAction | null = null;
const play = (name: string, opts: PlayAnimationOptions = {}): THREE.AnimationAction | null => {
const { fade = 0.3, loop = true, clampWhenFinished = true } = opts;
const next = actions[name];
if (!next) {
console.warn(`Animation clip "${name}" not found on model "${url}"`);
return null;
}
if (next === activeAction) return next;
next.loop = loop ? THREE.LoopRepeat : THREE.LoopOnce;
next.clampWhenFinished = clampWhenFinished;
if (activeAction) activeAction.fadeOut(fade);
next.reset().setEffectiveTimeScale(1).setEffectiveWeight(1).fadeIn(fade).play();
activeAction = next;
return next;
};
const stop = (fade = 0.3) => {
if (activeAction) {
activeAction.fadeOut(fade);
activeAction = null;
}
};
const names = gltf.animations.map((c) => c.name);
if (autoplay !== false && names.length) {
play(typeof autoplay === "string" ? autoplay : names[0], { fade: 0 });
}
return { model, animations: gltf.animations, names, mixer, actions, play, stop };
}
/**
* Enable rigid-body physics (powered by cannon-es). The world is stepped every
* frame and each body added with {@link addBody} keeps its mesh in sync.
*
* cannon-es is imported lazily here, so projects that never call this method
* don't bundle it. This makes the method asynchronous: `await` it before
* calling {@link addBody} or {@link addGround}.
* @param options - Gravity and default contact material settings
* @returns Promise resolving to this (for chaining)
*/
async enablePhysics(options: PhysicsOptions = {}) {
if (this.physicsWorld) return this;
const CANNON = (this.cannon ??= await import("cannon-es"));
const g = options.gravity ?? new THREE.Vector3(0, -9.82, 0);
const gravity = Array.isArray(g)
? new CANNON.Vec3(g[0], g[1], g[2])
: new CANNON.Vec3(g.x, g.y, g.z);
const world = new CANNON.World({ gravity });
world.allowSleep = options.allowSleep ?? true;
world.defaultContactMaterial.restitution = options.restitution ?? 0.3;
world.defaultContactMaterial.friction = options.friction ?? 0.4;
this.physicsWorld = world;
return this;
}
/**
* Give a mesh a rigid body and keep the two in sync each frame. The collision
* shape is derived from the mesh's bounding box (or sphere) and its scale.
* Requires {@link enablePhysics} to have been called first.
* @param mesh - The mesh to simulate (its transform is driven by the body)
* @param options - Mass, shape and damping
* @returns The created cannon-es Body
*/
addBody(mesh: THREE.Object3D, options: PhysicsBodyOptions = {}) {
if (!this.physicsWorld || !this.cannon) {
throw new Error("Call (and await) enablePhysics() before addBody().");
}
const CANNON = this.cannon;
const { mass = 1, shape = "box", linearDamping = 0.01, angularDamping = 0.01 } = options;
const body = new CANNON.Body({ mass, shape: this.shapeFromMesh(mesh, shape) });
body.position.set(mesh.position.x, mesh.position.y, mesh.position.z);
body.quaternion.set(
mesh.quaternion.x,
mesh.quaternion.y,
mesh.quaternion.z,
mesh.quaternion.w
);
body.linearDamping = linearDamping;
body.angularDamping = angularDamping;
this.physicsWorld.addBody(body);
if (mass > 0) this.physicsBodies.push({ mesh, body });
return body;
}
/**
* Add a static, infinite ground plane at the given height (default y = 0).
* @param y - Height of the ground plane
* @returns The created cannon-es Body
*/
addGround(y: number = 0) {
if (!this.physicsWorld || !this.cannon) {
throw new Error("Call (and await) enablePhysics() before addGround().");
}
const CANNON = this.cannon;
const body = new CANNON.Body({ mass: 0, shape: new CANNON.Plane() });
body.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
body.position.set(0, y, 0);
this.physicsWorld.addBody(body);
return body;
}
/**
* Remove a body from the physics world and stop syncing its mesh.
* @param body - The body returned by {@link addBody} / {@link addGround}
* @returns this for method chaining
*/
removeBody(body: CANNON.Body) {
if (this.physicsWorld) this.physicsWorld.removeBody(body);
this.physicsBodies = this.physicsBodies.filter((b) => b.body !== body);
return this;
}
/** Build a cannon-es collision shape from a mesh's geometry and scale. */
private shapeFromMesh(mesh: THREE.Object3D, shape: "box" | "sphere"): CANNON.Shape {
const CANNON = this.cannon!;
const geom = (mesh as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
const s = mesh.scale;
if (shape === "sphere") {
if (geom && !geom.boundingSphere) geom.computeBoundingSphere();
const radius = geom?.boundingSphere?.radius ?? 0.5;
return new CANNON.Sphere(radius * Math.max(s.x, s.y, s.z));
}
const size = new THREE.Vector3(1, 1, 1);
if (geom) {
if (!geom.boundingBox) geom.computeBoundingBox();
geom.boundingBox?.getSize(size);
}
return new CANNON.Box(new CANNON.Vec3((size.x * s.x) / 2, (size.y * s.y) / 2, (size.z * s.z) / 2));
}
/** * Enable interactive object picking with mouse events
* @param onClickCallback - Callback when object is clicked
* @param onContextMenuCallback - Callback when object is right-clicked
* @returns this for method chaining
*/
enablePicking(
onClickCallback?: (object: THREE.Object3D) => void,
onHoverCallback?: (object: THREE.Object3D | null) => void,
onContextMenuCallback?: (object: THREE.Object3D) => void
) {
this.pickingEnabled = true;
this.onObjectClick = onClickCallback;
this.onObjectHover = onHoverCallback;
this.onObjectContextMenu = onContextMenuCallback;
this.onObjectHover = onHoverCallback;
if (!this.container) {
console.warn('Container not set. Picking requires attachRenderer to be called first.');
return this;
}
// Mouse move listener for hover detection
this.container.addEventListener('mousemove', (event: MouseEvent) => {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// Perform raycasting
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
if (intersects.length > 0) {
const picked = intersects[0].object;
if (picked !== this.selectedObject) {
this.selectedObject = picked;
if (this.onObjectHover) {
this.onObjectHover(picked);
}
}
} else {
if (this.selectedObject !== null) {
this.selectedObject = null;
if (this.onObjectHover) {
this.onObjectHover(null);
}
}
}
});
// Track pointer gesture distance so an orbit/pan drag never fires a click.
this.container.addEventListener('pointerdown', (event: PointerEvent) => {
this.pointerDownX = event.clientX;
this.pointerDownY = event.clientY;
this.pointerDragging = false;
});
this.container.addEventListener('pointermove', (event: PointerEvent) => {
if (this.pointerDragging) return;
const dx = event.clientX - this.pointerDownX;
const dy = event.clientY - this.pointerDownY;
if (Math.hypot(dx, dy) > this.clickDragThreshold) {
this.pointerDragging = true;
}
});
// Mouse click listener (suppressed when the gesture was a drag)
this.container.addEventListener('click', () => {
if (this.pointerDragging) return;
if (this.selectedObject && this.onObjectClick) {
this.onObjectClick(this.selectedObject);
}
});
// Touch support for mobile devices: pick on touchend only if it was a tap.
let touchStartX = 0;
let touchStartY = 0;
let touchMoved = false;
this.container.addEventListener('touchstart', (event: TouchEvent) => {
if (event.touches.length === 1) {
touchStartX = event.touches[0].clientX;
touchStartY = event.touches[0].clientY;
touchMoved = false;
} else {
touchMoved = true; // multi-touch = gesture, never a tap
}
});
this.container.addEventListener('touchmove', (event: TouchEvent) => {
if (touchMoved || event.touches.length !== 1) return;
const dx = event.touches[0].clientX - touchStartX;
const dy = event.touches[0].clientY - touchStartY;
if (Math.hypot(dx, dy) > this.clickDragThreshold) touchMoved = true;
});
this.container.addEventListener('touchend', (event: TouchEvent) => {
if (touchMoved) return;
const touch = event.changedTouches[0];
if (!touch) return;
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
if (intersects.length > 0 && this.onObjectClick) {
this.onObjectClick(intersects[0].object);
}
});
// context menu
this.container.addEventListener('contextmenu', (event: MouseEvent) => {
event.preventDefault();
if (this.selectedObject && this.onObjectContextMenu) {
this.onObjectContextMenu(this.selectedObject);
}
});
return this;
}
/**
* Disable interactive object picking
* @returns this for method chaining
*/
disablePicking() {
this.pickingEnabled = false;
this.selectedObject = null;
this.onObjectClick = undefined;
this.onObjectHover = undefined;
return this;
}
/**
* Get currently hovered/selected object
* @returns The selected object or null
*/
getSelectedObject(): THREE.Object3D | null {
return this.selectedObject;
}
/**
* Pick object at specific mouse position
* @param mouseX - Normalized X coordinate (-1 to 1)
* @param mouseY - Normalized Y coordinate (-1 to 1)
* @returns PickingResult if object found, null otherwise
*/
pickAt(mouseX: number, mouseY: number) {
this.mouse.set(mouseX, mouseY);
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
if (intersects.length > 0) {
const intersection = intersects[0] as any;
return {
object: intersection.object,
distance: intersection.distance,
point: intersection.point,
normal: intersection.normal || new THREE.Vector3(0, 1, 0),
uv: intersection.uv || undefined
};
}
return null;
}
/** * Clean up scene resources and stop rendering
* Call this when you're done with the scene to prevent memory leaks
*/
dispose() {
this.stopRenderLoop();
this.tweens.removeAll();
this.mixers.forEach((m) => m.stopAllAction());
this.mixers = [];
this.physicsBodies = [];
this.physicsWorld = undefined;
this.animating = 0;
this.renderer.dispose();
this.scene.clear();
if (this.container && this.renderer.domElement.parentNode === this.container) {
this.container.removeChild(this.renderer.domElement);
}
}
}