import { Component, Suspense, useRef, useEffect, useState, type ReactNode } from 'react'
import { Tooltip } from '@radix-ui/themes'
import { ArrowsClockwiseIcon, CaretDownIcon, CaretUpIcon } from '@phosphor-icons/react'
import { Canvas, useThree } from '@react-three/fiber'
import * as THREE from 'three'
import { Html } from '@react-three/drei'
import { Physics } from '@react-three/rapier'
import { SplatRenderer } from '../modules/splat/SplatRenderer'
import { EnvironmentMap } from '../modules/environment/EnvironmentMap'
import { WorldCollider } from '../modules/collider/WorldCollider'
import { GroundPlane } from '../modules/collider/GroundPlane'
import { CharacterController, type CharacterControllerHandle } from '../modules/character/CharacterController'
import { FlyController, type FlyControllerHandle } from '../modules/character/FlyController'
import { ButterflyScene } from '../modules/butterfly/ButterflyScene'
import { ObjectGrid } from '../modules/scene/ObjectGrid'
import { OriginHelper } from '../modules/scene/OriginHelper'
import { AudioManager } from '../modules/audio/AudioManager'
import { PostProcessing } from '../modules/postprocessing/PostProcessing'
import { DEFAULT_SHADOW_CATCHER_COLOR, DEFAULT_SHADOW_CATCHER_OPACITY, shadowCatcherColor, shadowCatcherOpacity } from '../modules/scene/shadows'
import { getSplatUrl } from '../utils/worldLoader'
import { useDebugStore } from '../store/debug'
import { WorldRenderMode, ObjectRenderMode, ViewerQuality, type Vec3Tuple, type World, type WorldHoverPreview, type WorldObjectAsset, type WorldSceneProject } from '../types/world'
import { AppButton } from './AppButton'
import { chrome } from './AppChrome'
function ModelLoader() {
return (
Downloading Model...
This may take a moment depending on model size and network speed.
)
}
type CharHandle = CharacterControllerHandle | FlyControllerHandle
const DEFAULT_ENVIRONMENT_URL = '/hdri.jpg'
const DEFAULT_WORLD_SEMANTICS = {
metric_scale_factor: 1,
ground_plane_offset: 0,
flip_y: true,
}
function sunPositionFromRotation(rotation: Vec3Tuple): Vec3Tuple {
let x = 0
let y = 10
let z = 0
const [rx, ry, rz] = rotation
const cx = Math.cos(rx)
const sx = Math.sin(rx)
const cy = Math.cos(ry)
const sy = Math.sin(ry)
const cz = Math.cos(rz)
const sz = Math.sin(rz)
;[y, z] = [y * cx - z * sx, y * sx + z * cx]
;[x, z] = [x * cy + z * sy, -x * sy + z * cy]
;[x, y] = [x * cz - y * sz, x * sz + y * cz]
return [x, y, z]
}
interface OptionalAssetBoundaryProps {
label: string
resetKey: string
fallback?: ReactNode
children: ReactNode
}
interface OptionalAssetBoundaryState {
hasError: boolean
}
class OptionalAssetBoundary extends Component {
state: OptionalAssetBoundaryState = { hasError: false }
static getDerivedStateFromError(): OptionalAssetBoundaryState {
return { hasError: true }
}
componentDidCatch(error: unknown) {
console.warn(`Skipping optional world asset "${this.props.label}" because it failed to load.`, error)
}
componentDidUpdate(prevProps: OptionalAssetBoundaryProps) {
if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
this.setState({ hasError: false })
}
}
render() {
if (this.state.hasError) return this.props.fallback ?? null
return this.props.children
}
}
function GrayEnvironmentFallback() {
return (
<>
>
)
}
function DefaultEnvironment({ intensity }: { intensity: number }) {
return (
}>
)
}
interface Props {
world?: World
slug: string
sourceImageUrl?: string
hoveredWorldPreview?: WorldHoverPreview | null
objectAssets: WorldObjectAsset[]
allObjectAssets: WorldObjectAsset[]
worldSfxUrls: string[]
sceneProject?: WorldSceneProject
sceneProjectReady?: boolean
hoveredObjectAssetId?: string | null
hoveredObjectInstanceId?: string | null
editing?: boolean
setEditing?: (editing: boolean) => void
uiVisible?: boolean
onObjectHover?: (asset: WorldObjectAsset, hovering: boolean, instanceId?: string) => void
onSceneProjectSaved?: (project: WorldSceneProject) => void
onRefreshWorlds?: () => void
refreshingWorlds?: boolean
}
export function WorldViewer({
world: desiredWorld,
slug: desiredSlug,
sourceImageUrl,
hoveredWorldPreview,
objectAssets: desiredObjectAssets,
allObjectAssets,
worldSfxUrls,
sceneProject,
sceneProjectReady = true,
hoveredObjectAssetId,
hoveredObjectInstanceId,
editing = false,
setEditing,
uiVisible = true,
onObjectHover,
onSceneProjectSaved,
onRefreshWorlds,
refreshingWorlds = false,
}: Props) {
const charRef = useRef(null)
const worldRenderMode = useDebugStore((s) => s.worldRenderMode)
const objectRenderMode = useDebugStore((s) => s.objectRenderMode)
const viewerQuality = useDebugStore((s) => s.viewerQuality)
const controllerMode = useDebugStore((s) => s.controllerMode)
const butterfliesEnabled = useDebugStore((s) => s.butterfliesEnabled)
const controllerResetToken = useDebugStore((s) => s.controllerResetToken)
const environmentIntensity = useDebugStore((s) => s.environmentIntensity)
const sunIntensity = useDebugStore((s) => s.sunIntensity)
const sunColor = useDebugStore((s) => s.sunColor)
const [sourceThumbnailCollapsed, setSourceThumbnailCollapsed] = useState(false)
const colliderUrl = desiredWorld?.assets.mesh?.collider_mesh_url?.startsWith('/models/')
? desiredWorld.assets.mesh.collider_mesh_url
: ''
const panoUrl = desiredWorld?.assets.imagery?.pano_url?.startsWith('/models/')
? desiredWorld.assets.imagery.pano_url
: ''
useEffect(() => {
charRef.current?.reset()
}, [desiredSlug])
useEffect(() => {
if (controllerResetToken > 0) charRef.current?.reset()
}, [controllerResetToken])
const splatUrl = desiredWorld ? getSplatUrl(desiredWorld) : ''
const { ground_plane_offset, flip_y, metric_scale_factor } = desiredWorld?.assets.splats.semantics_metadata ?? DEFAULT_WORLD_SEMANTICS
const flipY = flip_y ?? true
const baseMetricScaleFactor = metric_scale_factor ?? 1
const baseGroundPlaneOffset = ground_plane_offset ?? 0
const isHighQuality = viewerQuality === ViewerQuality.High
const showScene = worldRenderMode !== WorldRenderMode.ObjectOnly
const showSplat = showScene && objectRenderMode === ObjectRenderMode.Lit
const showObjects = worldRenderMode !== WorldRenderMode.SplatOnly
const activeSceneSun = sceneProject?.sun
const activeSunIntensity = activeSceneSun?.intensity ?? sunIntensity
const activeEnvironmentIntensity = activeSceneSun?.environmentIntensity ?? environmentIntensity
const activeSunPosition = sunPositionFromRotation(activeSceneSun?.rotation ?? [0, 0, 0])
const activeMetricScaleFactor = sceneProject?.metricScaleFactor ?? baseMetricScaleFactor
const defaultGroundPlaneOffset = baseGroundPlaneOffset * (activeMetricScaleFactor / baseMetricScaleFactor)
const activeGroundPlaneOffset = sceneProject?.groundPlaneOffset ?? defaultGroundPlaneOffset
const sceneGroundPlaneColliderEnabled = sceneProject?.groundPlaneColliderEnabled ?? true
const activeGroundPlaneColliderEnabled = worldRenderMode === WorldRenderMode.ObjectOnly
? true
: sceneGroundPlaneColliderEnabled
const sceneShadowCatcherOpacity = sceneProject?.shadowCatcherOpacity
const activeShadowCatcherOpacity = shadowCatcherOpacity(sceneShadowCatcherOpacity ?? DEFAULT_SHADOW_CATCHER_OPACITY)
const sceneShadowCatcherColor = sceneProject?.shadowCatcherColor
const activeShadowCatcherColor = shadowCatcherColor(sceneShadowCatcherColor ?? DEFAULT_SHADOW_CATCHER_COLOR)
const objectPlacements = sceneProject?.instances ?? []
const objectPhysicsAssets = sceneProject?.instances.length ? allObjectAssets : desiredObjectAssets
const activeControllerMode = controllerMode
const hoveredObjectAsset = hoveredObjectAssetId
? allObjectAssets.find((asset) => asset.assetId === hoveredObjectAssetId)
?? desiredObjectAssets.find((asset) => asset.assetId === hoveredObjectAssetId)
: undefined
const activePreviewImageUrl = hoveredObjectAsset?.referenceImageUrl
?? hoveredObjectAsset?.thumbnailUrl
?? hoveredWorldPreview?.imageUrl
?? sourceImageUrl
const activePreviewAlt = hoveredObjectAsset
? `${hoveredObjectAsset.name} reference image`
: hoveredWorldPreview?.imageUrl
? hoveredWorldPreview.alt
: 'Original source'
return (
<>
{uiVisible && (
setSourceThumbnailCollapsed((collapsed) => !collapsed)}
/>
)}
>
)
}
function CameraLogger() {
const { camera } = useThree()
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'p') {
const { x, y, z } = camera.position
const euler = new THREE.Euler(0, 0, 0, 'YXZ').setFromQuaternion(camera.quaternion, 'YXZ')
const yaw = euler.y
const pitch = euler.x
const params = new URLSearchParams(window.location.search)
params.set('spawn', `${x.toFixed(2)},${y.toFixed(2)},${z.toFixed(2)},${yaw.toFixed(2)},${pitch.toFixed(2)}`)
const newUrl = `${window.location.origin}${window.location.pathname}?${params.toString()}`
console.log(`\n============== [Camera Spawn Logger] ==============`)
console.log(`Position: ${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)}`)
console.log(`Rotation: Yaw ${yaw.toFixed(2)}, Pitch ${pitch.toFixed(2)}`)
console.log(`Spawn URL: ${newUrl}`)
console.log(`===================================================\n`)
if (navigator.clipboard) {
navigator.clipboard.writeText(newUrl).then(() => {
console.log('✅ Spawn URL copied to clipboard!')
}).catch(() => {
console.log('❌ Failed to copy to clipboard')
})
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [camera])
return null
}
function SourceImageControls({
activeSourceImageUrl,
previewAlt,
thumbnailCollapsed,
refreshingWorlds,
onRefreshWorlds,
onThumbnailCollapseToggle,
}: {
activeSourceImageUrl?: string
previewAlt: string
thumbnailCollapsed: boolean
refreshingWorlds: boolean
onRefreshWorlds?: () => void
onThumbnailCollapseToggle: () => void
}) {
if (!activeSourceImageUrl && !import.meta.env.DEV) return null
return (
{activeSourceImageUrl ? (
thumbnailCollapsed ? (
{import.meta.env.DEV && onRefreshWorlds && (
)}
) : (
{import.meta.env.DEV && onRefreshWorlds && (
)}
)
) : (
import.meta.env.DEV && onRefreshWorlds && (
)
)}
)
}
function SourceThumbnailCollapseButton({
collapsed,
onToggle,
className = '',
}: {
collapsed: boolean
onToggle: () => void
className?: string
}) {
const Icon = collapsed ? CaretUpIcon : CaretDownIcon
return (
)
}
function RefreshWorldsButton({
refreshing,
onRefresh,
className = '',
}: {
refreshing: boolean
onRefresh: () => void
className?: string
}) {
return (
)
}