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.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user