279 lines
8.1 KiB
TypeScript
279 lines
8.1 KiB
TypeScript
"use client"
|
|
|
|
/**
|
|
* WebXR VR/AR Support for Kitchen Planner
|
|
* Immersive 3D viewing and AR placement preview
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
import { useThree, useFrame } from "@react-three/fiber"
|
|
import { useXR, XRButton, createXRStore, ARButton } from "@react-three/xr"
|
|
import * as THREE from "three"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Eye } from "lucide-react"
|
|
import { Text } from "@react-three/drei"
|
|
|
|
interface WebXRSupportProps {
|
|
enableVR?: boolean
|
|
enableAR?: boolean
|
|
onEnterVR?: () => void
|
|
onExitVR?: () => void
|
|
onEnterAR?: () => void
|
|
onExitAR?: () => void
|
|
}
|
|
|
|
const store = createXRStore()
|
|
|
|
/**
|
|
* WebXR Manager Component
|
|
*/
|
|
export function WebXRSupport({
|
|
enableVR = true,
|
|
enableAR = true,
|
|
onEnterVR,
|
|
onExitVR,
|
|
onEnterAR,
|
|
onExitAR
|
|
}: WebXRSupportProps) {
|
|
const [vrSupported, setVRSupported] = useState(false)
|
|
const [arSupported, setARSupported] = useState(false)
|
|
const { gl } = useThree()
|
|
|
|
useEffect(() => {
|
|
// Check WebXR support
|
|
if ('xr' in navigator) {
|
|
navigator.xr?.isSessionSupported('immersive-vr').then(setVRSupported)
|
|
navigator.xr?.isSessionSupported('immersive-ar').then(setARSupported)
|
|
}
|
|
}, [])
|
|
|
|
if (!enableVR && !enableAR) return null
|
|
|
|
return (
|
|
<div className="absolute top-4 left-4 z-50 flex gap-2">
|
|
{enableVR && vrSupported && (
|
|
<XRButton mode="immersive-vr"
|
|
store={store}
|
|
onClick={() => store.enterXR('immersive-vr')}
|
|
>
|
|
<Button className="gap-2">
|
|
<Eye className="w-4 h-4" /> Enter VR
|
|
</Button>
|
|
</XRButton>
|
|
)}
|
|
|
|
{enableAR && arSupported && (
|
|
<ARButton
|
|
store={store}
|
|
onClick={() => store.enterXR('immersive-ar')}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
/**
|
|
* VR Controllers Component
|
|
*/
|
|
export function VRControllers() {
|
|
const { session, controller } = useXR()
|
|
if (!session) return null
|
|
return (
|
|
<>
|
|
<XRButton mode="immersive-vr" store={store} />
|
|
<XRButton mode="immersive-ar" store={store} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* AR Hit Test - Place objects in real world
|
|
*/
|
|
export function ARHitTest({
|
|
onPlacement
|
|
}: {
|
|
onPlacement?: (position: THREE.Vector3, rotation: THREE.Euler) => void
|
|
}) {
|
|
const { session } = useXR()
|
|
const hitTestSourceRef = useRef<XRHitTestSource | null>(null)
|
|
const reticleRef = useRef<THREE.Mesh>(null)
|
|
|
|
useEffect(() => {
|
|
if (!session) return
|
|
|
|
// Request hit test source
|
|
session.requestReferenceSpace('viewer').then((referenceSpace) => {
|
|
if (session.requestHitTestSource && referenceSpace) {
|
|
session.requestHitTestSource?.({ space: referenceSpace as any as XRReferenceSpace })?.then((source: XRHitTestSource | undefined) => {
|
|
hitTestSourceRef.current = source as XRHitTestSource
|
|
})
|
|
}
|
|
})
|
|
}, [session])
|
|
|
|
useFrame((state, delta, frame) => {
|
|
if (!frame || !hitTestSourceRef.current || !session) return
|
|
|
|
const hitTestResults = frame.getHitTestResults(hitTestSourceRef.current)
|
|
|
|
if (hitTestResults.length > 0 && reticleRef.current) {
|
|
const hit = hitTestResults[0]
|
|
const pose = hit.getPose(session.renderState.baseLayerSpace as any as unknown as XRReferenceSpace)
|
|
reticleRef.current.visible = true
|
|
reticleRef.current.position.setFromMatrixPosition(new THREE.Matrix4().fromArray(pose?.transform.matrix ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]))
|
|
reticleRef.current.rotation.setFromRotationMatrix(new THREE.Matrix4().fromArray(pose?.transform.matrix ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]))
|
|
} else if (reticleRef.current) {
|
|
reticleRef.current.visible = false
|
|
}
|
|
})
|
|
return (
|
|
<mesh
|
|
ref={reticleRef}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
visible={false}
|
|
onClick={onPlacement}
|
|
>
|
|
<ringGeometry args={[0.15, 0.2, 32]} />
|
|
<meshBasicMaterial color="#ffffff" side={THREE.DoubleSide} opacity={0.8} transparent />
|
|
|
|
{/* Center dot */}
|
|
<mesh position={[0, 0, 0.001]}>
|
|
<circleGeometry args={[0.02, 32]} />
|
|
<meshBasicMaterial color="#ffffff" />
|
|
</mesh>
|
|
|
|
{/* Crosshair lines */}
|
|
<line>
|
|
<bufferGeometry>
|
|
<bufferAttribute args={[new Float32Array([-0.1, 0, 0, 0.1, 0, 0]), 3]}
|
|
array={new Float32Array([-0.1, 0, 0, 0.1, 0, 0])}
|
|
/>
|
|
</bufferGeometry>
|
|
<lineBasicMaterial color="#ffffff" />
|
|
</line>
|
|
<line>
|
|
<bufferGeometry>
|
|
<bufferAttribute args={[new Float32Array([0, -0.1, 0, 0, 0.1, 0]), 3]}
|
|
array={new Float32Array([0, -0.1, 0, 0, 0.1, 0])}
|
|
/>
|
|
</bufferGeometry>
|
|
<lineBasicMaterial color="#ffffff" />
|
|
</line>
|
|
</mesh>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* VR Teleportation
|
|
*/
|
|
export function VRTeleport({
|
|
roomBounds
|
|
}: {
|
|
roomBounds?: { width: number; length: number }
|
|
}) {
|
|
const { session, controller } = useXR()
|
|
const teleportRef = useRef<THREE.Mesh>(null)
|
|
const [teleportPosition, setTeleportPosition] = useState<THREE.Vector3 | null>(null)
|
|
|
|
useFrame(() => {
|
|
if (!session || !controller) return
|
|
|
|
const position = (controller as any as XRInputSource).gamepad?.axes as number[] | null | undefined
|
|
if (position && position.length === 3) {
|
|
setTeleportPosition(new THREE.Vector3(position[0] ?? 0, position[1] ?? 0, position[2] ?? 0))
|
|
} else {
|
|
setTeleportPosition(null)
|
|
}
|
|
})
|
|
return (
|
|
<group position={teleportPosition ?? undefined}>
|
|
{/* Panel border */}
|
|
<lineSegments>
|
|
<edgesGeometry args={[new THREE.PlaneGeometry(1, 0.5)]} />
|
|
<lineBasicMaterial color="#00ff00" />
|
|
</lineSegments>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Hand gesture detection
|
|
*/
|
|
export function useHandGestures() {
|
|
const { session, controller } = useXR()
|
|
const [pinching, setPinching] = useState(false)
|
|
const [pinchDistance, setPinchDistance] = useState(0)
|
|
|
|
useFrame((state, delta, frame) => {
|
|
if (!frame || !session || !controller) return
|
|
|
|
const sources = session.inputSources
|
|
|
|
for (const source of sources) {
|
|
if (source.hand) {
|
|
const joints = source.hand
|
|
const indexTip = joints.get('index-finger-tip')
|
|
const thumbTip = joints.get('thumb-tip')
|
|
|
|
if (indexTip && thumbTip) {
|
|
// @ts-ignore
|
|
const indexPose = frame.getJointPose(indexTip, session.renderState.baseSpace as unknown as XRReferenceSpace)
|
|
// @ts-ignore
|
|
const thumbPose = frame.getJointPose(thumbTip, session.renderState.baseSpace as unknown as XRReferenceSpace)
|
|
|
|
if (indexPose && thumbPose) {
|
|
const indexPos = new THREE.Vector3().setFromMatrixPosition(
|
|
new THREE.Matrix4().fromArray(indexPose?.transform.matrix ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] as any as number[] as any)
|
|
) as any
|
|
const thumbPos = new THREE.Vector3().setFromMatrixPosition(
|
|
new THREE.Matrix4().fromArray(thumbPose?.transform.matrix ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] as any as number[] as any)
|
|
) as any
|
|
|
|
const distance = indexPos.distanceTo(thumbPos)
|
|
setPinchDistance(distance)
|
|
setPinching(distance < 0.03) // 3cm threshold
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
return {
|
|
pinching,
|
|
pinchDistance
|
|
}
|
|
}
|
|
|
|
/**
|
|
* VR Performance Optimizer
|
|
*/
|
|
export function VRPerformanceOptimizer() {
|
|
const { session } = useXR()
|
|
const { gl, scene } = useThree()
|
|
|
|
useEffect(() => {
|
|
if (session) {
|
|
// Reduce quality for VR performance
|
|
gl.setPixelRatio(1) // Lower pixel ratio
|
|
|
|
// Disable expensive effects
|
|
scene.traverse((object) => {
|
|
if ((object as any).material) {
|
|
const materials = Array.isArray((object as any).material)
|
|
? (object as any).material
|
|
: [(object as any).material]
|
|
|
|
materials.forEach((mat: any) => {
|
|
if (mat.map) mat.map.anisotropy = 1 // Lower texture quality
|
|
})
|
|
}
|
|
})
|
|
} else {
|
|
// Restore quality
|
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
|
}
|
|
}, [gl, scene])
|
|
|
|
return null
|
|
}
|
|
|