├── frontend ├── src │ ├── vite-env.d.ts │ ├── holo │ │ ├── objects │ │ │ ├── socketstatus.ts │ │ │ ├── coords.ts │ │ │ ├── hand.ts │ │ │ └── InteractionState.ts │ │ ├── types │ │ │ └── streamstatus.ts │ │ ├── provider │ │ │ ├── WebSocketContext.tsx │ │ │ └── VideoStreamContext.tsx │ │ ├── hooks │ │ │ └── useSkeleton.ts │ │ └── components │ │ │ └── HolohandsOverlay.tsx │ ├── App.tsx │ ├── main.tsx │ ├── App.css │ ├── utils │ │ ├── local.ts │ │ ├── geometry.ts │ │ ├── scene.ts │ │ └── io.ts │ ├── store │ │ ├── agentTimeline.ts │ │ └── editor.ts │ ├── index.css │ ├── hooks │ │ └── useShortcuts.ts │ ├── agent │ │ ├── types.ts │ │ ├── agent.ts │ │ └── tools.ts │ ├── components │ │ ├── Layout.tsx │ │ ├── SnapPanel.tsx │ │ ├── Toolbar.tsx │ │ ├── BooleanPanel.tsx │ │ ├── VideoStream.tsx │ │ ├── AgentTimeline.tsx │ │ ├── Topbar.tsx │ │ └── ShapeIcons.tsx │ ├── provider │ │ └── ViewportContext.tsx │ ├── types.ts │ └── assets │ │ └── react.svg ├── tsconfig.json ├── vite.config.ts ├── index.html ├── eslint.config.js ├── tsconfig.node.json ├── tsconfig.app.json ├── package.json ├── public │ └── vite.svg ├── sample-server-response.json └── server │ └── index.mjs ├── .gitignore ├── package.json ├── requirements.txt ├── backend ├── processor.py ├── detector.py ├── hand_processor.py └── webserver.py └── README.md /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | .venv 4 | .DS_Store 5 | old/ 6 | .env.local -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@fal-ai/client": "^1.6.2" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/holo/objects/socketstatus.ts: -------------------------------------------------------------------------------- 1 | export type SocketStatus = 2 | | "Connecting..." 3 | | "Connected" 4 | | "Disconnected" 5 | | "Error"; 6 | -------------------------------------------------------------------------------- /frontend/src/holo/types/streamstatus.ts: -------------------------------------------------------------------------------- 1 | export type StreamStatus = 2 | | "idle" 3 | | "loading" 4 | | "error" 5 | | "streaming" 6 | | "stopped"; 7 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Layout from './components/Layout' 2 | 3 | function App() { 4 | return ( 5 | 6 | ) 7 | } 8 | 9 | export default App 10 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /frontend/src/holo/objects/coords.ts: -------------------------------------------------------------------------------- 1 | interface Coords { 2 | x: number; 3 | y: number; 4 | } 5 | 6 | const DEFAULT_COORDS: Coords = { x: 0, y: 0 }; 7 | 8 | export type { Coords }; 9 | export { DEFAULT_COORDS }; 10 | -------------------------------------------------------------------------------- /frontend/src/holo/objects/hand.ts: -------------------------------------------------------------------------------- 1 | export interface Hand { 2 | handedness: "Left" | "Right"; 3 | landmarks: number[][]; 4 | connections: number[][]; 5 | detected_symbols?: [string, number][]; 6 | } 7 | 8 | export const HAND_COLORS: Record<"Left" | "Right", string> = { 9 | Left: "#FF0000", 10 | Right: "#00FF00", 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import { ViewportProvider } from "./provider/ViewportContext.tsx"; 5 | 6 | createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { globalIgnores } from 'eslint/config' 7 | 8 | export default tseslint.config([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs['recommended-latest'], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "erasableSyntaxOnly": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | absl-py==2.3.1 2 | attrs==25.3.0 3 | blinker==1.9.0 4 | cffi==2.0.0 5 | click==8.2.1 6 | colorama==0.4.6 7 | contourpy==1.3.3 8 | cycler==0.12.1 9 | dnspython==2.8.0 10 | eventlet==0.40.3 11 | Flask==3.1.2 12 | flask-cors==6.0.1 13 | flatbuffers==25.2.10 14 | fonttools==4.59.2 15 | greenlet==3.2.4 16 | itsdangerous==2.2.0 17 | jax==0.7.1 18 | jaxlib==0.7.1 19 | Jinja2==3.1.6 20 | kiwisolver==1.4.9 21 | MarkupSafe==3.0.2 22 | matplotlib==3.10.6 23 | mediapipe==0.10.21 24 | ml_dtypes==0.5.3 25 | numpy==2.3.3 26 | opencv-contrib-python==4.11.0.86 27 | opencv-python==4.12.0.88 28 | opt_einsum==3.4.0 29 | orjson==3.11.3 30 | packaging==25.0 31 | pillow==11.3.0 32 | protobuf==4.25.8 33 | pycparser==2.23 34 | pyparsing==3.2.4 35 | python-dateutil==2.9.0.post0 36 | scipy==1.16.2 37 | sentencepiece==0.2.1 38 | six==1.17.0 39 | sounddevice==0.5.2 40 | Werkzeug==3.1.3 41 | -------------------------------------------------------------------------------- /frontend/src/holo/objects/InteractionState.ts: -------------------------------------------------------------------------------- 1 | import { Coords } from "./coords"; 2 | 3 | interface InteractionState { 4 | Left: InteractionStateHand | null; 5 | Right: InteractionStateHand | null; 6 | angleBetween: number; 7 | } 8 | 9 | interface InteractionStateHand { 10 | isHolding: boolean; 11 | isPinching: boolean; 12 | cursor: { 13 | coords: Coords; 14 | angle: number; 15 | } | null; 16 | depth: number; 17 | } 18 | 19 | const DEFAULT_INTERACTION_STATE_HAND: InteractionStateHand = { 20 | isHolding: false, 21 | isPinching: false, 22 | cursor: null, 23 | depth: 0, 24 | }; 25 | 26 | const DEFAULT_INTERACTION_STATE: InteractionState = { 27 | Left: null, 28 | Right: null, 29 | angleBetween: 0, 30 | }; 31 | 32 | export type { InteractionState, InteractionStateHand }; 33 | export { DEFAULT_INTERACTION_STATE, DEFAULT_INTERACTION_STATE_HAND }; 34 | -------------------------------------------------------------------------------- /frontend/src/utils/local.ts: -------------------------------------------------------------------------------- 1 | import type { EditorState, SceneObject } from '../types' 2 | import { jsonToScene, sceneToJSON, type SavedScene } from './scene' 3 | 4 | const KEY = 'ai-blender-scene' 5 | 6 | export function saveScene(state: EditorState) { 7 | const json = sceneToJSON(state) 8 | localStorage.setItem(KEY, json) 9 | } 10 | 11 | export function loadScene(): SavedScene | null { 12 | const json = localStorage.getItem(KEY) 13 | if (!json) return null 14 | // First try the new format 15 | const decoded = jsonToScene(json) 16 | if (decoded) return decoded 17 | // Backward compatibility: legacy saves were an array of SceneObject 18 | try { 19 | const legacy = JSON.parse(json) as SceneObject[] 20 | if (Array.isArray(legacy)) { 21 | return { 22 | version: 1, 23 | editor: { 24 | objects: legacy, 25 | selectedId: null, 26 | mode: 'translate', 27 | editorMode: 'object', 28 | snap: { enableSnapping: false, translateSnap: 0.5, rotateSnap: Math.PI / 12, scaleSnap: 0.1 }, 29 | }, 30 | } 31 | } 32 | } catch {} 33 | return null 34 | } 35 | -------------------------------------------------------------------------------- /backend/processor.py: -------------------------------------------------------------------------------- 1 | import eventlet 2 | eventlet.monkey_patch() 3 | 4 | import cv2 5 | from webserver import socketio, app 6 | from detector import HandDetector 7 | 8 | class VisionProcessor: 9 | def __init__(self): 10 | self.cap = cv2.VideoCapture(0) 11 | self.detector = HandDetector() 12 | self.last_hands = [] 13 | 14 | def process_video(self): 15 | with app.app_context(): 16 | while True: 17 | success, frame = self.cap.read() 18 | if not success: 19 | continue 20 | 21 | frame = cv2.flip(frame, 1) 22 | hands_data = self.detector.detect(frame) 23 | 24 | if hands_data != self.last_hands: 25 | try: 26 | socketio.emit('hand_data', hands_data) 27 | self.last_hands = hands_data 28 | except Exception as e: 29 | print("Emit error:", str(e)) 30 | 31 | eventlet.sleep(0.001) 32 | 33 | if __name__ == '__main__': 34 | processor = VisionProcessor() 35 | processor.process_video() -------------------------------------------------------------------------------- /frontend/src/store/agentTimeline.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import type { StepResult, SceneSnapshot } from "../agent/types"; 3 | 4 | interface AgentUiState { 5 | dryRun: boolean; 6 | setDryRun: (v: boolean) => void; 7 | steps: StepResult[]; 8 | setSteps: (s: StepResult[]) => void; 9 | snapshot: SceneSnapshot | null; 10 | setSnapshot: (s: SceneSnapshot | null) => void; 11 | lastExportName: string | null; 12 | setLastExportName: (n: string | null) => void; 13 | showTimeline: boolean; 14 | setShowTimeline: (v: boolean) => void; 15 | toggleTimeline: () => void; 16 | } 17 | 18 | export const useAgentTimeline = create()((set) => ({ 19 | dryRun: false, 20 | setDryRun: (v) => set((s) => ({ ...s, dryRun: v })), 21 | steps: [], 22 | setSteps: (steps) => set((s) => ({ ...s, steps })), 23 | snapshot: null, 24 | setSnapshot: (snapshot) => set((s) => ({ ...s, snapshot })), 25 | lastExportName: null, 26 | setLastExportName: (n) => set((s) => ({ ...s, lastExportName: n })), 27 | showTimeline: true, 28 | setShowTimeline: (v) => set((s) => ({ ...s, showTimeline: v })), 29 | toggleTimeline: () => set((s) => ({ ...s, showTimeline: !s.showTimeline })), 30 | })); -------------------------------------------------------------------------------- /backend/detector.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import mediapipe as mp 3 | 4 | class HandDetector: 5 | def __init__(self): 6 | self.mp_hands = mp.solutions.hands 7 | self.hands = self.mp_hands.Hands( 8 | static_image_mode=False, 9 | max_num_hands=2, 10 | min_detection_confidence=0.75, 11 | min_tracking_confidence=0.75) 12 | 13 | def detect(self, frame): 14 | rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) 15 | results = self.hands.process(rgb) 16 | return self._serialize_results(results) 17 | 18 | def _serialize_results(self, results): 19 | hands = [] 20 | if results.multi_hand_landmarks: 21 | for idx, landmarks in enumerate(results.multi_hand_landmarks): 22 | try: 23 | handedness = results.multi_handedness[idx].classification[0].label 24 | hand = { 25 | "handedness": handedness, 26 | "landmarks": [(float(lm.x), float(lm.y), float(lm.z)) for lm in landmarks.landmark], 27 | "connections": [[int(conn[0]), int(conn[1])] for conn in self.mp_hands.HAND_CONNECTIONS] 28 | } 29 | hands.append(hand) 30 | except (IndexError, AttributeError): 31 | continue 32 | return hands -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-blender", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=18" 8 | }, 9 | "scripts": { 10 | "dev": "vite", 11 | "build": "tsc -b && vite build", 12 | "lint": "eslint .", 13 | "preview": "vite preview", 14 | "server": "node server/index.mjs" 15 | }, 16 | "dependencies": { 17 | "@react-three/drei": "^10.7.5", 18 | "@react-three/fiber": "^9.3.0", 19 | "@types/file-saver": "^2.0.7", 20 | "@types/styled-components": "^5.1.34", 21 | "dotenv": "^16.4.5", 22 | "express": "^4.19.2", 23 | "file-saver": "^2.0.5", 24 | "immer": "^10.1.3", 25 | "react": "^19.1.1", 26 | "react-dom": "^19.1.1", 27 | "styled-components": "^6.1.19", 28 | "three": "^0.180.0", 29 | "three-bvh-csg": "^0.0.17", 30 | "three-csg-ts": "^3.2.0", 31 | "three-mesh-bvh": "^0.9.1", 32 | "three-stdlib": "^2.36.0", 33 | "zustand": "^5.0.8", 34 | "@fal-ai/client": "^1.0.0" 35 | }, 36 | "devDependencies": { 37 | "@eslint/js": "^9.33.0", 38 | "@types/react": "^19.1.10", 39 | "@types/react-dom": "^19.1.7", 40 | "@vitejs/plugin-react": "^5.0.0", 41 | "eslint": "^9.33.0", 42 | "eslint-plugin-react-hooks": "^5.2.0", 43 | "eslint-plugin-react-refresh": "^0.4.20", 44 | "globals": "^16.3.0", 45 | "typescript": "~5.8.3", 46 | "typescript-eslint": "^8.39.1", 47 | "vite": "^7.1.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/utils/geometry.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three' 2 | import type { SerializableGeometry } from '../types' 3 | 4 | export function serializeGeometry(geometry: THREE.BufferGeometry): SerializableGeometry { 5 | const pos = geometry.getAttribute('position') as THREE.BufferAttribute | null 6 | const norm = geometry.getAttribute('normal') as THREE.BufferAttribute | null 7 | const uv = geometry.getAttribute('uv') as THREE.BufferAttribute | null 8 | const index = geometry.getIndex() 9 | return { 10 | positions: pos ? Array.from(pos.array as Float32Array) : [], 11 | normals: norm ? Array.from(norm.array as Float32Array) : undefined, 12 | uvs: uv ? Array.from(uv.array as Float32Array) : undefined, 13 | indices: index ? Array.from(index.array as Uint16Array | Uint32Array) : undefined, 14 | } 15 | } 16 | 17 | export function deserializeGeometry(data: SerializableGeometry): THREE.BufferGeometry { 18 | const geometry = new THREE.BufferGeometry() 19 | if (data.positions && data.positions.length > 0) { 20 | geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(data.positions), 3)) 21 | } 22 | if (data.normals && data.normals.length > 0) { 23 | geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(data.normals), 3)) 24 | } 25 | if (data.uvs && data.uvs.length > 0) { 26 | geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(data.uvs), 2)) 27 | } 28 | if (data.indices && data.indices.length > 0) { 29 | const needsUint32 = Math.max(...data.indices) > 65535 30 | geometry.setIndex(new THREE.BufferAttribute(needsUint32 ? new Uint32Array(data.indices) : new Uint16Array(data.indices), 1)) 31 | } 32 | geometry.computeBoundingBox() 33 | geometry.computeBoundingSphere() 34 | if (!geometry.getAttribute('normal')) geometry.computeVertexNormals() 35 | return geometry 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/hooks/useShortcuts.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useEditor } from '../store/editor' 3 | 4 | export function useShortcuts() { 5 | const add = useEditor(s => s.addObject) 6 | const del = useEditor(s => s.deleteSelected) 7 | const dup = useEditor(s => s.duplicateSelected) 8 | const undo = useEditor(s => s.undo) 9 | const redo = useEditor(s => s.redo) 10 | const setMode = useEditor(s => s.setMode) 11 | 12 | useEffect(() => { 13 | function onKeyDown(e: KeyboardEvent) { 14 | if (e.target && (e.target as HTMLElement).tagName === 'INPUT') return 15 | 16 | // Command/Ctrl + L to show chat panel 17 | if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'l') { 18 | e.preventDefault() 19 | try { (useEditor as any).setState((s: any) => ({ ...s, showChatPanel: true })); } catch {} 20 | return 21 | } 22 | 23 | if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { 24 | e.preventDefault() 25 | if (e.shiftKey) redo(); else undo(); 26 | return 27 | } 28 | if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') { 29 | e.preventDefault(); dup(); return 30 | } 31 | if (e.key === 'Delete' || e.key === 'Backspace') { del(); return } 32 | if (e.key.toLowerCase() === 'g') { setMode('translate'); return } 33 | if (e.key.toLowerCase() === 'r') { setMode('rotate'); return } 34 | if (e.key.toLowerCase() === 's') { setMode('scale'); return } 35 | if (e.key.toLowerCase() === '1') { add('box'); return } 36 | if (e.key.toLowerCase() === '2') { add('sphere'); return } 37 | if (e.key.toLowerCase() === '3') { add('cylinder'); return } 38 | } 39 | window.addEventListener('keydown', onKeyDown) 40 | return () => window.removeEventListener('keydown', onKeyDown) 41 | }, [add, del, dup, undo, redo, setMode]) 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/agent/types.ts: -------------------------------------------------------------------------------- 1 | export type SceneEntityType = "mesh" | "light" | "camera" | "empty"; 2 | 3 | export type SceneSnapshot = { 4 | units: "meters"; 5 | upAxis: "Y"; 6 | counts: { meshes: number; lights: number; cameras: number }; 7 | selection: string[]; 8 | entities: Array<{ 9 | id: string; 10 | name: string; 11 | type: SceneEntityType; 12 | parentId?: string; 13 | childrenIds?: string[]; 14 | transform: { 15 | position: [number, number, number]; 16 | rotation: [number, number, number]; 17 | scale: [number, number, number]; 18 | }; 19 | geom?: { 20 | kind?: "cube" | "sphere" | "plane" | "cylinder"; 21 | stats?: { vertices: number; triangles: number }; 22 | bounds?: { min: [number, number, number]; max: [number, number, number] }; 23 | }; 24 | material?: { 25 | id: string; 26 | name: string; 27 | baseColorHex?: string; 28 | metalness?: number; 29 | roughness?: number; 30 | }; 31 | }>; 32 | bounds?: { min: [number, number, number]; max: [number, number, number] }; 33 | capabilities: string[]; 34 | }; 35 | 36 | export type Diff = { 37 | addedIds: string[]; 38 | removedIds: string[]; 39 | updatedIds: string[]; 40 | }; 41 | 42 | export type ToolResult = { 43 | success: boolean; 44 | diagnostics?: string[]; 45 | snapshot?: SceneSnapshot; 46 | changes?: Diff; 47 | payload?: any; 48 | }; 49 | 50 | export type ToolInvocation = { 51 | name: 52 | | "get_scene_summary" 53 | | "find" 54 | | "get_selection" 55 | | "create_primitive" 56 | | "select" 57 | | "transform" 58 | | "duplicate" 59 | | "delete" 60 | | "create_material" 61 | | "assign_material" 62 | | "export_glb" 63 | | "image_to_3d" 64 | | "update_name"; // small extension for rename 65 | args: any; 66 | }; 67 | 68 | export type StepResult = { 69 | tool: ToolInvocation; 70 | ok: boolean; 71 | diagnostics?: string[]; 72 | diff?: Diff; 73 | }; -------------------------------------------------------------------------------- /frontend/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import styled, { createGlobalStyle } from "styled-components"; 2 | import Topbar from "./Topbar"; 3 | import Toolbar from "./Toolbar"; 4 | import Inspector from "./Inspector"; 5 | // Viewport removed in favor of legacy Three.js renderer wired via HolohandsOverlay 6 | import { useShortcuts } from "../hooks/useShortcuts"; 7 | import BooleanPanel from "./BooleanPanel"; 8 | import HolohandsOverlay from "../holo/components/HolohandsOverlay"; 9 | import Viewport from "./Viewport"; 10 | import ChatPanel from "./ChatPanel"; 11 | import { useEditor } from "../store/editor"; 12 | import VideoStream from "./VideoStream"; 13 | import { VideoStreamProvider } from "../holo/provider/VideoStreamContext"; 14 | import AgentTimeline from "./AgentTimeline"; 15 | import { useAgentTimeline } from "../store/agentTimeline"; 16 | 17 | const Global = createGlobalStyle` 18 | html, body, #root { 19 | height: 100%; 20 | } 21 | body { 22 | margin: 0; 23 | background: #0b0e14; 24 | color: #e6e9ef; 25 | overflow: hidden; 26 | font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; 27 | } 28 | * { box-sizing: border-box; } 29 | button { cursor: pointer; } 30 | `; 31 | 32 | const Root = styled.div` 33 | position: relative; 34 | width: 100vw; 35 | height: 100vh; 36 | `; 37 | 38 | // const ViewportWrap = styled.div` 39 | // position: absolute; 40 | // inset: 56px 0 0 0; 41 | // `; 42 | 43 | export function Layout() { 44 | useShortcuts(); 45 | const showChat = useEditor((s) => s.showChatPanel); 46 | const editorMode = useEditor((s) => s.editorMode); 47 | const steps = useAgentTimeline((s) => s.steps); 48 | const showTimeline = useAgentTimeline((s) => s.showTimeline); 49 | return ( 50 | 51 | 52 | 53 | 54 | {/* Render different viewport based on editor mode */} 55 | {editorMode === "render" ? : } 56 | 57 | 58 | 59 | {showTimeline ? : null} 60 | {showChat ? : null} 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | export default Layout; 68 | -------------------------------------------------------------------------------- /frontend/src/components/SnapPanel.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { useEditor } from "../store/editor"; 3 | 4 | const Panel = styled.div` 5 | position: absolute; 6 | bottom: 12px; 7 | left: 228px; 8 | display: flex; 9 | height: 50px; 10 | gap: 8px; 11 | align-items: center; 12 | background: rgba(18, 20, 26, 0.85); 13 | border: 1px solid rgba(255, 255, 255, 0.08); 14 | padding: 8px 10px; 15 | border-radius: 10px; 16 | `; 17 | 18 | const Label = styled.label` 19 | opacity: 0.8; 20 | font-size: 12px; 21 | `; 22 | 23 | const Input = styled.input` 24 | width: 64px; 25 | background: #0f1116; 26 | border: 1px solid rgba(255, 255, 255, 0.08); 27 | color: #e6e9ef; 28 | border-radius: 6px; 29 | padding: 4px 6px; 30 | `; 31 | 32 | export function SnapPanel() { 33 | const snap = useEditor((s) => s.snap); 34 | const toggle = useEditor((s) => s.toggleSnap); 35 | const setSnap = useEditor((s) => s.setSnap); 36 | return ( 37 | 38 | 46 | 57 | 68 | 79 | 80 | ); 81 | } 82 | 83 | export default SnapPanel; 84 | -------------------------------------------------------------------------------- /frontend/src/components/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { useEditor } from "../store/editor"; 3 | import { 4 | BoxIcon, 5 | SphereIcon, 6 | CylinderIcon, 7 | ConeIcon, 8 | TorusIcon, 9 | PlaneIcon, 10 | } from "./ShapeIcons"; 11 | 12 | const Rail = styled.div` 13 | position: absolute; 14 | top: 56px; 15 | bottom: 12px; 16 | left: 12px; 17 | width: 52px; 18 | display: flex; 19 | flex-direction: column; 20 | gap: 8px; 21 | `; 22 | 23 | const Btn = styled.button` 24 | width: 52px; 25 | height: 40px; 26 | background: rgba(18, 20, 26, 0.9); 27 | color: #e6e9ef; 28 | border: 1px solid rgba(255, 255, 255, 0.08); 29 | border-radius: 10px; 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | transition: all 0.2s ease; 34 | 35 | &:hover { 36 | background: rgba(18, 20, 26, 1); 37 | border-color: rgba(255, 255, 255, 0.15); 38 | transform: translateY(-1px); 39 | } 40 | 41 | &:active { 42 | transform: translateY(0); 43 | } 44 | `; 45 | 46 | export function Toolbar() { 47 | const add = useEditor((s) => s.addObject); 48 | const editorMode = useEditor((s) => s.editorMode); 49 | 50 | return ( 51 | 52 | {/* Shape buttons - only show in object and edit modes */} 53 | {editorMode !== "render" && ( 54 | <> 55 | add("box")} title="Add Box"> 56 | 57 | 58 | add("sphere")} title="Add Sphere"> 59 | 60 | 61 | add("cylinder")} title="Add Cylinder"> 62 | 63 | 64 | add("cone")} title="Add Cone"> 65 | 66 | 67 | add("torus")} title="Add Torus"> 68 | 69 | 70 | add("plane")} title="Add Plane"> 71 | 72 | 73 | 74 | )} 75 | 76 | {/* Lighting buttons removed for render mode */} 77 | 78 | ); 79 | } 80 | 81 | export default Toolbar; 82 | -------------------------------------------------------------------------------- /frontend/src/components/BooleanPanel.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import styled from "styled-components"; 3 | import { useEditor } from "../store/editor"; 4 | 5 | const Panel = styled.div` 6 | position: absolute; 7 | bottom: 12px; 8 | right: 308px; 9 | display: flex; 10 | gap: 8px; 11 | align-items: center; 12 | height: 50px; 13 | background: rgba(18, 20, 26, 0.85); 14 | border: 1px solid rgba(255, 255, 255, 0.08); 15 | padding: 8px 10px; 16 | border-radius: 10px; 17 | `; 18 | 19 | const Select = styled.select` 20 | background: #0f1116; 21 | border: 1px solid rgba(255, 255, 255, 0.08); 22 | color: #e6e9ef; 23 | border-radius: 6px; 24 | padding: 6px 8px; 25 | `; 26 | 27 | const Btn = styled.button` 28 | background: #12141a; 29 | color: #e6e9ef; 30 | border: 1px solid rgba(255, 255, 255, 0.08); 31 | padding: 6px 10px; 32 | border-radius: 8px; 33 | `; 34 | 35 | export function BooleanPanel() { 36 | const objects = useEditor((s) => s.objects); 37 | const booleanOp = useEditor((s) => s.booleanOp); 38 | const [aId, setA] = useState(""); 39 | const [bId, setB] = useState(""); 40 | 41 | const options = useMemo( 42 | () => objects.map((o) => ({ id: o.id, name: o.name })), 43 | [objects] 44 | ); 45 | 46 | return ( 47 | 48 | 56 | 64 | booleanOp("union", aId, bId)} 67 | > 68 | Union 69 | 70 | booleanOp("subtract", aId, bId)} 73 | > 74 | Subtract 75 | 76 | booleanOp("intersect", aId, bId)} 79 | > 80 | Intersect 81 | 82 | 83 | ); 84 | } 85 | 86 | export default BooleanPanel; 87 | -------------------------------------------------------------------------------- /frontend/src/utils/scene.ts: -------------------------------------------------------------------------------- 1 | import type { EditorState, SceneObject, SnapSettings } from '../types' 2 | 3 | interface SavedEditorState { 4 | objects: SceneObject[] 5 | selectedId: string | null 6 | mode: EditorState['mode'] 7 | editorMode: EditorState['editorMode'] 8 | snap: SnapSettings 9 | } 10 | 11 | export interface SavedScene { 12 | version: 1 13 | editor: SavedEditorState 14 | } 15 | 16 | function isObject(value: unknown): value is Record { 17 | return typeof value === 'object' && value !== null 18 | } 19 | 20 | function coerceSceneObjectArray(value: unknown): SceneObject[] { 21 | if (!Array.isArray(value)) return [] 22 | return value.filter((o) => isObject(o)) as unknown as SceneObject[] 23 | } 24 | 25 | function coerceSnapSettings(value: unknown): SnapSettings { 26 | if (!isObject(value)) { 27 | return { enableSnapping: false, translateSnap: 0.5, rotateSnap: Math.PI / 12, scaleSnap: 0.1 } 28 | } 29 | const v = value as Partial 30 | return { 31 | enableSnapping: Boolean(v.enableSnapping), 32 | translateSnap: typeof v.translateSnap === 'number' ? v.translateSnap : 0.5, 33 | rotateSnap: typeof v.rotateSnap === 'number' ? v.rotateSnap : Math.PI / 12, 34 | scaleSnap: typeof v.scaleSnap === 'number' ? v.scaleSnap : 0.1, 35 | } 36 | } 37 | 38 | export function serializeScene(state: EditorState): SavedScene { 39 | const editor: SavedEditorState = { 40 | objects: JSON.parse(JSON.stringify(state.objects)), 41 | selectedId: state.selectedId, 42 | mode: state.mode, 43 | editorMode: state.editorMode, 44 | snap: { ...state.snap }, 45 | } 46 | return { version: 1, editor } 47 | } 48 | 49 | export function deserializeScene(input: unknown): SavedScene | null { 50 | if (!isObject(input)) return null 51 | const version = (input as any).version 52 | const editor = (input as any).editor 53 | if (version !== 1 || !isObject(editor)) return null 54 | const ed = editor as Record 55 | const objects = coerceSceneObjectArray(ed.objects) 56 | const selectedId = typeof ed.selectedId === 'string' || ed.selectedId === null ? (ed.selectedId as string | null) : null 57 | const mode = (ed.mode === 'translate' || ed.mode === 'rotate' || ed.mode === 'scale') ? (ed.mode as EditorState['mode']) : 'translate' 58 | const editorMode = (ed.editorMode === 'object' || ed.editorMode === 'edit') ? (ed.editorMode as EditorState['editorMode']) : 'object' 59 | const snap = coerceSnapSettings(ed.snap) 60 | return { version: 1, editor: { objects, selectedId, mode, editorMode, snap } } 61 | } 62 | 63 | export function sceneToJSON(state: EditorState): string { 64 | return JSON.stringify(serializeScene(state)) 65 | } 66 | 67 | export function jsonToScene(json: string): SavedScene | null { 68 | try { 69 | const data = JSON.parse(json) 70 | return deserializeScene(data) 71 | } catch { 72 | return null 73 | } 74 | } 75 | 76 | 77 | -------------------------------------------------------------------------------- /frontend/src/provider/ViewportContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useMemo, useRef } from "react"; 2 | 3 | type ViewportActions = { 4 | resetCamera: () => void; 5 | createCube: () => void; 6 | createSphere: () => void; 7 | startHandDrag: () => void; 8 | updateHandDragNormalized: (u: number, v: number) => void; 9 | endHandDrag: () => void; 10 | orbitRotate: (dxN: number, dyN: number) => void; 11 | orbitPan: (dxN: number, dyN: number) => void; 12 | orbitDolly: (delta: number) => void; 13 | }; 14 | 15 | type RegisteredActions = Partial; 16 | 17 | const noop = () => {}; 18 | 19 | const ViewportContext = createContext(null); 20 | 21 | export function ViewportProvider({ children }: { children: React.ReactNode }) { 22 | const actionsRef = useRef({}); 23 | 24 | const value = useMemo(() => { 25 | return { 26 | resetCamera: () => (actionsRef.current.resetCamera || noop)(), 27 | createCube: () => (actionsRef.current.createCube || noop)(), 28 | createSphere: () => (actionsRef.current.createSphere || noop)(), 29 | startHandDrag: () => (actionsRef.current.startHandDrag || noop)(), 30 | updateHandDragNormalized: (u: number, v: number) => 31 | (actionsRef.current.updateHandDragNormalized || noop)(u, v), 32 | endHandDrag: () => (actionsRef.current.endHandDrag || noop)(), 33 | orbitRotate: (dxN: number, dyN: number) => 34 | (actionsRef.current.orbitRotate || noop)(dxN, dyN), 35 | orbitPan: (dxN: number, dyN: number) => 36 | (actionsRef.current.orbitPan || noop)(dxN, dyN), 37 | orbitDolly: (delta: number) => 38 | (actionsRef.current.orbitDolly || noop)(delta), 39 | }; 40 | }, []); 41 | 42 | // Hidden registrar: expose a way for the viewport to register real actions 43 | // eslint-disable-next-line react/display-name 44 | (value as any).__registerViewportActions = (impl: RegisteredActions) => { 45 | actionsRef.current = { ...actionsRef.current, ...impl }; 46 | }; 47 | 48 | return ( 49 | 50 | {children} 51 | 52 | ); 53 | } 54 | 55 | export function useViewportActions() { 56 | const ctx = useContext(ViewportContext); 57 | if (!ctx) 58 | throw new Error( 59 | "useViewportActions must be used within ViewportProvider" 60 | ); 61 | return ctx; 62 | } 63 | 64 | export function useRegisterViewportActions() { 65 | const ctx = useContext(ViewportContext) as any; 66 | if (!ctx) 67 | throw new Error( 68 | "useRegisterViewportActions must be used within ViewportProvider" 69 | ); 70 | const register = (impl: RegisteredActions) => 71 | ctx.__registerViewportActions?.(impl); 72 | return register as (impl: RegisteredActions) => void; 73 | } 74 | -------------------------------------------------------------------------------- /backend/hand_processor.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | import mediapipe as mp 4 | 5 | class HandProcessor: 6 | def __init__(self): 7 | self.mp_drawing = mp.solutions.drawing_utils 8 | self.mp_hands = mp.solutions.hands 9 | self.grid_size = 400 10 | self.margin = 0.3 11 | 12 | def process_hand(self, frame, landmarks, handedness): 13 | # Draw on main frame 14 | annotated_frame = frame.copy() 15 | self.mp_drawing.draw_landmarks( 16 | annotated_frame, 17 | landmarks, 18 | self.mp_hands.HAND_CONNECTIONS, 19 | self.mp_drawing.DrawingSpec(color=(0,255,0), thickness=2), 20 | self.mp_drawing.DrawingSpec(color=(0,0,255), thickness=2)) 21 | 22 | # Create grid visualization 23 | grid_canvas = np.zeros((self.grid_size, self.grid_size, 3), dtype=np.uint8) 24 | self._draw_grid(grid_canvas) 25 | 26 | try: 27 | # Safety checks for landmarks 28 | xs = [lm.x for lm in landmarks.landmark] 29 | ys = [lm.y for lm in landmarks.landmark] 30 | if not xs or not ys: 31 | return annotated_frame, grid_canvas 32 | 33 | min_x, max_x = min(xs), max(xs) 34 | min_y, max_y = min(ys), max(ys) 35 | 36 | # Handle edge cases where hand is off-screen 37 | if max_x - min_x < 0.01 or max_y - min_y < 0.01: 38 | return annotated_frame, grid_canvas 39 | 40 | self._draw_upright_connections(grid_canvas, landmarks, min_x, max_x, min_y, max_y) 41 | 42 | hand_type = "Right" if "Left" in handedness.classification[0].label else "Left" 43 | cv2.putText(grid_canvas, hand_type, (10, 30), 44 | cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) 45 | 46 | except Exception as e: 47 | print(f"Hand processing error: {str(e)}") 48 | 49 | return annotated_frame, grid_canvas 50 | 51 | def _draw_upright_connections(self, canvas, landmarks, min_x, max_x, min_y, max_y): 52 | connections = self.mp_hands.HAND_CONNECTIONS 53 | for connection in connections: 54 | start_idx, end_idx = connection 55 | start_lm = landmarks.landmark[start_idx] 56 | end_lm = landmarks.landmark[end_idx] 57 | 58 | # Convert to grid coordinates with safety checks 59 | try: 60 | x1 = int((start_lm.x - min_x) / (max_x - min_x) * self.grid_size) 61 | y1 = self.grid_size - int((start_lm.y - min_y) / (max_y - min_y) * self.grid_size) 62 | x2 = int((end_lm.x - min_x) / (max_x - min_x) * self.grid_size) 63 | y2 = self.grid_size - int((end_lm.y - min_y) / (max_y - min_y) * self.grid_size) 64 | 65 | cv2.line(canvas, (x1, y1), (x2, y2), (255, 255, 255), 2) 66 | cv2.circle(canvas, (x1, y1), 3, (0, 255, 0), -1) 67 | cv2.circle(canvas, (x2, y2), 3, (0, 255, 0), -1) 68 | except: 69 | continue 70 | 71 | def _draw_grid(self, canvas, spacing=50): 72 | color = (60, 60, 60) 73 | for x in range(0, self.grid_size, spacing): 74 | cv2.line(canvas, (x, 0), (x, self.grid_size), color, 1) 75 | for y in range(0, self.grid_size, spacing): 76 | cv2.line(canvas, (0, y), (self.grid_size, y), color, 1) -------------------------------------------------------------------------------- /frontend/sample-server-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "success", 3 | "hands": [ 4 | { 5 | "handedness": "Right", 6 | "landmarks": [ 7 | [556.812, 288.275, 0], 8 | [509.324, 290.542, -0.028], 9 | [462.918, 269.514, -0.04], 10 | [431.867, 244.691, -0.049], 11 | [403.483, 225.03, -0.058], 12 | [459.716, 203.35, -0.008], 13 | [433.972, 167.59, -0.027], 14 | [420.523, 145.474, -0.046], 15 | [409.252, 124.545, -0.061], 16 | [483.825, 186.076, -0.011], 17 | [459.016, 145.968, -0.026], 18 | [443.771, 120.206, -0.043], 19 | [429.827, 97.841, -0.058], 20 | [510.547, 179.039, -0.02], 21 | [490.237, 141.539, -0.037], 22 | [476.988, 119.693, -0.049], 23 | [462.791, 100.651, -0.057], 24 | [540.184, 178.932, -0.032], 25 | [525.056, 147.603, -0.049], 26 | [515.683, 130.163, -0.054], 27 | [505.583, 114.745, -0.058] 28 | ], 29 | "connections": [ 30 | [3, 4], 31 | [0, 5], 32 | [17, 18], 33 | [0, 17], 34 | [13, 14], 35 | [13, 17], 36 | [18, 19], 37 | [5, 6], 38 | [5, 9], 39 | [14, 15], 40 | [0, 1], 41 | [9, 10], 42 | [1, 2], 43 | [9, 13], 44 | [10, 11], 45 | [19, 20], 46 | [6, 7], 47 | [15, 16], 48 | [2, 3], 49 | [11, 12], 50 | [7, 8] 51 | ], 52 | "detected_symbols": [] 53 | }, 54 | { 55 | "handedness": "Left", 56 | "landmarks": [ 57 | [131.743, 302.232, 0], 58 | [178.808, 293.657, -0.022], 59 | [220.768, 271.52, -0.031], 60 | [248.233, 250.009, -0.041], 61 | [269.883, 231.663, -0.051], 62 | [210.031, 206.221, -0.01], 63 | [233.451, 171.018, -0.029], 64 | [247.214, 149.509, -0.048], 65 | [258.922, 130.357, -0.062], 66 | [186.256, 192.054, -0.015], 67 | [206.491, 147.871, -0.03], 68 | [220.382, 121.189, -0.047], 69 | [232.372, 98.714, -0.06], 70 | [160.224, 188.502, -0.024], 71 | [175.314, 146.907, -0.04], 72 | [187.153, 123.523, -0.051], 73 | [198.883, 103.113, -0.059], 74 | [131.438, 194.408, -0.035], 75 | [139.224, 162.013, -0.05], 76 | [147.09, 142.235, -0.054], 77 | [156.221, 124.277, -0.055] 78 | ], 79 | "connections": [ 80 | [3, 4], 81 | [0, 5], 82 | [17, 18], 83 | [0, 17], 84 | [13, 14], 85 | [13, 17], 86 | [18, 19], 87 | [5, 6], 88 | [5, 9], 89 | [14, 15], 90 | [0, 1], 91 | [9, 10], 92 | [1, 2], 93 | [9, 13], 94 | [10, 11], 95 | [19, 20], 96 | [6, 7], 97 | [15, 16], 98 | [2, 3], 99 | [11, 12], 100 | [7, 8] 101 | ], 102 | "detected_symbols": [] 103 | } 104 | ], 105 | "image_size": { 106 | "width": 640, 107 | "height": 360 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Vector3 { 2 | x: number; 3 | y: number; 4 | z: number; 5 | } 6 | 7 | export interface Euler { 8 | x: number; 9 | y: number; 10 | z: number; 11 | } 12 | 13 | export type GeometryKind = 14 | | "box" 15 | | "sphere" 16 | | "cylinder" 17 | | "cone" 18 | | "torus" 19 | | "plane" 20 | | "custom"; 21 | 22 | export interface SerializableGeometry { 23 | positions: number[]; 24 | indices?: number[]; 25 | normals?: number[]; 26 | uvs?: number[]; 27 | } 28 | 29 | export interface GeometryParamsMap { 30 | box?: { 31 | width: number; 32 | height: number; 33 | depth: number; 34 | widthSegments?: number; 35 | heightSegments?: number; 36 | depthSegments?: number; 37 | }; 38 | sphere?: { 39 | radius: number; 40 | widthSegments?: number; 41 | heightSegments?: number; 42 | }; 43 | cylinder?: { 44 | radiusTop: number; 45 | radiusBottom: number; 46 | height: number; 47 | radialSegments?: number; 48 | }; 49 | cone?: { radius: number; height: number; radialSegments?: number }; 50 | torus?: { 51 | radius: number; 52 | tube: number; 53 | radialSegments?: number; 54 | tubularSegments?: number; 55 | }; 56 | plane?: { 57 | width: number; 58 | height: number; 59 | widthSegments?: number; 60 | heightSegments?: number; 61 | }; 62 | custom?: SerializableGeometry; 63 | } 64 | 65 | export interface MaterialProps { 66 | color: string; 67 | metalness: number; 68 | roughness: number; 69 | opacity: number; 70 | transparent: boolean; 71 | } 72 | 73 | export type LightType = "directional" | "point" | "spot" | "ambient"; 74 | 75 | export interface LightProps { 76 | color: string; 77 | intensity: number; 78 | distance?: number; // for point and spot lights 79 | angle?: number; // for spot lights 80 | penumbra?: number; // for spot lights 81 | decay?: number; // for point and spot lights 82 | } 83 | 84 | export interface SceneLight { 85 | id: string; 86 | name: string; 87 | type: LightType; 88 | position: Vector3; 89 | rotation: Euler; // for directional and spot lights 90 | props: LightProps; 91 | visible: boolean; 92 | castShadow: boolean; 93 | } 94 | 95 | export interface SceneObject { 96 | id: string; 97 | name: string; 98 | geometry: GeometryKind; 99 | geometryParams: GeometryParamsMap[keyof GeometryParamsMap] | undefined; 100 | position: Vector3; 101 | rotation: Euler; // radians 102 | scale: Vector3; 103 | material: MaterialProps; 104 | visible: boolean; 105 | locked: boolean; 106 | } 107 | 108 | export interface HistoryState { 109 | objects: SceneObject[]; 110 | selectedId: string | null; 111 | } 112 | 113 | export type TransformMode = "translate" | "rotate" | "scale"; 114 | 115 | export type EditorMode = "object" | "edit" | "render"; 116 | 117 | export interface SnapSettings { 118 | enableSnapping: boolean; 119 | translateSnap: number; // units 120 | rotateSnap: number; // radians 121 | scaleSnap: number; // unit step 122 | } 123 | 124 | export interface Checkpoint { 125 | id: string; 126 | label: string; 127 | timestamp: number; 128 | prompt?: string; 129 | response?: string; 130 | state: HistoryState; 131 | } 132 | 133 | export interface EditorState { 134 | objects: SceneObject[]; 135 | lights: SceneLight[]; 136 | selectedId: string | null; 137 | mode: TransformMode; 138 | editorMode?: EditorMode; 139 | snap: SnapSettings; 140 | past: HistoryState[]; 141 | future: HistoryState[]; 142 | isTransforming?: boolean; 143 | checkpoints: Checkpoint[]; 144 | } 145 | -------------------------------------------------------------------------------- /frontend/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/VideoStream.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { useState, useEffect } from "react"; 3 | import { useVideoStream } from "../holo/provider/VideoStreamContext"; 4 | 5 | const VideoContainer = styled.div` 6 | position: absolute; 7 | bottom: 12px; 8 | left: 12px; 9 | width: 200px; 10 | background: rgba(18, 20, 26, 0.85); 11 | border: 1px solid rgba(255, 255, 255, 0.08); 12 | border-radius: 8px; 13 | padding: 6px; 14 | display: flex; 15 | flex-direction: column; 16 | gap: 4px; 17 | z-index: 10; 18 | `; 19 | 20 | const Video = styled.video` 21 | width: 100%; 22 | height: 100px; 23 | background: #0f1116; 24 | border-radius: 6px; 25 | transform: scaleX(-1); 26 | object-fit: cover; 27 | border: 1px solid rgba(255, 255, 255, 0.06); 28 | `; 29 | 30 | const Status = styled.div` 31 | font-size: 10px; 32 | color: #8c8c8c; 33 | background: rgba(0, 0, 0, 0.3); 34 | padding: 3px 6px; 35 | border-radius: 4px; 36 | text-align: center; 37 | border: 1px solid rgba(255, 255, 255, 0.04); 38 | `; 39 | 40 | const CameraSelector = styled.select` 41 | background: rgba(0, 0, 0, 0.4); 42 | color: #e6e9ef; 43 | border: 1px solid rgba(255, 255, 255, 0.08); 44 | border-radius: 4px; 45 | padding: 2px 4px; 46 | font-size: 9px; 47 | width: 100%; 48 | margin-top: 2px; 49 | 50 | option { 51 | background: #1e222c; 52 | color: #e6e9ef; 53 | } 54 | `; 55 | 56 | const Controls = styled.div` 57 | display: flex; 58 | flex-direction: column; 59 | gap: 2px; 60 | `; 61 | 62 | export function VideoStream() { 63 | const { videoRef, status, getAvailableCameras, setActiveCamera } = 64 | useVideoStream(); 65 | const [cameras, setCameras] = useState([]); 66 | const [selectedCamera, setSelectedCamera] = useState(""); 67 | 68 | useEffect(() => { 69 | const loadCameras = async () => { 70 | const availableCameras = await getAvailableCameras(); 71 | setCameras(availableCameras); 72 | if (availableCameras.length > 0 && !selectedCamera) { 73 | setSelectedCamera(availableCameras[0].deviceId); 74 | } 75 | }; 76 | loadCameras(); 77 | }, [getAvailableCameras]); 78 | 79 | // Listen for device changes 80 | useEffect(() => { 81 | const handleDeviceChange = () => { 82 | const loadCameras = async () => { 83 | const availableCameras = await getAvailableCameras(); 84 | setCameras(availableCameras); 85 | }; 86 | loadCameras(); 87 | }; 88 | 89 | navigator.mediaDevices.addEventListener( 90 | "devicechange", 91 | handleDeviceChange 92 | ); 93 | return () => { 94 | navigator.mediaDevices.removeEventListener( 95 | "devicechange", 96 | handleDeviceChange 97 | ); 98 | }; 99 | }, [getAvailableCameras]); 100 | 101 | const handleCameraChange = ( 102 | event: React.ChangeEvent 103 | ) => { 104 | const cameraId = event.target.value; 105 | console.log("Switching to camera:", cameraId); 106 | setSelectedCamera(cameraId); 107 | setActiveCamera(cameraId); 108 | }; 109 | 110 | return ( 111 | 112 | 133 | ); 134 | } 135 | 136 | export default VideoStream; 137 | -------------------------------------------------------------------------------- /frontend/src/components/AgentTimeline.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import type { StepResult } from "../agent/types"; 3 | import { useAgentTimeline } from "../store/agentTimeline"; 4 | 5 | const Rail = styled.div` 6 | position: absolute; 7 | top: 56px; 8 | bottom: 12px; 9 | right: 300px; /* to the left of chat panel */ 10 | width: 260px; 11 | background: rgba(18, 20, 26, 0.9); 12 | color: #e6e9ef; 13 | border: 1px solid rgba(255, 255, 255, 0.08); 14 | border-radius: 12px; 15 | display: flex; 16 | flex-direction: column; 17 | overflow: hidden; 18 | z-index: 40; 19 | `; 20 | 21 | const Header = styled.div` 22 | padding: 8px 12px; 23 | font-weight: 600; 24 | border-bottom: 1px solid rgba(255, 255, 255, 0.06); 25 | display: flex; 26 | align-items: center; 27 | justify-content: space-between; 28 | `; 29 | 30 | const CloseBtn = styled.button` 31 | background: #12141a; 32 | color: #e6e9ef; 33 | border: 1px solid rgba(255,255,255,0.1); 34 | border-radius: 8px; 35 | padding: 4px 8px; 36 | font-size: 12px; 37 | `; 38 | 39 | const Steps = styled.div` 40 | flex: 1; 41 | overflow: auto; 42 | padding: 8px 10px; 43 | display: flex; 44 | flex-direction: column; 45 | gap: 6px; 46 | `; 47 | 48 | const Row = styled.div<{ ok: boolean }>` 49 | border: 1px solid rgba(255, 255, 255, 0.08); 50 | background: ${(p) => (p.ok ? "rgba(30, 34, 44, 0.7)" : "rgba(60, 20, 20, 0.5)")}; 51 | border-radius: 10px; 52 | padding: 8px; 53 | font-size: 12px; 54 | `; 55 | 56 | const Badge = styled.span` 57 | display: inline-block; 58 | padding: 2px 6px; 59 | margin-right: 6px; 60 | border: 1px solid rgba(255,255,255,0.12); 61 | border-radius: 999px; 62 | background: rgba(255,255,255,0.06); 63 | font-size: 11px; 64 | `; 65 | 66 | export function AgentTimeline({ steps }: { steps: StepResult[] }) { 67 | const setShow = useAgentTimeline((s) => s.setShowTimeline); 68 | return ( 69 | 70 |
71 |
Agent Timeline
72 | setShow(false)}>✕ 73 |
74 | 75 | {steps.length === 0 ? ( 76 |
No steps yet
77 | ) : ( 78 | steps.map((s, i) => { 79 | const tool = s.tool?.name; 80 | const args = s.tool?.args || {}; 81 | const diff = s.diff || { addedIds: [], removedIds: [], updatedIds: [] }; 82 | const diffs: string[] = []; 83 | if (diff.addedIds.length) diffs.push(`+${diff.addedIds.length} added`); 84 | if (diff.removedIds.length) diffs.push(`-${diff.removedIds.length} removed`); 85 | if (diff.updatedIds.length) diffs.push(`~${diff.updatedIds.length} updated`); 86 | return ( 87 | 88 |
89 |
{tool}
90 |
{s.ok ? "✅" : "❌"}
91 |
92 |
{shortArgs(args)}
93 |
94 | {diffs.map((d, j) => ( 95 | {d} 96 | ))} 97 |
98 |
99 | ); 100 | }) 101 | )} 102 |
103 |
104 | ); 105 | } 106 | 107 | function shortArgs(args: any): string { 108 | try { 109 | const pruned: any = {}; 110 | for (const k of Object.keys(args || {})) { 111 | const v = (args as any)[k]; 112 | if (v == null) continue; 113 | if (Array.isArray(v)) pruned[k] = v.map((x) => (typeof x === "number" ? Number(x.toFixed?.(3) ?? x) : x)); 114 | else if (typeof v === "object") pruned[k] = "{...}"; 115 | else pruned[k] = v; 116 | } 117 | return JSON.stringify(pruned); 118 | } catch { 119 | return ""; 120 | } 121 | } 122 | 123 | export default AgentTimeline; -------------------------------------------------------------------------------- /frontend/src/holo/provider/WebSocketContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useRef, useEffect, useMemo } from "react"; 2 | import type { ReactNode } from "react"; 3 | import type { SocketStatus } from "../objects/socketstatus"; 4 | 5 | interface WebSocketProps { 6 | url: string; 7 | children: ReactNode; 8 | } 9 | 10 | interface WebSocketContextType { 11 | sendFrame: (frame: ArrayBuffer) => boolean; 12 | getWebSocket: () => WebSocket | null; 13 | getConnectionStatus: () => SocketStatus; 14 | getAcknowledged: () => boolean; 15 | getData: () => object | null; 16 | getDataVersion: () => number; 17 | } 18 | 19 | const WebSocketContext = createContext(null); 20 | 21 | export const WebSocketProvider = ({ url, children }: WebSocketProps) => { 22 | const wsRef = useRef(null); 23 | const retryTimeoutRef = useRef(null); 24 | const acknowledgedRef = useRef(true); 25 | const dataRef = useRef(null); 26 | const dataVersionRef = useRef(0); 27 | const fallbackCounterRef = useRef(0); 28 | const RECONNECT_INTERVAL = 3000; 29 | 30 | const connectWebSocket = () => { 31 | const ws = new WebSocket(url); 32 | wsRef.current = ws; 33 | 34 | ws.onopen = () => { 35 | if (retryTimeoutRef.current) { 36 | clearInterval(retryTimeoutRef.current); 37 | retryTimeoutRef.current = null; 38 | } 39 | }; 40 | 41 | ws.onclose = () => { 42 | wsRef.current = null; 43 | }; 44 | 45 | ws.onerror = () => { 46 | ws.close(); 47 | }; 48 | 49 | ws.onmessage = async (event) => { 50 | let messageText = ""; 51 | if (typeof event.data === "string") { 52 | messageText = event.data; 53 | } else if (event.data instanceof Blob) { 54 | messageText = await event.data.text(); 55 | } 56 | 57 | try { 58 | const data = JSON.parse(messageText); 59 | if (data) { 60 | // Disable verbose per-message logging for performance 61 | dataRef.current = data; 62 | acknowledgedRef.current = true; 63 | dataVersionRef.current += 1; 64 | } 65 | } catch { 66 | // ignore parse errors 67 | } 68 | }; 69 | }; 70 | 71 | const retryConnection = () => { 72 | if (retryTimeoutRef.current) return; 73 | const timeout = window.setInterval(() => { 74 | if (wsRef.current?.readyState === WebSocket.OPEN) { 75 | retryTimeoutRef.current = null; 76 | clearInterval(timeout); 77 | return; 78 | } 79 | if (!wsRef.current) connectWebSocket(); 80 | }, RECONNECT_INTERVAL); 81 | retryTimeoutRef.current = timeout; 82 | }; 83 | 84 | useEffect(() => { 85 | if (!wsRef.current) connectWebSocket(); 86 | const fallback = window.setInterval(() => { 87 | if (!wsRef.current && !retryTimeoutRef.current) retryConnection(); 88 | fallbackCounterRef.current += 66; 89 | if (fallbackCounterRef.current > 5000) { 90 | acknowledgedRef.current = true; 91 | fallbackCounterRef.current = 0; 92 | } 93 | }, 66); 94 | return () => { 95 | clearInterval(fallback); 96 | if (wsRef.current) { 97 | wsRef.current.close(); 98 | wsRef.current = null; 99 | } 100 | }; 101 | }, [url]); 102 | 103 | const sendFrame = (frame: ArrayBuffer): boolean => { 104 | const socket = wsRef.current; 105 | if (!socket || socket.readyState !== WebSocket.OPEN) return false; 106 | socket.send(frame); 107 | acknowledgedRef.current = false; 108 | return true; 109 | }; 110 | 111 | const getConnectionStatus = (): SocketStatus => { 112 | const socket = wsRef.current; 113 | if (!socket) return "Disconnected"; 114 | switch (socket.readyState) { 115 | case WebSocket.CONNECTING: 116 | return "Connecting..."; 117 | case WebSocket.OPEN: 118 | return "Connected"; 119 | case WebSocket.CLOSING: 120 | return "Disconnected"; 121 | case WebSocket.CLOSED: 122 | return "Disconnected"; 123 | default: 124 | return "Disconnected"; 125 | } 126 | }; 127 | 128 | const value = useMemo( 129 | () => ({ 130 | sendFrame, 131 | getWebSocket: () => wsRef.current, 132 | getConnectionStatus, 133 | getAcknowledged: () => acknowledgedRef.current, 134 | getData: () => dataRef.current, 135 | getDataVersion: () => dataVersionRef.current, 136 | }), 137 | [] 138 | ); 139 | 140 | return ( 141 | 142 | {children} 143 | 144 | ); 145 | }; 146 | 147 | export const useWebSocket = () => { 148 | const ctx = useContext(WebSocketContext); 149 | if (!ctx) 150 | throw new Error("useWebSocket must be used within a WebSocketProvider"); 151 | return ctx; 152 | }; 153 | -------------------------------------------------------------------------------- /frontend/src/holo/provider/VideoStreamContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useCallback, 4 | useContext, 5 | useEffect, 6 | useMemo, 7 | useRef, 8 | useState, 9 | } from "react"; 10 | import type { StreamStatus } from "../types/streamstatus"; 11 | 12 | interface VideoStreamContextType { 13 | videoRef: React.RefObject; 14 | getAvailableCameras: () => Promise; 15 | captureFrame: () => Promise; 16 | setActiveCamera: (cameraId: string) => void; 17 | status: StreamStatus; 18 | getStream: () => MediaStream | null; 19 | } 20 | 21 | const VideoStreamContext = createContext(null); 22 | 23 | export const VideoStreamProvider: React.FC<{ children: React.ReactNode }> = ({ 24 | children, 25 | }) => { 26 | const activeCameraRef = useRef(null); 27 | const [status, setStatus] = useState("idle"); 28 | const streamRef = useRef(null); 29 | const videoRef = useRef(null); 30 | 31 | const getAvailableCameras = useCallback(async (): Promise< 32 | MediaDeviceInfo[] 33 | > => { 34 | try { 35 | await navigator.mediaDevices.getUserMedia({ video: true }); 36 | } catch {} 37 | const devices = await navigator.mediaDevices.enumerateDevices(); 38 | return devices.filter((d) => d.kind === "videoinput"); 39 | }, []); 40 | 41 | const stopStream = useCallback(() => { 42 | if (streamRef.current) { 43 | streamRef.current.getTracks().forEach((t) => t.stop()); 44 | streamRef.current = null; 45 | setStatus("stopped"); 46 | } 47 | }, []); 48 | 49 | const startStream = useCallback( 50 | async (deviceId: string) => { 51 | console.log("startStream called with deviceId:", deviceId); 52 | stopStream(); 53 | setStatus("loading"); 54 | try { 55 | const newStream = await navigator.mediaDevices.getUserMedia({ 56 | video: { 57 | deviceId, 58 | width: { ideal: 640 }, 59 | height: { ideal: 360 }, 60 | }, 61 | }); 62 | console.log("Got new stream:", newStream); 63 | streamRef.current = newStream; 64 | if (videoRef.current) { 65 | console.log("Setting video srcObject"); 66 | videoRef.current.srcObject = newStream; 67 | // Ensure the video plays after stream change 68 | videoRef.current.play().catch(console.error); 69 | } 70 | setStatus("streaming"); 71 | } catch (error) { 72 | console.error("Failed to start stream:", error); 73 | setStatus("error"); 74 | } 75 | }, 76 | [stopStream] 77 | ); 78 | 79 | const captureCanvasRef = useRef(null); 80 | const captureFrame = async (): Promise => { 81 | if (!videoRef.current) return null; 82 | if (!captureCanvasRef.current) 83 | captureCanvasRef.current = document.createElement("canvas"); 84 | const canvas = captureCanvasRef.current; 85 | canvas.width = 640; 86 | canvas.height = 360; 87 | const ctx = canvas.getContext("2d"); 88 | if (!ctx) return null; 89 | ctx.setTransform(-1, 0, 0, 1, canvas.width, 0); 90 | ctx.drawImage(videoRef.current, 0, 0, canvas.width, canvas.height); 91 | return new Promise((resolve) => { 92 | canvas.toBlob( 93 | (blob) => { 94 | if (!blob) return resolve(null); 95 | blob.arrayBuffer() 96 | .then(resolve) 97 | .catch(() => resolve(null)); 98 | }, 99 | "image/jpeg", 100 | 0.5 101 | ); 102 | }); 103 | }; 104 | 105 | const setActiveCamera = useCallback( 106 | (id: string) => { 107 | console.log("setActiveCamera called with:", id); 108 | activeCameraRef.current = id; 109 | startStream(id); 110 | }, 111 | [startStream] 112 | ); 113 | 114 | useEffect(() => { 115 | const init = async () => { 116 | const cams = await getAvailableCameras(); 117 | if (cams.length > 0 && !activeCameraRef.current) { 118 | activeCameraRef.current = cams[0].deviceId; 119 | startStream(cams[0].deviceId); 120 | } 121 | }; 122 | init(); 123 | }, [getAvailableCameras, startStream]); 124 | 125 | const value = useMemo( 126 | () => ({ 127 | videoRef, 128 | getAvailableCameras, 129 | captureFrame, 130 | setActiveCamera, 131 | status, 132 | getStream: () => streamRef.current, 133 | }), 134 | [status, setActiveCamera, getAvailableCameras, captureFrame] 135 | ); 136 | 137 | return ( 138 | 139 | {children} 140 | 141 | ); 142 | }; 143 | 144 | export const useVideoStream = () => { 145 | const ctx = useContext(VideoStreamContext); 146 | if (!ctx) 147 | throw new Error( 148 | "useVideoStream must be used within a VideoStreamProvider" 149 | ); 150 | return ctx; 151 | }; 152 | -------------------------------------------------------------------------------- /backend/webserver.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import eventlet 3 | import orjson 4 | import cv2 5 | import mediapipe as mp 6 | import numpy as np 7 | import base64 8 | import json 9 | from eventlet import wsgi 10 | from eventlet.websocket import WebSocketWSGI 11 | from flask import Flask, render_template, request, jsonify 12 | from flask_cors import CORS 13 | from scipy.spatial.distance import cdist 14 | 15 | eventlet.monkey_patch() 16 | 17 | app = Flask(__name__) 18 | CORS(app) 19 | 20 | # MediaPipe setup 21 | mp_hands = mp.solutions.hands 22 | hands = mp_hands.Hands( 23 | static_image_mode=False, 24 | max_num_hands=2, 25 | min_detection_confidence=0.65, 26 | min_tracking_confidence=0.65 27 | ) 28 | 29 | # Store hand symbols 30 | hand_symbols = [] 31 | 32 | @app.route('/') 33 | def index(): 34 | return render_template('index.html') 35 | 36 | @app.route('/save_handsymbol', methods=['POST']) 37 | def save_handsymbol(): 38 | data = request.json 39 | name, handedness, landmarks = data['name'], data['handedness'], data['landmarks'] 40 | 41 | wrist = landmarks[0] 42 | normalized_landmarks = np.array(landmarks) - wrist 43 | 44 | middle_finger_mcp = normalized_landmarks[9] 45 | angle = np.arctan2(middle_finger_mcp[1], middle_finger_mcp[0]) 46 | rotation_matrix = np.array([ 47 | [np.cos(-angle), -np.sin(-angle)], 48 | [np.sin(-angle), np.cos(-angle)] 49 | ]) 50 | rotated_landmarks = np.hstack((normalized_landmarks[:, :2] @ rotation_matrix.T, normalized_landmarks[:, 2:])) 51 | 52 | hand_symbols.append({ 53 | 'name': name, 54 | 'handedness': handedness, 55 | 'landmarks': rotated_landmarks.flatten() 56 | }) 57 | 58 | return jsonify({'status': 'success'}) 59 | 60 | @WebSocketWSGI 61 | def handle_websocket(ws): 62 | while True: 63 | message = ws.wait() 64 | if message is None: 65 | break 66 | try: 67 | start_time = datetime.datetime.now() 68 | 69 | if isinstance(message, bytes): 70 | frame_bytes = message 71 | else: 72 | payload = json.loads(message) 73 | image_data = payload.get("image", "") 74 | if image_data.startswith("data:image"): 75 | header, encoded = image_data.split(",", 1) 76 | frame_bytes = base64.b64decode(encoded) 77 | else: 78 | frame_bytes = None 79 | 80 | if frame_bytes is not None: 81 | np_arr = np.frombuffer(frame_bytes, np.uint8) 82 | frame = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) 83 | frame = cv2.resize(frame, (640, 360)) 84 | h, w = frame.shape[:2] 85 | 86 | if (datetime.datetime.now() - start_time).total_seconds() * 1000 > 50: 87 | print("Skipping frame: Pre-processing too slow") 88 | ws.send(orjson.dumps({'status': 'dropped'})) 89 | continue 90 | 91 | results = hands.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) 92 | if (datetime.datetime.now() - start_time).total_seconds() * 1000 > 50: 93 | print("Skipping frame: Hand processing too slow") 94 | ws.send(orjson.dumps({'status': 'dropped'})) 95 | continue 96 | 97 | hands_data = [] 98 | detected = {"Left": False, "Right": False} 99 | 100 | if results.multi_hand_landmarks: 101 | for idx, landmarks in enumerate(results.multi_hand_landmarks): 102 | if (datetime.datetime.now() - start_time).total_seconds() * 1000 > 50: 103 | print("Skipping frame: Exceeded time limit in loop") 104 | ws.send(orjson.dumps({'status': 'dropped'})) 105 | break 106 | 107 | handedness = results.multi_handedness[idx].classification[0].label 108 | if detected[handedness]: 109 | continue 110 | detected[handedness] = True 111 | 112 | hand_landmarks = np.array([[lm.x * w, lm.y * h, lm.z] for lm in landmarks.landmark]) 113 | wrist = hand_landmarks[0] 114 | normalized_landmarks = hand_landmarks - wrist 115 | 116 | middle_finger_mcp = normalized_landmarks[9] 117 | angle = np.arctan2(middle_finger_mcp[1], middle_finger_mcp[0]) 118 | rotation_matrix = np.array([ 119 | [np.cos(-angle), -np.sin(-angle)], 120 | [np.sin(-angle), np.cos(-angle)] 121 | ]) 122 | rotated_landmarks = np.hstack((normalized_landmarks[:, :2] @ rotation_matrix.T, normalized_landmarks[:, 2:])) 123 | flattened_landmarks = rotated_landmarks.flatten() 124 | 125 | detected_symbols = [] 126 | if hand_symbols: 127 | symbol_landmarks = np.array([ 128 | symbol['landmarks'] 129 | for symbol in hand_symbols if symbol['handedness'] == handedness 130 | ]) 131 | if symbol_landmarks.size > 0: 132 | similarities = (1 - cdist([flattened_landmarks], symbol_landmarks, metric='cosine')[0]).tolist() 133 | detected_symbols = sorted( 134 | zip([s['name'] for s in hand_symbols if s['handedness'] == handedness], similarities), 135 | key=lambda x: x[1], 136 | reverse=True 137 | )[:3] 138 | 139 | hands_data.append({ 140 | 'handedness': handedness, 141 | 'landmarks': hand_landmarks.round(3).tolist(), 142 | 'connections': [[conn[0], conn[1]] for conn in mp_hands.HAND_CONNECTIONS], 143 | 'detected_symbols': detected_symbols 144 | }) 145 | 146 | if (datetime.datetime.now() - start_time).total_seconds() * 1000 > 50: 147 | print("Skipping frame: Final check exceeded 50ms") 148 | ws.send(orjson.dumps({'status': 'dropped'})) 149 | continue 150 | 151 | print(datetime.datetime.now().strftime("%H:%M:%S") + " returned") 152 | ws.send(orjson.dumps({'status': 'success', 'hands': hands_data, 'image_size': {'width': w, 'height': h}})) 153 | except Exception as e: 154 | print("WebSocket error:", str(e)) 155 | 156 | def combined_app(environ, start_response): 157 | path = environ['PATH_INFO'] 158 | if path == '/ws': 159 | return handle_websocket(environ, start_response) 160 | return app(environ, start_response) 161 | 162 | if __name__ == '__main__': 163 | wsgi.server(eventlet.listen(('0.0.0.0', 6969), reuse_port=True), combined_app) 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ShapeShift 2 | 3 | ## 🚀 Join the ShapeShift Waitlist 4 | 5 | ### We’re getting ready to launch ShapeShift! Be among the first to try it out by joining the waitlist: 6 | 7 | ### 👉 [Sign up here](https://forms.gle/iuHUNXTYS36t82Hh8) 8 | 9 | ## How to run 10 | 11 | 1. Ensure you have Python and Node installed 12 | 2. (recommended) create a virtual environment with `python -m venv .venv` and activate it with `source .venv/bin/activate` 13 | 3. Install the dependencies with `pip install -r requirements.txt` 14 | 4. Run the backend with `python backend/webserver.py` 15 | 5. Run the following commands in a separate terminal to set up the frontend: 16 | - `cd frontend` 17 | - `npm install` 18 | - `npm run dev` 19 | 6. Open the browser and go to `http://localhost:5173` 20 | 7. Enjoy! (note: the AI agent is not currently configured to work properly, so a fallback mode is used instead) 21 | 22 | A production version will be released soon. 23 | 24 | ## Inspiration 25 | 26 | We've always been fascinated by the futuristic interfaces in movies like _Minority Report_ and _Iron Man_. Traditional 3D modeling software, while powerful, often has a steep learning curve and relies on clunky keyboard shortcuts and mouse clicks. We asked ourselves: what if we could sculpt 3D objects as naturally as if we were molding clay with our own hands? Our inspiration was to **bridge the gap between human intuition and digital creation**, building an accessible, immersive, and magical 3D modeling experience right in the browser. We wanted to create a tool that feels less like operating a machine and more like an extension of our own creativity. 27 | 28 | --- 29 | 30 | ## What it does 31 | 32 | ShapeShift is an innovative, browser-based 3D modeling platform that transforms your webcam into a powerful creation tool. It allows users to interact with a 3D environment in two revolutionary ways: 33 | 34 | - **👋 Real-Time Gesture Control:** Using your hands, you can directly manipulate objects in the 3D scene. ShapeShift tracks your hand movements in real-time, allowing you to grab, move, rotate, and scale objects with intuitive gestures, eliminating the need for a mouse and keyboard. 35 | - **🤖 AI Agent Assistant:** ShapeShift features an intelligent AI assistant that understands natural language. You can simply tell the AI what you want to create or modify. For example, you can say, "Create a red sphere and a blue cube, then place the sphere on top of the cube," and the AI will execute the commands. 36 | - **✨ AI-Powered 3D Model Generation:** Stuck for ideas? You can describe a concept like "a futuristic spaceship" or "a stylized tree," and our integrated generative AI will create a detailed 3D model for you on the fly, ready to be imported and manipulated in your scene. 37 | 38 | --- 39 | 40 | ## How we built it 41 | 42 | ShapeShift is built on a modern, multi-faceted tech stack designed for real-time performance and intelligence. 43 | 44 | - **Frontend:** The entire user interface is a dynamic web application built with **React**, **TypeScript**, and **Vite**. For rendering the 3D environment, we used the powerful **Three.js** library, primarily through the declarative APIs of **@react-three/fiber** and **@react-three/drei**. Global state management is handled efficiently by **Zustand**. 45 | 46 | - **Backend (Computer Vision):** The gesture-control engine is powered by a **Python** backend using **Flask** and **Eventlet** for high-performance WebSocket communication. We use **OpenCV** to capture the video feed from the user's webcam and the **MediaPipe** library to perform real-time hand landmark detection. This landmark data is then streamed to the frontend. 47 | 48 | - **Backend (AI Agent):** The AI assistant is orchestrated by a **Node.js** server using **Express**. This server acts as a middleware that defines a set of tools (functions) the AI can use to manipulate the 3D scene. It integrates with LLM providers (like Martian) and the **Fal AI** API for generative 3D model creation from text prompts. 49 | 50 | The components communicate seamlessly: the Python backend streams hand data to the React frontend via WebSockets, while the frontend sends user prompts to the Node.js server to trigger AI actions. 51 | 52 | --- 53 | 54 | ## Challenges we ran into 55 | 56 | Building a project this ambitious came with its fair share of hurdles. 57 | 58 | One of the biggest challenges was **minimizing latency**. For the gesture control to feel natural, the delay between a physical hand movement and the response in the 3D viewport had to be negligible. We spent a significant amount of time optimizing our Python backend, implementing frame-skipping logic and efficient data serialization to ensure the WebSocket connection remained snappy. 59 | 60 | Another difficulty was designing a **robust gesture recognition system**. Raw hand landmark data from MediaPipe is noisy and varies between users. We had to develop normalization and rotation-invariant processing techniques to translate this raw data into consistent, reliable commands like "pinch" or "grab." 61 | 62 | Finally, **integrating the three distinct parts** of our application (Python CV, Node.js AI, and React frontend) was a complex architectural task. Ensuring smooth communication and state synchronization between these services required careful planning and debugging. 63 | 64 | --- 65 | 66 | ## Accomplishments that we're proud of 67 | 68 | We are incredibly proud of creating a **truly multi-modal interface** for 3D creation. The ability to seamlessly switch between direct, physical manipulation with your hands and high-level, abstract commands with your voice is the core of what we wanted to achieve. 69 | 70 | The **low-latency performance** of the hand-tracking pipeline is a major accomplishment. It feels fluid and responsive, which is crucial for making the experience immersive rather than frustrating. 71 | 72 | Furthermore, we developed a **comprehensive and powerful toolset for our AI agent**. The AI can do more than just create primitives; it can perform complex boolean operations, modify materials, duplicate objects, and even generate entirely new models, giving users immense creative leverage through simple language. 73 | 74 | --- 75 | 76 | ## What we learned 77 | 78 | This project was a massive learning experience. We learned a great deal about the intricacies of **real-time computer vision** and the importance of performance optimization at every step of the data pipeline. We also gained a deep appreciation for the complexities of **human-computer interaction design**, especially when creating novel interfaces that don't rely on established conventions. 79 | 80 | On the AI front, we learned how to effectively design and implement a **tool-using AI agent**. Defining clear, non-overlapping functions and engineering the system prompts to get reliable, structured output from the LLM was a fascinating challenge that pushed our understanding of applied AI. 81 | 82 | --- 83 | 84 | ## What's next for ShapeShift 85 | 86 | The future is bright and three-dimensional! We have many ideas for where to take ShapeShift next: 87 | 88 | - **Expanded Gesture Library:** We plan to introduce more complex and two-handed gestures for advanced actions like scaling the entire scene, drawing custom shapes, or performing intricate sculpting operations. 89 | - **Multi-User Collaboration:** We want to turn ShapeShift into a collaborative space where multiple users can join a session and build together in real-time, seeing each other's virtual hands. 90 | - **AR/VR Integration:** The ultimate goal is to break free from the 2D screen entirely. We plan to adapt our interaction model for augmented and virtual reality headsets to create a fully immersive 3D modeling environment. 91 | - **Smarter AI:** We will continue to enhance our AI's capabilities, enabling it to understand more complex, multi-step commands and maintain a better contextual awareness of the user's project goals. 92 | -------------------------------------------------------------------------------- /frontend/src/components/Topbar.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { useEditor } from "../store/editor"; 3 | import { 4 | buildSceneFromObjects, 5 | exportSceneToGLB, 6 | exportSceneToGLTF, 7 | importObjectsFromGLTF, 8 | } from "../utils/io"; 9 | import { saveScene, loadScene } from "../utils/local"; 10 | import { sceneToJSON, jsonToScene } from "../utils/scene"; 11 | import { saveAs } from "file-saver"; 12 | import type { EditorState } from "../types"; 13 | 14 | const Bar = styled.div` 15 | height: 48px; 16 | display: flex; 17 | align-items: center; 18 | justify-content: space-between; 19 | padding: 0 12px; 20 | background: rgba(20, 22, 28, 0.9); 21 | border-bottom: 1px solid rgba(255, 255, 255, 0.06); 22 | backdrop-filter: blur(10px); 23 | position: relative; 24 | z-index: 100; 25 | `; 26 | 27 | const Group = styled.div` 28 | display: flex; 29 | gap: 8px; 30 | `; 31 | 32 | const Btn = styled.button` 33 | background: #12141a; 34 | color: #e6e9ef; 35 | border: 1px solid rgba(255, 255, 255, 0.08); 36 | padding: 6px 10px; 37 | border-radius: 8px; 38 | `; 39 | 40 | const HiddenInput = styled.input` 41 | display: none; 42 | `; 43 | export function Topbar() { 44 | const undo = useEditor((s) => s.undo); 45 | const redo = useEditor((s) => s.redo); 46 | const setEditorMode = useEditor((s) => s.setEditorMode); 47 | const editorMode = useEditor((s) => s.editorMode); 48 | const clear = useEditor((s) => s.clear); 49 | const objects = useEditor((s) => s.objects); 50 | const mode = useEditor((s) => s.mode); 51 | const snap = useEditor((s) => s.snap); 52 | 53 | async function onExport() { 54 | const scene = buildSceneFromObjects(objects); 55 | // Export GLB (binary) and GLTF (JSON) 56 | await exportSceneToGLB(scene); 57 | await exportSceneToGLTF(scene); 58 | } 59 | 60 | function onSave() { 61 | const state = { 62 | objects, 63 | selectedId: useEditor.getState().selectedId, 64 | lights: useEditor.getState().lights, 65 | mode, 66 | editorMode, 67 | snap, 68 | past: [], 69 | future: [], 70 | checkpoints: [], 71 | } as EditorState; 72 | // Persist to localStorage 73 | saveScene(state); 74 | // Download as JSON file for explicit user feedback 75 | const json = sceneToJSON(state); 76 | const blob = new Blob([json], { type: "application/json" }); 77 | saveAs(blob, "scene.json"); 78 | } 79 | 80 | function applyLoaded(loaded: ReturnType) { 81 | if (!loaded) return; 82 | useEditor.setState((s) => ({ 83 | ...s, 84 | objects: loaded.editor.objects, 85 | selectedId: loaded.editor.selectedId, 86 | mode: loaded.editor.mode, 87 | editorMode: loaded.editor.editorMode, 88 | snap: loaded.editor.snap, 89 | past: [], 90 | future: [], 91 | })); 92 | } 93 | 94 | function onLoad() { 95 | // Prefer file picker to visibly do something; fallback to localStorage if no file chosen 96 | const input = document.getElementById( 97 | "load-file" 98 | ) as HTMLInputElement | null; 99 | if (input) input.click(); 100 | else applyLoaded(loadScene()); 101 | } 102 | 103 | function onLoadFileChange(e: React.ChangeEvent) { 104 | const file = e.target.files?.[0]; 105 | if (!file) return; 106 | const reader = new FileReader(); 107 | reader.onload = () => { 108 | const text = String(reader.result || ""); 109 | const decoded = jsonToScene(text) || loadScene(); 110 | applyLoaded(decoded); 111 | // reset input so selecting the same file again triggers change 112 | e.target.value = ""; 113 | }; 114 | reader.readAsText(file); 115 | } 116 | 117 | return ( 118 | 119 | 120 | clear()}>New 121 | Save 122 | Load 123 | 125 | document.getElementById("import-file")?.click() 126 | } 127 | > 128 | Import 129 | 130 | Export 131 | 137 | ) => { 138 | const file = e.target.files?.[0]; 139 | if (!file) return; 140 | const ext = 141 | file.name.toLowerCase().split(".").pop() || ""; 142 | try { 143 | if (ext === "gltf" || ext === "glb") { 144 | const imported = await importObjectsFromGLTF( 145 | file 146 | ); 147 | if (imported.length > 0) { 148 | useEditor.setState((s) => ({ 149 | ...s, 150 | objects: [...s.objects, ...imported], 151 | selectedId: 152 | imported[imported.length - 1]?.id ?? 153 | s.selectedId, 154 | past: [], 155 | future: [], 156 | })); 157 | } 158 | } else if (ext === "json") { 159 | const text = await file.text(); 160 | const decoded = jsonToScene(text); 161 | applyLoaded(decoded); 162 | } 163 | } finally { 164 | e.target.value = ""; 165 | } 166 | }} 167 | /> 168 | 174 | 175 | 176 | setEditorMode("object")} 178 | style={{ opacity: editorMode === "object" ? 1 : 0.7 }} 179 | > 180 | Object 181 | 182 | setEditorMode("edit")} 184 | style={{ opacity: editorMode === "edit" ? 1 : 0.7 }} 185 | > 186 | Edit 187 | 188 | setEditorMode("render")} 190 | style={{ opacity: editorMode === "render" ? 1 : 0.7 }} 191 | > 192 | Render 193 | 194 | 195 | 196 | Undo 197 | Redo 198 | { 200 | try { 201 | (useEditor as any).setState((s: any) => ({ 202 | ...s, 203 | showInspector: !s.showInspector, 204 | })); 205 | } catch {} 206 | }} 207 | > 208 | 🔍 209 | 210 | { 212 | try { 213 | (useEditor as any).setState((s: any) => ({ 214 | ...s, 215 | showChatPanel: true, 216 | })); 217 | } catch {} 218 | }} 219 | > 220 | 💬 221 | 222 | 223 | 224 | ); 225 | } 226 | 227 | export default Topbar; 228 | -------------------------------------------------------------------------------- /frontend/src/holo/hooks/useSkeleton.ts: -------------------------------------------------------------------------------- 1 | // Minimal placeholder to keep types and structure ready; full port available on request 2 | import { useCallback, useRef } from "react"; 3 | import type { Hand } from "../objects/hand"; 4 | 5 | interface ImageSize { 6 | width: number; 7 | height: number; 8 | } 9 | 10 | export default function useSkeleton({ 11 | overlayCanvasRef, 12 | fpsRef, 13 | updateInteractionState, 14 | }: { 15 | overlayCanvasRef: React.RefObject; 16 | fpsRef: React.RefObject; 17 | updateInteractionState: (s: any) => void; 18 | }) { 19 | // History for holding detection (thumb-index and middle-thumb distances) 20 | const distanceHistoryIndexThumb = useRef< 21 | Record<"Left" | "Right", number[]> 22 | >({ 23 | Left: [], 24 | Right: [], 25 | }); 26 | const distanceHistoryMiddleThumb = useRef< 27 | Record<"Left" | "Right", number[]> 28 | >({ 29 | Left: [], 30 | Right: [], 31 | }); 32 | const historyTimeIndexThumb = useRef>({ 33 | Left: [], 34 | Right: [], 35 | }); 36 | const historyTimeMiddleThumb = useRef>({ 37 | Left: [], 38 | Right: [], 39 | }); 40 | 41 | // Cursor smoothing 42 | const previousCursor = useRef< 43 | Record<"Left" | "Right", { x: number; y: number } | null> 44 | >({ 45 | Left: null, 46 | Right: null, 47 | }); 48 | 49 | // Lower smoothing to reduce latency of cursor and drawings 50 | const smoothingFactor = 0.2; 51 | 52 | const smoothValue = (previous: number | null, current: number) => { 53 | if (previous === null) return current; 54 | return previous * (1 - smoothingFactor) + current * smoothingFactor; 55 | }; 56 | 57 | const processHands = useCallback( 58 | ( 59 | hands: Hand[], 60 | imageSize: ImageSize, 61 | ctx: CanvasRenderingContext2D 62 | ) => { 63 | const state: any = { Left: null, Right: null, angleBetween: 0 }; 64 | 65 | // Get actual canvas dimensions 66 | const targetW = overlayCanvasRef.current?.width || imageSize.width; 67 | const targetH = 68 | overlayCanvasRef.current?.height || imageSize.height; 69 | 70 | // Scale from original video resolution to current canvas size 71 | const scaleX = targetW / imageSize.width; 72 | const scaleY = targetH / imageSize.height; 73 | 74 | hands.forEach((hand) => { 75 | const thumb = hand.landmarks[4]; 76 | const index = hand.landmarks[8]; 77 | const middle = hand.landmarks[12]; 78 | 79 | const cx = (thumb[0] + index[0]) / 2; 80 | const cy = (thumb[1] + index[1]) / 2; 81 | 82 | // Smooth cursor 83 | const prev = previousCursor.current[hand.handedness]; 84 | const smoothed = { 85 | x: smoothValue(prev?.x ?? null, cx), 86 | y: smoothValue(prev?.y ?? null, cy), 87 | } as { x: number; y: number }; 88 | previousCursor.current[hand.handedness] = smoothed; 89 | 90 | // Depth estimation based on hand size 91 | const xVals = hand.landmarks.map((lm) => lm[0] * scaleX); 92 | const yVals = hand.landmarks.map((lm) => lm[1] * scaleY); 93 | const minX = Math.min(...xVals); 94 | const maxX = Math.max(...xVals); 95 | const minY = Math.min(...yVals); 96 | const maxY = Math.max(...yVals); 97 | const handDiag = Math.hypot(maxX - minX, maxY - minY); 98 | const canvasDiag = Math.hypot(targetW, targetH); 99 | const depth = 100 | 1 - Math.min(Math.max(handDiag / canvasDiag, 0), 1); 101 | 102 | // Distances for holding detection (scaled to canvas) 103 | const distIndexThumb = Math.hypot( 104 | (index[0] - thumb[0]) * scaleX, 105 | (index[1] - thumb[1]) * scaleY 106 | ); 107 | const distMiddleThumb = Math.hypot( 108 | (middle[0] - thumb[0]) * scaleX, 109 | (middle[1] - thumb[1]) * scaleY 110 | ); 111 | const now = Date.now(); 112 | const side = hand.handedness; 113 | distanceHistoryIndexThumb.current[side].push(distIndexThumb); 114 | historyTimeIndexThumb.current[side].push(now); 115 | distanceHistoryMiddleThumb.current[side].push(distMiddleThumb); 116 | historyTimeMiddleThumb.current[side].push(now); 117 | // Keep last ~50ms 118 | while ( 119 | historyTimeIndexThumb.current[side].length > 0 && 120 | now - historyTimeIndexThumb.current[side][0] > 50 121 | ) { 122 | historyTimeIndexThumb.current[side].shift(); 123 | distanceHistoryIndexThumb.current[side].shift(); 124 | } 125 | while ( 126 | historyTimeMiddleThumb.current[side].length > 0 && 127 | now - historyTimeMiddleThumb.current[side][0] > 50 128 | ) { 129 | historyTimeMiddleThumb.current[side].shift(); 130 | distanceHistoryMiddleThumb.current[side].shift(); 131 | } 132 | 133 | // Compute moving average and variability 134 | const arr = distanceHistoryIndexThumb.current[side]; 135 | const avg = 136 | arr.reduce((a, b) => a + b, 0) / Math.max(arr.length, 1); 137 | const variance = 138 | arr.reduce((s, v) => s + Math.pow(v - avg, 2), 0) / 139 | Math.max(arr.length, 1); 140 | const std = Math.sqrt(variance); 141 | 142 | // Hand spread baseline 143 | const spread = (maxX - minX + (maxY - minY)) / 2; 144 | const holdThreshold = 0.25 * spread; 145 | const stabilityThreshold = 0.05 * spread; 146 | const isHolding = 147 | avg < holdThreshold && std < stabilityThreshold; 148 | 149 | // Simple pinching based on clustered fingertips near centroid 150 | const fingertipIndices = [4, 8, 12, 16, 20]; 151 | const fingertipPoints = fingertipIndices.map( 152 | (i) => hand.landmarks[i] 153 | ); 154 | const centroid = [ 155 | fingertipPoints.reduce((s, p) => s + p[0], 0) / 156 | fingertipPoints.length, 157 | fingertipPoints.reduce((s, p) => s + p[1], 0) / 158 | fingertipPoints.length, 159 | ]; 160 | const distances = fingertipPoints.map((p) => 161 | Math.hypot(p[0] - centroid[0], p[1] - centroid[1]) 162 | ); 163 | const maxDistance = Math.max(...distances); 164 | const pinchThreshold = 0.3 * spread; 165 | const isPinching = maxDistance < pinchThreshold; 166 | 167 | const handState = { 168 | isHolding, 169 | isPinching, 170 | cursor: { 171 | coords: { 172 | x: smoothed.x * scaleX, 173 | y: smoothed.y * scaleY, 174 | }, 175 | angle: 0, 176 | }, 177 | depth, 178 | }; 179 | state[side] = handState; 180 | 181 | // draw cursor dot and simple bone lines scaled to canvas size 182 | if (overlayCanvasRef.current) { 183 | ctx.save(); 184 | ctx.fillStyle = isHolding 185 | ? "#ffaa00" 186 | : isPinching 187 | ? "#4caf50" 188 | : "#f44336"; 189 | ctx.beginPath(); 190 | ctx.arc( 191 | smoothed.x * scaleX, 192 | smoothed.y * scaleY, 193 | 6, 194 | 0, 195 | Math.PI * 2 196 | ); 197 | ctx.fill(); 198 | if ((hand as any).connections) { 199 | ctx.strokeStyle = "#88aaff"; 200 | ctx.lineWidth = 2; 201 | for (const [a, b] of (hand as any) 202 | .connections as number[][]) { 203 | const p = hand.landmarks[a]; 204 | const q = hand.landmarks[b]; 205 | ctx.beginPath(); 206 | ctx.moveTo(p[0] * scaleX, p[1] * scaleY); 207 | ctx.lineTo(q[0] * scaleX, q[1] * scaleY); 208 | ctx.stroke(); 209 | } 210 | } 211 | ctx.restore(); 212 | } 213 | }); 214 | 215 | updateInteractionState(state); 216 | }, 217 | [overlayCanvasRef, updateInteractionState] 218 | ); 219 | const drawStrokes = useCallback((_ctx: CanvasRenderingContext2D) => {}, []); 220 | return { processHands, drawStrokes }; 221 | } 222 | -------------------------------------------------------------------------------- /frontend/src/utils/io.ts: -------------------------------------------------------------------------------- 1 | import { GLTFExporter, GLTFLoader } from "three-stdlib"; 2 | import { saveAs } from "file-saver"; 3 | import * as THREE from "three"; 4 | import type { SceneObject, GeometryParamsMap } from "../types"; 5 | import { serializeGeometry } from "./geometry"; 6 | import { nanoid } from "nanoid"; 7 | 8 | export function buildSceneFromObjects(objects: SceneObject[]): THREE.Scene { 9 | const scene = new THREE.Scene(); 10 | const light = new THREE.AmbientLight(0xffffff, 1); 11 | scene.add(light); 12 | for (const o of objects) { 13 | let geometry: THREE.BufferGeometry; 14 | switch (o.geometry) { 15 | case "box": { 16 | const p = o.geometryParams as 17 | | GeometryParamsMap["box"] 18 | | undefined; 19 | geometry = new THREE.BoxGeometry( 20 | p?.width ?? 1, 21 | p?.height ?? 1, 22 | p?.depth ?? 1 23 | ); 24 | break; 25 | } 26 | case "sphere": { 27 | const p = o.geometryParams as 28 | | GeometryParamsMap["sphere"] 29 | | undefined; 30 | geometry = new THREE.SphereGeometry( 31 | p?.radius ?? 0.5, 32 | p?.widthSegments ?? 8, 33 | p?.heightSegments ?? 8 34 | ); 35 | // Ensure smooth shading 36 | geometry.computeVertexNormals(); 37 | break; 38 | } 39 | case "cylinder": { 40 | const p = o.geometryParams as 41 | | GeometryParamsMap["cylinder"] 42 | | undefined; 43 | geometry = new THREE.CylinderGeometry( 44 | p?.radiusTop ?? 0.5, 45 | p?.radiusBottom ?? 0.5, 46 | p?.height ?? 1, 47 | p?.radialSegments ?? 32 48 | ); 49 | break; 50 | } 51 | case "cone": { 52 | const p = o.geometryParams as 53 | | GeometryParamsMap["cone"] 54 | | undefined; 55 | geometry = new THREE.ConeGeometry( 56 | p?.radius ?? 0.5, 57 | p?.height ?? 1, 58 | p?.radialSegments ?? 32 59 | ); 60 | break; 61 | } 62 | case "torus": { 63 | const p = o.geometryParams as 64 | | GeometryParamsMap["torus"] 65 | | undefined; 66 | geometry = new THREE.TorusGeometry( 67 | p?.radius ?? 0.5, 68 | p?.tube ?? 0.2, 69 | p?.radialSegments ?? 8, 70 | p?.tubularSegments ?? 16 71 | ); 72 | // Ensure smooth shading 73 | geometry.computeVertexNormals(); 74 | break; 75 | } 76 | case "plane": { 77 | const p = o.geometryParams as 78 | | GeometryParamsMap["plane"] 79 | | undefined; 80 | geometry = new THREE.PlaneGeometry( 81 | p?.width ?? 1, 82 | p?.height ?? 1, 83 | p?.widthSegments ?? 1, 84 | p?.heightSegments ?? 1 85 | ); 86 | break; 87 | } 88 | default: 89 | geometry = new THREE.BoxGeometry(1, 1, 1); 90 | } 91 | const material = new THREE.MeshStandardMaterial({ 92 | color: new THREE.Color(o.material.color), 93 | metalness: o.material.metalness, 94 | roughness: o.material.roughness, 95 | opacity: o.material.opacity, 96 | transparent: o.material.transparent, 97 | }); 98 | const mesh = new THREE.Mesh(geometry, material); 99 | mesh.position.set(o.position.x, o.position.y, o.position.z); 100 | mesh.rotation.set(o.rotation.x, o.rotation.y, o.rotation.z); 101 | mesh.scale.set(o.scale.x, o.scale.y, o.scale.z); 102 | mesh.visible = o.visible; 103 | scene.add(mesh); 104 | } 105 | return scene; 106 | } 107 | 108 | export async function exportSceneToGLB( 109 | scene: THREE.Scene, 110 | filename = "scene.glb" 111 | ) { 112 | const exporter = new GLTFExporter(); 113 | return new Promise((resolve, reject) => { 114 | exporter.parse( 115 | scene, 116 | (gltf) => { 117 | try { 118 | if (gltf instanceof ArrayBuffer) { 119 | const blob = new Blob([gltf], { 120 | type: "model/gltf-binary", 121 | }); 122 | saveAs(blob, filename); 123 | } else { 124 | const json = JSON.stringify(gltf); 125 | const blob = new Blob([json], { 126 | type: "application/json", 127 | }); 128 | saveAs(blob, filename.replace(/\.glb$/, ".gltf")); 129 | } 130 | resolve(); 131 | } catch (e) { 132 | reject(e); 133 | } 134 | }, 135 | (error) => reject(error), 136 | { binary: true } 137 | ); 138 | }); 139 | } 140 | 141 | export async function exportSceneToGLTF( 142 | scene: THREE.Scene, 143 | filename = "scene.gltf" 144 | ) { 145 | const exporter = new GLTFExporter(); 146 | return new Promise((resolve, reject) => { 147 | exporter.parse( 148 | scene, 149 | (gltf) => { 150 | try { 151 | if (gltf instanceof ArrayBuffer) { 152 | // If binary returned, convert to JSON first 153 | reject( 154 | new Error("Expected JSON glTF export, got binary") 155 | ); 156 | return; 157 | } 158 | const json = JSON.stringify(gltf); 159 | const blob = new Blob([json], { type: "application/json" }); 160 | saveAs(blob, filename); 161 | resolve(); 162 | } catch (e) { 163 | reject(e); 164 | } 165 | }, 166 | (error) => reject(error), 167 | { binary: false } 168 | ); 169 | }); 170 | } 171 | 172 | function meshToSceneObject(mesh: THREE.Mesh): SceneObject | null { 173 | const geometry = mesh.geometry as THREE.BufferGeometry | undefined; 174 | if (!geometry) return null; 175 | const material = mesh.material as 176 | | THREE.MeshStandardMaterial 177 | | THREE.MeshPhysicalMaterial 178 | | undefined; 179 | const color = 180 | material && "color" in material && (material as any).color 181 | ? ((material as any).color as THREE.Color) 182 | : new THREE.Color("#9aa7ff"); 183 | const metalness = 184 | material && "metalness" in material 185 | ? Number((material as any).metalness ?? 0.1) 186 | : 0.1; 187 | const roughness = 188 | material && "roughness" in material 189 | ? Number((material as any).roughness ?? 0.8) 190 | : 0.8; 191 | const opacity = 192 | material && "opacity" in material 193 | ? Number((material as any).opacity ?? 1) 194 | : 1; 195 | const transparent = 196 | material && "transparent" in material 197 | ? Boolean((material as any).transparent ?? false) 198 | : false; 199 | 200 | return { 201 | id: nanoid(8), 202 | name: mesh.name || "Imported", 203 | geometry: "custom", 204 | geometryParams: serializeGeometry(geometry), 205 | position: { 206 | x: mesh.position.x, 207 | y: mesh.position.y, 208 | z: mesh.position.z, 209 | }, 210 | rotation: { 211 | x: mesh.rotation.x, 212 | y: mesh.rotation.y, 213 | z: mesh.rotation.z, 214 | }, 215 | scale: { x: mesh.scale.x, y: mesh.scale.y, z: mesh.scale.z }, 216 | material: { 217 | color: `#${color.getHexString()}`, 218 | metalness, 219 | roughness, 220 | opacity, 221 | transparent, 222 | }, 223 | visible: mesh.visible, 224 | locked: false, 225 | }; 226 | } 227 | 228 | function extractObjectsFromThreeScene(root: THREE.Object3D): SceneObject[] { 229 | const result: SceneObject[] = []; 230 | root.traverse((child) => { 231 | if ((child as THREE.Mesh).isMesh) { 232 | const obj = meshToSceneObject(child as THREE.Mesh); 233 | if (obj) result.push(obj); 234 | } 235 | }); 236 | return result; 237 | } 238 | 239 | export async function importObjectsFromGLTF( 240 | file: File 241 | ): Promise { 242 | const loader = new GLTFLoader(); 243 | const ext = file.name.toLowerCase().split(".").pop() || ""; 244 | if (ext === "glb") { 245 | const arrayBuffer = await file.arrayBuffer(); 246 | return new Promise((resolve, reject) => { 247 | loader.parse( 248 | arrayBuffer as unknown as ArrayBuffer, 249 | "", 250 | (gltf) => { 251 | try { 252 | const objs = extractObjectsFromThreeScene(gltf.scene); 253 | resolve(objs); 254 | } catch (e) { 255 | reject(e); 256 | } 257 | }, 258 | (err) => reject(err) 259 | ); 260 | }); 261 | } 262 | if (ext === "gltf") { 263 | const text = await file.text(); 264 | return new Promise((resolve, reject) => { 265 | loader.parse( 266 | text, 267 | "", 268 | (gltf) => { 269 | try { 270 | const objs = extractObjectsFromThreeScene(gltf.scene); 271 | resolve(objs); 272 | } catch (e) { 273 | reject(e); 274 | } 275 | }, 276 | (err) => reject(err) 277 | ); 278 | }); 279 | } 280 | throw new Error("Unsupported file type for GLTF import"); 281 | } 282 | -------------------------------------------------------------------------------- /frontend/server/index.mjs: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import dotenv from 'dotenv' 3 | import { fal } from '@fal-ai/client' 4 | 5 | dotenv.config({ path: '.env.local' }) 6 | dotenv.config() 7 | 8 | const app = express() 9 | const PORT = process.env.PORT || 8787 10 | 11 | app.use(express.json({ limit: '20mb' })) 12 | app.use((req, res, next) => { 13 | res.setHeader('Access-Control-Allow-Origin', '*') 14 | res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') 15 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') 16 | if (req.method === 'OPTIONS') return res.sendStatus(200) 17 | next() 18 | }) 19 | 20 | const rawFalKey = (process.env.FAL_KEY || process.env.VITE_FAL_KEY || process.env.FAL_API_KEY || '').trim() 21 | // Normalize common mistakes: leading 'Bearer ' and wrapping quotes 22 | const resolvedFalKey = rawFalKey.replace(/^Bearer\s+/i, '').replace(/^"|"$/g, '').replace(/^'|'$/g, '') 23 | fal.config({ credentials: resolvedFalKey }) 24 | 25 | function getLlms() { 26 | const martianKey = process.env.MARTIAN_API_KEY || process.env.VITE_MARTIAN_API_KEY 27 | const martianBase = process.env.MARTIAN_BASE_URL || 'https://api.withmartian.com/v1' 28 | 29 | if (martianKey) { 30 | return { provider: 'martian', baseUrl: martianBase, apiKey: martianKey, model: process.env.MARTIAN_MODEL || process.env.LLM_MODEL || 'openai/gpt-4.1-nano' } 31 | } 32 | return null 33 | } 34 | 35 | const tools = [ 36 | { type: 'function', function: { name: 'addObject', description: 'Add a primitive to the scene', parameters: { type: 'object', properties: { kind: { type: 'string', enum: ['box', 'sphere', 'cylinder', 'cone', 'torus', 'plane'] }, params: { type: 'object', additionalProperties: true } }, required: ['kind'] } } }, 37 | { type: 'function', function: { name: 'selectObject', description: 'Select an object by id or name', parameters: { type: 'object', properties: { target: { type: 'string' } }, required: ['target'] } } }, 38 | { type: 'function', function: { name: 'updateTransform', description: 'Update position, rotation (radians), or scale. Use deltas when requested to move/rotate/scale by amounts.', parameters: { type: 'object', properties: { id: { type: 'string' }, position: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } } }, rotation: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } } }, scale: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } } }, isDelta: { type: 'boolean', description: 'If true, apply values as deltas (additive). Otherwise set absolute.' } }, required: ['id'] } } }, 39 | { type: 'function', function: { name: 'updateTransformMany', description: 'Batch update transforms for multiple objects. Use isDelta true for relative moves.', parameters: { type: 'object', properties: { items: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, target: { type: 'string' }, position: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } } }, rotation: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } } }, scale: { type: 'object', properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } } }, isDelta: { type: 'boolean' } } } } }, required: ['items'] } } }, 40 | { type: 'function', function: { name: 'updateMaterial', description: 'Set material color (hex), metalness, roughness, opacity', parameters: { type: 'object', properties: { id: { type: 'string' }, color: { type: 'string' }, metalness: { type: 'number' }, roughness: { type: 'number' }, opacity: { type: 'number' }, transparent: { type: 'boolean' } }, required: ['id'] } } }, 41 | { type: 'function', function: { name: 'updateMaterialMany', description: 'Batch update materials for multiple objects.', parameters: { type: 'object', properties: { items: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, target: { type: 'string' }, color: { type: 'string' }, metalness: { type: 'number' }, roughness: { type: 'number' }, opacity: { type: 'number' }, transparent: { type: 'boolean' } } } } }, required: ['items'] } } }, 42 | { type: 'function', function: { name: 'updateGeometry', description: 'Change geometry kind and optional params', parameters: { type: 'object', properties: { id: { type: 'string' }, kind: { type: 'string', enum: ['box', 'sphere', 'cylinder', 'cone', 'torus', 'plane'] }, params: { type: 'object', additionalProperties: true } }, required: ['id', 'kind'] } } }, 43 | { type: 'function', function: { name: 'duplicateSelected', parameters: { type: 'object', properties: {} } } }, 44 | { type: 'function', function: { name: 'deleteSelected', parameters: { type: 'object', properties: {} } } }, 45 | { type: 'function', function: { name: 'booleanOp', description: 'Run boolean operation between two objects A and B', parameters: { type: 'object', properties: { op: { type: 'string', enum: ['union', 'subtract', 'intersect'] }, a: { type: 'string' }, b: { type: 'string' } }, required: ['op', 'a', 'b'] } } }, 46 | { type: 'function', function: { name: 'undo', parameters: { type: 'object', properties: {} } } }, 47 | { type: 'function', function: { name: 'redo', parameters: { type: 'object', properties: {} } } }, 48 | { type: 'function', function: { name: 'toggleSnap', parameters: { type: 'object', properties: { enabled: { type: 'boolean' } }, required: ['enabled'] } } }, 49 | { type: 'function', function: { name: 'setSnap', parameters: { type: 'object', properties: { translateSnap: { type: 'number' }, rotateSnap: { type: 'number' }, scaleSnap: { type: 'number' } } } } }, 50 | { type: 'function', function: { name: 'setMode', parameters: { type: 'object', properties: { mode: { type: 'string', enum: ['translate', 'rotate', 'scale'] } }, required: ['mode'] } } }, 51 | { type: 'function', function: { name: 'generateModelRodin', description: 'Generate a 3D model via Fal Rodin and return a GLB URL. Provide either imageUrl(s) or prompt.', parameters: { type: 'object', properties: { imageUrl: { type: 'string' }, prompt: { type: 'string' }, quality: { type: 'string', enum: ['high','medium','low','extra-low'] }, material: { type: 'string', enum: ['PBR','Shaded'] } } } } }, 52 | { type: 'function', function: { name: 'addRepeatedObjects', description: 'Add N primitives spaced to avoid overlap. Default spacingX is based on size or 1.2 units.', parameters: { type: 'object', properties: { kind: { type: 'string', enum: ['box', 'sphere', 'cylinder', 'cone', 'torus', 'plane'] }, count: { type: 'integer', minimum: 1 }, params: { type: 'object', additionalProperties: true }, spacingX: { type: 'number' }, spacingY: { type: 'number' }, spacingZ: { type: 'number' }, startX: { type: 'number' }, startY: { type: 'number' }, startZ: { type: 'number' } }, required: ['kind','count'] } } }, 53 | { type: 'function', function: { name: 'updateName', description: 'Rename an object by id or exact name', parameters: { type: 'object', properties: { id: { type: 'string' }, target: { type: 'string' }, name: { type: 'string' } }, required: ['name'] } } }, 54 | ] 55 | 56 | const systemPrompt = `You are a 3D modeling copilot. Use the provided tools to carry out user requests in a precise, deterministic way. 57 | - Prefer tool calls over text responses whenever possible. 58 | - Units: position/scale in editor units. Rotation is in radians; if the user gives degrees, convert to radians. 59 | - If the user asks to "move/rotate/scale by" amounts, use isDelta: true. 60 | - When the user refers to objects by name, assume exact match on existing scene object names if provided in context. 61 | - For requests that target multiple meshes at once (e.g., "set all legs red", "move A, B, C by x 0.5"), use updateTransformMany or updateMaterialMany with all items batched in one response. 62 | - For requests like "add N boxes/cylinders", prefer addRepeatedObjects to ensure non-overlapping placement using spacing. 63 | - When the user asks to rename something, use updateName. 64 | Only produce natural language when no tool call is appropriate.` 65 | 66 | app.get('/api/health', (req, res) => { 67 | const llm = getLlms() 68 | const falConfigured = !!resolvedFalKey 69 | res.json({ ok: !!llm, provider: llm?.provider ?? null, baseUrl: llm?.baseUrl ?? null, model: llm?.model ?? null, hasKey: !!llm?.apiKey, falConfigured }) 70 | }) 71 | 72 | app.post('/api/rodin', async (req, res) => { 73 | try { 74 | const { imageUrl, prompt, quality = 'medium', material = 'PBR' } = req.body || {} 75 | if (!imageUrl && !prompt) return res.status(400).json({ error: 'Provide imageUrl or prompt' }) 76 | 77 | const result = await fal.subscribe('fal-ai/hyper3d/rodin', { 78 | input: { 79 | ...(imageUrl ? { input_image_urls: imageUrl } : { prompt }), 80 | geometry_file_format: 'glb', 81 | material, 82 | quality 83 | } 84 | }) 85 | const glbUrl = result?.data?.model_mesh?.url 86 | if (!glbUrl) return res.status(502).json({ error: 'No model URL from Rodin', details: result }) 87 | res.json({ glbUrl }) 88 | } catch (e) { 89 | const err = e || {} 90 | console.error('[Rodin] Error', err) 91 | res.status(500).json({ 92 | error: 'Rodin call failed', 93 | details: String(err?.message || err), 94 | status: err?.status || err?.code || null, 95 | name: err?.name || null, 96 | }) 97 | } 98 | }) 99 | 100 | app.post('/api/chat', async (req, res) => { 101 | const llm = getLlms() 102 | if (!llm) return res.status(500).json({ error: 'No LLM API key configured. Set MARTIAN_API_KEY (+ optional MARTIAN_BASE_URL) or OPENAI_API_KEY.' }) 103 | 104 | const { user, sceneSummary, focusContext } = req.body || {} 105 | if (!user || typeof user !== 'string') return res.status(400).json({ error: 'Missing user prompt' }) 106 | 107 | const messages = [ 108 | { role: 'system', content: systemPrompt }, 109 | focusContext ? { role: 'system', content: `Focus: ${focusContext}` } : null, 110 | sceneSummary ? { role: 'system', content: `Scene: ${sceneSummary}` } : null, 111 | { role: 'user', content: user } 112 | ].filter(Boolean) 113 | 114 | const url = `${llm.baseUrl.replace(/\/$/, '')}/chat/completions` 115 | 116 | try { 117 | const r = await fetch(url, { 118 | method: 'POST', 119 | headers: { 120 | 'Content-Type': 'application/json', 121 | 'Authorization': `Bearer ${llm.apiKey}`, 122 | }, 123 | body: JSON.stringify({ 124 | model: llm.model, 125 | temperature: 0, 126 | messages, 127 | tools, 128 | tool_choice: 'auto' 129 | }) 130 | }) 131 | const data = await r.json() 132 | if (!r.ok) { 133 | console.error('Upstream error', data) 134 | return res.status(500).json({ error: 'Upstream error', details: data }) 135 | } 136 | return res.json(data) 137 | } catch (err) { 138 | console.error('Request failed', err) 139 | return res.status(500).json({ error: 'Request failed', details: String(err) }) 140 | } 141 | }) 142 | 143 | console.log(`[FAL] configured=${!!resolvedFalKey}`) 144 | 145 | const detected = getLlms() 146 | console.log(`[LLM] provider=${detected?.provider ?? 'none'} base=${detected?.baseUrl ?? 'n/a'} model=${detected?.model ?? 'n/a'} hasKey=${!!detected?.apiKey}`) 147 | 148 | app.listen(PORT, () => { 149 | console.log(`LLM server listening on http://localhost:${PORT}`) 150 | }) -------------------------------------------------------------------------------- /frontend/src/agent/agent.ts: -------------------------------------------------------------------------------- 1 | import type { StepResult, ToolInvocation, ToolResult, SceneSnapshot } from "./types"; 2 | import * as Tools from "./tools"; 3 | 4 | export type AgentPlan = { steps: ToolInvocation[] }; 5 | 6 | export type AgentRunResult = { 7 | snapshot: SceneSnapshot; 8 | transcript: StepResult[]; 9 | }; 10 | 11 | export function planFromPrompt(prompt: string, snapshot: SceneSnapshot): AgentPlan { 12 | const lc = prompt.toLowerCase().trim(); 13 | const steps: ToolInvocation[] = []; 14 | 15 | // export 16 | if (/^export\b/.test(lc)) { 17 | steps.push({ name: "export_glb", args: {} }); 18 | return { steps }; 19 | } 20 | 21 | // acceptance test shortcuts 22 | if (/delete\s+the\s+cube/.test(lc)) { 23 | steps.push({ name: "find", args: { nameContains: "cube" } }); 24 | steps.push({ name: "select", args: { query: { nameContains: "cube" } } }); 25 | steps.push({ name: "delete", args: { ids: [] } }); 26 | return { steps }; 27 | } 28 | if (/duplicate\s+the\s+sphere/.test(lc) || /duplicate\s+the\s+.*sphere/.test(lc)) { 29 | steps.push({ name: "find", args: { nameContains: "sphere" } }); 30 | steps.push({ name: "select", args: { query: { nameContains: "sphere" } } }); 31 | steps.push({ name: "duplicate", args: { ids: [] } }); 32 | if (/\+?2\s+on\s+x/.test(lc)) steps.push({ name: "transform", args: { id: "", translate: [2, 0, 0] } }); 33 | if (/satellite/.test(lc)) steps.push({ name: "update_name", args: { id: "", name: "satellite" } }); 34 | return { steps: steps.slice(0, 8) }; 35 | } 36 | 37 | // generic: add primitive 38 | const addMatch = lc.match(/^add\s+(box|cube|sphere|plane|cylinder|cone)\b(.*)$/); 39 | if (addMatch) { 40 | const kindWord = addMatch[1]; 41 | const rest = addMatch[2] || ""; 42 | const kind = kindWord === "box" || kindWord === "cube" ? "cube" : (kindWord as any); 43 | const dims: any = {}; 44 | const sizeM = rest.match(/(\d+(?:\.\d+)?)\s*m\b/); 45 | if (kind === "cube" && sizeM) dims.x = dims.y = dims.z = parseFloat(sizeM[1]); 46 | if (kind === "sphere") { 47 | const r = rest.match(/radius\s+(-?\d*\.?\d+)/) || rest.match(/(\d+(?:\.\d+)?)\s*m\b/); 48 | if (r) dims.radius = parseFloat(r[1]); 49 | } 50 | if (kind === "plane") { 51 | const w = rest.match(/width\s+(-?\d*\.?\d+)/) || rest.match(/x\s+(-?\d*\.?\d+)/); 52 | const h = rest.match(/height\s+(-?\d*\.?\d+)/) || rest.match(/y\s+(-?\d*\.?\d+)/); 53 | if (w) dims.x = parseFloat(w[1]); 54 | if (h) dims.y = parseFloat(h[1]); 55 | } 56 | if (kind === "cylinder") { 57 | const rad = rest.match(/radius\s+(-?\d*\.?\d+)/); 58 | const h = rest.match(/height\s+(-?\d*\.?\d+)/); 59 | if (rad) dims.radius = parseFloat(rad[1]); 60 | if (h) dims.height = parseFloat(h[1]); 61 | } 62 | if (kind === "cone") { 63 | const rad = rest.match(/radius\s+(-?\d*\.?\d+)/) || sizeM; 64 | const h = rest.match(/height\s+(-?\d*\.?\d+)/) || sizeM; 65 | if (rad) dims.radius = parseFloat(rad[1]); 66 | if (h) dims.height = parseFloat(h[1]); 67 | } 68 | steps.push({ name: "create_primitive", args: { kind, name: capitalize(kind), dims } }); 69 | return { steps: steps.slice(0, 8) }; 70 | } 71 | 72 | // generic: select by name 73 | const sel = lc.match(/^select\s+(.+)$/); 74 | if (sel) { 75 | const name = sel[1].trim(); 76 | steps.push({ name: "select", args: { query: { nameContains: name } } }); 77 | return { steps }; 78 | } 79 | 80 | // generic: delete selected 81 | if (/^(delete|remove|del)\b/.test(lc)) { 82 | steps.push({ name: "delete", args: { ids: [] } }); 83 | return { steps }; 84 | } 85 | 86 | // generic: duplicate selected 87 | if (/^(duplicate|copy|dup)\b/.test(lc)) { 88 | steps.push({ name: "duplicate", args: { ids: [] } }); 89 | return { steps }; 90 | } 91 | 92 | // generic: color 93 | const color = lc.match(/(?:color|colour)\s+([^\s]+)/); 94 | if (color) { 95 | steps.push({ name: "assign_material", args: { id: "", materialId: color[1] } }); 96 | return { steps }; 97 | } 98 | 99 | // generic: opacity 100 | const op = lc.match(/opacity\s+(-?\d*\.?\d+)/); 101 | if (op) { 102 | // use create_material+assign not needed; opacity not covered -> skip in MVP planner 103 | } 104 | 105 | // generic: move/translate 106 | const move = lc.match(/^(move|translate)\b(.*)$/); 107 | if (move) { 108 | let rest = move[2] || ""; 109 | // Try to resolve a named target from the snapshot 110 | const names = (snapshot.entities || []).map((e) => e.name.toLowerCase()); 111 | const nameHit = names.find((n) => n && rest.includes(n)); 112 | if (nameHit) { 113 | steps.push({ name: "select", args: { query: { nameContains: nameHit } } }); 114 | rest = rest.replace(nameHit, ""); 115 | } 116 | 117 | // Words: left/right/up/down/forward/back (+ optional distance) 118 | const amtWord = (w: string) => { 119 | const m = rest.match(new RegExp(`${w}\\s+(-?\\d*\\.?\\d+)`)); 120 | return m ? parseFloat(m[1]) : 1; 121 | }; 122 | let dx = 0, dy = 0, dz = 0; 123 | if (/(^|\s)left(\s|$)/.test(rest)) dx -= amtWord("left"); 124 | if (/(^|\s)right(\s|$)/.test(rest)) dx += amtWord("right"); 125 | if (/(^|\s)up(\s|$)/.test(rest)) dy += amtWord("up"); 126 | if (/(^|\s)down(\s|$)/.test(rest)) dy -= amtWord("down"); 127 | // Adjust semantics: forward => +Z, back => -Z for this editor's convention 128 | if (/(^|\s)forward(\s|$)/.test(rest)) dz += amtWord("forward"); 129 | if (/(^|\s)back(ward|wards)?(\s|$)/.test(rest)) dz -= amtWord("back"); 130 | 131 | // Also allow explicit axis numbers to override/add 132 | const dxExplicit = rest.match(/x\s+(-?\d*\.?\d+)/); 133 | const dyExplicit = rest.match(/y\s+(-?\d*\.?\d+)/); 134 | const dzExplicit = rest.match(/z\s+(-?\d*\.?\d+)/); 135 | if (dxExplicit) dx = parseFloat(dxExplicit[1]); 136 | if (dyExplicit) dy = parseFloat(dyExplicit[1]); 137 | if (dzExplicit) dz = parseFloat(dzExplicit[1]); 138 | 139 | steps.push({ name: "transform", args: { id: "", translate: [dx, dy, dz] } }); 140 | return { steps }; 141 | } 142 | 143 | // generic: rotate (assume radians) 144 | const rot = lc.match(/^rot(ate)?\b(.*)$/); 145 | if (rot) { 146 | const rest = rot[2] || ""; 147 | const rx = parseFloat(rest.match(/x\s+(-?\d*\.?\d+)/)?.[1] || "0"); 148 | const ry = parseFloat(rest.match(/y\s+(-?\d*\.?\d+)/)?.[1] || "0"); 149 | const rz = parseFloat(rest.match(/z\s+(-?\d*\.?\d+)/)?.[1] || "0"); 150 | steps.push({ name: "transform", args: { id: "", rotate: [rx, ry, rz] } }); 151 | return { steps }; 152 | } 153 | 154 | // generic: scale (uniform or per-axis multipliers) 155 | const sc = lc.match(/^scale\b(.*)$/); 156 | if (sc) { 157 | const rest = sc[1] || ""; 158 | const uni = rest.match(/\b(-?\d*\.\d+|\d+)\b/); 159 | const sx = parseFloat(rest.match(/x\s+(-?\d*\.?\d+)/)?.[1] || (uni ? uni[1] : "1")); 160 | const sy = parseFloat(rest.match(/y\s+(-?\d*\.?\d+)/)?.[1] || (uni ? uni[1] : "1")); 161 | const sz = parseFloat(rest.match(/z\s+(-?\d*\.?\d+)/)?.[1] || (uni ? uni[1] : "1")); 162 | steps.push({ name: "transform", args: { id: "", scale: [sx, sy, sz] } }); 163 | return { steps }; 164 | } 165 | 166 | // acceptance test 1 combined prompt 167 | if (/create\s+a\s+1m\s+blue\s+cube/.test(lc) || /0\.5m\s+red\s+sphere/.test(lc)) { 168 | if (/create\s+a\s+1m\s+blue\s+cube/.test(lc)) { 169 | steps.push({ name: "create_primitive", args: { kind: "cube", name: "Cube", dims: { x: 1, y: 1, z: 1 } } }); 170 | steps.push({ name: "select", args: { query: { nameContains: "Cube" } } }); 171 | steps.push({ name: "assign_material", args: { id: "", materialId: "blue" } }); 172 | } 173 | if (/0\.5m\s+red\s+sphere/.test(lc)) { 174 | steps.push({ name: "create_primitive", args: { kind: "sphere", name: "Sphere", dims: { radius: 0.5 } } }); 175 | steps.push({ name: "select", args: { query: { nameContains: "Sphere" } } }); 176 | steps.push({ name: "assign_material", args: { id: "", materialId: "red" } }); 177 | steps.push({ name: "transform", args: { id: "", translate: [0, 1, 0] } }); 178 | } 179 | return { steps: steps.slice(0, 8) }; 180 | } 181 | 182 | // default: no plan 183 | return { steps }; 184 | } 185 | 186 | export async function runAgent(prompt: string, apply: boolean): Promise { 187 | const s0 = Tools.get_scene_summary().snapshot!; 188 | console.log("[Agent] prompt=", prompt, "apply=", apply); 189 | 190 | const plan = planFromPrompt(prompt, s0); 191 | console.log("[Agent] planned steps=", plan.steps); 192 | 193 | if (!apply) { 194 | console.log("[Agent] Dry-run: returning planned steps only"); 195 | return { snapshot: s0, transcript: plan.steps.map((t) => ({ tool: t, ok: true, diagnostics: ["dry_run"], diff: { addedIds: [], removedIds: [], updatedIds: [] } })) }; 196 | } 197 | 198 | const transcript: StepResult[] = []; 199 | let lastSnapshot = s0; 200 | 201 | for (const step of plan.steps) { 202 | let filled = { ...step } as ToolInvocation; 203 | if (filled.name === "delete" && (!filled.args.ids || filled.args.ids.length === 0)) { 204 | const sel = Tools.get_selection().payload?.ids || []; 205 | filled.args = { ...filled.args, ids: sel }; 206 | } 207 | if ((filled.name === "transform" || filled.name === "update_name" || filled.name === "assign_material") && (!filled.args.id || filled.args.id === "")) { 208 | const sel = Tools.get_selection().payload?.ids || []; 209 | filled.args = { ...filled.args, id: sel[0] }; 210 | } 211 | if (filled.name === "duplicate" && (!filled.args.ids || filled.args.ids.length === 0)) { 212 | const sel = Tools.get_selection().payload?.ids || []; 213 | filled.args = { ...filled.args, ids: sel }; 214 | } 215 | 216 | console.log("[Agent] executing:", filled); 217 | let result = await execute(filled); 218 | console.log("[Agent] result:", result); 219 | if (!result.success) { 220 | if (filled.name === "select" && filled.args?.query?.nameContains) { 221 | console.log("[Agent] retry: find + select for", filled.args.query.nameContains); 222 | result = await execute({ name: "find", args: { nameContains: filled.args.query.nameContains } }); 223 | if (result.success) { 224 | const ids = result.payload?.ids || []; 225 | result = await execute({ name: "select", args: { ids } }); 226 | } 227 | } 228 | } 229 | transcript.push({ tool: filled, ok: !!result.success, diagnostics: result.diagnostics, diff: result.changes }); 230 | lastSnapshot = result.snapshot || lastSnapshot; 231 | if (!result.success) { 232 | console.warn("[Agent] step failed, stopping", filled, result.diagnostics); 233 | break; 234 | } 235 | } 236 | 237 | console.log("[Agent] done. counts=", lastSnapshot.counts, "selection=", lastSnapshot.selection); 238 | return { snapshot: lastSnapshot, transcript }; 239 | } 240 | 241 | async function execute(inv: ToolInvocation): Promise { 242 | switch (inv.name) { 243 | case "get_scene_summary": 244 | return Tools.get_scene_summary(); 245 | case "find": 246 | return Tools.find(inv.args); 247 | case "get_selection": 248 | return Tools.get_selection(); 249 | case "create_primitive": 250 | return Tools.create_primitive(inv.args); 251 | case "select": 252 | return Tools.select(inv.args); 253 | case "transform": 254 | return Tools.transform(inv.args); 255 | case "duplicate": 256 | return Tools.duplicate(inv.args); 257 | case "delete": 258 | return Tools.del(inv.args); 259 | case "create_material": 260 | return Tools.create_material(inv.args); 261 | case "assign_material": 262 | return Tools.assign_material(inv.args); 263 | case "export_glb": 264 | return await Tools.export_glb(inv.args); 265 | case "image_to_3d": 266 | return Tools.image_to_3d(inv.args); 267 | case "update_name": 268 | return Tools.update_name(inv.args); 269 | default: 270 | return { success: false, diagnostics: ["unknown_tool"], snapshot: Tools.get_scene_summary().snapshot }; 271 | } 272 | } 273 | 274 | function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } -------------------------------------------------------------------------------- /frontend/src/holo/components/HolohandsOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { WebSocketProvider, useWebSocket } from "../provider/WebSocketContext"; 3 | import { ThreeDProvider } from "../provider/ThreeDContext"; 4 | import Editable3DObject from "./ThreeRenderer"; 5 | import { useVideoStream } from "../provider/VideoStreamContext"; 6 | import { useViewportActions } from "../../provider/ViewportContext"; 7 | import useSkeleton from "../hooks/useSkeleton"; 8 | import type { InteractionState } from "../objects/InteractionState"; 9 | 10 | function OverlayInner() { 11 | const { 12 | startHandDrag, 13 | updateHandDragNormalized, 14 | endHandDrag, 15 | orbitRotate, 16 | orbitPan, 17 | orbitDolly, 18 | } = useViewportActions() as any; 19 | const { getConnectionStatus, getData, sendFrame, getAcknowledged } = 20 | useWebSocket(); 21 | const { captureFrame } = useVideoStream(); 22 | const canvasRef = useRef(null); 23 | const viewportRef = useRef(null); 24 | const ctxRef = useRef(null); 25 | const fpsRef = useRef(0); 26 | // no longer needed when using old-style loop 27 | const interactionRef = useRef({ 28 | Left: null, 29 | Right: null, 30 | angleBetween: 0, 31 | }); 32 | const prevPinchingRef = useRef(false); 33 | 34 | const { processHands } = useSkeleton({ 35 | overlayCanvasRef: canvasRef, 36 | fpsRef, 37 | updateInteractionState: (s: InteractionState) => { 38 | interactionRef.current = s; 39 | }, 40 | }); 41 | 42 | // Use the Three.js renderer's canvas for hand overlay 43 | useEffect(() => { 44 | const getRendererCanvas = () => { 45 | const rendererCanvas = (interactionRef as any).canvasOverlayRef 46 | ?.current; 47 | if (rendererCanvas) { 48 | canvasRef.current = rendererCanvas; 49 | ctxRef.current = rendererCanvas.getContext("2d", { 50 | alpha: true, 51 | desynchronized: true, 52 | } as any) as CanvasRenderingContext2D | null; 53 | 54 | // Force initial size sync 55 | const mount = rendererCanvas.parentElement; 56 | if (mount) { 57 | const { clientWidth, clientHeight } = mount; 58 | rendererCanvas.width = clientWidth; 59 | rendererCanvas.height = clientHeight; 60 | rendererCanvas.style.width = `${clientWidth}px`; 61 | rendererCanvas.style.height = `${clientHeight}px`; 62 | } 63 | } 64 | }; 65 | 66 | // Try immediately and retry if not ready 67 | getRendererCanvas(); 68 | const interval = setInterval(() => { 69 | if (!canvasRef.current) getRendererCanvas(); 70 | else clearInterval(interval); 71 | }, 100); 72 | 73 | return () => clearInterval(interval); 74 | }, []); 75 | 76 | // Initialize cached 2D context 77 | useEffect(() => { 78 | const canvas = canvasRef.current; 79 | if (!canvas) return; 80 | // desynchronized hint can reduce latency on some browsers/GPUs 81 | ctxRef.current = 82 | (canvas.getContext("2d", { 83 | alpha: true, 84 | desynchronized: true, 85 | } as any) as CanvasRenderingContext2D | null) ?? null; 86 | }, []); 87 | 88 | // Main loop: draw every frame; capture/send runs asynchronously when acked 89 | useEffect(() => { 90 | let raf: number; 91 | let prevL: { x: number; y: number } | null = null; 92 | let prevR: { x: number; y: number } | null = null; 93 | let prevDist: number | null = null; 94 | const lastStatusRef = { value: "" } as { value: string }; 95 | const lastStatusUpdateRef = { value: 0 } as { value: number }; 96 | let capturing = false; 97 | 98 | // FPS counters 99 | const frameCountRef = { value: 0 } as { value: number }; 100 | const lastTimeRef = { value: Date.now() } as { value: number }; 101 | 102 | const tick = () => { 103 | // Throttle status updates (no await) 104 | const now = performance.now(); 105 | if (now - lastStatusUpdateRef.value > 250) { 106 | const s = getConnectionStatus(); 107 | // Status tracking removed - video now integrated into chat panel 108 | lastStatusRef.value = s; 109 | lastStatusUpdateRef.value = now; 110 | } 111 | 112 | // If server ready, start an async capture/send without blocking the loop 113 | if (getAcknowledged() && !capturing) { 114 | capturing = true; 115 | captureFrame() 116 | .then((frame) => { 117 | if (frame) sendFrame(frame); 118 | }) 119 | .finally(() => { 120 | capturing = false; 121 | }); 122 | } 123 | 124 | const canvas = canvasRef.current; 125 | if (canvas) { 126 | const ctx = 127 | ctxRef.current || 128 | (canvas.getContext( 129 | "2d" 130 | ) as CanvasRenderingContext2D | null); 131 | if (ctx) { 132 | if (!ctxRef.current) ctxRef.current = ctx; 133 | 134 | const data = getData() as any; 135 | if ( 136 | data && 137 | data.hands && 138 | canvas.width > 0 && 139 | canvas.height > 0 140 | ) { 141 | ctx.clearRect(0, 0, canvas.width, canvas.height); 142 | // Use original video dimensions since hand landmarks are normalized to that 143 | const size = { 144 | width: 640, // Original video width 145 | height: 360, // Original video height 146 | }; 147 | processHands(data.hands, size, ctx); 148 | 149 | const anyPinch = Boolean( 150 | (interactionRef.current.Left && 151 | interactionRef.current.Left.isPinching) || 152 | (interactionRef.current.Right && 153 | interactionRef.current.Right.isPinching) 154 | ); 155 | if (anyPinch && !prevPinchingRef.current) 156 | startHandDrag(); 157 | const refHand = 158 | interactionRef.current.Right || 159 | interactionRef.current.Left; 160 | if (refHand && refHand.cursor) { 161 | const u = Math.max( 162 | 0, 163 | Math.min( 164 | 1, 165 | refHand.cursor.coords.x / size.width 166 | ) 167 | ); 168 | const v = Math.max( 169 | 0, 170 | Math.min( 171 | 1, 172 | 1 - refHand.cursor.coords.y / size.height 173 | ) 174 | ); 175 | updateHandDragNormalized(u, v); 176 | } 177 | const L = interactionRef.current.Left; 178 | const R = interactionRef.current.Right; 179 | const lHold = Boolean(L?.isHolding); 180 | const rHold = Boolean(R?.isHolding); 181 | 182 | if (lHold && rHold && L?.cursor && R?.cursor) { 183 | const dxPan = 184 | (L.cursor.coords.x - 185 | (prevL?.x ?? L.cursor.coords.x)) / 186 | size.width; 187 | const dyPan = 188 | (L.cursor.coords.y - 189 | (prevL?.y ?? L.cursor.coords.y)) / 190 | size.height; 191 | orbitPan(dxPan, dyPan); 192 | prevL = { 193 | x: L.cursor.coords.x, 194 | y: L.cursor.coords.y, 195 | }; 196 | 197 | const currDist = Math.hypot( 198 | R.cursor.coords.x - L.cursor.coords.x, 199 | R.cursor.coords.y - L.cursor.coords.y 200 | ); 201 | if (prevDist != null) { 202 | const deltaZoom = 203 | (prevDist - currDist) / size.width; 204 | orbitDolly(deltaZoom); 205 | } 206 | prevDist = currDist; 207 | } else if ( 208 | (lHold && L?.cursor) || 209 | (rHold && R?.cursor) 210 | ) { 211 | const H = rHold ? R! : L!; 212 | const prev = rHold ? prevR : prevL; 213 | const dx = 214 | (H.cursor!.coords.x - 215 | (prev?.x ?? H.cursor!.coords.x)) / 216 | size.width; 217 | const dy = 218 | (H.cursor!.coords.y - 219 | (prev?.y ?? H.cursor!.coords.y)) / 220 | size.height; 221 | orbitRotate(dx, dy); 222 | if (rHold) 223 | prevR = { 224 | x: H.cursor!.coords.x, 225 | y: H.cursor!.coords.y, 226 | }; 227 | else 228 | prevL = { 229 | x: H.cursor!.coords.x, 230 | y: H.cursor!.coords.y, 231 | }; 232 | prevDist = null; 233 | } else { 234 | prevL = prevR = null; 235 | prevDist = null; 236 | } 237 | if (!anyPinch && prevPinchingRef.current) endHandDrag(); 238 | prevPinchingRef.current = anyPinch; 239 | } 240 | 241 | // FPS calc 242 | frameCountRef.value += 1; 243 | const deltaMs = Date.now() - lastTimeRef.value; 244 | if (deltaMs >= 1000) { 245 | fpsRef.current = frameCountRef.value; 246 | frameCountRef.value = 0; 247 | lastTimeRef.value = Date.now(); 248 | } 249 | } 250 | } 251 | 252 | raf = requestAnimationFrame(tick); 253 | }; 254 | 255 | raf = requestAnimationFrame(tick); 256 | return () => cancelAnimationFrame(raf); 257 | }, [ 258 | getConnectionStatus, 259 | getAcknowledged, 260 | captureFrame, 261 | sendFrame, 262 | getData, 263 | processHands, 264 | startHandDrag, 265 | updateHandDragNormalized, 266 | endHandDrag, 267 | orbitRotate, 268 | orbitPan, 269 | orbitDolly, 270 | ]); 271 | 272 | // Force rerenders on status changes; data is consumed directly in loop 273 | 274 | return ( 275 |
284 |
294 | 295 | 298 | 299 |
300 |
301 | ); 302 | } 303 | 304 | export default function HolohandsOverlay() { 305 | const wsUrl = "ws://localhost:6969/ws"; // adjust if needed 306 | return ( 307 | 308 | 309 | 310 | ); 311 | } 312 | -------------------------------------------------------------------------------- /frontend/src/agent/tools.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { nanoid } from "nanoid"; 3 | import { useEditor } from "../store/editor"; 4 | import type { SceneObject } from "../types"; 5 | import { buildSceneFromObjects, exportSceneToGLB } from "../utils/io"; 6 | import type { Diff, SceneSnapshot, ToolResult } from "./types"; 7 | 8 | const materialRegistry: Record = {}; 9 | 10 | const COLOR_MAP: Record = { 11 | red: "#ff4d4f", 12 | green: "#52c41a", 13 | blue: "#1890ff", 14 | yellow: "#fadb14", 15 | orange: "#fa8c16", 16 | purple: "#722ed1", 17 | pink: "#eb2f96", 18 | white: "#ffffff", 19 | black: "#000000", 20 | gray: "#8c8c8c", 21 | }; 22 | 23 | function nowState() { 24 | return (useEditor as any).getState?.(); 25 | } 26 | 27 | function snapshot(): SceneSnapshot { 28 | const st = nowState(); 29 | const objects: SceneObject[] = st?.objects ?? []; 30 | const selectedId: string | null = st?.selectedId ?? null; 31 | const entities = objects.map((o) => ({ 32 | id: o.id, 33 | name: o.name, 34 | type: "mesh" as const, 35 | transform: { 36 | position: [o.position.x, o.position.y, o.position.z] as [number, number, number], 37 | rotation: [o.rotation.x, o.rotation.y, o.rotation.z] as [number, number, number], 38 | scale: [o.scale.x, o.scale.y, o.scale.z] as [number, number, number], 39 | }, 40 | geom: mapGeom(o), 41 | material: { 42 | id: o.id, 43 | name: o.name + " Material", 44 | baseColorHex: o.material.color, 45 | metalness: o.material.metalness, 46 | roughness: o.material.roughness, 47 | }, 48 | })); 49 | const counts = { meshes: objects.length, lights: (st?.lights ?? []).length, cameras: 0 }; 50 | return { 51 | units: "meters", 52 | upAxis: "Y", 53 | counts, 54 | selection: selectedId ? [selectedId] : [], 55 | entities, 56 | capabilities: [ 57 | "get_scene_summary", 58 | "find", 59 | "get_selection", 60 | "create_primitive", 61 | "select", 62 | "transform", 63 | "duplicate", 64 | "delete", 65 | "create_material", 66 | "assign_material", 67 | "export_glb", 68 | "image_to_3d", 69 | ], 70 | }; 71 | } 72 | 73 | function mapGeom(o: SceneObject): SceneSnapshot["entities"][number]["geom"] { 74 | switch (o.geometry) { 75 | case "box": 76 | return { kind: "cube" }; 77 | case "sphere": 78 | return { kind: "sphere" }; 79 | case "plane": 80 | return { kind: "plane" }; 81 | case "cylinder": 82 | return { kind: "cylinder" }; 83 | default: 84 | return {}; 85 | } 86 | } 87 | 88 | function diff(beforeIds: string[], afterIds: string[], updatedIds: string[] = []): Diff { 89 | const before = new Set(beforeIds); 90 | const after = new Set(afterIds); 91 | const addedIds = Array.from(after).filter((id) => !before.has(id)); 92 | const removedIds = Array.from(before).filter((id) => !after.has(id)); 93 | return { addedIds, removedIds, updatedIds }; 94 | } 95 | 96 | function idsOfObjects(): string[] { 97 | const st = nowState(); 98 | return ((st?.objects ?? []) as SceneObject[]).map((o) => o.id); 99 | } 100 | 101 | function ok(partial: Partial = {}): ToolResult { 102 | const res = { success: true, diagnostics: [], changes: { addedIds: [], removedIds: [], updatedIds: [] }, snapshot: snapshot(), ...partial } as ToolResult; 103 | return res; 104 | } 105 | 106 | function fail(diags: string[]): ToolResult { 107 | const res = { success: false, diagnostics: diags, snapshot: snapshot() } as ToolResult; 108 | return res; 109 | } 110 | 111 | // READ 112 | export function get_scene_summary(): ToolResult { 113 | const s = ok(); 114 | console.log("[Tool] get_scene_summary -> counts:", s.snapshot?.counts); 115 | return s; 116 | } 117 | 118 | export function find(args: { type?: "mesh" | "light" | "camera"; nameContains?: string; tag?: string }): ToolResult { 119 | const st = nowState(); 120 | const objects: SceneObject[] = st?.objects ?? []; 121 | const subset = objects.filter((o) => (args?.nameContains ? o.name.toLowerCase().includes(args.nameContains.toLowerCase()) : true)); 122 | const ids = subset.map((o) => o.id); 123 | console.log("[Tool] find:", args, "->", ids); 124 | return ok({ payload: { ids } }); 125 | } 126 | 127 | export function get_selection(): ToolResult { 128 | const st = nowState(); 129 | const id = st?.selectedId ?? null; 130 | console.log("[Tool] get_selection ->", id ? [id] : []); 131 | return ok({ payload: { ids: id ? [id] : [] } }); 132 | } 133 | 134 | // WRITE 135 | export function create_primitive(args: { kind: "cube" | "sphere" | "plane" | "cylinder" | "cone"; name?: string; dims?: { x?: number; y?: number; z?: number; radius?: number; height?: number }; parentId?: string }): ToolResult { 136 | console.log("[Tool] create_primitive:", args); 137 | const before = idsOfObjects(); 138 | const st = nowState(); 139 | if (!st) return fail(["state_unavailable"]); 140 | const kMap: Record = { cube: "box", sphere: "sphere", plane: "plane", cylinder: "cylinder", cone: "cone" }; 141 | const kind = args?.kind as keyof typeof kMap; 142 | if (!kind || !(kind in kMap)) return fail(["unsupported_kind"]); 143 | 144 | const addObject = st.addObject as (kind: any, params?: any) => void; 145 | const updateName = st.updateName as (id: string, name: string) => void; 146 | addObject(kMap[kind] as any, toParams(kMap[kind] as any, args?.dims)); 147 | const after = idsOfObjects(); 148 | const d = diff(before, after); 149 | const newId = d.addedIds[0]; 150 | if (newId && args?.name && updateName) updateName(newId, args.name); 151 | const res = ok({ changes: d }); 152 | console.log("[Tool] create_primitive diff:", d); 153 | return res; 154 | } 155 | 156 | function toParams(kind: "box" | "sphere" | "plane" | "cylinder" | "cone", dims: any) { 157 | if (!dims) return undefined; 158 | if (kind === "box") return { width: dims.x ?? 1, height: dims.y ?? 1, depth: dims.z ?? 1 }; 159 | if (kind === "sphere") return { radius: dims.radius ?? 0.5 }; 160 | if (kind === "plane") return { width: dims.x ?? 1, height: dims.y ?? 1 }; 161 | if (kind === "cylinder") return { radiusTop: dims.radius ?? 0.5, radiusBottom: dims.radius ?? 0.5, height: dims.height ?? 1 }; 162 | if (kind === "cone") return { radius: dims.radius ?? 0.5, height: dims.height ?? 1 }; 163 | return undefined; 164 | } 165 | 166 | export function select(args: { ids?: string[]; query?: { type?: string; nameContains?: string } }): ToolResult { 167 | console.log("[Tool] select:", args); 168 | const st = nowState(); 169 | if (!st) return fail(["state_unavailable"]); 170 | const selectFn = st.select as (id: string | null) => void; 171 | let ids = args?.ids ?? []; 172 | if ((!ids || ids.length === 0) && args?.query?.nameContains) { 173 | const res = find({ nameContains: args.query.nameContains }).payload?.ids || []; 174 | ids = res; 175 | } 176 | selectFn(ids && ids.length ? ids[0] : null); 177 | const out = ok({ changes: { addedIds: [], removedIds: [], updatedIds: [] } }); 178 | console.log("[Tool] select ->", ids && ids.length ? ids[0] : null); 179 | return out; 180 | } 181 | 182 | export function transform(args: { id: string; translate?: [number, number, number]; rotate?: [number, number, number]; scale?: [number, number, number]; space?: "world" | "local" }): ToolResult { 183 | console.log("[Tool] transform:", args); 184 | const st = nowState(); 185 | if (!st) return fail(["state_unavailable"]); 186 | const obj = (st.objects as SceneObject[]).find((o) => o.id === args?.id); 187 | if (!obj) return fail(["not_found"]); 188 | 189 | if (args?.scale) { 190 | const sx = Math.abs(args.scale[0] ?? 1); 191 | const sy = Math.abs(args.scale[1] ?? 1); 192 | const sz = Math.abs(args.scale[2] ?? 1); 193 | if (sx > 100 || sy > 100 || sz > 100) return fail(["scale_exceeds_cap_100x"]); 194 | } 195 | 196 | const before = idsOfObjects(); 197 | const updateTransform = st.updateTransform as (id: string, partial: any) => void; 198 | const partial: any = {}; 199 | if (args?.translate) partial.position = { x: obj.position.x + args.translate[0], y: obj.position.y + args.translate[1], z: obj.position.z + args.translate[2] }; 200 | if (args?.rotate) partial.rotation = { x: obj.rotation.x + args.rotate[0], y: obj.rotation.y + args.rotate[1], z: obj.rotation.z + args.rotate[2] }; 201 | if (args?.scale) partial.scale = { x: obj.scale.x * args.scale[0], y: obj.scale.y * args.scale[1], z: obj.scale.z * args.scale[2] }; 202 | updateTransform(obj.id, partial); 203 | const after = idsOfObjects(); 204 | const d = diff(before, after, [obj.id]); 205 | console.log("[Tool] transform diff:", d); 206 | return ok({ changes: d }); 207 | } 208 | 209 | export function duplicate(args: { ids: string[]; withChildren?: boolean }): ToolResult { 210 | console.log("[Tool] duplicate:", args); 211 | const st = nowState(); 212 | if (!st) return fail(["state_unavailable"]); 213 | const ids = args?.ids ?? []; 214 | if (ids.length !== 1) return fail(["duplicate_requires_single_selection_mvp"]); 215 | const sel = st.select as (id: string | null) => void; 216 | sel(ids[0]); 217 | const before = idsOfObjects(); 218 | (st.duplicateSelected as () => void)(); 219 | const after = idsOfObjects(); 220 | const d = diff(before, after); 221 | console.log("[Tool] duplicate diff:", d); 222 | return ok({ changes: d }); 223 | } 224 | 225 | export function del(args: { ids: string[] }): ToolResult { 226 | console.log("[Tool] delete:", args); 227 | const st = nowState(); 228 | if (!st) return fail(["state_unavailable"]); 229 | const ids = args?.ids ?? []; 230 | if (ids.length !== 1) return fail(["delete_requires_single_selection_mvp"]); 231 | const sel = st.select as (id: string | null) => void; 232 | sel(ids[0]); 233 | const before = idsOfObjects(); 234 | ;(st.deleteSelected as () => void)(); 235 | const after = idsOfObjects(); 236 | const d = diff(before, after); 237 | console.log("[Tool] delete diff:", d); 238 | return ok({ changes: d }); 239 | } 240 | 241 | export function create_material(args: { name: string; pbr: { baseColorHex?: string; metalness?: number; roughness?: number } }): ToolResult { 242 | const id = nanoid(6); 243 | materialRegistry[id] = { id, name: args?.name || "Material", pbr: args?.pbr || {} }; 244 | console.log("[Tool] create_material ->", id); 245 | return ok({ payload: { id } }); 246 | } 247 | 248 | export function assign_material(args: { id: string; materialId: string }): ToolResult { 249 | console.log("[Tool] assign_material:", args); 250 | const st = nowState(); 251 | if (!st) return fail(["state_unavailable"]); 252 | const obj = (st.objects as SceneObject[]).find((o) => o.id === args?.id); 253 | if (!obj) return fail(["not_found"]); 254 | const updateMaterial = st.updateMaterial as (id: string, partial: any) => void; 255 | 256 | let pbr: { baseColorHex?: string; metalness?: number; roughness?: number } | null = null; 257 | const reg = materialRegistry[args.materialId]; 258 | if (reg) pbr = reg.pbr; 259 | else { 260 | const lower = (args.materialId || "").toLowerCase(); 261 | const hex = lower.startsWith("#") ? lower : (COLOR_MAP[lower] || null); 262 | if (hex) pbr = { baseColorHex: hex }; 263 | } 264 | if (!pbr) return fail(["material_not_found"]); 265 | 266 | updateMaterial(obj.id, { 267 | color: pbr.baseColorHex, 268 | metalness: typeof pbr.metalness === "number" ? pbr.metalness : undefined, 269 | roughness: typeof pbr.roughness === "number" ? pbr.roughness : undefined, 270 | }); 271 | const res = ok({ changes: { addedIds: [], removedIds: [], updatedIds: [obj.id] } }); 272 | console.log("[Tool] assign_material -> updated", obj.id); 273 | return res; 274 | } 275 | 276 | export async function export_glb(args: { ids?: string[]; embedTextures?: boolean }): Promise { 277 | console.log("[Tool] export_glb:", args); 278 | try { 279 | const st = nowState(); 280 | if (!st) return fail(["state_unavailable"]); 281 | const scene = buildSceneFromObjects(st.objects as SceneObject[]); 282 | await exportSceneToGLB(scene, "scene.glb"); 283 | const res = ok(); 284 | console.log("[Tool] export_glb -> done"); 285 | return res; 286 | } catch (e: any) { 287 | console.error("[Tool] export_glb error", e); 288 | return fail(["export_failed", String(e?.message || e)]); 289 | } 290 | } 291 | 292 | export function image_to_3d(args: { imageRef: string; outFormat: "glb" | "obj" }): ToolResult { 293 | console.log("[Tool] image_to_3d (stub):", args); 294 | return { success: true, diagnostics: ["job_queued"], snapshot: snapshot() }; 295 | } 296 | 297 | export function update_name(args: { id: string; name: string }): ToolResult { 298 | console.log("[Tool] update_name:", args); 299 | const st = nowState(); 300 | if (!st) return fail(["state_unavailable"]); 301 | const u = st.updateName as (id: string, name: string) => void; 302 | if (!args?.id || !args?.name) return fail(["missing_args"]); 303 | u(args.id, args.name); 304 | return ok({ changes: { addedIds: [], removedIds: [], updatedIds: [args.id] } }); 305 | } -------------------------------------------------------------------------------- /frontend/src/components/ShapeIcons.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface ShapeIconProps { 4 | size?: number; 5 | color?: string; 6 | } 7 | 8 | export const BoxIcon: React.FC = ({ 9 | size = 24, 10 | color = "#e6e9ef", 11 | }) => ( 12 | 13 | {/* 3D Cube with proper perspective */} 14 | {/* Front face */} 15 | 24 | {/* Top face */} 25 | 31 | {/* Right face */} 32 | 38 | {/* Bottom face */} 39 | 45 | {/* Left face */} 46 | 52 | {/* Back face (dashed for depth) */} 53 | 61 | 62 | ); 63 | 64 | export const SphereIcon: React.FC = ({ 65 | size = 24, 66 | color = "#e6e9ef", 67 | }) => ( 68 | 69 | {/* 3D Sphere with longitude/latitude lines */} 70 | 79 | {/* Longitude lines */} 80 | 90 | 100 | {/* Latitude lines */} 101 | 111 | 121 | 122 | ); 123 | 124 | export const CylinderIcon: React.FC = ({ 125 | size = 24, 126 | color = "#e6e9ef", 127 | }) => ( 128 | 129 | {/* 3D Cylinder */} 130 | 139 | 148 | {/* Side lines */} 149 | 150 | 151 | {/* Inner ellipse for depth */} 152 | 162 | 163 | ); 164 | 165 | export const ConeIcon: React.FC = ({ 166 | size = 24, 167 | color = "#e6e9ef", 168 | }) => ( 169 | 170 | {/* 3D Cone */} 171 | 180 | {/* Cone sides */} 181 | 182 | 183 | {/* Inner ellipse for depth */} 184 | 194 | 195 | ); 196 | 197 | export const TorusIcon: React.FC = ({ 198 | size = 24, 199 | color = "#e6e9ef", 200 | }) => ( 201 | 202 | {/* 3D Torus - outer ring */} 203 | 212 | {/* Inner ring */} 213 | 222 | {/* Perspective lines */} 223 | 233 | 243 | 244 | ); 245 | 246 | export const PlaneIcon: React.FC = ({ 247 | size = 24, 248 | color = "#e6e9ef", 249 | }) => ( 250 | 251 | {/* Flat plane - main rectangle */} 252 | 261 | {/* Grid lines to show it's a flat surface */} 262 | 271 | 280 | 289 | 298 | 307 | 316 | {/* Corner indicators to show it's flat */} 317 | 318 | 319 | 320 | 321 | 322 | ); 323 | 324 | export const DuplicateIcon: React.FC = ({ 325 | size = 24, 326 | color = "#e6e9ef", 327 | }) => ( 328 | 329 | {/* Copy icon */} 330 | 339 | 348 | 349 | ); 350 | 351 | export const DeleteIcon: React.FC = ({ 352 | size = 24, 353 | color = "#e6e9ef", 354 | }) => ( 355 | 356 | {/* Trash can icon */} 357 | 358 | 364 | 372 | 380 | 381 | ); 382 | 383 | export const DirectionalLightIcon: React.FC = ({ 384 | size = 24, 385 | color = "#e6e9ef", 386 | }) => ( 387 | 388 | {/* Sun with rays */} 389 | 397 | {/* Sun rays */} 398 | 399 | 407 | 408 | 416 | 424 | 432 | 440 | 448 | 449 | ); 450 | 451 | export const PointLightIcon: React.FC = ({ 452 | size = 24, 453 | color = "#e6e9ef", 454 | }) => ( 455 | 456 | {/* Light bulb */} 457 | 465 | {/* Light rays emanating outward */} 466 | 475 | 484 | {/* Base of light bulb */} 485 | 494 | 495 | ); 496 | 497 | export const SpotLightIcon: React.FC = ({ 498 | size = 24, 499 | color = "#e6e9ef", 500 | }) => ( 501 | 502 | {/* Spotlight cone */} 503 | 509 | {/* Light beam */} 510 | 517 | {/* Base */} 518 | 527 | 528 | ); 529 | 530 | export const AmbientLightIcon: React.FC = ({ 531 | size = 24, 532 | color = "#e6e9ef", 533 | }) => ( 534 | 535 | {/* Ambient light symbol - soft glow */} 536 | 545 | 554 | 563 | {/* Soft rays */} 564 | 571 | 572 | ); 573 | -------------------------------------------------------------------------------- /frontend/src/store/editor.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { nanoid } from "nanoid"; 3 | import { produce } from "immer"; 4 | import type { 5 | EditorState, 6 | GeometryKind, 7 | GeometryParamsMap, 8 | HistoryState, 9 | SceneObject, 10 | SceneLight, 11 | SnapSettings, 12 | TransformMode, 13 | Vector3, 14 | Euler, 15 | EditorMode, 16 | LightType, 17 | LightProps, 18 | } from "../types"; 19 | import * as THREE from "three"; 20 | import { CSG } from "three-csg-ts"; 21 | import { serializeGeometry } from "../utils/geometry"; 22 | 23 | function createDefaultMaterial() { 24 | return { 25 | color: "#9aa7ff", 26 | metalness: 0.1, 27 | roughness: 0.8, 28 | opacity: 1, 29 | transparent: false, 30 | }; 31 | } 32 | 33 | function createVector3(x = 0, y = 0, z = 0): Vector3 { 34 | return { x, y, z }; 35 | } 36 | 37 | function createEuler(x = 0, y = 0, z = 0): Euler { 38 | return { x, y, z }; 39 | } 40 | 41 | function createDefaultLightProps(type: LightType): LightProps { 42 | switch (type) { 43 | case "directional": 44 | return { 45 | color: "#ffffff", 46 | intensity: 1, 47 | }; 48 | case "point": 49 | return { 50 | color: "#ffffff", 51 | intensity: 1, 52 | distance: 0, 53 | decay: 2, 54 | }; 55 | case "spot": 56 | return { 57 | color: "#ffffff", 58 | intensity: 1, 59 | distance: 0, 60 | angle: Math.PI / 3, 61 | penumbra: 0, 62 | decay: 2, 63 | }; 64 | case "ambient": 65 | return { 66 | color: "#ffffff", 67 | intensity: 0.4, 68 | }; 69 | default: 70 | return { 71 | color: "#ffffff", 72 | intensity: 1, 73 | }; 74 | } 75 | } 76 | 77 | function createLight(type: LightType): SceneLight { 78 | return { 79 | id: nanoid(8), 80 | name: `${type.charAt(0).toUpperCase() + type.slice(1)} Light`, 81 | type, 82 | position: createVector3(0, 2, 0), 83 | rotation: createEuler(0, 0, 0), 84 | props: createDefaultLightProps(type), 85 | visible: true, 86 | castShadow: type !== "ambient", 87 | }; 88 | } 89 | 90 | function buildGeometryFromObject(o: SceneObject): THREE.BufferGeometry { 91 | switch (o.geometry) { 92 | case "box": { 93 | const p = o.geometryParams as GeometryParamsMap["box"] | undefined; 94 | return new THREE.BoxGeometry( 95 | p?.width ?? 1, 96 | p?.height ?? 1, 97 | p?.depth ?? 1 98 | ); 99 | } 100 | case "sphere": { 101 | const p = o.geometryParams as 102 | | GeometryParamsMap["sphere"] 103 | | undefined; 104 | const geometry = new THREE.SphereGeometry(p?.radius ?? 0.5, 8, 8); 105 | // Ensure smooth shading 106 | geometry.computeVertexNormals(); 107 | return geometry; 108 | } 109 | case "cylinder": { 110 | const p = o.geometryParams as 111 | | GeometryParamsMap["cylinder"] 112 | | undefined; 113 | return new THREE.CylinderGeometry( 114 | p?.radiusTop ?? 0.5, 115 | p?.radiusBottom ?? 0.5, 116 | p?.height ?? 1, 117 | 32 118 | ); 119 | } 120 | case "cone": { 121 | const p = o.geometryParams as GeometryParamsMap["cone"] | undefined; 122 | return new THREE.ConeGeometry(p?.radius ?? 0.5, p?.height ?? 1, 32); 123 | } 124 | case "torus": { 125 | const p = o.geometryParams as 126 | | GeometryParamsMap["torus"] 127 | | undefined; 128 | const geometry = new THREE.TorusGeometry( 129 | p?.radius ?? 0.5, 130 | p?.tube ?? 0.2, 131 | 8, 132 | 16 133 | ); 134 | // Ensure smooth shading 135 | geometry.computeVertexNormals(); 136 | return geometry; 137 | } 138 | case "plane": { 139 | const p = o.geometryParams as 140 | | GeometryParamsMap["plane"] 141 | | undefined; 142 | return new THREE.PlaneGeometry(p?.width ?? 1, p?.height ?? 1); 143 | } 144 | case "custom": { 145 | const g = new THREE.BufferGeometry(); 146 | return g; 147 | } 148 | default: 149 | return new THREE.BoxGeometry(1, 1, 1); 150 | } 151 | } 152 | 153 | function createObject( 154 | kind: K, 155 | name?: string, 156 | params?: GeometryParamsMap[K] 157 | ): SceneObject { 158 | return { 159 | id: nanoid(8), 160 | name: name ?? kind.charAt(0).toUpperCase() + kind.slice(1), 161 | geometry: kind, 162 | geometryParams: params as GeometryParamsMap[keyof GeometryParamsMap], 163 | position: createVector3(), 164 | rotation: createEuler(), 165 | scale: createVector3(1, 1, 1), 166 | material: createDefaultMaterial(), 167 | visible: true, 168 | locked: false, 169 | }; 170 | } 171 | 172 | function snapshot(state: EditorState): HistoryState { 173 | return { 174 | objects: JSON.parse(JSON.stringify(state.objects)), 175 | selectedId: state.selectedId, 176 | }; 177 | } 178 | 179 | const initialState: EditorState & { 180 | isTransforming?: boolean; 181 | isGizmoInteracting?: boolean; 182 | } = { 183 | objects: [], 184 | lights: [], 185 | selectedId: null, 186 | mode: "translate", 187 | editorMode: "object", 188 | snap: { 189 | enableSnapping: false, 190 | translateSnap: 0.5, 191 | rotateSnap: Math.PI / 12, 192 | scaleSnap: 0.1, 193 | }, 194 | past: [], 195 | future: [], 196 | isTransforming: false, 197 | isGizmoInteracting: false, 198 | checkpoints: [], 199 | }; 200 | 201 | // Allow nested partials for transform updates 202 | type TransformPartial = { 203 | position?: Partial; 204 | rotation?: Partial; 205 | scale?: Partial; 206 | }; 207 | 208 | interface EditorStore extends EditorState { 209 | addObject: ( 210 | kind: K, 211 | params?: GeometryParamsMap[K] 212 | ) => void; 213 | deleteSelected: () => void; 214 | duplicateSelected: () => void; 215 | select: (id: string | null) => void; 216 | setMode: (mode: TransformMode) => void; 217 | setEditorMode: (mode: EditorMode) => void; 218 | beginTransform: () => void; 219 | endTransform: () => void; 220 | updateTransform: (id: string, partial: TransformPartial) => void; 221 | updateMaterial: ( 222 | id: string, 223 | partial: Partial 224 | ) => void; 225 | updateName: (id: string, name: string) => void; 226 | updateGeometry: ( 227 | id: string, 228 | kind: K, 229 | params?: GeometryParamsMap[K] 230 | ) => void; 231 | addLight: (type: LightType) => void; 232 | updateLightTransform: (id: string, partial: TransformPartial) => void; 233 | updateLightProps: (id: string, partial: Partial) => void; 234 | deleteLight: (id: string) => void; 235 | toggleSnap: (enabled: boolean) => void; 236 | setSnap: (partial: Partial) => void; 237 | booleanOp: ( 238 | op: "union" | "subtract" | "intersect", 239 | aId: string, 240 | bId: string 241 | ) => void; 242 | addCheckpoint: (meta: { 243 | label?: string; 244 | prompt?: string; 245 | response?: string; 246 | }) => void; 247 | restoreCheckpoint: (id: string) => void; 248 | deleteCheckpoint: (id: string) => void; 249 | undo: () => void; 250 | redo: () => void; 251 | clear: () => void; 252 | setGizmoInteracting: (interacting: boolean) => void; 253 | showChatPanel: boolean; 254 | setShowChatPanel: (visible: boolean) => void; 255 | toggleChatPanel: () => void; 256 | showInspector: boolean; 257 | setShowInspector: (visible: boolean) => void; 258 | toggleInspector: () => void; 259 | } 260 | 261 | export const useEditor = create()((set) => ({ 262 | ...initialState, 263 | showChatPanel: true, 264 | showInspector: false, 265 | addObject: (kind, params) => 266 | set((state) => { 267 | const newObj = createObject(kind, undefined, params); 268 | const next = produce(state, (draft) => { 269 | draft.past.push(snapshot(state)); 270 | draft.future = []; 271 | draft.objects.push(newObj); 272 | draft.selectedId = newObj.id; 273 | }); 274 | return next; 275 | }), 276 | deleteSelected: () => 277 | set((state) => { 278 | if (!state.selectedId) return state; 279 | const next = produce(state, (draft) => { 280 | draft.past.push(snapshot(state)); 281 | draft.future = []; 282 | draft.objects = draft.objects.filter( 283 | (o) => o.id !== state.selectedId 284 | ); 285 | draft.selectedId = null; 286 | }); 287 | return next; 288 | }), 289 | duplicateSelected: () => 290 | set((state) => { 291 | const id = state.selectedId; 292 | if (!id) return state; 293 | const original = state.objects.find((o) => o.id === id); 294 | if (!original) return state; 295 | const copy: SceneObject = JSON.parse(JSON.stringify(original)); 296 | copy.id = nanoid(8); 297 | copy.name = original.name + " Copy"; 298 | copy.position = { 299 | ...copy.position, 300 | x: copy.position.x + 0.5, 301 | y: copy.position.y + 0.5, 302 | }; 303 | const next = produce(state, (draft) => { 304 | draft.past.push(snapshot(state)); 305 | draft.future = []; 306 | draft.objects.push(copy); 307 | draft.selectedId = copy.id; 308 | }); 309 | return next; 310 | }), 311 | select: (id) => 312 | set((state) => ({ 313 | ...state, 314 | selectedId: id, 315 | showInspector: id !== null, // Open inspector when something is selected 316 | })), 317 | setMode: (mode) => set((state) => ({ ...state, mode })), 318 | setEditorMode: (mode) => set((state) => ({ ...state, editorMode: mode })), 319 | setGizmoInteracting: (interacting) => 320 | set((state) => ({ ...state, isGizmoInteracting: interacting })), 321 | setShowChatPanel: (visible) => 322 | set((state) => ({ ...state, showChatPanel: visible })), 323 | toggleChatPanel: () => 324 | set((state) => ({ ...state, showChatPanel: !state.showChatPanel })), 325 | setShowInspector: (visible) => 326 | set((state) => ({ ...state, showInspector: visible })), 327 | toggleInspector: () => 328 | set((state) => ({ ...state, showInspector: !state.showInspector })), 329 | beginTransform: () => 330 | set((state) => { 331 | if (state.isTransforming) return state; 332 | const next = produce(state, (draft) => { 333 | draft.past.push(snapshot(state as unknown as EditorState)); 334 | draft.future = []; 335 | draft.isTransforming = true; 336 | }); 337 | return next; 338 | }), 339 | endTransform: () => 340 | set((state) => 341 | produce(state, (draft) => { 342 | draft.isTransforming = false; 343 | }) 344 | ), 345 | updateTransform: (id, partial) => 346 | set((state) => { 347 | const next = produce(state, (draft) => { 348 | const obj = draft.objects.find((o: SceneObject) => o.id === id); 349 | if (!obj) return; 350 | if (partial.position) 351 | obj.position = { ...obj.position, ...partial.position }; 352 | if (partial.rotation) 353 | obj.rotation = { ...obj.rotation, ...partial.rotation }; 354 | if (partial.scale) 355 | obj.scale = { ...obj.scale, ...partial.scale }; 356 | }); 357 | return next; 358 | }), 359 | updateMaterial: (id, partial) => 360 | set((state) => 361 | produce(state, (draft) => { 362 | const obj = draft.objects.find((o) => o.id === id); 363 | if (obj) obj.material = { ...obj.material, ...partial }; 364 | }) 365 | ), 366 | updateName: (id, name) => 367 | set((state) => 368 | produce(state, (draft) => { 369 | const obj = draft.objects.find((o) => o.id === id); 370 | if (obj) obj.name = name; 371 | }) 372 | ), 373 | updateGeometry: ( 374 | id: string, 375 | kind: K, 376 | params?: GeometryParamsMap[K] 377 | ) => 378 | set((state) => 379 | produce(state, (draft) => { 380 | const obj = draft.objects.find((o) => o.id === id); 381 | if (!obj) return; 382 | obj.geometry = kind; 383 | obj.geometryParams = 384 | params as GeometryParamsMap[keyof GeometryParamsMap]; 385 | }) 386 | ), 387 | toggleSnap: (enabled) => 388 | set((state) => ({ 389 | ...state, 390 | snap: { ...state.snap, enableSnapping: enabled }, 391 | })), 392 | setSnap: (partial) => 393 | set((state) => ({ ...state, snap: { ...state.snap, ...partial } })), 394 | booleanOp: (op, aId, bId) => 395 | set((state) => { 396 | const a = state.objects.find((o) => o.id === aId); 397 | const b = state.objects.find((o) => o.id === bId); 398 | if (!a || !b) return state; 399 | const ga = buildGeometryFromObject(a); 400 | const gb = buildGeometryFromObject(b); 401 | const ma = new THREE.Mesh(ga); 402 | ma.position.set(a.position.x, a.position.y, a.position.z); 403 | ma.rotation.set(a.rotation.x, a.rotation.y, a.rotation.z); 404 | ma.scale.set(a.scale.x, a.scale.y, a.scale.z); 405 | ma.updateMatrixWorld(true); 406 | 407 | const mb = new THREE.Mesh(gb); 408 | mb.position.set(b.position.x, b.position.y, b.position.z); 409 | mb.rotation.set(b.rotation.x, b.rotation.y, b.rotation.z); 410 | mb.scale.set(b.scale.x, b.scale.y, b.scale.z); 411 | mb.updateMatrixWorld(true); 412 | 413 | let result: THREE.Mesh; 414 | if (op === "union") result = CSG.union(ma, mb); 415 | else if (op === "subtract") result = CSG.subtract(ma, mb); 416 | else result = CSG.intersect(ma, mb); 417 | 418 | const resultGeom = ( 419 | result.geometry as THREE.BufferGeometry 420 | ).clone(); 421 | resultGeom.computeVertexNormals(); 422 | const serial = serializeGeometry(resultGeom); 423 | 424 | const newObj = createObject("custom", "Boolean", serial); 425 | newObj.material = { ...a.material }; 426 | 427 | const next = produce(state, (draft) => { 428 | draft.past.push(snapshot(state)); 429 | draft.future = []; 430 | draft.objects.push(newObj); 431 | draft.selectedId = newObj.id; 432 | }); 433 | return next; 434 | }), 435 | addCheckpoint: (meta) => 436 | set((state) => 437 | produce(state, (draft) => { 438 | const id = nanoid(6); 439 | const label = 440 | meta.label ?? 441 | (meta.prompt ? meta.prompt.slice(0, 40) : "Checkpoint"); 442 | draft.checkpoints.unshift({ 443 | id, 444 | label, 445 | timestamp: Date.now(), 446 | prompt: meta.prompt, 447 | response: meta.response, 448 | state: snapshot(state), 449 | }); 450 | }) 451 | ), 452 | restoreCheckpoint: (id) => 453 | set((state) => 454 | produce(state, (draft) => { 455 | const cp = draft.checkpoints.find((c) => c.id === id); 456 | if (!cp) return; 457 | draft.past.push(snapshot(state)); 458 | draft.future = []; 459 | draft.objects = JSON.parse(JSON.stringify(cp.state.objects)); 460 | draft.selectedId = cp.state.selectedId; 461 | }) 462 | ), 463 | deleteCheckpoint: (id) => 464 | set((state) => 465 | produce(state, (draft) => { 466 | draft.checkpoints = draft.checkpoints.filter( 467 | (c) => c.id !== id 468 | ); 469 | }) 470 | ), 471 | undo: () => 472 | set((state) => { 473 | if (state.past.length === 0) return state; 474 | const prev = state.past[state.past.length - 1]; 475 | const next = produce(state, (draft) => { 476 | draft.future.unshift({ 477 | objects: state.objects, 478 | selectedId: state.selectedId, 479 | }); 480 | draft.past = state.past.slice(0, -1); 481 | draft.objects = JSON.parse(JSON.stringify(prev.objects)); 482 | draft.selectedId = prev.selectedId; 483 | }); 484 | return next; 485 | }), 486 | redo: () => 487 | set((state) => { 488 | if (state.future.length === 0) return state; 489 | const nextFuture = state.future[0]; 490 | const next = produce(state, (draft) => { 491 | draft.past.push(snapshot(state)); 492 | draft.future = state.future.slice(1); 493 | draft.objects = JSON.parse(JSON.stringify(nextFuture.objects)); 494 | draft.selectedId = nextFuture.selectedId; 495 | }); 496 | return next; 497 | }), 498 | clear: () => set(() => JSON.parse(JSON.stringify(initialState))), 499 | addLight: (type) => 500 | set((state) => { 501 | const newLight = createLight(type); 502 | console.log("Adding light:", newLight); 503 | const next = produce(state, (draft) => { 504 | draft.past.push(snapshot(state)); 505 | draft.future = []; 506 | draft.lights.push(newLight); 507 | draft.selectedId = newLight.id; 508 | }); 509 | console.log("Lights after adding:", next.lights); 510 | return next; 511 | }), 512 | updateLightTransform: (id, partial) => 513 | set((state) => 514 | produce(state, (draft) => { 515 | const light = draft.lights.find((l) => l.id === id); 516 | if (!light) return; 517 | if (partial.position) { 518 | light.position = { ...light.position, ...partial.position }; 519 | } 520 | if (partial.rotation) { 521 | light.rotation = { ...light.rotation, ...partial.rotation }; 522 | } 523 | if (partial.scale) { 524 | // Lights don't typically use scale, but we'll support it for consistency 525 | light.position = { ...light.position, ...partial.scale }; 526 | } 527 | }) 528 | ), 529 | updateLightProps: (id, partial) => 530 | set((state) => 531 | produce(state, (draft) => { 532 | const light = draft.lights.find((l) => l.id === id); 533 | if (!light) return; 534 | light.props = { ...light.props, ...partial }; 535 | }) 536 | ), 537 | deleteLight: (id) => 538 | set((state) => { 539 | const next = produce(state, (draft) => { 540 | draft.past.push(snapshot(state)); 541 | draft.future = []; 542 | draft.lights = draft.lights.filter((l) => l.id !== id); 543 | if (draft.selectedId === id) { 544 | draft.selectedId = null; 545 | } 546 | }); 547 | return next; 548 | }), 549 | })); 550 | --------------------------------------------------------------------------------