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:
parent
c26338f355
commit
03dbc4ac98
@ -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<string | null>(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() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- ROBOT BODY SECTION --- */}
|
||||
<section
|
||||
id="section-body"
|
||||
style={{
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<h3 style={sectionTitleStyle}>Robot Body</h3>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
{BODY_OPTIONS.map((opt) => {
|
||||
const isActive = activeBody === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => handleBodySelect(opt.id)}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: isActive
|
||||
? '1px solid rgba(59, 130, 246, 0.5)'
|
||||
: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
background: isActive
|
||||
? 'rgba(59, 130, 246, 0.06)'
|
||||
: 'rgba(248, 248, 246, 0.4)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
color: isActive ? '#374151' : '#64748b',
|
||||
marginBottom: '2px',
|
||||
}}>
|
||||
{opt.label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '0.65rem',
|
||||
color: '#94a3b8',
|
||||
lineHeight: 1.3,
|
||||
}}>
|
||||
{opt.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- PRICING --- */}
|
||||
<PricingEngine />
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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<ColorConfig>) => void;
|
||||
setPersonaAttire: (attire: string) => void;
|
||||
setBody: (body: RobotBody) => void;
|
||||
addPayload: (payload: Payload) => void;
|
||||
removePayload: (payloadId: string) => void;
|
||||
updatePayload: (payloadId: string, updates: Partial<Payload>) => 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<ConfigStore>((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<ConfigState> | null
|
||||
}
|
||||
}
|
||||
|
||||
const body: RobotBody = data.b === 'edu' ? 'edu' : 'basic';
|
||||
|
||||
return {
|
||||
activeColors: data.c,
|
||||
activePersonaAttire: data.p,
|
||||
activeBody: body,
|
||||
activePayloads: data.y,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user