UNPKG

3d-scene-creator

Version:

A simple utility to create and manage 3D scenes

933 lines (831 loc) 31.5 kB
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); } } }