Jeprary
fix: prevent local dropped models from being overwritten on refresh
300e645
Raw
History Blame Contribute Delete
11.1 kB
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from 'react'
import { WorldViewer } from './components/WorldViewer'
import { WorldSidebar } from './components/WorldSidebar'
import { BottomLeftControls, ViewerModeHotkeys } from './components/BottomLeftControls'
import { TouchControls } from './components/TouchControls'
import { useSceneProject } from './modules/scene/useSceneProject'
import { fetchWorlds, loadWorlds } from './utils/worldLoader'
import { useDebugStore } from './store/debug'
import { isEditableTarget } from './utils/dom'
import type { WorldEntry, WorldHoverPreview, WorldObjectAsset, World } from './types/world'
import { UploadOverlay } from './components/UploadOverlay'
const LevaPanel = import.meta.env.DEV
? lazy(() => import('leva').then((module) => ({ default: module.Leva })))
: null
const DebugPanel = import.meta.env.DEV
? lazy(() => import('./components/DebugPanel').then((module) => ({ default: module.DebugPanel })))
: null
export function App() {
const [worlds, setWorlds] = useState(loadWorlds)
const [refreshingWorlds, setRefreshingWorlds] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const refreshTimeoutRef = useRef<number | undefined>(undefined)
const [activeSlug, setActiveSlug] = useState<string | undefined>(worlds[0]?.slug)
const refreshWorlds = useCallback(async () => {
if (!import.meta.env.DEV) return
setRefreshingWorlds(true)
try {
const fetched = await fetchWorlds()
let combined: WorldEntry[] = []
setWorlds((prev) => {
const locals = prev.filter(w => w.slug.startsWith('local-'))
combined = [...locals, ...fetched]
return combined
})
setActiveSlug((prevSlug) => {
if (combined.length && !combined.find((w) => w.slug === prevSlug)) {
return combined[0].slug
}
return prevSlug
})
} catch (error) {
console.warn('Could not refresh local world assets.', error)
} finally {
setRefreshingWorlds(false)
}
}, [])
useEffect(() => {
refreshWorlds()
}, [refreshWorlds])
useEffect(() => {
if (!import.meta.env.DEV) return
const refreshSoon = () => {
window.clearTimeout(refreshTimeoutRef.current)
refreshTimeoutRef.current = window.setTimeout(() => {
void refreshWorlds()
}, 150)
}
import.meta.hot?.on('models-changed', refreshSoon)
return () => {
window.clearTimeout(refreshTimeoutRef.current)
import.meta.hot?.off('models-changed', refreshSoon)
}
}, [refreshWorlds])
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
const files = e.dataTransfer.files
if (!files.length) return
const file = files[0]
const lowerName = file.name.toLowerCase()
if (!lowerName.endsWith('.ply') && !lowerName.endsWith('.spz') && !lowerName.endsWith('.splat') && !lowerName.endsWith('.ksplat') && !lowerName.endsWith('.sog')) return
// Create a local object URL to view the file instantly without uploading it to the server!
const objectUrl = URL.createObjectURL(file)
const isSpz = lowerName.endsWith('.spz')
const worldId = 'local-' + Date.now()
const newWorld: World = {
world_id: worldId,
display_name: file.name,
assets: {
splats: {
ply_url: isSpz ? undefined : objectUrl,
spz_urls: isSpz ? { full_res: objectUrl } : {},
semantics_metadata: { metric_scale_factor: 1, ground_plane_offset: 0, flip_y: true }
},
mesh: { collider_mesh_url: '' },
imagery: { pano_url: '' },
thumbnail_url: '',
caption: ''
},
world_marble_url: '',
tags: [],
world_prompt: '',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
const newEntry: WorldEntry = {
slug: worldId,
project: { slug: worldId, display_name: file.name },
world: newWorld,
worldVersions: [{ index: 1, label: 'v1', complete: true, world: newWorld }],
objectAssets: [],
allObjectAssets: [],
sourceImageVersions: [],
worldSfxUrls: []
}
setWorlds(prev => [newEntry, ...prev])
setActiveSlug(worldId)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
}
if (!worlds.length && !uploading) {
return (
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
className="flex items-center justify-center h-screen text-white bg-black"
>
Drag and drop a .ply, .spz, .splat, .ksplat, or .sog file here to begin
</div>
)
}
return (
<div onDrop={handleDrop} onDragOver={handleDragOver} className="w-screen h-screen relative bg-black">
<LoadedApp
worlds={worlds}
activeSlug={activeSlug}
setActiveSlug={setActiveSlug}
refreshingWorlds={refreshingWorlds}
onRefreshWorlds={refreshWorlds}
/>
{uploading && <UploadOverlay progress={uploadProgress} />}
</div>
)
}
function LoadedApp({
worlds,
activeSlug,
setActiveSlug,
refreshingWorlds,
onRefreshWorlds,
}: {
worlds: WorldEntry[]
activeSlug: string | undefined
setActiveSlug: (slug: string) => void
refreshingWorlds: boolean
onRefreshWorlds: () => void
}) {
const [editing, setEditing] = useState(false)
const levaCollapsed = useDebugStore((s) => s.levaCollapsed)
const setLevaCollapsed = useDebugStore((s) => s.setLevaCollapsed)
const [uiHidden, setUiHidden] = useState(false)
const [sceneProjectEnabled, setSceneProjectEnabled] = useState(true)
const [selectedWorldVersions, setSelectedWorldVersions] = useState<Record<string, number>>({})
const [hoveredObjectAssetId, setHoveredObjectAssetId] = useState<string | null>(null)
const [hoveredObjectInstanceId, setHoveredObjectInstanceId] = useState<string | null>(null)
const [hoveredWorldPreview, setHoveredWorldPreview] = useState<WorldHoverPreview | null>(null)
const slug = activeSlug ?? worlds[0]?.slug
const entry = worlds.find((w) => w.slug === slug) ?? worlds[0]
const showLeva = import.meta.env.VITE_SHOW_LEVA === 'true'
const uiVisible = !uiHidden
const defaultWorldVersionIndex = entry?.worldVersions[entry.worldVersions.length - 1]?.index
const activeWorldVersionIndex = selectedWorldVersions[entry?.slug] ?? defaultWorldVersionIndex
const activeWorldVersion = entry?.worldVersions.find((version) => version.index === activeWorldVersionIndex)
const activeWorld = activeWorldVersion?.world ?? entry?.world
const renderableObjectAssets = entry?.objectAssets.filter((asset) => asset.complete && asset.url) || []
const renderableAllObjectAssets = entry?.allObjectAssets.filter((asset) => asset.complete && asset.url) || []
const { sceneProject, sceneProjectReady, updateSceneProject } = useSceneProject(entry?.slug || '', '', entry?.sceneProject)
const sceneProjectActive = Boolean(sceneProject && sceneProjectEnabled)
useEffect(() => {
setSceneProjectEnabled(true)
setHoveredObjectAssetId(null)
setHoveredObjectInstanceId(null)
setHoveredWorldPreview(null)
}, [entry?.slug])
const handleObjectHover = useCallback((asset: WorldObjectAsset, hovering: boolean, instanceId?: string) => {
setHoveredObjectAssetId((current) => {
if (hovering) return asset.assetId
return current === asset.assetId ? null : current
})
setHoveredObjectInstanceId((current) => {
if (hovering) return instanceId ?? null
return current === instanceId ? null : current
})
}, [])
const handleWorldHover = useCallback((preview: WorldHoverPreview, hovering: boolean) => {
setHoveredWorldPreview((current) => {
if (hovering) return preview
return current?.slug === preview.slug ? null : current
})
}, [])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return
if (event.code !== 'Backquote') return
event.preventDefault()
setUiHidden((hidden) => !hidden)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
if (!entry) return null
return (
<div className="relative w-screen h-screen bg-black overflow-hidden select-none [&_*]:focus:outline-none [&_*]:focus-visible:outline-none [&_*]:focus:ring-0 [&_*]:focus-visible:ring-0">
<ViewerModeHotkeys />
{!editing && LevaPanel && DebugPanel && showLeva && uiVisible && (
<div className="hidden md:block">
<Suspense fallback={null}>
<LevaPanel
collapsed={{ collapsed: levaCollapsed, onChange: setLevaCollapsed }}
theme={{ sizes: { rootWidth: '380px', controlWidth: '180px' } }}
/>
<DebugPanel />
</Suspense>
</div>
)}
<WorldViewer
world={activeWorld}
slug={entry.slug}
sourceImageUrl={entry.sourceImageUrl}
hoveredWorldPreview={hoveredWorldPreview}
objectAssets={renderableObjectAssets}
allObjectAssets={renderableAllObjectAssets}
worldSfxUrls={entry.worldSfxUrls}
sceneProject={editing || sceneProjectEnabled ? sceneProject : undefined}
sceneProjectReady={sceneProjectReady}
hoveredObjectAssetId={hoveredObjectAssetId}
hoveredObjectInstanceId={hoveredObjectInstanceId}
editing={editing}
setEditing={setEditing}
uiVisible={uiVisible}
onObjectHover={handleObjectHover}
onSceneProjectSaved={updateSceneProject}
onRefreshWorlds={onRefreshWorlds}
refreshingWorlds={refreshingWorlds}
/>
{uiVisible && (
<div className={`fixed inset-x-4 top-4 sm:left-4 sm:right-auto ${editing ? 'z-30' : 'z-10'}`}>
<WorldSidebar
worlds={worlds}
activeSlug={entry.slug}
setActiveSlug={setActiveSlug}
setEditing={setEditing}
compact={editing}
activeSceneProject={sceneProject}
activeSceneProjectEnabled={sceneProjectActive}
onActiveSceneProjectToggle={() => setSceneProjectEnabled((enabled) => !enabled)}
activeWorldVersionIndex={activeWorldVersionIndex}
hoveredObjectAssetId={hoveredObjectAssetId}
hoveredObjectInstanceId={hoveredObjectInstanceId}
onObjectHover={handleObjectHover}
onWorldHover={handleWorldHover}
onActiveWorldVersionChange={(index) => setSelectedWorldVersions((versions) => ({
...versions,
[entry.slug]: index,
}))}
/>
</div>
)}
{!editing && uiVisible && (
<>
<TouchControls />
</>
)}
{uiVisible && (
<div className="fixed inset-x-0 bottom-4 z-20 flex justify-center px-4 sm:left-4 sm:right-auto sm:justify-start sm:px-0">
<BottomLeftControls />
</div>
)}
</div>
)
}