Files
Lars Nolden b463af35d0 spark 2.1.0 splat viewer tab with OS-fused gyro look-around
WebView-hosted Spark renderer (butterfly.spz demo scene), fixed camera
position, rotation driven by expo-sensors DeviceMotion (ROTATION_VECTOR /
CoreMotion xMagneticNorthZVertical) via injectJavaScript; web fallback
uses deviceorientation in an iframe. Tap to re-center yaw.
2026-08-11 18:05:17 +02:00

154 lines
4.8 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { SPLAT_VIEWER_HTML, type ViewerMessage } from './viewer-html';
// expo-sensors' DeviceMotion web shim does not emit `rotation`, so on web we
// read the browser's own OS-fused attitude directly. Chrome/Android exposes
// the magnetometer-referenced fusion on `deviceorientationabsolute`; other
// browsers (incl. iOS Safari, which fuses via CoreMotion) use
// `deviceorientation`.
const ORIENTATION_EVENT =
typeof window !== 'undefined' && 'ondeviceorientationabsolute' in window
? 'deviceorientationabsolute'
: 'deviceorientation';
// iOS Safari 13+ gates motion events behind a user-gesture permission prompt.
const orientationEventStatics =
typeof DeviceOrientationEvent !== 'undefined'
? (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<'granted' | 'denied'> })
: {};
const DEG2RAD = Math.PI / 180;
/**
* Web splat viewer host: same Spark viewer page as native, hosted in an
* iframe and fed rotation samples via postMessage. Camera position is fixed.
*/
export function SplatViewer() {
const frameRef = useRef<HTMLIFrameElement>(null);
const [status, setStatus] = useState('Loading Spark renderer…');
const [needsPermission, setNeedsPermission] = useState(false);
const onOrientation = useCallback((event: DeviceOrientationEvent) => {
if (event.alpha == null || event.beta == null || event.gamma == null) return;
frameRef.current?.contentWindow?.postMessage(
{
type: 'rotation',
alpha: event.alpha * DEG2RAD,
beta: event.beta * DEG2RAD,
gamma: event.gamma * DEG2RAD,
orientation: (typeof screen !== 'undefined' && screen.orientation?.angle) || 0,
},
'*',
);
}, []);
useEffect(() => {
const onViewerMessage = (event: MessageEvent) => {
if (event.source !== frameRef.current?.contentWindow) return;
const message = event.data as ViewerMessage;
switch (message?.type) {
case 'progress':
setStatus(`Downloading scene… ${Math.round((100 * message.loaded) / message.total)}%`);
break;
case 'ready':
setStatus(`${message.numSplats.toLocaleString()} splats — rotate the device to look around, tap to re-center.`);
break;
case 'error':
setStatus(`Viewer error: ${message.message}`);
break;
}
};
window.addEventListener('message', onViewerMessage);
const listener = onOrientation as EventListener;
if (typeof orientationEventStatics.requestPermission === 'function') {
setNeedsPermission(true);
} else {
window.addEventListener(ORIENTATION_EVENT, listener);
}
return () => {
window.removeEventListener('message', onViewerMessage);
window.removeEventListener(ORIENTATION_EVENT, listener);
};
}, [onOrientation]);
const requestMotionAccess = useCallback(async () => {
try {
const response = await orientationEventStatics.requestPermission?.();
if (response === 'granted') {
window.addEventListener(ORIENTATION_EVENT, onOrientation as EventListener);
setNeedsPermission(false);
} else {
setStatus('Motion access denied — showing a static view.');
setNeedsPermission(false);
}
} catch (error) {
setStatus(`Motion permission failed: ${error instanceof Error ? error.message : error}`);
}
}, [onOrientation]);
return (
<View style={styles.container}>
<iframe
ref={frameRef}
title="Spark splat viewer"
srcDoc={SPLAT_VIEWER_HTML}
style={iframeStyle}
allow="accelerometer; gyroscope; magnetometer"
/>
{needsPermission && (
<Pressable style={styles.permissionButton} onPress={requestMotionAccess}>
<Text style={styles.permissionLabel}>Enable motion control</Text>
</Pressable>
)}
<Text style={styles.status}>{status}</Text>
</View>
);
}
const iframeStyle: React.CSSProperties = {
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
border: 0,
background: '#000',
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
permissionButton: {
position: 'absolute',
top: '45%',
alignSelf: 'center',
backgroundColor: 'rgba(10, 126, 164, 0.9)',
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 20,
},
permissionLabel: {
color: '#fff',
fontSize: 15,
fontWeight: '600',
},
status: {
position: 'absolute',
bottom: 12,
left: 12,
right: 12,
color: '#fff',
fontSize: 12,
textAlign: 'center',
backgroundColor: 'rgba(0,0,0,0.55)',
borderRadius: 8,
paddingVertical: 6,
paddingHorizontal: 10,
overflow: 'hidden',
},
});