mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 14:27:38 +00:00
Move duplicated animation tree utilities (findAnimatedNode, evaluateNodeChain, findNodePath, evaluateLocalTransform), click animation (queueClickAnimation, playQueuedAnimation, buildRotationTracks), and raycast hit testing (getClickedMesh) from PlantRenderer and BuildingRenderer into AnimatedRenderer. Add loadTextures() and createMeshMaterial() helpers to BaseRenderer, replacing identical texture-loading loops and material-creation code across all four renderers. PlantRenderer: 279 → 73 lines (-74%) BuildingRenderer: 245 → 57 lines (-77%)
305 lines
9.8 KiB
JavaScript
305 lines
9.8 KiB
JavaScript
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
|
|
/**
|
|
* Base renderer providing shared Three.js setup, lighting, texture creation,
|
|
* geometry building, and animation loop for LEGO model viewers.
|
|
*/
|
|
export class BaseRenderer {
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.animating = false;
|
|
this.modelGroup = null;
|
|
this.textures = new Map();
|
|
|
|
this.scene = new THREE.Scene();
|
|
|
|
this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
|
|
|
|
this.renderer = new THREE.WebGLRenderer({
|
|
canvas,
|
|
antialias: true,
|
|
alpha: true
|
|
});
|
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
this.renderer.setClearColor(0x000000, 0);
|
|
|
|
this.setupLighting();
|
|
|
|
this.controls = null;
|
|
this._didDrag = false;
|
|
}
|
|
|
|
setupLighting() {
|
|
const ambient = new THREE.AmbientLight(0xffffff, 0.8);
|
|
this.scene.add(ambient);
|
|
|
|
const sunLight = new THREE.DirectionalLight(0xffffff, 0.6);
|
|
sunLight.position.set(1, 2, 3);
|
|
this.scene.add(sunLight);
|
|
}
|
|
|
|
setupControls(target) {
|
|
this.controls = new OrbitControls(this.camera, this.canvas);
|
|
this.controls.enableZoom = true;
|
|
this.controls.enablePan = true;
|
|
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);
|
|
|
|
this._initialAutoRotate = this.controls.autoRotate;
|
|
this.controls.saveState();
|
|
}
|
|
|
|
resetView() {
|
|
if (!this.controls) return;
|
|
this.controls.reset();
|
|
this.controls.autoRotate = this._initialAutoRotate;
|
|
}
|
|
|
|
wasDragged() {
|
|
return this._didDrag;
|
|
}
|
|
|
|
/**
|
|
* Create a Three.js texture from parsed texture data
|
|
*/
|
|
createTexture(textureData) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = textureData.width;
|
|
canvas.height = textureData.height;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
const imageData = ctx.createImageData(textureData.width, textureData.height);
|
|
for (let i = 0; i < textureData.pixels.length; i++) {
|
|
const colorIdx = textureData.pixels[i];
|
|
const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 };
|
|
imageData.data[i * 4 + 0] = color.r;
|
|
imageData.data[i * 4 + 1] = color.g;
|
|
imageData.data[i * 4 + 2] = color.b;
|
|
imageData.data[i * 4 + 3] = 255;
|
|
}
|
|
ctx.putImageData(imageData, 0, 0);
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.minFilter = THREE.NearestFilter;
|
|
texture.magFilter = THREE.NearestFilter;
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
return texture;
|
|
}
|
|
|
|
/**
|
|
* Build the texture lookup map from an array of texture data objects.
|
|
* @param {Array} textures - Texture data with name, width, height, palette, pixels
|
|
* @param {boolean} overwrite - If false, skip textures already in the map
|
|
*/
|
|
loadTextures(textures, overwrite = true) {
|
|
if (!textures) return;
|
|
for (const tex of textures) {
|
|
if (!tex.name) continue;
|
|
const key = tex.name.toLowerCase();
|
|
if (overwrite || !this.textures.has(key)) {
|
|
this.textures.set(key, this.createTexture(tex));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a material for a mesh using its texture or color properties.
|
|
* @param {object} mesh - Mesh with properties (textureName, color)
|
|
* @param {THREE.Color} [fallbackColor] - Color when mesh has no texture or color
|
|
*/
|
|
createMeshMaterial(mesh, fallbackColor = null) {
|
|
const meshTexName = mesh.properties?.textureName?.toLowerCase();
|
|
if (meshTexName && this.textures.has(meshTexName)) {
|
|
return new THREE.MeshLambertMaterial({
|
|
map: this.textures.get(meshTexName),
|
|
side: THREE.DoubleSide,
|
|
color: 0xffffff
|
|
});
|
|
}
|
|
|
|
const meshColor = mesh.properties?.color;
|
|
const color = meshColor
|
|
? new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255)
|
|
: (fallbackColor || new THREE.Color(0.5, 0.5, 0.5));
|
|
|
|
return new THREE.MeshLambertMaterial({
|
|
color,
|
|
side: THREE.DoubleSide
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a single geometry from mesh data
|
|
*/
|
|
createGeometry(mesh, lod) {
|
|
if (!mesh.polygonIndices || mesh.polygonIndices.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0;
|
|
|
|
const vertexIndicesPacked = [];
|
|
for (const poly of mesh.polygonIndices) {
|
|
vertexIndicesPacked.push(poly.a, poly.b, poly.c);
|
|
}
|
|
|
|
const textureIndicesFlat = [];
|
|
if (hasTexture) {
|
|
for (const texPoly of mesh.textureIndices) {
|
|
textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c);
|
|
}
|
|
}
|
|
|
|
const meshVertices = [];
|
|
const meshNormals = [];
|
|
const meshUvs = [];
|
|
const indices = [];
|
|
|
|
for (let i = 0; i < vertexIndicesPacked.length; i++) {
|
|
const packed = vertexIndicesPacked[i];
|
|
|
|
if ((packed & 0x80000000) !== 0) {
|
|
indices.push(meshVertices.length);
|
|
|
|
const gv = packed & 0xFFFF;
|
|
const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 };
|
|
meshVertices.push([-v.x, v.y, v.z]);
|
|
|
|
const gn = (packed >>> 16) & 0x7fff;
|
|
const n = lod.normals[gn] || { x: 0, y: 1, z: 0 };
|
|
meshNormals.push([-n.x, n.y, n.z]);
|
|
|
|
if (hasTexture && lod.textureVertices && lod.textureVertices.length > 0) {
|
|
const tex = textureIndicesFlat[i];
|
|
const uv = lod.textureVertices[tex] || { u: 0, v: 0 };
|
|
meshUvs.push([uv.u, 1 - uv.v]);
|
|
}
|
|
} else {
|
|
indices.push(packed & 0xFFFF);
|
|
}
|
|
}
|
|
|
|
// Reverse face winding
|
|
for (let i = 0; i < indices.length; i += 3) {
|
|
const temp = indices[i];
|
|
indices[i] = indices[i + 2];
|
|
indices[i + 2] = temp;
|
|
}
|
|
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshVertices.flat(), 3));
|
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(meshNormals.flat(), 3));
|
|
geometry.setIndex(indices);
|
|
|
|
if (hasTexture && meshUvs.length > 0) {
|
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(meshUvs.flat(), 2));
|
|
}
|
|
|
|
return geometry;
|
|
}
|
|
|
|
centerAndScaleModel(scaleFactor) {
|
|
if (!this.modelGroup) return;
|
|
|
|
const box = new THREE.Box3().setFromObject(this.modelGroup);
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
const size = box.getSize(new THREE.Vector3());
|
|
|
|
const maxDim = Math.max(size.x, size.y, size.z);
|
|
if (maxDim > 0) {
|
|
const scale = scaleFactor / maxDim;
|
|
this.modelGroup.scale.setScalar(scale);
|
|
// Position must account for scale: Three.js applies scale before
|
|
// translation, so vertex v maps to (position + scale * v).
|
|
// To center: position = -center * scale → v maps to scale*(v - center).
|
|
this.modelGroup.position.copy(center).multiplyScalar(-scale);
|
|
} else {
|
|
this.modelGroup.position.sub(center);
|
|
}
|
|
}
|
|
|
|
clearModel() {
|
|
if (this.modelGroup) {
|
|
this.modelGroup.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
child.geometry?.dispose();
|
|
child.material?.dispose();
|
|
}
|
|
});
|
|
this.scene.remove(this.modelGroup);
|
|
this.modelGroup = null;
|
|
}
|
|
|
|
for (const texture of this.textures.values()) {
|
|
texture.dispose();
|
|
}
|
|
this.textures.clear();
|
|
}
|
|
|
|
start() {
|
|
this.animating = true;
|
|
this.animate();
|
|
}
|
|
|
|
stop() {
|
|
this.animating = false;
|
|
}
|
|
|
|
animate = () => {
|
|
if (!this.animating) return;
|
|
requestAnimationFrame(this.animate);
|
|
|
|
this.updateAnimation();
|
|
|
|
this.renderer.render(this.scene, this.camera);
|
|
}
|
|
|
|
/**
|
|
* Override in subclasses for custom animation logic.
|
|
* Called each frame before rendering.
|
|
*/
|
|
updateAnimation() {
|
|
this.controls?.update();
|
|
}
|
|
|
|
resize(width, height) {
|
|
this.camera.aspect = width / height;
|
|
this.camera.updateProjectionMatrix();
|
|
this.renderer.setSize(width, height, false);
|
|
}
|
|
|
|
dispose() {
|
|
this.animating = false;
|
|
if (this.controls) {
|
|
this.controls.dispose();
|
|
this.canvas.removeEventListener('pointerdown', this._onPointerDown);
|
|
this.canvas.removeEventListener('pointermove', this._onPointerMove);
|
|
}
|
|
this.clearModel();
|
|
this.renderer?.dispose();
|
|
}
|
|
}
|