diff --git a/src/components/ConfigPanel.tsx b/src/components/ConfigPanel.tsx index 0d56526..53ac3fb 100644 --- a/src/components/ConfigPanel.tsx +++ b/src/components/ConfigPanel.tsx @@ -5,9 +5,15 @@ import { configStore, useConfigStore } from '@/store/useConfigStore'; import { personaStore, usePersonaStore } from '@/store/usePersonaStore'; import { PricingEngine } from './PricingEngine'; +const BODY_OPTIONS: { id: 'basic' | 'edu'; label: string; description: string }[] = [ + { id: 'basic', label: 'Basic', description: 'Standard G1 chassis' }, + { id: 'edu', label: 'EDU', description: 'Education / research variant' }, +]; + export function ConfigPanel() { const activeColors = useConfigStore((s) => s.activeColors); const activePersona = useConfigStore((s) => s.activePersonaAttire); + const activeBody = useConfigStore((s) => s.activeBody); const personas = usePersonaStore((s) => s.personas); // Track which persona is loading (waiting for GLB to download) const [loadingPersona, setLoadingPersona] = useState(null); @@ -36,6 +42,10 @@ export function ConfigPanel() { configStore.getState().setColors({ [key]: value }); }, []); + const handleBodySelect = useCallback((body: 'basic' | 'edu') => { + configStore.getState().setBody(body); + }, []); + const handlePersonaSelect = useCallback((attire: string) => { // Only show loading for dynamic (uploaded) attire that has a GLB to download const persona = personaStore.getState().personas.find((p) => p.id === attire); @@ -176,6 +186,59 @@ export function ConfigPanel() { + {/* --- ROBOT BODY SECTION --- */} +
+

Robot Body

+
+ {BODY_OPTIONS.map((opt) => { + const isActive = activeBody === opt.id; + return ( + + ); + })} +
+
+ {/* --- PRICING --- */} diff --git a/src/components/RobotModel.tsx b/src/components/RobotModel.tsx index bf91801..7d1bbb0 100644 --- a/src/components/RobotModel.tsx +++ b/src/components/RobotModel.tsx @@ -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 = { '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 { - constructor(props: AttireErrorBoundaryProps) { +class ModelErrorBoundary extends Component { + 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 ; +} + 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(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 ( - + + + + + {attireGlbPath && ( - { setDisplayedAttire('none'); setAttireReady(false); }}> + { setDisplayedAttire('none'); setAttireReady(false); }}> - + )} ); diff --git a/src/hooks/useUrlSync.test.ts b/src/hooks/useUrlSync.test.ts index 64a7d71..66726d5 100644 --- a/src/hooks/useUrlSync.test.ts +++ b/src/hooks/useUrlSync.test.ts @@ -27,6 +27,7 @@ describe('URL Serialization', () => { const encoded = serializeConfig({ activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, activePersonaAttire: 'Emarati Kandura', + activeBody: 'basic', activePayloads: [{ id: 'cam1', type: 'camera', position: 'head' }], isHydrated: true, }); @@ -45,6 +46,7 @@ describe('URL Serialization', () => { const original = { activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, activePersonaAttire: 'Industrial Vest', + activeBody: 'edu' as const, activePayloads: [ { id: 'cam1', type: 'camera', position: 'head' }, { id: 'light1', type: 'light', position: 'arm' }, diff --git a/src/store/useConfigStore.test.ts b/src/store/useConfigStore.test.ts index 223cd6a..deeae2c 100644 --- a/src/store/useConfigStore.test.ts +++ b/src/store/useConfigStore.test.ts @@ -16,11 +16,25 @@ describe('useConfigStore (vanilla)', () => { accent: '#f59e0b', }); expect(state.activePersonaAttire).toBe('none'); + expect(state.activeBody).toBe('basic'); expect(state.activePayloads).toEqual([]); expect(state.isHydrated).toBe(false); }); }); + describe('setBody', () => { + it('should update active body to edu', () => { + configStore.getState().setBody('edu'); + expect(configStore.getState().activeBody).toBe('edu'); + }); + + it('should update active body back to basic', () => { + configStore.getState().setBody('edu'); + configStore.getState().setBody('basic'); + expect(configStore.getState().activeBody).toBe('basic'); + }); + }); + describe('setColors', () => { it('should update primary color', () => { configStore.getState().setColors({ primary: '#ff0000' }); @@ -112,6 +126,7 @@ describe('useConfigStore (vanilla)', () => { it('should reset all state to defaults', () => { configStore.getState().setColors({ primary: '#ff0000' }); configStore.getState().setPersonaAttire('Industrial Vest'); + configStore.getState().setBody('edu'); configStore.getState().addPayload({ id: 'cam1', type: 'camera', position: 'head' }); configStore.getState().setHydrated(true); @@ -120,6 +135,7 @@ describe('useConfigStore (vanilla)', () => { const state = configStore.getState(); expect(state.activeColors.primary).toBe('#96a2b6'); expect(state.activePersonaAttire).toBe('none'); + expect(state.activeBody).toBe('basic'); expect(state.activePayloads).toEqual([]); expect(state.isHydrated).toBe(false); }); @@ -149,6 +165,7 @@ describe('serializeConfig & deserializeConfig', () => { const state: ConfigState = { activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, activePersonaAttire: 'Test Attire', + activeBody: 'basic', activePayloads: [{ id: 'p1', type: 'camera', position: 'head' }], isHydrated: true, }; @@ -162,6 +179,7 @@ describe('serializeConfig & deserializeConfig', () => { const state: ConfigState = { activeColors: { primary: '#ff0000', secondary: '#00ff00', accent: '#0000ff' }, activePersonaAttire: 'Test Attire', + activeBody: 'basic', activePayloads: [{ id: 'p1', type: 'camera', position: 'head' }], isHydrated: true, }; @@ -190,6 +208,7 @@ describe('serializeConfig & deserializeConfig', () => { const original: ConfigState = { activeColors: { primary: '#aabbcc', secondary: '#ddeeff', accent: '#112233' }, activePersonaAttire: 'Emarati Kandura', + activeBody: 'edu', activePayloads: [ { id: 'cam1', type: 'PTZ Camera', position: 'head' }, { id: 'light1', type: 'LED Array', position: 'chest' }, @@ -204,6 +223,7 @@ describe('serializeConfig & deserializeConfig', () => { expect(decoded).not.toBeNull(); expect(decoded?.activeColors).toEqual(original.activeColors); expect(decoded?.activePersonaAttire).toBe(original.activePersonaAttire); + expect(decoded?.activeBody).toBe(original.activeBody); expect(decoded?.activePayloads).toHaveLength(3); expect(decoded?.activePayloads).toEqual(original.activePayloads); }); diff --git a/src/store/useConfigStore.ts b/src/store/useConfigStore.ts index 589f408..03e9007 100644 --- a/src/store/useConfigStore.ts +++ b/src/store/useConfigStore.ts @@ -13,9 +13,12 @@ export interface Payload { position: string; } +export type RobotBody = 'basic' | 'edu'; + export interface ConfigState { activeColors: ColorConfig; activePersonaAttire: string; + activeBody: RobotBody; activePayloads: Payload[]; isHydrated: boolean; } @@ -23,6 +26,7 @@ export interface ConfigState { export interface ConfigActions { setColors: (colors: Partial) => void; setPersonaAttire: (attire: string) => void; + setBody: (body: RobotBody) => void; addPayload: (payload: Payload) => void; removePayload: (payloadId: string) => void; updatePayload: (payloadId: string, updates: Partial) => void; @@ -44,6 +48,7 @@ const defaultColors: ColorConfig = { const defaultState: ConfigState = { activeColors: defaultColors, activePersonaAttire: 'none', + activeBody: 'basic', activePayloads: [], isHydrated: false, }; @@ -62,6 +67,10 @@ export const configStore = createStore((set) => ({ set({ activePersonaAttire: attire }); }, + setBody: (body: RobotBody) => { + set({ activeBody: body }); + }, + addPayload: (payload: Payload) => { set((state) => { if (state.activePayloads.some((p) => p.id === payload.id)) { @@ -126,6 +135,9 @@ export const useActiveColors = () => export const usePersonaAttire = () => useConfigStore((state) => state.activePersonaAttire); +export const useActiveBody = () => + useConfigStore((state) => state.activeBody); + export const usePayloads = () => useConfigStore((state) => state.activePayloads); @@ -137,6 +149,7 @@ export const serializeConfig = (state: ConfigState): string => { const data = { c: state.activeColors, p: state.activePersonaAttire, + b: state.activeBody, y: state.activePayloads, }; return btoa(JSON.stringify(data)); @@ -216,9 +229,12 @@ export const deserializeConfig = (encoded: string): Partial | null } } + const body: RobotBody = data.b === 'edu' ? 'edu' : 'basic'; + return { activeColors: data.c, activePersonaAttire: data.p, + activeBody: body, activePayloads: data.y, }; } catch (error) {