Add drag-to-orbit controls to vehicle and actor editors

Use Three.js OrbitControls in BaseRenderer for rotation-only orbiting
with damping. Vehicle editor auto-rotates and resets on part switch.
Actor editor uses orbit without auto-rotate (has skeletal animations).
Drag vs click detection uses pointermove threshold to avoid false
positives from autoRotate damping.
This commit is contained in:
Christian Semmler 2026-02-13 16:59:54 -08:00
parent f796ea4299
commit fe9117dd5e
5 changed files with 63 additions and 11 deletions

View File

@ -94,6 +94,9 @@ export class ActorRenderer extends BaseRenderer {
this.camera.position.set(2, 0.8, 3.5); this.camera.position.set(2, 0.8, 3.5);
this.camera.lookAt(0, 0.2, 0); this.camera.lookAt(0, 0.2, 0);
this.setupControls(new THREE.Vector3(0, 0.2, 0));
this.controls.autoRotate = false;
this.raycaster = new THREE.Raycaster(); this.raycaster = new THREE.Raycaster();
} }
@ -887,17 +890,13 @@ export class ActorRenderer extends BaseRenderer {
if (this.mixer) { if (this.mixer) {
this.mixer.update(delta); this.mixer.update(delta);
} else if (this.modelGroup) {
// Fallback: rotate if no animation loaded
this.modelGroup.rotation.y += 0.01;
} }
this.controls?.update();
} }
dispose() { dispose() {
this.animating = false;
this.stopAnimation(); this.stopAnimation();
this.clearModel(); super.dispose();
this.renderer?.dispose();
this.animationCache.clear(); this.animationCache.clear();
} }
} }

View File

@ -1,4 +1,5 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/** /**
* Base renderer providing shared Three.js setup, lighting, texture creation, * Base renderer providing shared Three.js setup, lighting, texture creation,
@ -24,6 +25,9 @@ export class BaseRenderer {
this.renderer.setClearColor(0x000000, 0); this.renderer.setClearColor(0x000000, 0);
this.setupLighting(); this.setupLighting();
this.controls = null;
this._didDrag = false;
} }
setupLighting() { setupLighting() {
@ -35,6 +39,39 @@ export class BaseRenderer {
this.scene.add(sunLight); this.scene.add(sunLight);
} }
setupControls(target) {
this.controls = new OrbitControls(this.camera, this.canvas);
this.controls.enableZoom = false;
this.controls.enablePan = false;
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.1;
this.controls.autoRotate = true;
this.controls.autoRotateSpeed = 4.0;
this.controls.target.copy(target);
this.controls.addEventListener('start', () => {
this.controls.autoRotate = false;
});
this._onPointerDown = (e) => {
this._didDrag = false;
this._pointerStart = { x: e.clientX, y: e.clientY };
};
this._onPointerMove = (e) => {
if (!this._pointerStart) return;
const dx = e.clientX - this._pointerStart.x;
const dy = e.clientY - this._pointerStart.y;
if (dx * dx + dy * dy > 9) this._didDrag = true;
};
this.canvas.addEventListener('pointerdown', this._onPointerDown);
this.canvas.addEventListener('pointermove', this._onPointerMove);
}
wasDragged() {
return this._didDrag;
}
/** /**
* Create a Three.js texture from parsed texture data * Create a Three.js texture from parsed texture data
*/ */
@ -190,9 +227,7 @@ export class BaseRenderer {
* Called each frame before rendering. * Called each frame before rendering.
*/ */
updateAnimation() { updateAnimation() {
if (this.modelGroup) { this.controls?.update();
this.modelGroup.rotation.y += 0.01;
}
} }
resize(width, height) { resize(width, height) {
@ -203,6 +238,11 @@ export class BaseRenderer {
dispose() { dispose() {
this.animating = false; this.animating = false;
if (this.controls) {
this.controls.dispose();
this.canvas.removeEventListener('pointerdown', this._onPointerDown);
this.canvas.removeEventListener('pointermove', this._onPointerMove);
}
this.clearModel(); this.clearModel();
this.renderer?.dispose(); this.renderer?.dispose();
} }

View File

@ -14,6 +14,8 @@ export class VehiclePartRenderer extends BaseRenderer {
this.camera.position.set(0, 0, 3); this.camera.position.set(0, 0, 3);
this.camera.lookAt(0, 0, 0); this.camera.lookAt(0, 0, 0);
this.setupControls(new THREE.Vector3(0, 0, 0));
} }
/** /**
@ -56,6 +58,7 @@ export class VehiclePartRenderer extends BaseRenderer {
this.centerAndScaleModel(1.5); this.centerAndScaleModel(1.5);
this.scene.add(this.modelGroup); this.scene.add(this.modelGroup);
this.controls.autoRotate = true;
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }

View File

@ -203,6 +203,7 @@
function handleCanvasClick(event) { function handleCanvasClick(event) {
if (!renderer || !slot?.characters || !charState) return; if (!renderer || !slot?.characters || !charState) return;
if (renderer.wasDragged()) return;
const playerId = slot.header?.actorId; const playerId = slot.header?.actorId;
let acted = false; let acted = false;
@ -393,10 +394,14 @@
canvas { canvas {
display: block; display: block;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: grab;
max-width: 100%; max-width: 100%;
} }
canvas:active {
cursor: grabbing;
}
canvas:focus { canvas:focus {
outline: none; outline: none;
} }

View File

@ -251,6 +251,7 @@
function cycleColor() { function cycleColor() {
if (!currentPart || partError) return; if (!currentPart || partError) return;
if (renderer?.wasDragged()) return;
// Find current color index and cycle to next // Find current color index and cycle to next
const currentIdx = LegoColorNames.indexOf(currentColorValue); const currentIdx = LegoColorNames.indexOf(currentColorValue);
@ -398,10 +399,14 @@
canvas { canvas {
display: block; display: block;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: grab;
max-width: 100%; max-width: 100%;
} }
canvas:active {
cursor: grabbing;
}
canvas:focus { canvas:focus {
outline: none; outline: none;
} }