From f7b8a349214ad0588b94bf6261e79b57dd21de2c Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Sat, 14 Feb 2026 10:33:08 -0800 Subject: [PATCH] DRY up renderer hierarchy: extract shared logic into base classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%) --- src/core/rendering/ActorRenderer.js | 37 +--- src/core/rendering/AnimatedRenderer.js | 180 ++++++++++++++++++ src/core/rendering/BaseRenderer.js | 42 +++++ src/core/rendering/BuildingRenderer.js | 201 +------------------- src/core/rendering/PlantRenderer.js | 220 +--------------------- src/core/rendering/VehiclePartRenderer.js | 7 +- 6 files changed, 240 insertions(+), 447 deletions(-) diff --git a/src/core/rendering/ActorRenderer.js b/src/core/rendering/ActorRenderer.js index 71046ca..a7ec5be 100644 --- a/src/core/rendering/ActorRenderer.js +++ b/src/core/rendering/ActorRenderer.js @@ -109,21 +109,9 @@ export class ActorRenderer extends AnimatedRenderer { const actorInfo = ActorInfoInit[actorIndex]; const charState = characters[actorIndex]; - // Build texture lookup - for (const tex of globalTextures) { - if (tex.name) { - 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)); - } - } - } + // Build texture lookup (vehicle textures don't overwrite global ones) + this.loadTextures(globalTextures); + if (vehicleInfo) this.loadTextures(vehicleTextures, false); this.modelGroup = new THREE.Group(); this.partGroups = []; @@ -321,24 +309,7 @@ export class ActorRenderer extends AnimatedRenderer { for (const mesh of lod.meshes) { const geometry = this.createGeometry(mesh, lod); if (!geometry) continue; - - 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)); + this.vehicleGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh))); } } diff --git a/src/core/rendering/AnimatedRenderer.js b/src/core/rendering/AnimatedRenderer.js index a26e3d1..345a8aa 100644 --- a/src/core/rendering/AnimatedRenderer.js +++ b/src/core/rendering/AnimatedRenderer.js @@ -16,6 +16,7 @@ export class AnimatedRenderer extends BaseRenderer { this.currentAction = null; this.animationCache = new Map(); this.raycaster = new THREE.Raycaster(); + this._queuedClickAnim = null; } // ─── Animation Utilities ───────────────────────────────────────── @@ -138,6 +139,185 @@ export class AnimatedRenderer extends BaseRenderer { 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 ──────────────────────────────────────────── clearModel() { diff --git a/src/core/rendering/BaseRenderer.js b/src/core/rendering/BaseRenderer.js index a3398f9..c16823c 100644 --- a/src/core/rendering/BaseRenderer.js +++ b/src/core/rendering/BaseRenderer.js @@ -109,6 +109,48 @@ export class BaseRenderer { 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 */ diff --git a/src/core/rendering/BuildingRenderer.js b/src/core/rendering/BuildingRenderer.js index 13d2051..7ce0e20 100644 --- a/src/core/rendering/BuildingRenderer.js +++ b/src/core/rendering/BuildingRenderer.js @@ -9,7 +9,6 @@ import { AnimatedRenderer } from './AnimatedRenderer.js'; export class BuildingRenderer extends AnimatedRenderer { constructor(canvas) { super(canvas); - this._queuedClickAnim = null; this.camera.position.set(2.5, 2.0, 4.0); this.camera.lookAt(0, -0.3, 0); @@ -27,17 +26,13 @@ export class BuildingRenderer extends AnimatedRenderer { if (!rois || rois.length === 0) return; - // Build texture lookup - if (textures) { - for (const tex of textures) { - if (tex.name) { - this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); - } - } - } + this.loadTextures(textures); 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) { const lods = roi.lods || []; if (lods.length === 0) continue; @@ -46,32 +41,7 @@ export class BuildingRenderer extends AnimatedRenderer { for (const mesh of lod.meshes) { const geometry = this.createGeometry(mesh, lod); if (!geometry) continue; - - 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)); + this.modelGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh, fallbackColor))); } } @@ -80,166 +50,7 @@ export class BuildingRenderer extends AnimatedRenderer { 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) { - this._queuedClickAnim = { 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; + super.queueClickAnimation(`BuildingAnim${buildingIndex}_${move}`); } } diff --git a/src/core/rendering/PlantRenderer.js b/src/core/rendering/PlantRenderer.js index ff5e84a..7ca289b 100644 --- a/src/core/rendering/PlantRenderer.js +++ b/src/core/rendering/PlantRenderer.js @@ -19,7 +19,6 @@ const VARIANT_SCALE = [1.6, 1.8, 1.6, 2.0]; export class PlantRenderer extends AnimatedRenderer { constructor(canvas) { super(canvas); - this._queuedClickAnim = null; this.camera.position.set(1.5, 1.2, 2.5); this.camera.lookAt(0, 0.2, 0); @@ -40,56 +39,25 @@ export class PlantRenderer extends AnimatedRenderer { const lodName = PlantLodNames[variant]?.[color]; if (!lodName) return; - // Find the part data (case-insensitive) const partData = partsMap.get(lodName.toLowerCase()); if (!partData) return; - // Build texture lookup - if (textures) { - for (const tex of textures) { - if (tex.name) { - this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); - } - } - } + this.loadTextures(textures); this.modelGroup = new THREE.Group(); const lods = partData.lods || []; 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 for (const mesh of lod.meshes) { const geometry = this.createGeometry(mesh, lod); if (!geometry) continue; - - 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.modelGroup.add(new THREE.Mesh(geometry, this.createMeshMaterial(mesh, fallbackColor))); } this.centerAndScaleModel(VARIANT_SCALE[variant] ?? 2.0); @@ -97,182 +65,8 @@ export class PlantRenderer extends AnimatedRenderer { 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) { - 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 animName = `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; + super.queueClickAnimation(`PlantAnim${suffix}${move}`); } } diff --git a/src/core/rendering/VehiclePartRenderer.js b/src/core/rendering/VehiclePartRenderer.js index 39283d9..31fbb9b 100644 --- a/src/core/rendering/VehiclePartRenderer.js +++ b/src/core/rendering/VehiclePartRenderer.js @@ -42,12 +42,7 @@ export class VehiclePartRenderer extends BaseRenderer { this.colorableMeshes = []; this.partsMap = partsMap; - // Build texture lookup map (case-insensitive) - for (const tex of textureList) { - if (tex.name) { - this.textures.set(tex.name.toLowerCase(), this.createTexture(tex)); - } - } + this.loadTextures(textureList); const legoColor = LegoColors[colorName] || LegoColors['lego red']; const threeLegoColor = new THREE.Color(legoColor.r / 255, legoColor.g / 255, legoColor.b / 255);