Entregou Fases 2 e 3

X-Lovable-Edit-ID: edt-592b9b15-cc02-4e22-ae0c-2254793d1331
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-12 11:31:54 +00:00
10 changed files with 730 additions and 26 deletions
+4
View File
@@ -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 = () => (
<Route path="/" element={<Index />} />
<Route path="/viewer" element={<Viewer />} />
<Route path="/xr" element={<XRSession />} />
{/* DEVKIT: remove this route to strip devkit */}
<Route path="/devkit/smoke" element={<SmokeTest />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
+149 -25
View File
@@ -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<HTMLInputElement>(null);
const rafRef = useRef<number | null>(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 (
<button
@@ -76,11 +105,17 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
}
return (
<div className="fixed top-20 right-2 z-50 w-72 rounded-lg border border-green-500/40 bg-black/90 font-mono text-[10px] text-green-300 shadow-2xl backdrop-blur">
{/* Header */}
<div className="fixed top-20 right-2 z-50 w-80 rounded-lg border border-green-500/40 bg-black/90 font-mono text-[10px] text-green-300 shadow-2xl backdrop-blur">
<div className="flex items-center justify-between border-b border-green-500/30 px-2 py-1.5">
<span className="font-bold text-green-400">🧪 DEVKIT</span>
<div className="flex items-center gap-1">
<button
onClick={exportReport}
className="rounded border border-green-500/40 px-1.5 py-0.5 text-[9px] text-green-300 hover:bg-green-500/20"
title="Baixa JSON + screenshot"
>
Relatório
</button>
<button
onClick={onToggleSimXR}
className={`rounded px-1.5 py-0.5 text-[9px] ${simXR ? 'bg-green-500 text-black' : 'border border-green-500/40 text-green-300 hover:bg-green-500/20'}`}
@@ -91,26 +126,22 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-green-500/20">
{(['inputs', 'logs'] as Tab[]).map((t) => (
{(['inputs', 'cena', 'xr', 'macro', 'logs'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 px-2 py-1 text-[10px] uppercase ${tab === t ? 'bg-green-500/20 text-green-200' : 'text-green-500/70 hover:text-green-300'}`}
className={`flex-1 px-1 py-1 text-[10px] uppercase ${tab === t ? 'bg-green-500/20 text-green-200' : 'text-green-500/70 hover:text-green-300'}`}
>
{t}
</button>
))}
</div>
{/* Content */}
<div className="max-h-[55vh] overflow-y-auto p-2">
<div className="max-h-[60vh] overflow-y-auto p-2">
{tab === 'inputs' && (
<div className="space-y-2">
<div className="text-green-500/70">
fake: {isFakeActive() ? 'ATIVO' : 'inativo'}
</div>
<div className="text-green-500/70">fake: {isFakeActive() ? 'ATIVO' : 'inativo'}</div>
{(['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) {
</div>
</div>
)}
{tab === 'cena' && (
<div className="space-y-1">
<Row k="meshes" v={String(sceneStats.meshCount)} />
<Row k="triângulos" v={sceneStats.triCount.toLocaleString()} />
<Row k="bbox (m)" v={sceneStats.modelBboxSize.map(n => n.toFixed(3)).join(' × ')} />
<Row k="pos (m)" v={sceneStats.modelGroupPos.map(n => n.toFixed(3)).join(', ')} />
<Row k="rot (°)" v={sceneStats.modelGroupRotDeg.map(n => n.toFixed(1)).join(', ')} />
<Row k="escala" v={sceneStats.modelGroupScale.toFixed(3)} />
</div>
)}
{tab === 'xr' && (
<div className="space-y-1">
<Row k="modo" v={sceneStats.xrMode ?? '—'} />
<Row k="FPS" v={String(sceneStats.fps)} />
<Row k="inputs" v={String(sceneStats.xrInputCount)} />
<Row k="planos" v={String(sceneStats.xrPlanesCount)} />
<Row k="hit-test" v={sceneStats.xrHitTestActive ? 'sim' : 'não'} />
<Row k="navigator.xr" v={typeof navigator !== 'undefined' && 'xr' in navigator ? 'ok' : 'ausente'} />
<Row k="HTTPS" v={location.protocol === 'https:' ? 'sim' : 'NÃO'} />
</div>
)}
{tab === 'macro' && (
<div className="space-y-2">
<div className="text-green-500/70">fase: <span className="text-green-300">{macroState.phase}</span></div>
<div className="flex gap-1 flex-wrap">
{macroState.phase !== 'recording' ? (
<button onClick={startRecording} className="rounded border border-red-500/50 bg-red-500/10 px-2 py-0.5 text-red-300 hover:bg-red-500/20"> Gravar</button>
) : (
<button onClick={stopRecording} className="rounded border border-yellow-500/50 bg-yellow-500/10 px-2 py-0.5 text-yellow-300"> Parar</button>
)}
{macroState.phase !== 'playing' ? (
<button
onClick={() => startPlaying()}
disabled={!macroState.current}
className="rounded border border-green-500/50 bg-green-500/10 px-2 py-0.5 text-green-300 hover:bg-green-500/20 disabled:opacity-40"
> Play</button>
) : (
<button onClick={stopPlaying} className="rounded border border-yellow-500/50 bg-yellow-500/10 px-2 py-0.5 text-yellow-300"> Stop</button>
)}
<button
onClick={() => macroState.current && downloadMacro(macroState.current)}
disabled={!macroState.current}
className="rounded border border-green-500/40 px-2 py-0.5 hover:bg-green-500/20 disabled:opacity-40"
> Salvar</button>
<button
onClick={() => fileRef.current?.click()}
className="rounded border border-green-500/40 px-2 py-0.5 hover:bg-green-500/20"
> Carregar</button>
<input
ref={fileRef}
type="file"
accept="application/json"
className="hidden"
onChange={async (e) => {
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 = '';
}}
/>
</div>
{macroState.current && (
<div className="rounded border border-green-500/20 p-1.5">
<div className="font-bold text-green-300">{macroState.current.name}</div>
<div>frames: {macroState.current.frames.length}</div>
<div>duração: {(macroState.current.duration / 1000).toFixed(1)}s</div>
</div>
)}
{macroState.phase === 'recording' && (
<div className="text-yellow-300">gravando {macroState.rec.length} frames</div>
)}
<div className="text-green-500/60 leading-tight border-t border-green-500/20 pt-1">
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.
</div>
</div>
)}
{tab === 'logs' && (
<div className="space-y-1">
<input
@@ -144,8 +260,7 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
<div className="space-y-0.5">
{logBuffer
.filter((l) => !logFilter || l.msg.toLowerCase().includes(logFilter.toLowerCase()))
.slice(-80)
.reverse()
.slice(-80).reverse()
.map((l, i) => (
<div
key={i}
@@ -164,3 +279,12 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
</div>
);
}
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-2 border-b border-green-500/10 py-0.5">
<span className="text-green-500/70">{k}</span>
<span className="text-green-200 break-all text-right">{v}</span>
</div>
);
}
+2
View File
@@ -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<string>();
const apply = () => {
if (isPlaybackActive()) return; // macro playback owns inputs
const pos = new THREE.Vector3();
const quat = new THREE.Quaternion();
const scl = new THREE.Vector3();
+11
View File
@@ -0,0 +1,11 @@
/**
* DEVKIT — Drives macro recording/playback inside the R3F frame loop.
* Mounted inside <Canvas>. No visual output.
*/
import { useFrame } from '@react-three/fiber';
import { tickMacro } from './macroStore';
export function MacroDriver() {
useFrame(() => { tickMacro(); });
return null;
}
+47
View File
@@ -0,0 +1,47 @@
/**
* DEVKIT — samples scene + XR session every frame, writes to sceneStatsStore.
* Mounted inside <Canvas>. 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;
}
+190
View File
@@ -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<string> };
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<StepResult[]>([]);
const [running, setRunning] = useState(false);
if (!devkit) return <Navigate to="/" replace />;
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 (
<div className="min-h-screen bg-background p-6 font-mono text-foreground">
<div className="mx-auto max-w-2xl space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">🧪 DevKit · Smoke Test</h1>
<Link to="/" className="text-xs text-primary hover:underline"> Home</Link>
</div>
<p className="text-sm text-muted-foreground">
Roteiro automático que exercita store, demo, medição, checklist, fake input e gravador.
Execute antes de enviar build para o testador com Quest.
</p>
<div className="flex items-center gap-3">
<button
onClick={run}
disabled={running}
className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground hover:opacity-90 disabled:opacity-40"
>
{running ? 'Executando…' : '▶ Rodar suite'}
</button>
{results.length > 0 && (
<span className="text-sm">
<span className="text-green-400"> {passed}</span>
{' · '}
<span className={failed ? 'text-red-400' : 'text-muted-foreground'}> {failed}</span>
</span>
)}
</div>
<div className="space-y-1 rounded border border-border bg-card p-3">
{results.length === 0 && (
<div className="text-xs text-muted-foreground italic">aguardando execução</div>
)}
{results.map((r, i) => (
<div
key={i}
className={`flex items-start justify-between gap-3 border-b border-border/50 py-1 text-xs ${
r.ok ? 'text-green-400' : 'text-red-400'
}`}
>
<div className="flex-1">
<span className="mr-2">{r.ok ? '✓' : '✕'}</span>
{r.name}
<div className="ml-5 text-muted-foreground">{r.detail}</div>
</div>
<span className="text-muted-foreground">{r.ms.toFixed(0)}ms</span>
</div>
))}
</div>
</div>
</div>
);
}
+92
View File
@@ -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<THREE.Group>(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 (
<group ref={groupRef}>
<mesh position={[0.07, 0, 0]}>
<planeGeometry args={[0.18, 0.10]} />
<meshBasicMaterial color="#000000" transparent opacity={0.65} depthWrite={false} />
</mesh>
<Text
ref={textRef as unknown as React.Ref<THREE.Mesh>}
fontSize={0.0075}
color="#22c55e"
anchorX="left"
anchorY="top"
position={[-0.018, 0.045, 0.001]}
maxWidth={0.17}
lineHeight={1.3}
>
{' '}
</Text>
</group>
);
}
+147
View File
@@ -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<Macro> {
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);
});
}
+80
View File
@@ -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];
}
+8 -1
View File
@@ -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 (
<group
ref={ref}
userData={{ __xrModelRoot: true }}
position={[
-modelInfo.center.x + fineTuning.posX,
-modelInfo.center.y + fineTuning.posY,
@@ -531,7 +535,7 @@ const XRSession = () => {
<XRMeasurementOverlay />
<ControllerFineTuning freeMove={freeMove} />
<XRDebugHud />
{devkit ? <XRDevHud /> : <XRDebugHud />}
{/* DEVKIT: fake controllers visible in scene + keyboard driver */}
{devkit && simXR && <FakeControllers />}
{/* DEVKIT: orbit camera in SimXR so you can navigate around the model */}
@@ -566,6 +570,9 @@ const XRSession = () => {
<XRSnapHandler />
<XRGrid />
{/* DEVKIT: scene/XR stats probe + macro recorder driver */}
{devkit && <SceneStatsProbe />}
{devkit && <MacroDriver />}
</XR>
</Canvas>