Files
fabrikanabytok/apps/fabrikanabytok/components/planner/webxr-support.tsx
2025-11-28 20:48:15 +01:00

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
}