DRY up renderer hierarchy: extract shared logic into base classes

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%)
This commit is contained in:
Christian Semmler 2026-02-14 10:33:08 -08:00
parent b8dc4b8898
commit f7b8a34921
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
6 changed files with 240 additions and 447 deletions

View File

@ -109,21 +109,9 @@ export class ActorRenderer extends AnimatedRenderer {
const actorInfo = ActorInfoInit[actorIndex]; const actorInfo = ActorInfoInit[actorIndex];
const charState = characters[actorIndex]; const charState = characters[actorIndex];
// Build texture lookup // Build texture lookup (vehicle textures don't overwrite global ones)
for (const tex of globalTextures) { this.loadTextures(globalTextures);
if (tex.name) { if (vehicleInfo) this.loadTextures(vehicleTextures, false);
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
}
}
// Merge vehicle textures (if present)
if (vehicleInfo && vehicleTextures) {
for (const tex of vehicleTextures) {
if (tex.name && !this.textures.has(tex.name.toLowerCase())) {
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
}
}
}
this.modelGroup = new THREE.Group(); this.modelGroup = new THREE.Group();
this.partGroups = []; this.partGroups = [];
@ -321,24 +309,7 @@ export class ActorRenderer extends AnimatedRenderer {
for (const mesh of lod.meshes) { for (const mesh of lod.meshes) {
const geometry = this.createGeometry(mesh, lod); const geometry = this.createGeometry(mesh, lod);
if (!geometry) continue; if (!geometry) continue;
this.vehicleGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh)));
let material;
const meshTexName = mesh.properties?.textureName?.toLowerCase();
if (meshTexName && this.textures.has(meshTexName)) {
material = new THREE.MeshLambertMaterial({
map: this.textures.get(meshTexName),
side: THREE.DoubleSide,
color: 0xffffff
});
} else {
const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 };
material = new THREE.MeshLambertMaterial({
color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
side: THREE.DoubleSide
});
}
this.vehicleGroup.add(new THREE.Mesh(geometry, material));
} }
} }

View File

@ -16,6 +16,7 @@ export class AnimatedRenderer extends BaseRenderer {
this.currentAction = null; this.currentAction = null;
this.animationCache = new Map(); this.animationCache = new Map();
this.raycaster = new THREE.Raycaster(); this.raycaster = new THREE.Raycaster();
this._queuedClickAnim = null;
} }
// ─── Animation Utilities ───────────────────────────────────────── // ─── Animation Utilities ─────────────────────────────────────────
@ -138,6 +139,185 @@ export class AnimatedRenderer extends BaseRenderer {
return { before, after: keys[idx] || null }; return { before, after: keys[idx] || null };
} }
// ─── Click Animation ─────────────────────────────────────────────
/**
* Queue a click animation by name. Subclasses may override to
* accept domain-specific arguments and construct the name.
* @param {string} animName - Animation asset name
*/
queueClickAnimation(animName) {
this._queuedClickAnim = animName;
}
/**
* Play the queued click animation (one-shot), then resume auto-rotate.
*/
async playQueuedAnimation() {
if (!this._queuedClickAnim || !this.modelGroup) return;
const animName = this._queuedClickAnim;
this._queuedClickAnim = null;
try {
const animData = await this.fetchAnimationByName(animName);
if (!animData || !this.modelGroup) return;
const tracks = this.buildRotationTracks(animData);
if (tracks.length === 0) return;
this.stopAnimation();
const clip = new THREE.AnimationClip('clickAnim', -1, tracks);
this.mixer = new THREE.AnimationMixer(this.modelGroup);
const action = this.mixer.clipAction(clip);
action.setLoop(THREE.LoopOnce);
action.clampWhenFinished = false;
this.currentAction = action;
action.play();
this.mixer.addEventListener('finished', () => {
this.stopAnimation();
this.controls.autoRotate = true;
});
} catch (e) {
// Animation unavailable — ignore
}
}
// ─── Raycast Hit Testing ─────────────────────────────────────────
/**
* Check if any mesh in the model was clicked.
* @returns {boolean} True if any mesh was hit
*/
getClickedMesh(mouseEvent) {
if (!this.modelGroup) return false;
const rect = this.canvas.getBoundingClientRect();
const mouse = new THREE.Vector2(
((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
-((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
);
this.raycaster.setFromCamera(mouse, this.camera);
const meshes = [];
this.modelGroup.traverse((child) => {
if (child instanceof THREE.Mesh) meshes.push(child);
});
return this.raycaster.intersectObjects(meshes).length > 0;
}
// ─── Simple Animation Tree Utilities ─────────────────────────────
/**
* Build rotation-only keyframe tracks by finding the deepest animated
* node and evaluating the composed transform chain at each keyframe time.
* Used by plant and building animations (single-group models).
*/
buildRotationTracks(animData) {
const duration = animData.duration;
const timesSet = new Set([0]);
this.collectKeyframeTimes(animData.rootNode, timesSet);
const times = [...timesSet].filter(t => t <= duration).sort((a, b) => a - b);
const targetNode = this.findAnimatedNode(animData.rootNode);
if (!targetNode) return [];
const quatValues = [];
const timesSec = [];
for (const time of times) {
const mat = this.evaluateNodeChain(animData.rootNode, targetNode, time);
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();
mat.decompose(position, quaternion, scale);
timesSec.push(time / 1000);
quatValues.push(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
}
return [
new THREE.QuaternionKeyframeTrack('.quaternion', timesSec, quatValues)
];
}
/**
* Find the deepest node in the animation tree that has keyframe data.
*/
findAnimatedNode(node) {
for (const child of node.children) {
const found = this.findAnimatedNode(child);
if (found) return found;
}
const d = node.data;
if (d.translationKeys.length > 0 || d.rotationKeys.length > 0 || d.scaleKeys.length > 0) {
return node;
}
return null;
}
/**
* Evaluate the composed transform matrix from root down to targetNode.
*/
evaluateNodeChain(node, targetNode, time) {
const path = [];
if (!this.findNodePath(node, targetNode, path)) {
return new THREE.Matrix4();
}
let mat = new THREE.Matrix4();
for (const n of path) {
const local = this.evaluateLocalTransform(n.data, time);
mat.multiply(local);
}
return mat;
}
/**
* Build the path from current node to target via depth-first search.
*/
findNodePath(current, target, path) {
path.push(current);
if (current === target) return true;
for (const child of current.children) {
if (this.findNodePath(child, target, path)) return true;
}
path.pop();
return false;
}
/**
* Evaluate the local transform matrix for an animation node at a given time.
*/
evaluateLocalTransform(data, time) {
let mat = new THREE.Matrix4();
if (data.scaleKeys.length > 0) {
const scale = this.interpolateVertex(data.scaleKeys, time, false);
if (scale) mat.scale(scale);
}
if (data.rotationKeys.length > 0) {
const rotMat = this.evaluateRotation(data.rotationKeys, time);
mat = rotMat.multiply(mat);
}
if (data.translationKeys.length > 0) {
const vertex = this.interpolateVertex(data.translationKeys, time, true);
if (vertex) {
mat.elements[12] += vertex.x;
mat.elements[13] += vertex.y;
mat.elements[14] += vertex.z;
}
}
return mat;
}
// ─── Scene Management ──────────────────────────────────────────── // ─── Scene Management ────────────────────────────────────────────
clearModel() { clearModel() {

View File

@ -109,6 +109,48 @@ export class BaseRenderer {
return texture; 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 * Create a single geometry from mesh data
*/ */

View File

@ -9,7 +9,6 @@ import { AnimatedRenderer } from './AnimatedRenderer.js';
export class BuildingRenderer extends AnimatedRenderer { export class BuildingRenderer extends AnimatedRenderer {
constructor(canvas) { constructor(canvas) {
super(canvas); super(canvas);
this._queuedClickAnim = null;
this.camera.position.set(2.5, 2.0, 4.0); this.camera.position.set(2.5, 2.0, 4.0);
this.camera.lookAt(0, -0.3, 0); this.camera.lookAt(0, -0.3, 0);
@ -27,17 +26,13 @@ export class BuildingRenderer extends AnimatedRenderer {
if (!rois || rois.length === 0) return; if (!rois || rois.length === 0) return;
// Build texture lookup this.loadTextures(textures);
if (textures) {
for (const tex of textures) {
if (tex.name) {
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
}
}
}
this.modelGroup = new THREE.Group(); this.modelGroup = new THREE.Group();
const colorEntry = LegoColors['lego white'] || { r: 255, g: 255, b: 255 };
const fallbackColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255);
for (const roi of rois) { for (const roi of rois) {
const lods = roi.lods || []; const lods = roi.lods || [];
if (lods.length === 0) continue; if (lods.length === 0) continue;
@ -46,32 +41,7 @@ export class BuildingRenderer extends AnimatedRenderer {
for (const mesh of lod.meshes) { for (const mesh of lod.meshes) {
const geometry = this.createGeometry(mesh, lod); const geometry = this.createGeometry(mesh, lod);
if (!geometry) continue; if (!geometry) continue;
this.modelGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh, fallbackColor)));
let material;
const meshTexName = mesh.properties?.textureName?.toLowerCase();
if (meshTexName && this.textures.has(meshTexName)) {
material = new THREE.MeshLambertMaterial({
map: this.textures.get(meshTexName),
side: THREE.DoubleSide,
color: 0xffffff
});
} else {
const meshColor = mesh.properties?.color;
if (meshColor) {
material = new THREE.MeshLambertMaterial({
color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
side: THREE.DoubleSide
});
} else {
const colorEntry = LegoColors['lego white'] || { r: 255, g: 255, b: 255 };
material = new THREE.MeshLambertMaterial({
color: new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255),
side: THREE.DoubleSide
});
}
}
this.modelGroup.add(new THREE.Mesh(geometry, material));
} }
} }
@ -80,166 +50,7 @@ export class BuildingRenderer extends AnimatedRenderer {
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }
/**
* Check if the building mesh was clicked.
* @returns {boolean} True if any mesh was hit
*/
getClickedMesh(mouseEvent) {
if (!this.modelGroup) return false;
const rect = this.canvas.getBoundingClientRect();
const mouse = new THREE.Vector2(
((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
-((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
);
this.raycaster.setFromCamera(mouse, this.camera);
const meshes = [];
this.modelGroup.traverse((child) => {
if (child instanceof THREE.Mesh) meshes.push(child);
});
return this.raycaster.intersectObjects(meshes).length > 0;
}
// ─── Animation System ────────────────────────────────────────────
/**
* Queue a click animation to play.
* @param {number} buildingIndex - Building index (9-14)
* @param {number} move - The building's move value
*/
queueClickAnimation(buildingIndex, move) { queueClickAnimation(buildingIndex, move) {
this._queuedClickAnim = { buildingIndex, move }; super.queueClickAnimation(`BuildingAnim${buildingIndex}_${move}`);
}
/**
* Play a queued click animation if available.
*/
async playQueuedAnimation() {
if (!this._queuedClickAnim || !this.modelGroup) return;
const { buildingIndex, move } = this._queuedClickAnim;
this._queuedClickAnim = null;
const animName = `BuildingAnim${buildingIndex}_${move}`;
try {
const animData = await this.fetchAnimationByName(animName);
if (!animData || !this.modelGroup) return;
const tracks = this.buildBuildingTracks(animData);
if (tracks.length === 0) return;
this.stopAnimation();
const clip = new THREE.AnimationClip('buildingClick', -1, tracks);
this.mixer = new THREE.AnimationMixer(this.modelGroup);
const action = this.mixer.clipAction(clip);
action.setLoop(THREE.LoopOnce);
action.clampWhenFinished = false;
this.currentAction = action;
action.play();
this.mixer.addEventListener('finished', () => {
this.stopAnimation();
this.controls.autoRotate = true;
});
} catch (e) {
// Animation unavailable — ignore
}
}
/**
* Build animation tracks for a building. Same approach as PlantRenderer.
*/
buildBuildingTracks(animData) {
const duration = animData.duration;
const timesSet = new Set([0]);
this.collectKeyframeTimes(animData.rootNode, timesSet);
const times = [...timesSet].filter(t => t <= duration).sort((a, b) => a - b);
const buildingNode = this.findAnimatedNode(animData.rootNode);
if (!buildingNode) return [];
const quatValues = [];
const timesSec = [];
for (const time of times) {
const mat = this.evaluateNodeChain(animData.rootNode, buildingNode, time);
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();
mat.decompose(position, quaternion, scale);
timesSec.push(time / 1000);
quatValues.push(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
}
return [
new THREE.QuaternionKeyframeTrack('.quaternion', timesSec, quatValues)
];
}
findAnimatedNode(node) {
for (const child of node.children) {
const found = this.findAnimatedNode(child);
if (found) return found;
}
const d = node.data;
if (d.translationKeys.length > 0 || d.rotationKeys.length > 0 || d.scaleKeys.length > 0) {
return node;
}
return null;
}
evaluateNodeChain(node, targetNode, time) {
const path = [];
if (!this.findPath(node, targetNode, path)) {
return new THREE.Matrix4();
}
let mat = new THREE.Matrix4();
for (const n of path) {
const local = this.evaluateLocalTransform(n.data, time);
mat.multiply(local);
}
return mat;
}
findPath(current, target, path) {
path.push(current);
if (current === target) return true;
for (const child of current.children) {
if (this.findPath(child, target, path)) return true;
}
path.pop();
return false;
}
evaluateLocalTransform(data, time) {
let mat = new THREE.Matrix4();
if (data.scaleKeys.length > 0) {
const scale = this.interpolateVertex(data.scaleKeys, time, false);
if (scale) mat.scale(scale);
}
if (data.rotationKeys.length > 0) {
const rotMat = this.evaluateRotation(data.rotationKeys, time);
mat = rotMat.multiply(mat);
}
if (data.translationKeys.length > 0) {
const vertex = this.interpolateVertex(data.translationKeys, time, true);
if (vertex) {
mat.elements[12] += vertex.x;
mat.elements[13] += vertex.y;
mat.elements[14] += vertex.z;
}
}
return mat;
} }
} }

View File

@ -19,7 +19,6 @@ const VARIANT_SCALE = [1.6, 1.8, 1.6, 2.0];
export class PlantRenderer extends AnimatedRenderer { export class PlantRenderer extends AnimatedRenderer {
constructor(canvas) { constructor(canvas) {
super(canvas); super(canvas);
this._queuedClickAnim = null;
this.camera.position.set(1.5, 1.2, 2.5); this.camera.position.set(1.5, 1.2, 2.5);
this.camera.lookAt(0, 0.2, 0); this.camera.lookAt(0, 0.2, 0);
@ -40,56 +39,25 @@ export class PlantRenderer extends AnimatedRenderer {
const lodName = PlantLodNames[variant]?.[color]; const lodName = PlantLodNames[variant]?.[color];
if (!lodName) return; if (!lodName) return;
// Find the part data (case-insensitive)
const partData = partsMap.get(lodName.toLowerCase()); const partData = partsMap.get(lodName.toLowerCase());
if (!partData) return; if (!partData) return;
// Build texture lookup this.loadTextures(textures);
if (textures) {
for (const tex of textures) {
if (tex.name) {
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
}
}
}
this.modelGroup = new THREE.Group(); this.modelGroup = new THREE.Group();
const lods = partData.lods || []; const lods = partData.lods || [];
if (lods.length === 0) return; if (lods.length === 0) return;
const colorName = PLANT_COLOR_MAP[color] || 'lego green';
const colorEntry = LegoColors[colorName] || LegoColors['lego green'];
const fallbackColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255);
const lod = lods[lods.length - 1]; // Highest quality const lod = lods[lods.length - 1]; // Highest quality
for (const mesh of lod.meshes) { for (const mesh of lod.meshes) {
const geometry = this.createGeometry(mesh, lod); const geometry = this.createGeometry(mesh, lod);
if (!geometry) continue; if (!geometry) continue;
this.modelGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh, fallbackColor)));
let material;
const meshTexName = mesh.properties?.textureName?.toLowerCase();
if (meshTexName && this.textures.has(meshTexName)) {
material = new THREE.MeshLambertMaterial({
map: this.textures.get(meshTexName),
side: THREE.DoubleSide,
color: 0xffffff
});
} else {
const meshColor = mesh.properties?.color;
if (meshColor) {
material = new THREE.MeshLambertMaterial({
color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
side: THREE.DoubleSide
});
} else {
// Fallback to plant color
const colorName = PLANT_COLOR_MAP[color] || 'lego green';
const colorEntry = LegoColors[colorName] || LegoColors['lego green'];
material = new THREE.MeshLambertMaterial({
color: new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255),
side: THREE.DoubleSide
});
}
}
this.modelGroup.add(new THREE.Mesh(geometry, material));
} }
this.centerAndScaleModel(VARIANT_SCALE[variant] ?? 2.0); this.centerAndScaleModel(VARIANT_SCALE[variant] ?? 2.0);
@ -97,182 +65,8 @@ export class PlantRenderer extends AnimatedRenderer {
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }
/**
* Check if the plant mesh was clicked.
* @returns {boolean} True if any mesh was hit
*/
getClickedMesh(mouseEvent) {
if (!this.modelGroup) return false;
const rect = this.canvas.getBoundingClientRect();
const mouse = new THREE.Vector2(
((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
-((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
);
this.raycaster.setFromCamera(mouse, this.camera);
const meshes = [];
this.modelGroup.traverse((child) => {
if (child instanceof THREE.Mesh) meshes.push(child);
});
return this.raycaster.intersectObjects(meshes).length > 0;
}
// ─── Animation System ────────────────────────────────────────────
/**
* Queue a click animation to play.
* @param {number} variant - Plant variant (0-3)
* @param {number} move - The plant's move value
*/
queueClickAnimation(variant, move) { queueClickAnimation(variant, move) {
this._queuedClickAnim = { variant, move };
}
/**
* Play a queued click animation if available.
* Called after model reload or directly for non-visual changes.
*/
async playQueuedAnimation() {
if (!this._queuedClickAnim || !this.modelGroup) return;
const { variant, move } = this._queuedClickAnim;
this._queuedClickAnim = null;
const suffix = VARIANT_ANIM_SUFFIX[variant]; const suffix = VARIANT_ANIM_SUFFIX[variant];
const animName = `PlantAnim${suffix}${move}`; super.queueClickAnimation(`PlantAnim${suffix}${move}`);
try {
const animData = await this.fetchAnimationByName(animName);
if (!animData || !this.modelGroup) return;
const tracks = this.buildPlantTracks(animData);
if (tracks.length === 0) return;
this.stopAnimation();
const clip = new THREE.AnimationClip('plantClick', -1, tracks);
this.mixer = new THREE.AnimationMixer(this.modelGroup);
const action = this.mixer.clipAction(clip);
action.setLoop(THREE.LoopOnce);
action.clampWhenFinished = false;
this.currentAction = action;
action.play();
this.mixer.addEventListener('finished', () => {
this.stopAnimation();
this.controls.autoRotate = true;
});
} catch (e) {
// Animation unavailable — ignore
}
}
/**
* Build animation tracks for a plant. Maps animation tree nodes
* to the model group (the entire plant is a single group).
*/
buildPlantTracks(animData) {
const duration = animData.duration;
const timesSet = new Set([0]);
this.collectKeyframeTimes(animData.rootNode, timesSet);
const times = [...timesSet].filter(t => t <= duration).sort((a, b) => a - b);
// Find the deepest non-root node that has animation data —
// map it to our modelGroup
const plantNode = this.findPlantNode(animData.rootNode);
if (!plantNode) return [];
const quatValues = [];
const timesSec = [];
for (const time of times) {
const mat = this.evaluateNodeChain(animData.rootNode, plantNode, time);
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();
mat.decompose(position, quaternion, scale);
timesSec.push(time / 1000);
quatValues.push(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
}
// Only emit rotation tracks — position tracks would override the
// centering applied by centerAndScaleModel() since the animation
// uses the game's world-space coordinates.
return [
new THREE.QuaternionKeyframeTrack('.quaternion', timesSec, quatValues)
];
}
/**
* Find the first leaf/deepest node with animation data in the tree.
*/
findPlantNode(node) {
// Depth-first: prefer children
for (const child of node.children) {
const found = this.findPlantNode(child);
if (found) return found;
}
// If this node has actual keyframe data, use it
const d = node.data;
if (d.translationKeys.length > 0 || d.rotationKeys.length > 0 || d.scaleKeys.length > 0) {
return node;
}
return null;
}
/**
* Evaluate the composed matrix from root down to targetNode at a given time.
*/
evaluateNodeChain(node, targetNode, time) {
const path = [];
if (!this.findPath(node, targetNode, path)) {
return new THREE.Matrix4();
}
let mat = new THREE.Matrix4();
for (const n of path) {
const local = this.evaluateLocalTransform(n.data, time);
mat.multiply(local);
}
return mat;
}
findPath(current, target, path) {
path.push(current);
if (current === target) return true;
for (const child of current.children) {
if (this.findPath(child, target, path)) return true;
}
path.pop();
return false;
}
evaluateLocalTransform(data, time) {
let mat = new THREE.Matrix4();
if (data.scaleKeys.length > 0) {
const scale = this.interpolateVertex(data.scaleKeys, time, false);
if (scale) mat.scale(scale);
}
if (data.rotationKeys.length > 0) {
const rotMat = this.evaluateRotation(data.rotationKeys, time);
mat = rotMat.multiply(mat);
}
if (data.translationKeys.length > 0) {
const vertex = this.interpolateVertex(data.translationKeys, time, true);
if (vertex) {
mat.elements[12] += vertex.x;
mat.elements[13] += vertex.y;
mat.elements[14] += vertex.z;
}
}
return mat;
} }
} }

View File

@ -42,12 +42,7 @@ export class VehiclePartRenderer extends BaseRenderer {
this.colorableMeshes = []; this.colorableMeshes = [];
this.partsMap = partsMap; this.partsMap = partsMap;
// Build texture lookup map (case-insensitive) this.loadTextures(textureList);
for (const tex of textureList) {
if (tex.name) {
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
}
}
const legoColor = LegoColors[colorName] || LegoColors['lego red']; const legoColor = LegoColors[colorName] || LegoColors['lego red'];
const threeLegoColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255); const threeLegoColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255);