diff --git a/src/frontend/src/features/texture-set/components/TextureSetList.tsx b/src/frontend/src/features/texture-set/components/TextureSetList.tsx
index 8f2fe60d..64ec6ff7 100644
--- a/src/frontend/src/features/texture-set/components/TextureSetList.tsx
+++ b/src/frontend/src/features/texture-set/components/TextureSetList.tsx
@@ -20,7 +20,7 @@ import {
import { CreateTextureSetDialog } from '@/features/texture-set/dialogs/CreateTextureSetDialog'
import { useTabContext } from '@/hooks/useTabContext'
import { useUploadProgress } from '@/hooks/useUploadProgress'
-import { useDragAndDrop } from '@/shared/hooks/useFileUpload'
+import { UploadDropZone } from '@/shared/components/UploadDropZone'
import { type TextureSetDto, TextureSetKind } from '@/types'
import { TextureSetGrid } from './TextureSetGrid'
@@ -227,12 +227,11 @@ export function TextureSetList() {
invalidateTextureSets()
}
- // Use drag and drop hook
- const { onDrop, onDragOver, onDragEnter, onDragLeave } =
- useDragAndDrop(handleFileDrop)
-
return (
-
+
@@ -309,10 +308,6 @@ export function TextureSetList() {
textureSets={textureSets}
loading={loading}
onTextureSetSelect={handleViewDetails}
- onDrop={onDrop}
- onDragOver={onDragOver}
- onDragEnter={onDragEnter}
- onDragLeave={onDragLeave}
onTextureSetRecycled={handleTextureSetRecycled}
onTextureSetUpdated={invalidateTextureSets}
/>
@@ -346,6 +341,6 @@ export function TextureSetList() {
onSubmit={handleCreateTextureSet}
/>
)}
-
+
)
}
diff --git a/src/frontend/src/mocks/dynamic-demo/shared.ts b/src/frontend/src/mocks/dynamic-demo/shared.ts
index c0b5f869..efb4c245 100644
--- a/src/frontend/src/mocks/dynamic-demo/shared.ts
+++ b/src/frontend/src/mocks/dynamic-demo/shared.ts
@@ -33,6 +33,7 @@ import {
storeThumbnail,
} from '../db/demoDb'
import {
+ generateEnvironmentMapThumbnail,
generateExrChannelPreview,
generateHdrChannelPreview,
generateImageChannelPreview,
@@ -159,7 +160,7 @@ export const seedFileAssets: Record
= {
402: 'texture_albedo.png',
501: 'test-tone.wav',
601: 'hdri/potsdamer_platz_1k.hdr',
- 602: 'global texture/normal.exr',
+ 602: 'hdri/potsdamer_platz_1k.hdr',
}
export function paginate(items: T[], page: number, pageSize: number) {
@@ -687,16 +688,33 @@ export function generateVersionThumbnailAsync(
export function generateEnvironmentMapThumbnailAsync(
envMapId: number,
fileBlob: Blob,
+ fileName: string,
+ variantId?: number
+) {
+ generateEnvironmentMapThumbnail(fileBlob, fileName)
+ .then(preview => {
+ const writes: Promise[] = [
+ storeThumbnail(`envMapPreview:${envMapId}`, preview),
+ ]
+ if (variantId) {
+ writes.push(
+ storeThumbnail(`envMapVariantPreview:${variantId}`, preview)
+ )
+ }
+ return Promise.all(writes)
+ })
+ .catch(() => {})
+}
+
+export function generateEnvironmentMapVariantThumbnailAsync(
+ variantId: number,
+ fileBlob: Blob,
fileName: string
) {
- const lower = fileName.toLowerCase()
- const gen = lower.endsWith('.hdr')
- ? generateHdrChannelPreview(fileBlob, 'rgb')
- : lower.endsWith('.exr')
- ? generateExrChannelPreview(fileBlob, 'rgb')
- : generateImageChannelPreview(fileBlob, 'rgb')
- gen
- .then(preview => storeThumbnail(`envMapPreview:${envMapId}`, preview))
+ generateEnvironmentMapThumbnail(fileBlob, fileName)
+ .then(preview =>
+ storeThumbnail(`envMapVariantPreview:${variantId}`, preview)
+ )
.catch(() => {})
}
@@ -1177,13 +1195,35 @@ export async function prewarmSeedThumbnails(): Promise {
*/
export async function prewarmSeedEnvironmentMapThumbnails(): Promise {
const seedItems = [
- { envMapId: 1, fileId: 601, fileName: 'potsdamer_platz_1k.hdr' },
+ {
+ envMapId: 1,
+ variantId: 1,
+ fileId: 601,
+ fileName: 'potsdamer_platz_1k.hdr',
+ isPreviewVariant: true,
+ },
+ {
+ envMapId: 1,
+ variantId: 2,
+ fileId: 601,
+ fileName: 'potsdamer_platz_1k.hdr',
+ isPreviewVariant: false,
+ },
]
- for (const { envMapId, fileId, fileName } of seedItems) {
- const cacheKey = `envMapPreview:${envMapId}`
- const existing = await getThumbnail(cacheKey)
- if (existing) continue
+ for (const {
+ envMapId,
+ variantId,
+ fileId,
+ fileName,
+ isPreviewVariant,
+ } of seedItems) {
+ const variantKey = `envMapVariantPreview:${variantId}`
+ const mapKey = `envMapPreview:${envMapId}`
+
+ const existingVariant = await getThumbnail(variantKey)
+ const existingMap = isPreviewVariant ? await getThumbnail(mapKey) : null
+ if (existingVariant && (!isPreviewVariant || existingMap)) continue
const seedPath = seedFileAssets[fileId]
if (!seedPath) continue
@@ -1195,17 +1235,11 @@ export async function prewarmSeedEnvironmentMapThumbnails(): Promise {
if (!response.ok) continue
const blob = await response.blob()
- let preview: Blob
-
- if (fileName.endsWith('.hdr')) {
- preview = await generateHdrChannelPreview(blob, 'rgb')
- } else if (fileName.endsWith('.exr')) {
- preview = await generateExrChannelPreview(blob, 'rgb')
- } else {
- preview = await generateImageChannelPreview(blob, 'rgb')
+ const preview = await generateEnvironmentMapThumbnail(blob, fileName)
+ await storeThumbnail(variantKey, preview)
+ if (isPreviewVariant) {
+ await storeThumbnail(mapKey, preview)
}
-
- await storeThumbnail(cacheKey, preview)
} catch {
// Silently ignore — preview requests will still generate on demand.
}
diff --git a/src/frontend/src/mocks/dynamicDemoHandlers.ts b/src/frontend/src/mocks/dynamicDemoHandlers.ts
index 9b024b6a..ee48137e 100644
--- a/src/frontend/src/mocks/dynamicDemoHandlers.ts
+++ b/src/frontend/src/mocks/dynamicDemoHandlers.ts
@@ -16,6 +16,7 @@ import {
enrichModel,
fetchStaticAsset,
generateEnvironmentMapThumbnailAsync,
+ generateEnvironmentMapVariantThumbnailAsync,
generateExrChannelPreview,
generateHdrChannelPreview,
generateImageChannelPreview,
@@ -2070,11 +2071,20 @@ export const dynamicDemoHandlers = [
http.get(
'*/environment-maps/:id/variants/:variantId/preview',
async ({ params }) => {
+ const variantId = Number(params.variantId)
+
+ const cached = await getThumbnail(`envMapVariantPreview:${variantId}`)
+ if (cached) {
+ return new HttpResponse(cached, {
+ headers: { 'Content-Type': cached.type || 'image/png' },
+ })
+ }
+
const environmentMap = await getById('environmentMaps', Number(params.id))
if (!environmentMap) return new HttpResponse(null, { status: 404 })
const variant = (environmentMap.variants ?? []).find(
- item => item.id === Number(params.variantId) && !item.isDeleted
+ item => item.id === variantId && !item.isDeleted
)
if (!variant) return new HttpResponse(null, { status: 404 })
@@ -2273,7 +2283,8 @@ export const dynamicDemoHandlers = [
generateEnvironmentMapThumbnailAsync(
environmentMapId,
thumbnailSource,
- thumbnailSource.name
+ thumbnailSource.name,
+ variantId
)
}
@@ -2436,6 +2447,16 @@ export const dynamicDemoHandlers = [
syncEnvironmentMapDerivedFields(environmentMap)
await put('environmentMaps', environmentMap)
+ const variantThumbnailSource =
+ file ?? (cubeFaceFiles ? cubeFaceFiles.px : null)
+ if (variantThumbnailSource) {
+ generateEnvironmentMapVariantThumbnailAsync(
+ variantId,
+ variantThumbnailSource,
+ variantThumbnailSource.name
+ )
+ }
+
return HttpResponse.json({
variantId,
fileId,
@@ -2479,22 +2500,42 @@ export const dynamicDemoHandlers = [
await put('environmentMaps', environmentMap)
await removeThumbnail(`envMapPreview:${environmentMap.id}`)
- if (environmentMap.previewFileId) {
- const source = await loadEnvironmentMapPreviewBlob(
- environmentMap.previewFileId
- )
- if (source) {
+
+ const activeVariants = (environmentMap.variants ?? []).filter(
+ variant => !variant.isDeleted
+ )
+
+ for (const variant of activeVariants) {
+ await removeThumbnail(`envMapVariantPreview:${variant.id}`)
+
+ const variantFileId =
+ variant.previewFileId ??
+ variant.panoramicFile?.fileId ??
+ variant.cubeFaces?.px.fileId ??
+ variant.fileId ??
+ null
+ if (!variantFileId) continue
+
+ const source = await loadEnvironmentMapPreviewBlob(variantFileId)
+ if (!source) continue
+
+ if (variant.id === environmentMap.previewVariantId) {
generateEnvironmentMapThumbnailAsync(
environmentMap.id,
source.blob,
+ source.fileName,
+ variant.id
+ )
+ } else {
+ generateEnvironmentMapVariantThumbnailAsync(
+ variant.id,
+ source.blob,
source.fileName
)
}
}
- const regeneratedVariantIds = (environmentMap.variants ?? [])
- .filter(variant => !variant.isDeleted)
- .map(variant => variant.id)
+ const regeneratedVariantIds = activeVariants.map(variant => variant.id)
return HttpResponse.json({
message:
diff --git a/src/frontend/src/mocks/services/browserAssetProcessor.ts b/src/frontend/src/mocks/services/browserAssetProcessor.ts
index d42d17aa..7790fd57 100644
--- a/src/frontend/src/mocks/services/browserAssetProcessor.ts
+++ b/src/frontend/src/mocks/services/browserAssetProcessor.ts
@@ -8,18 +8,22 @@
* 2. Audio Waveforms — OfflineAudioContext peak extraction → Canvas 2D PNG
*/
import {
+ ACESFilmicToneMapping,
AmbientLight,
Box3,
CanvasTexture,
Color,
DirectionalLight,
+ EquirectangularReflectionMapping,
type Group,
Mesh,
MeshStandardMaterial,
type Object3D,
PerspectiveCamera,
+ PMREMGenerator,
Scene,
SphereGeometry,
+ type Texture,
Vector3,
WebGLRenderer,
} from 'three'
@@ -712,6 +716,109 @@ export async function generateTextureSetThumbnail(
}
}
+// ─── Environment Map Thumbnails ─────────────────────────────────────────
+
+async function loadEquirectangularEnvTexture(
+ blob: Blob,
+ fileName: string
+): Promise {
+ const lower = fileName.toLowerCase()
+ const url = URL.createObjectURL(blob)
+ try {
+ if (lower.endsWith('.hdr')) {
+ const { RGBELoader } = await import('three-stdlib')
+ const loader = new RGBELoader()
+ return await new Promise((resolve, reject) => {
+ loader.load(url, resolve as (tex: unknown) => void, undefined, reject)
+ })
+ }
+ if (lower.endsWith('.exr')) {
+ const { EXRLoader } = await import('three/addons/loaders/EXRLoader.js')
+ const loader = new EXRLoader()
+ return await new Promise((resolve, reject) => {
+ loader.load(url, resolve as (tex: unknown) => void, undefined, reject)
+ })
+ }
+ const bitmap = await createImageBitmap(blob)
+ return new CanvasTexture(bitmap as unknown as HTMLCanvasElement)
+ } finally {
+ URL.revokeObjectURL(url)
+ }
+}
+
+/**
+ * Render an environment map thumbnail as an animated WebP: a polished
+ * metallic sphere using the env map for IBL, orbited 360° around the
+ * camera. Mirrors the real asset-processor's environmentMapProcessor.js
+ * output — tone-mapped through ACES so HDRs no longer blow out to white.
+ */
+export async function generateEnvironmentMapThumbnail(
+ fileBlob: Blob,
+ fileName: string,
+ width = 256,
+ height = 256
+): Promise {
+ try {
+ const envTexture = await loadEquirectangularEnvTexture(fileBlob, fileName)
+ envTexture.mapping = EquirectangularReflectionMapping
+
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+
+ const renderer = new WebGLRenderer({
+ canvas,
+ alpha: true,
+ antialias: true,
+ preserveDrawingBuffer: true,
+ })
+ renderer.setSize(width, height)
+ renderer.setClearColor(0x2a2a2e, 1)
+ renderer.toneMapping = ACESFilmicToneMapping
+ renderer.toneMappingExposure = 1.0
+
+ const pmrem = new PMREMGenerator(renderer)
+ const prefiltered = pmrem.fromEquirectangular(envTexture).texture
+
+ const scene = new Scene()
+ scene.background = envTexture
+ scene.environment = prefiltered
+
+ const camera = new PerspectiveCamera(45, width / height, 0.01, 1000)
+
+ const geometry = new SphereGeometry(1, 64, 64)
+ const material = new MeshStandardMaterial({
+ color: 0xffffff,
+ metalness: 1.0,
+ roughness: 0.08,
+ envMapIntensity: 1.2,
+ })
+ scene.add(new Mesh(geometry, material))
+
+ const fov = camera.fov * (Math.PI / 180)
+ const distance = (2.0 / (2 * Math.tan(fov / 2))) * 1.8
+
+ const thumbnailBlob = await renderOrbitAnimation(
+ renderer,
+ scene,
+ camera,
+ distance,
+ width,
+ height
+ )
+
+ renderer.dispose()
+ pmrem.dispose()
+ geometry.dispose()
+ material.dispose()
+ envTexture.dispose()
+ prefiltered.dispose()
+ return thumbnailBlob
+ } catch {
+ return generatePlaceholderThumbnail(width, height, '#4a90d9')
+ }
+}
+
// ─── Audio Waveforms ────────────────────────────────────────────────────
/**
diff --git a/src/frontend/src/shared/components/UploadDropZone.css b/src/frontend/src/shared/components/UploadDropZone.css
new file mode 100644
index 00000000..8859d8bc
--- /dev/null
+++ b/src/frontend/src/shared/components/UploadDropZone.css
@@ -0,0 +1,19 @@
+/* Canonical drop-zone visual.
+ * Single source of truth for the file-drop affordance. The transparent
+ * baseline border keeps layout stable when the active state turns the
+ * border solid; only the color/background change on .drag-over so the
+ * page does not jump.
+ */
+.upload-drop-zone {
+ position: relative;
+ border: 2px dashed transparent;
+ background-color: transparent;
+ transition:
+ border-color 0.18s ease,
+ background-color 0.18s ease;
+}
+
+.upload-drop-zone.drag-over {
+ border-color: var(--primary-color, #3182ce);
+ background-color: rgba(49, 130, 206, 0.04);
+}
diff --git a/src/frontend/src/shared/components/UploadDropZone.tsx b/src/frontend/src/shared/components/UploadDropZone.tsx
new file mode 100644
index 00000000..9707ebee
--- /dev/null
+++ b/src/frontend/src/shared/components/UploadDropZone.tsx
@@ -0,0 +1,47 @@
+import { forwardRef, ReactNode } from 'react'
+
+import { useDragAndDrop } from '@/shared/hooks/useFileUpload'
+
+import './UploadDropZone.css'
+
+interface UploadDropZoneProps {
+ onFilesDropped: (files: File[]) => void
+ children: ReactNode
+ className?: string
+ disabled?: boolean
+}
+
+/**
+ * Canonical drop-zone wrapper. Provides a single visual treatment (dashed
+ * primary-color border + subtle background) for file drag-and-drop across
+ * the app, and routes drops to `onFilesDropped`.
+ *
+ * The wrapped element receives `.upload-drop-zone` plus any caller-provided
+ * `className` for layout, and toggles `.drag-over` while a file drag is over
+ * it. Tab drags and other non-file drags are ignored at the hook level.
+ *
+ * Pass `disabled` to opt out (e.g. while a modal owns drag handling).
+ */
+export const UploadDropZone = forwardRef(
+ function UploadDropZone(
+ { onFilesDropped, children, className, disabled = false },
+ ref
+ ) {
+ const handlers = useDragAndDrop(onFilesDropped)
+ const dragHandlers = disabled ? {} : handlers
+
+ return (
+
+ {children}
+
+ )
+ }
+)
+
+export default UploadDropZone
diff --git a/src/frontend/src/shared/components/UploadableGrid.css b/src/frontend/src/shared/components/UploadableGrid.css
deleted file mode 100644
index f33bdd64..00000000
--- a/src/frontend/src/shared/components/UploadableGrid.css
+++ /dev/null
@@ -1,67 +0,0 @@
-.uploadable-grid-container {
- position: relative;
- min-height: 200px;
-}
-
-.uploadable-grid-container.dragging {
- opacity: 0.6;
-}
-
-.uploadable-grid-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(59, 130, 246, 0.1);
- border: 2px dashed #3b82f6;
- border-radius: 8px;
- display: flex;
- align-items: center;
- justify-content: center;
- pointer-events: none;
- z-index: 100;
-}
-
-.uploadable-grid-overlay-content {
- text-align: center;
- color: #3b82f6;
-}
-
-.uploadable-grid-overlay-content i {
- font-size: 3rem;
- display: block;
- margin-bottom: 1rem;
-}
-
-.uploadable-grid-overlay-content p {
- font-size: 1.1rem;
- font-weight: 500;
- margin: 0;
-}
-
-.uploadable-grid-loading {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- text-align: center;
- background: rgba(255, 255, 255, 0.95);
- padding: 2rem;
- border-radius: 8px;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
- z-index: 101;
-}
-
-.uploadable-grid-loading i {
- font-size: 2.5rem;
- color: #3b82f6;
- display: block;
- margin-bottom: 1rem;
-}
-
-.uploadable-grid-loading p {
- margin: 0;
- font-size: 1rem;
- color: #666;
-}
diff --git a/src/frontend/src/shared/components/UploadableGrid.tsx b/src/frontend/src/shared/components/UploadableGrid.tsx
deleted file mode 100644
index ad33ee73..00000000
--- a/src/frontend/src/shared/components/UploadableGrid.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import './UploadableGrid.css'
-
-import { type ReactNode, useCallback, useRef, useState } from 'react'
-
-interface UploadableGridProps {
- children: ReactNode
- onFilesDropped: (files: File[]) => void
- isUploading?: boolean
- uploadMessage?: string
- className?: string
-}
-
-export function UploadableGrid({
- children,
- onFilesDropped,
- isUploading = false,
- uploadMessage = 'Drop files here to upload',
- className = '',
-}: UploadableGridProps) {
- const [isDragging, setIsDragging] = useState(false)
- const dragCounter = useRef(0)
-
- const handleDragEnter = useCallback((e: React.DragEvent) => {
- e.preventDefault()
- e.stopPropagation()
- dragCounter.current++
- if (dragCounter.current === 1) {
- setIsDragging(true)
- }
- }, [])
-
- const handleDragLeave = useCallback((e: React.DragEvent) => {
- e.preventDefault()
- e.stopPropagation()
- dragCounter.current--
- if (dragCounter.current === 0) {
- setIsDragging(false)
- }
- }, [])
-
- const handleDragOver = useCallback((e: React.DragEvent) => {
- e.preventDefault()
- e.stopPropagation()
- }, [])
-
- const handleDrop = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault()
- e.stopPropagation()
- dragCounter.current = 0
- setIsDragging(false)
-
- const files = Array.from(e.dataTransfer.files)
- if (files.length > 0) {
- onFilesDropped(files)
- }
- },
- [onFilesDropped]
- )
-
- return (
-
- {children}
- {isDragging && (
-
- )}
- {isUploading && (
-
- )}
-
- )
-}
diff --git a/src/frontend/src/shared/components/container-tabs/ContainerEnvironmentMapsTab.tsx b/src/frontend/src/shared/components/container-tabs/ContainerEnvironmentMapsTab.tsx
index 5b6c0880..e87003a0 100644
--- a/src/frontend/src/shared/components/container-tabs/ContainerEnvironmentMapsTab.tsx
+++ b/src/frontend/src/shared/components/container-tabs/ContainerEnvironmentMapsTab.tsx
@@ -8,7 +8,7 @@ import { useEffect, useRef } from 'react'
import { getEnvironmentMapPrimaryPreviewUrl } from '@/features/environment-map/utils/environmentMapUtils'
import { useTabContext } from '@/hooks/useTabContext'
-import { UploadableGrid } from '@/shared/components'
+import { UploadDropZone } from '@/shared/components/UploadDropZone'
import { useContainerEnvironmentMaps } from '@/shared/hooks/useContainerEnvironmentMaps'
import { type ContainerAdapter } from '@/shared/types/ContainerTypes'
@@ -60,10 +60,8 @@ export function ContainerEnvironmentMapsTab({
<>
-
@@ -137,7 +135,7 @@ export function ContainerEnvironmentMapsTab({
) : null}