diff --git a/src/components/editor/scene/EditorMap.tsx b/src/components/editor/scene/EditorMap.tsx index 38129e9..baea5a6 100644 --- a/src/components/editor/scene/EditorMap.tsx +++ b/src/components/editor/scene/EditorMap.tsx @@ -1,9 +1,10 @@ import { useRef, useEffect, useState } from "react"; -import { Grid, TransformControls, useGLTF } from "@react-three/drei"; +import { Grid, TransformControls } from "@react-three/drei"; import type { ThreeEvent } from "@react-three/fiber"; import * as THREE from "three"; import { useClonedObject } from "@/hooks/three/useClonedObject"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor"; interface EditorMapProps { @@ -258,7 +259,12 @@ function EditorModelNode({ const originalMaterialsRef = useRef( new Map(), ); - const { scene } = useGLTF(modelUrl); + const { scene } = useLoggedGLTF(modelUrl, { + scope: "EditorMap.EditorModelNode", + position: node.position, + rotation: node.rotation, + scale: node.scale, + }); const sceneInstance = useClonedObject(scene); const pointerHandlers = createEditorNodePointerHandlers( index, diff --git a/src/components/three/gameplay/RepairCaseModel.tsx b/src/components/three/gameplay/RepairCaseModel.tsx index 6c46e6a..01485db 100644 --- a/src/components/three/gameplay/RepairCaseModel.tsx +++ b/src/components/three/gameplay/RepairCaseModel.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef } from "react"; -import { useGLTF } from "@react-three/drei"; import { useFrame, useThree } from "@react-three/fiber"; import gsap from "gsap"; import * as THREE from "three"; @@ -16,6 +15,7 @@ import { REPAIR_CASE_ROTATION_RESET_SPEED, } from "@/data/gameplay/repairCaseConfig"; import { useClonedObject } from "@/hooks/three/useClonedObject"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import type { ModelTransformProps } from "@/types/three/three"; import { toVector3Scale } from "@/utils/three/scale"; @@ -42,7 +42,12 @@ export function RepairCaseModel({ scale = 1, }: RepairCaseModelProps): React.JSX.Element { const camera = useThree((state) => state.camera); - const { scene } = useGLTF(modelPath); + const { scene } = useLoggedGLTF(modelPath, { + scope: "RepairCaseModel", + position, + rotation, + scale, + }); const model = useClonedObject(scene); const groupRef = useRef(null); const lidRef = useRef(null); diff --git a/src/components/three/gameplay/RepairCaseObject.tsx b/src/components/three/gameplay/RepairCaseObject.tsx index 1b4da23..2307794 100644 --- a/src/components/three/gameplay/RepairCaseObject.tsx +++ b/src/components/three/gameplay/RepairCaseObject.tsx @@ -8,6 +8,7 @@ import { } from "@/data/gameplay/repairCaseConfig"; import { AudioManager } from "@/managers/AudioManager"; import type { Vector3Tuple } from "@/types/three/three"; +import { logModelLoadError } from "@/utils/three/modelLoadLogger"; interface RepairCaseErrorBoundaryProps { children: ReactNode; @@ -31,7 +32,15 @@ class RepairCaseErrorBoundary extends Component< } componentDidCatch(error: Error): void { - console.warn("Failed to load repair case model", error); + logModelLoadError( + { + modelPath: REPAIR_CASE_MODEL_PATH, + scope: "RepairCaseObject", + position: [0, -0.45, 0], + scale: 1.5, + }, + error, + ); } render(): ReactNode { diff --git a/src/components/three/interaction/TriggerObject.tsx b/src/components/three/interaction/TriggerObject.tsx index 4a42fd3..77f4cc6 100644 --- a/src/components/three/interaction/TriggerObject.tsx +++ b/src/components/three/interaction/TriggerObject.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; -import { useGLTF } from "@react-three/drei"; import { RigidBody } from "@react-three/rapier"; import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { useClonedObject } from "@/hooks/three/useClonedObject"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { TRIGGER_DEFAULT_COLLIDERS, TRIGGER_DEFAULT_LABEL, @@ -38,7 +38,10 @@ function SpawnedModelInstance({ path: string; position: Vector3Tuple; }): React.JSX.Element { - const { scene } = useGLTF(path); + const { scene } = useLoggedGLTF(path, { + scope: "TriggerObject.SpawnedModel", + position, + }); const model = useClonedObject(scene); return ; diff --git a/src/components/three/models/AnimatedModel.tsx b/src/components/three/models/AnimatedModel.tsx index 0f7e98c..c467e03 100644 --- a/src/components/three/models/AnimatedModel.tsx +++ b/src/components/three/models/AnimatedModel.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useGLTF, useAnimations } from "@react-three/drei"; +import { useAnimations } from "@react-three/drei"; import type { AnimationAction } from "three"; import * as THREE from "three"; import { AnimatedModelContext, type AnimatedModelContextValue, } from "@/components/three/models/useAnimatedModel"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import type { Vector3Tuple } from "@/types/three/three"; export interface AnimatedModelConfig { @@ -40,7 +41,12 @@ export function AnimatedModel({ children, }: AnimatedModelProps): React.JSX.Element { const groupRef = useRef(null); - const { scene, animations } = useGLTF(modelPath); + const { scene, animations } = useLoggedGLTF(modelPath, { + scope: "AnimatedModel", + position, + rotation, + scale, + }); const model = useMemo(() => scene.clone(true), [scene]); const { actions, names, mixer } = useAnimations(animations, groupRef); diff --git a/src/components/three/models/ExplodableModel.tsx b/src/components/three/models/ExplodableModel.tsx index 80fffd8..c8542c7 100644 --- a/src/components/three/models/ExplodableModel.tsx +++ b/src/components/three/models/ExplodableModel.tsx @@ -1,14 +1,16 @@ import type { ReactNode } from "react"; import { Component, useEffect, useMemo } from "react"; import { useFrame } from "@react-three/fiber"; -import { useGLTF } from "@react-three/drei"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useClonedObject } from "@/hooks/three/useClonedObject"; import { ExplodedModel } from "@/utils/three/ExplodedModel"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; +import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { toVector3Scale } from "@/utils/three/scale"; interface ModelErrorBoundaryProps { children: ReactNode; + modelPath: string; position?: Vector3Tuple | undefined; } @@ -30,7 +32,14 @@ class ModelErrorBoundary extends Component< } componentDidCatch(error: Error): void { - console.warn("Failed to load explodable model", error); + logModelLoadError( + { + modelPath: this.props.modelPath, + scope: "ExplodableModel", + position: this.props.position, + }, + error, + ); } render(): ReactNode { @@ -52,7 +61,11 @@ export function ExplodableModel( props: ExplodableModelInnerProps, ): React.JSX.Element { return ( - + ); @@ -66,7 +79,12 @@ function ExplodableModelInner({ scale = 1, splitDistance = 1.2, }: ExplodableModelInnerProps): React.JSX.Element { - const { scene } = useGLTF(modelPath); + const { scene } = useLoggedGLTF(modelPath, { + scope: "ExplodableModel", + position, + rotation, + scale, + }); const model = useClonedObject(scene); const explodedModel = useMemo( () => new ExplodedModel(model, { distance: splitDistance }), diff --git a/src/components/three/models/SimpleModel.tsx b/src/components/three/models/SimpleModel.tsx index e536619..c559db2 100644 --- a/src/components/three/models/SimpleModel.tsx +++ b/src/components/three/models/SimpleModel.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useGLTF } from "@react-three/drei"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import type { Vector3Tuple } from "@/types/three/three"; export interface SimpleModelConfig { @@ -24,7 +24,12 @@ export function SimpleModel({ receiveShadow = true, children, }: SimpleModelProps): React.JSX.Element { - const { scene } = useGLTF(modelPath); + const { scene } = useLoggedGLTF(modelPath, { + scope: "SimpleModel", + position, + rotation, + scale, + }); const model = useMemo(() => scene.clone(true), [scene]); const parsedScale = diff --git a/src/components/three/world/SkyModel.tsx b/src/components/three/world/SkyModel.tsx index 15a5a21..56fd5eb 100644 --- a/src/components/three/world/SkyModel.tsx +++ b/src/components/three/world/SkyModel.tsx @@ -3,6 +3,7 @@ import { useGLTF } from "@react-three/drei"; import { useRef } from "react"; import * as THREE from "three"; import { useClonedObject } from "@/hooks/three/useClonedObject"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; interface SkyModelProps { modelPath: string; @@ -13,7 +14,10 @@ const SKY_MODEL_SCALE = 1; export function SkyModel({ modelPath }: SkyModelProps): React.JSX.Element { const camera = useThree((state) => state.camera); const groupRef = useRef(null); - const { scene } = useGLTF(modelPath); + const { scene } = useLoggedGLTF(modelPath, { + scope: "SkyModel", + scale: SKY_MODEL_SCALE, + }); const model = useClonedObject(scene); useFrame(() => { diff --git a/src/hooks/animation/useCharacterAnimation.ts b/src/hooks/animation/useCharacterAnimation.ts index 02aac0f..193bc04 100644 --- a/src/hooks/animation/useCharacterAnimation.ts +++ b/src/hooks/animation/useCharacterAnimation.ts @@ -1,7 +1,8 @@ import { useRef, useEffect, useState, useCallback, useMemo } from "react"; -import { useGLTF, useAnimations } from "@react-three/drei"; +import { useAnimations } from "@react-three/drei"; import type { AnimationAction, AnimationMixer } from "three"; import * as THREE from "three"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; export interface CharacterAnimationConfig { modelPath: string; @@ -34,7 +35,9 @@ export function useCharacterAnimation( } = config; const groupRef = useRef(null); - const { scene, animations } = useGLTF(modelPath); + const { scene, animations } = useLoggedGLTF(modelPath, { + scope: "useCharacterAnimation", + }); const model = useMemo(() => scene.clone(true), [scene]); const { actions, names, mixer } = useAnimations(animations, groupRef); const [currentAnimation, setCurrentAnimation] = useState(initialAnimation); diff --git a/src/hooks/three/useLoggedGLTF.ts b/src/hooks/three/useLoggedGLTF.ts new file mode 100644 index 0000000..8e08ce3 --- /dev/null +++ b/src/hooks/three/useLoggedGLTF.ts @@ -0,0 +1,24 @@ +import { useEffect, useRef } from "react"; +import { useGLTF } from "@react-three/drei"; +import { + logModelLoadSuccess, + type ModelLoadLogContext, +} from "@/utils/three/modelLoadLogger"; + +export function useLoggedGLTF( + modelPath: string, + context: Omit, +) { + const gltf = useGLTF(modelPath); + const hasLoggedRef = useRef(false); + const { position, rotation, scale, scope } = context; + + useEffect(() => { + if (hasLoggedRef.current) return; + + hasLoggedRef.current = true; + logModelLoadSuccess({ modelPath, position, rotation, scale, scope }, gltf); + }, [gltf, modelPath, position, rotation, scale, scope]); + + return gltf; +} diff --git a/src/utils/three/modelLoadLogger.ts b/src/utils/three/modelLoadLogger.ts new file mode 100644 index 0000000..9160a9d --- /dev/null +++ b/src/utils/three/modelLoadLogger.ts @@ -0,0 +1,68 @@ +import { logger } from "@/utils/core/logger"; +import type { Vector3Tuple } from "@/types/three/three"; + +export interface ModelLoadLogContext { + modelPath: string; + scope: string; + position?: Vector3Tuple | undefined; + rotation?: Vector3Tuple | undefined; + scale?: Vector3Tuple | number | undefined; +} + +interface LoadedModelInfo { + scene: { + name: string; + }; + animations: Array<{ + name: string; + }>; +} + +function getModelLoadHint(error: Error): string | undefined { + const message = error.message.toLowerCase(); + + if ( + message.includes("unexpected token 'v'") || + message.includes("version https://git-lfs") || + message.includes("git-lfs") + ) { + return "This file looks like a Git LFS pointer instead of a real GLTF asset. Run `git lfs pull` or replace the asset."; + } + + if (message.includes("couldn't load texture")) { + return "A texture referenced by the GLTF could not be loaded. Check file names, casing, and paths next to the model."; + } + + return undefined; +} + +export function logModelLoadSuccess( + context: ModelLoadLogContext, + gltf: LoadedModelInfo, +): void { + logger.debug("ModelLoader", "Model loaded", { + modelPath: context.modelPath, + scope: context.scope, + position: context.position, + rotation: context.rotation, + scale: context.scale, + sceneName: gltf.scene.name || null, + animations: gltf.animations.map((animation) => animation.name), + animationCount: gltf.animations.length, + }); +} + +export function logModelLoadError( + context: ModelLoadLogContext, + error: Error, +): void { + logger.error("ModelLoader", "Model failed to load", { + modelPath: context.modelPath, + scope: context.scope, + position: context.position, + rotation: context.rotation, + scale: context.scale, + reason: error.message, + hint: getModelLoadHint(error), + }); +} diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 9f46e33..f5a4097 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -1,10 +1,12 @@ import type { ReactNode } from "react"; import { Component, useEffect, useRef, useState } from "react"; -import { useGLTF } from "@react-three/drei"; import * as THREE from "three"; import { useClonedObject } from "@/hooks/three/useClonedObject"; +import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode"; +import { logger } from "@/utils/core/logger"; import { loadMapSceneData } from "@/utils/map/loadMapSceneData"; +import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import type { MapNode } from "@/types/editor/editor"; import type { OctreeReadyHandler } from "@/types/three/three"; @@ -15,6 +17,8 @@ interface LoadedMapNode { interface ErrorBoundaryProps { children: ReactNode; + modelUrl: string; + node: MapNode; } interface ErrorBoundaryState { @@ -35,7 +39,16 @@ class ModelErrorBoundary extends Component< } componentDidCatch(error: Error): void { - console.warn("Failed to load model", error); + logModelLoadError( + { + modelPath: this.props.modelUrl, + scope: "GameMap.ModelInstance", + position: this.props.node.position, + rotation: this.props.node.rotation, + scale: this.props.node.scale, + }, + error, + ); } render(): ReactNode { @@ -62,7 +75,7 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element { try { const sceneData = await loadMapSceneData(); if (!sceneData) { - console.warn("map.json not found"); + logger.warn("GameMap", "map.json not found"); return; } @@ -74,14 +87,20 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element { sceneData.mapNodes.length - loadedMapNodes.length; if (missingModelCount > 0) { - console.warn( - `${missingModelCount} map nodes were skipped because their model files are missing.`, + logger.warn( + "GameMap", + "Map nodes skipped because model files are missing", + { + missingModelCount, + }, ); } setMapNodes(loadedMapNodes); } catch (error) { - console.error("Error loading map:", error); + logger.error("GameMap", "Error loading map", { + error: error instanceof Error ? error : new Error(String(error)), + }); } }; @@ -91,7 +110,11 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element { return ( {mapNodes.map((mapNode, index) => ( - + ))} @@ -106,9 +129,14 @@ function ModelInstance({ node: MapNode; modelUrl: string; }): React.JSX.Element { - const { scene } = useGLTF(modelUrl); - const sceneInstance = useClonedObject(scene); const { position, rotation, scale } = node; + const { scene } = useLoggedGLTF(modelUrl, { + scope: "GameMap.ModelInstance", + position, + rotation, + scale, + }); + const sceneInstance = useClonedObject(scene); return ( - +