feat: add AttireErrorBoundary component for improved error handling in attire model loading
Some checks are pending
CI/CD / test-and-build (push) Waiting to run
CI/CD / deploy (push) Blocked by required conditions

This commit is contained in:
Najjar\NajjarV02 2026-04-17 15:19:54 +04:00
parent ed6ebcc8af
commit 6dc705b332
2 changed files with 36 additions and 21 deletions

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useRef, useMemo, useEffect, Suspense, useState } from 'react'; import { useRef, useMemo, useEffect, Suspense, useState, Component, type ReactNode } from 'react';
import { useGLTF } from '@react-three/drei'; import { useGLTF } from '@react-three/drei';
import { useFrame } from '@react-three/fiber'; import { useFrame } from '@react-three/fiber';
import * as THREE from 'three'; import * as THREE from 'three';
@ -24,6 +24,30 @@ function buildAttireGlbMap(personas: { id: string; modelPath?: string }[]): Reco
return { ...STATIC_ATTIRE_GLB, ...dynamic }; // uploaded GLBs override static return { ...STATIC_ATTIRE_GLB, ...dynamic }; // uploaded GLBs override static
} }
interface AttireErrorBoundaryProps {
children: ReactNode;
onError: () => void;
}
interface AttireErrorBoundaryState { hasError: boolean; }
class AttireErrorBoundary extends Component<AttireErrorBoundaryProps, AttireErrorBoundaryState> {
constructor(props: AttireErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): AttireErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.warn('[AttireModel] Failed to load GLB, falling back to base robot:', error.message);
this.props.onError();
}
render() {
if (this.state.hasError) return null;
return this.props.children;
}
}
function easeInOutCubic(t: number): number { function easeInOutCubic(t: number): number {
return t < 0.5 return t < 0.5
? 4 * t * t * t ? 4 * t * t * t
@ -205,13 +229,14 @@ export function RobotModel({ onError }: RobotModelProps) {
<primitive object={processedScene} /> <primitive object={processedScene} />
{attireGlbPath && ( {attireGlbPath && (
<Suspense fallback={null}> <AttireErrorBoundary key={attireGlbPath} onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
<AttireModel <Suspense fallback={null}>
key={attireGlbPath} <AttireModel
glbPath={attireGlbPath} glbPath={attireGlbPath}
onLoaded={handleAttireLoaded} onLoaded={handleAttireLoaded}
/> />
</Suspense> </Suspense>
</AttireErrorBoundary>
)} )}
</group> </group>
); );

View File

@ -49,18 +49,6 @@ export const DEFAULT_PERSONAS: PersonaOption[] = [
description: 'Professional navy suit', description: 'Professional navy suit',
colors: { torso: '#1e293b', legs: '#1e293b' }, colors: { torso: '#1e293b', legs: '#1e293b' },
}, },
{
id: 'robot-doctor',
label: 'Robot Doctor',
description: 'Medical doctor attire',
colors: { torso: '#ffffff', legs: '#ffffff' },
},
{
id: 'security-guard',
label: 'Security Guard',
description: 'Security personnel uniform',
colors: { torso: '#1c1c1c', legs: '#1c1c1c' },
},
]; ];
const STORAGE_KEY = 'lootah-personas'; const STORAGE_KEY = 'lootah-personas';
@ -138,11 +126,13 @@ export const personaStore = createStore<PersonaStore>((set, get) => ({
hydrate: () => { hydrate: () => {
const stored = loadFromStorage(); const stored = loadFromStorage();
if (stored && stored.length > 0) { if (stored && stored.length > 0) {
// Only re-inject truly built-in personas (those still in DEFAULT_PERSONAS) if missing.
// Dynamic/uploaded personas that were deleted via the dashboard must NOT be re-added.
const storedIds = new Set(stored.map((s) => s.id)); const storedIds = new Set(stored.map((s) => s.id));
const missing = DEFAULT_PERSONAS.filter((d) => !storedIds.has(d.id)); const missing = DEFAULT_PERSONAS.filter((d) => !storedIds.has(d.id));
set({ personas: [...stored, ...missing], isHydrated: true }); set({ personas: [...stored, ...missing], isHydrated: true });
} else { } else {
set({ isHydrated: true }); set({ personas: [...DEFAULT_PERSONAS], isHydrated: true });
} }
}, },
})); }));