diff --git a/src/App.tsx b/src/App.tsx index 00a16eb..9875793 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,8 @@ import Index from "./pages/Index"; import Viewer from "./pages/Viewer"; import XRSession from "./pages/XRSession"; import NotFound from "./pages/NotFound"; +// DEVKIT: remove this import + route to strip devkit +import SmokeTest from "./devkit/SmokeTest"; const queryClient = new QueryClient(); @@ -20,6 +22,8 @@ const App = () => ( } /> } /> } /> + {/* DEVKIT: remove this route to strip devkit */} + } /> } /> diff --git a/src/devkit/DevPanel.tsx b/src/devkit/DevPanel.tsx index 63115c3..ae33ea0 100644 --- a/src/devkit/DevPanel.tsx +++ b/src/devkit/DevPanel.tsx @@ -1,24 +1,21 @@ /** - * DEVKIT — HTML overlay panel with diagnostics. + * DEVKIT — HTML overlay panel with diagnostics + macro recorder + export. * - * Tabs: - * - Inputs: live grip/trigger/pose values for both controllers (real or fake) - * - Logs: captures last 100 console.log/warn/error entries with filter - * - * Always visible when ?devkit=1, top-right corner. + * Tabs: Inputs · Cena · XR · Macro · Logs + * Export: snapshot JSON + canvas screenshot, both downloaded. */ import { useEffect, useRef, useState } from 'react'; import { fakeInput, isFakeActive } from './fakeInputStore'; +import { sceneStats } from './sceneStatsStore'; +import { + macroState, subscribeMacro, startRecording, stopRecording, + startPlaying, stopPlaying, downloadMacro, loadMacroFromFile, +} from './macroStore'; -interface LogEntry { - ts: number; - level: 'log' | 'warn' | 'error'; - msg: string; -} +interface LogEntry { ts: number; level: 'log' | 'warn' | 'error'; msg: string; } const logBuffer: LogEntry[] = []; let installed = false; - function installLogger() { if (installed) return; installed = true; @@ -38,7 +35,7 @@ function installLogger() { }); } -type Tab = 'inputs' | 'logs'; +type Tab = 'inputs' | 'cena' | 'xr' | 'macro' | 'logs'; interface DevPanelProps { simXR: boolean; @@ -50,9 +47,14 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) { const [collapsed, setCollapsed] = useState(false); const [, tick] = useState(0); const [logFilter, setLogFilter] = useState(''); + const fileRef = useRef(null); const rafRef = useRef(null); useEffect(() => { installLogger(); }, []); + useEffect(() => { + const unsub = subscribeMacro(() => tick((n) => n + 1)); + return () => { unsub; }; + }, []); useEffect(() => { let last = 0; @@ -64,6 +66,33 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) { return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, []); + const exportReport = async () => { + const canvas = document.querySelector('canvas'); + const screenshot = canvas ? canvas.toDataURL('image/png') : null; + const report = { + ts: new Date().toISOString(), + url: window.location.href, + userAgent: navigator.userAgent, + simXR, + fakeActive: isFakeActive(), + stats: { ...sceneStats }, + inputs: { + left: { grip: fakeInput.left.gripValue, trigger: fakeInput.left.triggerValue }, + right: { grip: fakeInput.right.gripValue, trigger: fakeInput.right.triggerValue }, + }, + logs: logBuffer.slice(-100), + }; + const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `devkit-report-${Date.now()}.json`; a.click(); + URL.revokeObjectURL(url); + if (screenshot) { + const a2 = document.createElement('a'); + a2.href = screenshot; a2.download = `devkit-screenshot-${Date.now()}.png`; a2.click(); + } + }; + if (collapsed) { return ( ))} - {/* Content */} -
+
{tab === 'inputs' && (
-
- fake: {isFakeActive() ? 'ATIVO' : 'inativo'} -
+
fake: {isFakeActive() ? 'ATIVO' : 'inativo'}
{(['left', 'right'] as const).map((h) => { const s = fakeInput[h]; const grabbing = s.gripValue > 0.7; @@ -133,6 +164,91 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
)} + + {tab === 'cena' && ( +
+ + + n.toFixed(3)).join(' × ')} /> + n.toFixed(3)).join(', ')} /> + n.toFixed(1)).join(', ')} /> + +
+ )} + + {tab === 'xr' && ( +
+ + + + + + + +
+ )} + + {tab === 'macro' && ( +
+
fase: {macroState.phase}
+
+ {macroState.phase !== 'recording' ? ( + + ) : ( + + )} + {macroState.phase !== 'playing' ? ( + + ) : ( + + )} + + + { + const f = e.target.files?.[0]; + if (!f) return; + try { + const macro = await loadMacroFromFile(f); + macroState.current = macro; + tick((n) => n + 1); + } catch { /* noop */ } + e.target.value = ''; + }} + /> +
+ {macroState.current && ( +
+
{macroState.current.name}
+
frames: {macroState.current.frames.length}
+
duração: {(macroState.current.duration / 1000).toFixed(1)}s
+
+ )} + {macroState.phase === 'recording' && ( +
gravando… {macroState.rec.length} frames
+ )} +
+ Use os atalhos do teclado para encenar o bug; depois clique em Parar e Salvar. + No próximo round, Carregar o JSON e clicar em Play reproduz exatamente. +
+
+ )} + {tab === 'logs' && (
{logBuffer .filter((l) => !logFilter || l.msg.toLowerCase().includes(logFilter.toLowerCase())) - .slice(-80) - .reverse() + .slice(-80).reverse() .map((l, i) => (
); } + +function Row({ k, v }: { k: string; v: string }) { + return ( +
+ {k} + {v} +
+ ); +} diff --git a/src/devkit/FakeControllers.tsx b/src/devkit/FakeControllers.tsx index 58e421f..eb22ae2 100644 --- a/src/devkit/FakeControllers.tsx +++ b/src/devkit/FakeControllers.tsx @@ -17,6 +17,7 @@ import { useEffect, useRef, useState } from 'react'; import { useFrame } from '@react-three/fiber'; import * as THREE from 'three'; import { fakeInput, setFakeActive } from './fakeInputStore'; +import { isPlaybackActive } from './macroStore'; const STEP = 0.03; // m per keypress @@ -35,6 +36,7 @@ export function FakeControllers() { const keys = new Set(); const apply = () => { + if (isPlaybackActive()) return; // macro playback owns inputs const pos = new THREE.Vector3(); const quat = new THREE.Quaternion(); const scl = new THREE.Vector3(); diff --git a/src/devkit/MacroDriver.tsx b/src/devkit/MacroDriver.tsx new file mode 100644 index 0000000..88505a4 --- /dev/null +++ b/src/devkit/MacroDriver.tsx @@ -0,0 +1,11 @@ +/** + * DEVKIT — Drives macro recording/playback inside the R3F frame loop. + * Mounted inside . No visual output. + */ +import { useFrame } from '@react-three/fiber'; +import { tickMacro } from './macroStore'; + +export function MacroDriver() { + useFrame(() => { tickMacro(); }); + return null; +} diff --git a/src/devkit/SceneStatsProbe.tsx b/src/devkit/SceneStatsProbe.tsx new file mode 100644 index 0000000..294c6cc --- /dev/null +++ b/src/devkit/SceneStatsProbe.tsx @@ -0,0 +1,47 @@ +/** + * DEVKIT — samples scene + XR session every frame, writes to sceneStatsStore. + * Mounted inside . No visual output. + */ +import { useEffect, useRef } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import { useXR } from '@react-three/xr'; +import { sceneStats, updateModelStats } from './sceneStatsStore'; + +export function SceneStatsProbe() { + const { scene } = useThree(); + const session = useXR((s) => s.session); + const frameCount = useRef(0); + const lastFpsTime = useRef(performance.now()); + + useEffect(() => { + sceneStats.xrMode = session ? 'immersive-ar' : null; + }, [session]); + + useFrame((_state, _dt, frame: XRFrame | undefined) => { + frameCount.current++; + const now = performance.now(); + if (now - lastFpsTime.current >= 500) { + sceneStats.fps = Math.round((frameCount.current * 1000) / (now - lastFpsTime.current)); + frameCount.current = 0; + lastFpsTime.current = now; + } + + // Find the grabbable model group by traversing for first userData marker, fallback: scan + let modelGroup: import('three').Object3D | null = null; + scene.traverse((o) => { + if (!modelGroup && o.userData?.__xrModelRoot) modelGroup = o; + }); + updateModelStats(modelGroup); + + if (frame) { + try { + const planes = (frame as unknown as { detectedPlanes?: { size?: number } }).detectedPlanes; + sceneStats.xrPlanesCount = planes?.size ?? 0; + } catch { sceneStats.xrPlanesCount = 0; } + sceneStats.xrInputCount = frame.session?.inputSources?.length ?? 0; + } + sceneStats.lastUpdate = now; + }); + + return null; +} diff --git a/src/devkit/SmokeTest.tsx b/src/devkit/SmokeTest.tsx new file mode 100644 index 0000000..f74467a --- /dev/null +++ b/src/devkit/SmokeTest.tsx @@ -0,0 +1,190 @@ +/** + * DEVKIT — `/devkit/smoke` smoke test runner. + * + * Scripted suite that exercises the store + demo pipeline without a headset. + * Run before sending a build to the Quest tester. All steps must be ✅. + */ +import { useState } from 'react'; +import { Link, Navigate } from 'react-router-dom'; +import { useModelStore } from '@/stores/useModelStore'; +import { generateDemoBeamGLB } from '@/lib/generateDemoBeam'; +import { useDevKit } from './useDevKit'; +import { fakeInput } from './fakeInputStore'; +import { startRecording, stopRecording, macroState } from './macroStore'; + +interface StepResult { name: string; ok: boolean; detail: string; ms: number; } + +type Step = { name: string; run: () => Promise }; + +function buildSteps(): Step[] { + const store = useModelStore.getState; + return [ + { + name: 'gerar demo beam (IPE 200)', + run: async () => { + const { blob, fileName, fileSize } = await generateDemoBeamGLB(); + const url = URL.createObjectURL(blob); + store().setModel({ url, fileName, fileSize }); + return `${fileName} • ${(fileSize / 1024).toFixed(1)} KB`; + }, + }, + { + name: 'modelo no store', + run: async () => { + const m = store().model; + if (!m?.url) throw new Error('model.url ausente'); + return m.fileName; + }, + }, + { + name: 'fineTuning reset', + run: async () => { + store().resetFineTuning(); + const ft = store().fineTuning; + if (ft.posX !== 0 || ft.scale !== 1) throw new Error('reset falhou'); + return 'ok'; + }, + }, + { + name: 'medição cria distância correta', + run: async () => { + store().clearMeasurements(); + store().addMeasurePoint({ x: 0, y: 0, z: 0 }); + store().addMeasurePoint({ x: 0.1, y: 0, z: 0 }); + const ms = store().measurements; + if (ms.length !== 1) throw new Error(`esperado 1 medição, obteve ${ms.length}`); + const d = ms[0].distanceMM; + if (Math.abs(d - 100) > 0.01) throw new Error(`esperado 100mm, obteve ${d.toFixed(2)}`); + return `${d.toFixed(2)} mm`; + }, + }, + { + name: 'checklist defaults', + run: async () => { + store().resetChecklist(); + const cl = store().checklist; + if (cl.length !== 4) throw new Error(`esperado 4 itens, obteve ${cl.length}`); + if (!cl.every(i => i.status === 'pending')) throw new Error('itens não-pending'); + return `${cl.length} itens`; + }, + }, + { + name: 'fake input grip simulado', + run: async () => { + fakeInput.left.gripValue = 1; + await new Promise(r => setTimeout(r, 50)); + if (fakeInput.left.gripValue !== 1) throw new Error('grip não foi setado'); + fakeInput.left.gripValue = 0; + return 'ok'; + }, + }, + { + name: 'macro recorder ciclo curto', + run: async () => { + startRecording(); + await new Promise(r => setTimeout(r, 200)); + const m = stopRecording(); + if (!m) throw new Error('macro vazia'); + if (macroState.phase !== 'idle') throw new Error('fase não voltou a idle'); + return `${m.frames.length} frames • ${m.duration.toFixed(0)}ms`; + }, + }, + { + name: 'navigator.xr disponível', + run: async () => { + if (!('xr' in navigator)) throw new Error('navigator.xr ausente'); + return 'ok'; + }, + }, + { + name: 'HTTPS ativo', + run: async () => { + if (location.protocol !== 'https:' && location.hostname !== 'localhost') { + throw new Error(`protocolo ${location.protocol}`); + } + return location.protocol; + }, + }, + ]; +} + +export default function SmokeTest() { + const devkit = useDevKit(); + const [results, setResults] = useState([]); + const [running, setRunning] = useState(false); + + if (!devkit) return ; + + const run = async () => { + setRunning(true); + setResults([]); + const steps = buildSteps(); + const out: StepResult[] = []; + for (const s of steps) { + const t0 = performance.now(); + try { + const detail = await s.run(); + out.push({ name: s.name, ok: true, detail, ms: performance.now() - t0 }); + } catch (e) { + out.push({ name: s.name, ok: false, detail: (e as Error).message, ms: performance.now() - t0 }); + } + setResults([...out]); + } + setRunning(false); + }; + + const passed = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok).length; + + return ( +
+
+
+

🧪 DevKit · Smoke Test

+ ← Home +
+

+ Roteiro automático que exercita store, demo, medição, checklist, fake input e gravador. + Execute antes de enviar build para o testador com Quest. +

+
+ + {results.length > 0 && ( + + ✓ {passed} + {' · '} + ✕ {failed} + + )} +
+ +
+ {results.length === 0 && ( +
aguardando execução…
+ )} + {results.map((r, i) => ( +
+
+ {r.ok ? '✓' : '✕'} + {r.name} +
{r.detail}
+
+ {r.ms.toFixed(0)}ms +
+ ))} +
+
+
+ ); +} diff --git a/src/devkit/XRDevHud.tsx b/src/devkit/XRDevHud.tsx new file mode 100644 index 0000000..fbe72c8 --- /dev/null +++ b/src/devkit/XRDevHud.tsx @@ -0,0 +1,92 @@ +/** + * DEVKIT — In-world expanded HUD anchored to left controller (or fake left). + * + * Replaces the production XRDebugHud while devkit is on. Shows inputs, FPS, + * model transform, plane count — readable inside the headset. + */ +import { useRef } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import { Text } from '@react-three/drei'; +import * as THREE from 'three'; +import { sceneStats } from './sceneStatsStore'; +import { fakeInput, isFakeActive } from './fakeInputStore'; + +const _m = new THREE.Matrix4(); +const _p = new THREE.Vector3(); +const _q = new THREE.Quaternion(); +const _s = new THREE.Vector3(); +const _off = new THREE.Vector3(0.10, 0.05, -0.05); // above wrist, away from face + +export function XRDevHud() { + const groupRef = useRef(null); + const textRef = useRef<{ text: string } & THREE.Object3D>(null); + const { camera, gl } = useThree(); + + useFrame((_state, _dt, frame: XRFrame | undefined) => { + if (!groupRef.current) return; + + let anchored = false; + + if (isFakeActive()) { + _m.copy(fakeInput.left.gripWorld); + anchored = true; + } else if (frame) { + const ref = gl.xr.getReferenceSpace(); + if (ref) { + for (const src of frame.session.inputSources) { + if (src.handedness === 'left' && src.gripSpace) { + const pose = frame.getPose(src.gripSpace, ref); + if (pose) { _m.fromArray(pose.transform.matrix); anchored = true; break; } + } + } + } + } + + if (anchored) { + _m.decompose(_p, _q, _s); + const off = _off.clone().applyQuaternion(_q); + groupRef.current.position.copy(_p).add(off); + groupRef.current.quaternion.copy(_q); + } else { + // fallback: HUD floats in front of camera + const off = new THREE.Vector3(-0.18, -0.10, -0.5).applyQuaternion(camera.quaternion); + groupRef.current.position.copy(camera.position).add(off); + groupRef.current.quaternion.copy(camera.quaternion); + } + + if (textRef.current) { + const lines = [ + `FPS ${sceneStats.fps} XR ${sceneStats.xrMode ? 'on' : 'off'} fake:${isFakeActive() ? '1' : '0'}`, + `inputs:${sceneStats.xrInputCount} planes:${sceneStats.xrPlanesCount}`, + `meshes:${sceneStats.meshCount} tris:${sceneStats.triCount}`, + `pos ${sceneStats.modelGroupPos.map((v) => v.toFixed(2)).join(' ')}`, + `rot ${sceneStats.modelGroupRotDeg.map((v) => v.toFixed(0)).join(' ')}`, + `bbox ${sceneStats.modelBboxSize.map((v) => v.toFixed(2)).join(' ')}`, + `L g${fakeInput.left.gripValue.toFixed(2)} t${fakeInput.left.triggerValue.toFixed(2)}`, + `R g${fakeInput.right.gripValue.toFixed(2)} t${fakeInput.right.triggerValue.toFixed(2)}`, + ]; + (textRef.current as unknown as { text: string }).text = lines.join('\n'); + } + }); + + return ( + + + + + + } + fontSize={0.0075} + color="#22c55e" + anchorX="left" + anchorY="top" + position={[-0.018, 0.045, 0.001]} + maxWidth={0.17} + lineHeight={1.3} + > + {' '} + + + ); +} diff --git a/src/devkit/macroStore.ts b/src/devkit/macroStore.ts new file mode 100644 index 0000000..e8b3241 --- /dev/null +++ b/src/devkit/macroStore.ts @@ -0,0 +1,147 @@ +/** + * DEVKIT — Macro recorder/player for fake controller inputs. + * + * Records fakeInput poses + grip/trigger at ~30Hz to a JSON-serializable + * timeline. Replays by writing values back into fakeInput. Used to reproduce + * bugs reported by the headset tester. + */ +import * as THREE from 'three'; +import { fakeInput } from './fakeInputStore'; + +export interface MacroFrame { + t: number; // ms since start + l: { m: number[]; g: number; t: number }; + r: { m: number[]; g: number; t: number }; +} + +export interface Macro { + name: string; + duration: number; + frames: MacroFrame[]; +} + +type Phase = 'idle' | 'recording' | 'playing'; + +interface State { + phase: Phase; + recStart: number; + rec: MacroFrame[]; + play: { macro: Macro; start: number; idx: number } | null; + current: Macro | null; + listeners: Set<() => void>; +} + +export const macroState: State = { + phase: 'idle', + recStart: 0, + rec: [], + play: null, + current: null, + listeners: new Set(), +}; + +function notify() { for (const l of macroState.listeners) l(); } + +export function subscribeMacro(cb: () => void) { + macroState.listeners.add(cb); + return () => macroState.listeners.delete(cb); +} + +export function startRecording() { + macroState.phase = 'recording'; + macroState.recStart = performance.now(); + macroState.rec = []; + notify(); +} + +export function stopRecording(): Macro { + const macro: Macro = { + name: `macro-${new Date().toISOString().slice(11, 19)}`, + duration: performance.now() - macroState.recStart, + frames: macroState.rec.slice(), + }; + macroState.phase = 'idle'; + macroState.current = macro; + notify(); + return macro; +} + +export function startPlaying(macro?: Macro) { + const m = macro ?? macroState.current; + if (!m || m.frames.length === 0) return; + macroState.play = { macro: m, start: performance.now(), idx: 0 }; + macroState.phase = 'playing'; + notify(); +} + +export function stopPlaying() { + macroState.play = null; + macroState.phase = 'idle'; + notify(); +} + +export function tickMacro() { + if (macroState.phase === 'recording') { + const t = performance.now() - macroState.recStart; + // throttle to ~30Hz + const last = macroState.rec[macroState.rec.length - 1]; + if (!last || t - last.t > 33) { + macroState.rec.push({ + t, + l: { m: matrixToArray(fakeInput.left.gripWorld), g: fakeInput.left.gripValue, t: fakeInput.left.triggerValue }, + r: { m: matrixToArray(fakeInput.right.gripWorld), g: fakeInput.right.gripValue, t: fakeInput.right.triggerValue }, + }); + } + } else if (macroState.phase === 'playing' && macroState.play) { + const t = performance.now() - macroState.play.start; + const frames = macroState.play.macro.frames; + while ( + macroState.play.idx < frames.length - 1 && + frames[macroState.play.idx + 1].t <= t + ) { + macroState.play.idx++; + } + const f = frames[macroState.play.idx]; + if (f) { + arrayToMatrix(f.l.m, fakeInput.left.gripWorld); + fakeInput.left.gripValue = f.l.g; + fakeInput.left.triggerValue = f.l.t; + arrayToMatrix(f.r.m, fakeInput.right.gripWorld); + fakeInput.right.gripValue = f.r.g; + fakeInput.right.triggerValue = f.r.t; + } + if (t > macroState.play.macro.duration) stopPlaying(); + } +} + +export function isPlaybackActive() { + return macroState.phase === 'playing'; +} + +function matrixToArray(m: THREE.Matrix4): number[] { + return Array.from(m.elements); +} +function arrayToMatrix(arr: number[], target: THREE.Matrix4) { + target.fromArray(arr); +} + +export function downloadMacro(macro: Macro) { + const blob = new Blob([JSON.stringify(macro, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${macro.name}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +export function loadMacroFromFile(file: File): Promise { + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => { + try { resolve(JSON.parse(String(r.result)) as Macro); } catch (e) { reject(e); } + }; + r.onerror = reject; + r.readAsText(file); + }); +} diff --git a/src/devkit/sceneStatsStore.ts b/src/devkit/sceneStatsStore.ts new file mode 100644 index 0000000..ae9290f --- /dev/null +++ b/src/devkit/sceneStatsStore.ts @@ -0,0 +1,80 @@ +/** + * DEVKIT — global mutable snapshot of scene + XR session diagnostics. + * + * Written every frame by SceneStatsProbe (inside Canvas), read by DevPanel + * (HTML overlay) and XRDevHud (in-world). Plain object to avoid re-renders. + */ +import * as THREE from 'three'; + +export interface SceneStats { + fps: number; + meshCount: number; + triCount: number; + modelBboxSize: [number, number, number]; + modelGroupPos: [number, number, number]; + modelGroupRotDeg: [number, number, number]; + modelGroupScale: number; + xrMode: string | null; + xrPlanesCount: number; + xrHitTestActive: boolean; + xrInputCount: number; + lastUpdate: number; +} + +export const sceneStats: SceneStats = { + fps: 0, + meshCount: 0, + triCount: 0, + modelBboxSize: [0, 0, 0], + modelGroupPos: [0, 0, 0], + modelGroupRotDeg: [0, 0, 0], + modelGroupScale: 1, + xrMode: null, + xrPlanesCount: 0, + xrHitTestActive: false, + xrInputCount: 0, + lastUpdate: 0, +}; + +export function snapshotStats(): SceneStats { + return { ...sceneStats }; +} + +const _v = new THREE.Vector3(); +const _q = new THREE.Quaternion(); +const _e = new THREE.Euler(); +const _box = new THREE.Box3(); + +export function updateModelStats(group: THREE.Object3D | null) { + if (!group) return; + group.updateWorldMatrix(true, true); + group.getWorldPosition(_v); + sceneStats.modelGroupPos = [_v.x, _v.y, _v.z]; + group.getWorldQuaternion(_q); + _e.setFromQuaternion(_q); + sceneStats.modelGroupRotDeg = [ + THREE.MathUtils.radToDeg(_e.x), + THREE.MathUtils.radToDeg(_e.y), + THREE.MathUtils.radToDeg(_e.z), + ]; + group.getWorldScale(_v); + sceneStats.modelGroupScale = _v.x; + + let meshes = 0; + let tris = 0; + group.traverse((o) => { + if (o instanceof THREE.Mesh && o.geometry) { + meshes++; + const idx = o.geometry.index; + const pos = o.geometry.attributes.position; + if (idx) tris += idx.count / 3; + else if (pos) tris += pos.count / 3; + } + }); + sceneStats.meshCount = meshes; + sceneStats.triCount = Math.round(tris); + + _box.setFromObject(group); + _box.getSize(_v); + sceneStats.modelBboxSize = [_v.x, _v.y, _v.z]; +} diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index d2df4a7..09d53c6 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -17,6 +17,9 @@ import { XRGrabbable } from '@/components/three/XRGrabbable'; import { useDevKit } from '@/devkit/useDevKit'; import { DevPanel } from '@/devkit/DevPanel'; import { FakeControllers } from '@/devkit/FakeControllers'; +import { SceneStatsProbe } from '@/devkit/SceneStatsProbe'; +import { MacroDriver } from '@/devkit/MacroDriver'; +import { XRDevHud } from '@/devkit/XRDevHud'; // --- Diagnóstico XR --- console.log('[XR] Inicializando store...'); @@ -121,6 +124,7 @@ function XRModel({ url }: { url: string }) { return ( { - + {devkit ? : } {/* DEVKIT: fake controllers visible in scene + keyboard driver */} {devkit && simXR && } {/* DEVKIT: orbit camera in SimXR so you can navigate around the model */} @@ -566,6 +570,9 @@ const XRSession = () => { + {/* DEVKIT: scene/XR stats probe + macro recorder driver */} + {devkit && } + {devkit && }