feat(export): texture references — stamp sources at load, reference mode in GLB export (#532)

Bake v2 exporter side: library/preset, legacy scene-material, and item-GLB
textures get a validated pascalTextureRef stamped at load (origin-checked,
env-derived). Material-ish sources resolve to 'library-material' (storage
bucket) or 'app-material' (static catalog on the assets CDN) by URL shape.
exportSceneToGlb grows a textures: 'embed' | 'reference' option — reference
mode swaps stamped textures for 1x1 canvas-backed placeholders (skipping the
WebGPU blit) and writes the ref to both texture and image extras via a
GLTFExporter writeTexture plugin. Download GLB stays embed; the bake page
passes reference.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-22 08:33:38 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent cb6fadbc28
commit 0fba611a05
8 changed files with 657 additions and 12 deletions
+8
View File
@@ -119,6 +119,14 @@ export {
SCENE_THEMES,
type SceneTheme,
} from './lib/scene-themes'
export {
getPascalTextureRef,
type PascalTextureColorSpace,
type PascalTextureMap,
type PascalTextureRef,
stampPascalTextureRef,
textureMapForSlot,
} from './lib/texture-reference'
export { packNormalToRGB, unpackRGBToNormal } from './lib/tsl-compat'
export { useItemLightPool } from './store/use-item-light-pool'
export { applyCountryUnitDefault, default as useViewer } from './store/use-viewer'
+20 -1
View File
@@ -17,6 +17,7 @@ import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
import { resolveCdnUrl } from './asset-url'
import { isKtx2Url, ktx2Loader, whenKtx2Ready } from './ktx2-loader'
import { getSceneTheme } from './scene-themes'
import { stampPascalTextureRef } from './texture-reference'
export type RenderShading = 'solid' | 'rendered'
export type ColorPreset = 'clay' | 'white' | 'mono' | 'blueprint'
@@ -212,7 +213,10 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = pickTextureLoader(textureConfig.url).load(textureConfig.url)
const resolvedUrl = /^(?:asset|blob|data):/.test(textureConfig.url)
? textureConfig.url
: (resolveCdnUrl(textureConfig.url) ?? textureConfig.url)
const texture = pickTextureLoader(resolvedUrl).load(resolvedUrl)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
@@ -220,6 +224,11 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
texture.repeat.set(repeatX, repeatY)
texture.updateMatrix()
texture.colorSpace = THREE.SRGBColorSpace
stampPascalTextureRef(texture, {
kind: 'project-asset',
src: resolvedUrl,
slot: 'map',
})
textureCache.set(cacheKey, texture)
return texture
@@ -281,6 +290,11 @@ function getPresetTexture(
const texture = pickTextureLoader(resolvedPath).load(resolvedPath)
applyTextureProperties(texture, props, slot)
stampPascalTextureRef(texture, {
kind: 'material',
src: resolvedPath,
slot: slot ?? 'map',
})
setTextureCacheKey(texture, cacheKey)
textureCache.set(cacheKey, texture)
return texture
@@ -336,6 +350,11 @@ async function loadPresetTexture(
const promise = load
.then((texture) => {
applyTextureProperties(texture, props, slot)
stampPascalTextureRef(texture, {
kind: 'material',
src: resolvedPath,
slot: slot ?? 'map',
})
setTextureCacheKey(texture, cacheKey)
textureCache.set(cacheKey, texture)
textureLoadPromises.delete(cacheKey)
@@ -0,0 +1,102 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import * as THREE from 'three'
import { getPascalTextureRef, stampPascalTextureRef } from './texture-reference'
// The module reads the storage origin lazily on first use, so setting the env
// here (before any stamp call) pins it for the whole test file.
process.env.NEXT_PUBLIC_SUPABASE_URL ??= 'https://test-storage.supabase.co'
const STORAGE_ORIGIN = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin
describe('Pascal texture references', () => {
test("resolves 'material' input to library-material for storage-bucket URLs", () => {
const texture = new THREE.Texture()
texture.colorSpace = THREE.SRGBColorSpace
const src = `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/mtl_1/oak_basecolor_512.ktx2`
const ref = stampPascalTextureRef(texture, { kind: 'material', src, slot: 'map' })
expect(ref).toEqual({
v: 1,
kind: 'library-material',
src,
map: 'basecolor',
colorSpace: 'srgb',
})
expect(getPascalTextureRef(texture)).toEqual(ref)
})
test("resolves 'material' input to app-material for assets-CDN catalog URLs", () => {
const cdnOrigin = new URL(process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https://editor.pascal.app')
.origin
const texture = new THREE.Texture()
const src = `${cdnOrigin}/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2`
const ref = stampPascalTextureRef(texture, { kind: 'material', src, slot: 'normalMap' })
expect(ref).toEqual({ v: 1, kind: 'app-material', src, map: 'normal', colorSpace: 'linear' })
expect(getPascalTextureRef(texture)).toEqual(ref)
const foreign = new THREE.Texture()
expect(
stampPascalTextureRef(foreign, {
kind: 'material',
src: 'https://example.com/material/concrete/x/x_basecolor_512.ktx2',
slot: 'map',
}),
).toBeNull()
})
test('stamps the exact project-asset payload for Pascal storage URLs', () => {
const texture = new THREE.Texture()
texture.colorSpace = THREE.SRGBColorSpace
const ref = stampPascalTextureRef(texture, {
kind: 'project-asset',
src: `${STORAGE_ORIGIN}/storage/v1/object/public/project-assets/project/asset.png`,
slot: 'map',
})
expect(ref).toEqual({
v: 1,
kind: 'project-asset',
src: `${STORAGE_ORIGIN}/storage/v1/object/public/project-assets/project/asset.png`,
map: 'basecolor',
colorSpace: 'srgb',
})
expect(getPascalTextureRef(texture)).toEqual(ref)
})
test('keeps local and non-Pascal URLs unstamped', () => {
for (const src of [
'asset://project/asset',
'blob:https://editor.pascal.app/asset',
'data:image/png;base64,AAAA',
'https://example.com/storage/v1/object/public/project-assets/project/asset.png',
]) {
const texture = new THREE.Texture()
expect(stampPascalTextureRef(texture, { kind: 'project-asset', src, slot: 'map' })).toBeNull()
expect(texture.userData.pascalTextureRef).toBeUndefined()
}
})
test('includes imageIndex only for item GLB references', () => {
const texture = new THREE.Texture()
const ref = stampPascalTextureRef(texture, {
kind: 'item-glb',
src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/model.glb`,
slot: 'normalMap',
imageIndex: 2,
})
expect(ref).toEqual({
v: 1,
kind: 'item-glb',
src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/model.glb`,
imageIndex: 2,
map: 'normal',
colorSpace: 'linear',
})
})
})
@@ -0,0 +1,185 @@
import type * as THREE from 'three'
import { ASSETS_CDN_URL } from './asset-url'
export type PascalTextureMap =
| 'basecolor'
| 'normal'
| 'roughness'
| 'metalness'
| 'height'
| 'other'
export type PascalTextureColorSpace = 'srgb' | 'linear'
type PascalTextureRefBase = {
v: 1
src: string
map: PascalTextureMap
colorSpace: PascalTextureColorSpace
}
export type PascalTextureRef =
| (PascalTextureRefBase & {
kind: 'library-material' | 'app-material' | 'project-asset'
})
| (PascalTextureRefBase & {
kind: 'item-glb'
imageIndex: number
})
const STORAGE_BUCKET_BY_KIND = {
'library-material': 'materials',
'item-glb': 'items',
'project-asset': 'project-assets',
} as const
let cachedStorageOrigin: string | null | undefined
function pascalStorageOrigin(): string | null {
if (cachedStorageOrigin !== undefined) return cachedStorageOrigin
try {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
cachedStorageOrigin = url ? new URL(url).origin : null
} catch {
cachedStorageOrigin = null
}
return cachedStorageOrigin
}
/** Static catalog materials ship in the app's public dir and resolve through
* the assets CDN (`/material/{category}/{slug}/{slug}_{map}_{size}.ktx2`) —
* a server-known KTX2 source like the storage buckets, just app-hosted. */
function isAppMaterialUrl(src: string): boolean {
try {
const url = new URL(src)
return url.origin === new URL(ASSETS_CDN_URL).origin && url.pathname.startsWith('/material/')
} catch {
return false
}
}
const TEXTURE_MAPS = new Set<PascalTextureMap>([
'basecolor',
'normal',
'roughness',
'metalness',
'height',
'other',
])
function isPascalStorageUrl(src: string, kind: keyof typeof STORAGE_BUCKET_BY_KIND): boolean {
const origin = pascalStorageOrigin()
if (!origin) return false
try {
const url = new URL(src)
const bucket = STORAGE_BUCKET_BY_KIND[kind]
return url.origin === origin && url.pathname.startsWith(`/storage/v1/object/public/${bucket}/`)
} catch {
return false
}
}
export function textureMapForSlot(slot: string): PascalTextureMap {
switch (slot) {
case 'map':
return 'basecolor'
case 'normalMap':
return 'normal'
case 'roughnessMap':
return 'roughness'
case 'metalnessMap':
return 'metalness'
case 'displacementMap':
case 'bumpMap':
return 'height'
default:
return 'other'
}
}
function textureColorSpace(texture: THREE.Texture): PascalTextureColorSpace {
return texture.colorSpace === 'srgb' ? 'srgb' : 'linear'
}
export function stampPascalTextureRef(
texture: THREE.Texture,
input:
| {
/** 'material' resolves to library-material (storage bucket) or
* app-material (static catalog on the assets CDN) by URL shape. */
kind: 'material' | 'project-asset'
src: string
slot: string
}
| {
kind: 'item-glb'
src: string
slot: string
imageIndex: number
},
): PascalTextureRef | null {
const base = {
v: 1 as const,
src: input.src,
map: textureMapForSlot(input.slot),
colorSpace: textureColorSpace(texture),
}
let ref: PascalTextureRef
if (input.kind === 'item-glb') {
if (!isPascalStorageUrl(input.src, 'item-glb')) return null
if (!Number.isInteger(input.imageIndex) || input.imageIndex < 0) return null
ref = { ...base, kind: 'item-glb', imageIndex: input.imageIndex }
} else {
const kind =
input.kind === 'material'
? isPascalStorageUrl(input.src, 'library-material')
? 'library-material'
: isAppMaterialUrl(input.src)
? 'app-material'
: null
: isPascalStorageUrl(input.src, 'project-asset')
? 'project-asset'
: null
if (!kind) return null
ref = { ...base, kind }
}
texture.userData.pascalTextureRef = ref
return ref
}
export function getPascalTextureRef(texture: THREE.Texture): PascalTextureRef | null {
const raw = texture.userData.pascalTextureRef
if (!raw || typeof raw !== 'object') return null
const candidate = raw as Record<string, unknown>
const kind = candidate.kind
if (
candidate.v !== 1 ||
(kind !== 'library-material' &&
kind !== 'app-material' &&
kind !== 'item-glb' &&
kind !== 'project-asset') ||
typeof candidate.src !== 'string' ||
!(kind === 'app-material'
? isAppMaterialUrl(candidate.src)
: isPascalStorageUrl(candidate.src, kind)) ||
typeof candidate.map !== 'string' ||
!TEXTURE_MAPS.has(candidate.map as PascalTextureMap) ||
(candidate.colorSpace !== 'srgb' && candidate.colorSpace !== 'linear')
) {
return null
}
const base: PascalTextureRefBase = {
v: 1,
src: candidate.src,
map: candidate.map as PascalTextureMap,
colorSpace: candidate.colorSpace,
}
if (kind === 'item-glb') {
if (!Number.isInteger(candidate.imageIndex) || (candidate.imageIndex as number) < 0) return null
return { ...base, kind, imageIndex: candidate.imageIndex as number }
}
if (candidate.imageIndex !== undefined) return null
return { ...base, kind }
}