feat(configure): add Basic vs EDU robot body selector under Persona Attire

- New `activeBody` field in config store with `setBody` action and URL sync (`b` key, defaults to `basic` for backward compat)
- Robot Body section in ConfigPanel between Persona Attire and Pricing
- RobotModel base mesh extracted into BaseBodyMesh subcomponent wrapped in error boundary so a missing EDU GLB silently falls back to basic
- Tests cover defaults, setBody, reset, and round-trip serialization

Note: drop `/public/Unitree_G1_EDU.glb` to enable the EDU variant — until then EDU selection falls back to basic with a console warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Najjar\NajjarV02
2026-05-19 17:11:14 +04:00
co-authored by Claude Opus 4.7
parent c26338f355
commit 03dbc4ac98
5 changed files with 199 additions and 71 deletions
+98 -71
View File
@@ -1,10 +1,10 @@
'use client';
import { useRef, useMemo, useEffect, Suspense, useState, Component, type ReactNode } from 'react';
import { useRef, useMemo, useEffect, Suspense, useState, useCallback, Component, type ReactNode } from 'react';
import { useGLTF } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { useConfigStore } from '@/store/useConfigStore';
import { configStore, useConfigStore } from '@/store/useConfigStore';
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
// Configure Draco decoder so compressed .glb files load correctly
@@ -16,6 +16,11 @@ const STATIC_ATTIRE_GLB: Record<string, string> = {
'business-suit': '/Suit.glb',
};
const BODY_GLB: Record<'basic' | 'edu', string> = {
basic: '/Unitree_G1.glb',
edu: '/Unitree_G1_EDU.glb',
};
// Attire models are loaded on-demand to avoid blocking the initial 50 MB robot load
/** Merge static map with any custom GLBs stored in the persona store */
@@ -27,22 +32,23 @@ function buildAttireGlbMap(personas: { id: string; modelPath?: string }[]): Reco
return { ...STATIC_ATTIRE_GLB, ...dynamic }; // uploaded GLBs override static
}
interface AttireErrorBoundaryProps {
interface ErrorBoundaryProps {
children: ReactNode;
onError: () => void;
tag: string;
}
interface AttireErrorBoundaryState { hasError: boolean; }
interface ErrorBoundaryState { hasError: boolean; }
class AttireErrorBoundary extends Component<AttireErrorBoundaryProps, AttireErrorBoundaryState> {
constructor(props: AttireErrorBoundaryProps) {
class ModelErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): AttireErrorBoundaryState {
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.warn('[AttireModel] Failed to load GLB, falling back to base robot:', error.message);
console.warn(`[${this.props.tag}] Failed to load GLB, falling back:`, error.message);
this.props.onError();
}
render() {
@@ -57,6 +63,71 @@ function easeInOutCubic(t: number): number {
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function BaseBodyMesh({ glbPath, primaryColor, visible }: { glbPath: string; primaryColor: string; visible: boolean }) {
useEffect(() => {
return () => {
useGLTF.clear(glbPath);
};
}, [glbPath]);
const { scene } = useGLTF(glbPath);
const processedScene = useMemo(() => {
const clonedScene = scene.clone();
clonedScene.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (!child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#96a2b6',
metalness: 0.8,
roughness: 0.2,
});
}
if (child.material instanceof THREE.MeshStandardMaterial) {
child.material.envMapIntensity = 1;
child.material.needsUpdate = true;
}
}
});
const box = new THREE.Box3().setFromObject(clonedScene);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
clonedScene.scale.setScalar(scale);
const offset = new THREE.Vector3(
-center.x * scale,
-center.y * scale + 0.5,
-center.z * scale
);
clonedScene.position.copy(offset);
return clonedScene;
}, [scene]);
useEffect(() => {
processedScene.visible = visible;
}, [visible, processedScene]);
useEffect(() => {
processedScene.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
if (child.name.startsWith('Unitree_G1')) {
child.material.color.set(primaryColor);
child.material.needsUpdate = true;
}
}
});
}, [primaryColor, processedScene]);
return <primitive object={processedScene} />;
}
function AttireModel({ glbPath, onLoaded }: { glbPath: string; onLoaded: () => void }) {
// Clear stale useGLTF cache entries for paths that share the same base filename
// (e.g. /models/robot-doctor.glb?v=1 replaced by ?v=2). This prevents Three.js
@@ -107,7 +178,7 @@ interface RobotModelProps {
onError?: (error: Error) => void;
}
export function RobotModel({ onError }: RobotModelProps) {
export function RobotModel({ onError: _onError }: RobotModelProps) {
const groupRef = useRef<THREE.Group>(null);
const spinningRef = useRef(false);
@@ -119,7 +190,8 @@ export function RobotModel({ onError }: RobotModelProps) {
const [attireReady, setAttireReady] = useState(false);
const previousAttireRef = useRef('none');
const { scene } = useGLTF('/Unitree_G1.glb');
const activeBody = useConfigStore((state) => state.activeBody);
const bodyGlbPath = BODY_GLB[activeBody] ?? BODY_GLB.basic;
const activeColors = useConfigStore((state) => state.activeColors);
const activePersonaAttire = useConfigStore((state) => state.activePersonaAttire);
@@ -161,85 +233,40 @@ export function RobotModel({ onError }: RobotModelProps) {
}
});
const processedScene = useMemo(() => {
const clonedScene = scene.clone();
clonedScene.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (!child.material) {
child.material = new THREE.MeshStandardMaterial({
color: '#96a2b6',
metalness: 0.8,
roughness: 0.2,
});
}
if (child.material instanceof THREE.MeshStandardMaterial) {
child.material.envMapIntensity = 1;
child.material.needsUpdate = true;
}
}
});
const box = new THREE.Box3().setFromObject(clonedScene);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
clonedScene.scale.setScalar(scale);
const offset = new THREE.Vector3(
-center.x * scale,
-center.y * scale + 0.5,
-center.z * scale
);
clonedScene.position.copy(offset);
return clonedScene;
}, [scene]);
// Hide base robot only when attire is selected AND the attire GLB has loaded
// Show base robot when 'none' is selected or attire is still loading
const showBase = displayedAttire === 'none' || !attireReady;
useEffect(() => {
processedScene.visible = showBase;
}, [showBase, processedScene]);
// Apply primary color to the base robot only
useEffect(() => {
if (!groupRef.current) return;
groupRef.current.traverse((child) => {
if (child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial) {
if (child.name.startsWith('Unitree_G1')) {
child.material.color.set(activeColors.primary);
child.material.needsUpdate = true;
}
}
});
}, [activeColors]);
const attireGlbPath = buildAttireGlbMap(personas)[displayedAttire] || null;
const handleAttireLoaded = () => {
const handleAttireLoaded = useCallback(() => {
setAttireReady(true);
};
}, []);
const handleBodyError = useCallback(() => {
// Fallback to basic body when EDU (or any non-basic) GLB fails to load
if (configStore.getState().activeBody !== 'basic') {
configStore.getState().setBody('basic');
}
}, []);
return (
<group ref={groupRef}>
<primitive object={processedScene} />
<ModelErrorBoundary key={bodyGlbPath} tag="BodyModel" onError={handleBodyError}>
<Suspense fallback={null}>
<BaseBodyMesh glbPath={bodyGlbPath} primaryColor={activeColors.primary} visible={showBase} />
</Suspense>
</ModelErrorBoundary>
{attireGlbPath && (
<AttireErrorBoundary key={attireGlbPath} onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
<ModelErrorBoundary key={attireGlbPath} tag="AttireModel" onError={() => { setDisplayedAttire('none'); setAttireReady(false); }}>
<Suspense fallback={null}>
<AttireModel
glbPath={attireGlbPath}
onLoaded={handleAttireLoaded}
/>
</Suspense>
</AttireErrorBoundary>
</ModelErrorBoundary>
)}
</group>
);