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
@@ -29,7 +29,9 @@ export function BakeExporter({
await nextFrames() await nextFrames()
const sceneGroup = scene.getObjectByName('scene-renderer') const sceneGroup = scene.getObjectByName('scene-renderer')
if (!sceneGroup) throw new Error('scene-renderer group not found') if (!sceneGroup) throw new Error('scene-renderer group not found')
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, {
textures: 'reference',
})
onComplete(buffer) onComplete(buffer)
} catch (err) { } catch (err) {
// The bake worker relays page console output into the job's error // The bake worker relays page console output into the job's error
+80 -1
View File
@@ -2,7 +2,13 @@ import { afterEach, describe, expect, test } from 'bun:test'
import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core' import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core'
import { buildDoorPreviewMesh } from '@pascal-app/viewer' import { buildDoorPreviewMesh } from '@pascal-app/viewer'
import * as THREE from 'three' import * as THREE from 'three'
import { prepareSceneForExport } from './glb-export' import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js'
import { prepareSceneForExport, writeTextureReferenceExtras } from './glb-export'
// The reference module reads the storage origin lazily on first use, so
// setting the env here (before any validation call) pins it for the file.
process.env.NEXT_PUBLIC_SUPABASE_URL ??= 'https://test-storage.supabase.co'
const STORAGE_ORIGIN = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin
afterEach(() => { afterEach(() => {
sceneRegistry.clear() sceneRegistry.clear()
@@ -62,6 +68,79 @@ describe('prepareSceneForExport', () => {
expect(meshes[0]!.material).toBe(meshes[1]!.material) expect(meshes[0]!.material).toBe(meshes[1]!.material)
}) })
test('reference mode swaps stamped compressed textures and leaves unstamped textures embedded', () => {
const root = new THREE.Group()
const stamped = new THREE.CompressedTexture([], 4, 4)
stamped.wrapS = THREE.MirroredRepeatWrapping
stamped.wrapT = THREE.ClampToEdgeWrapping
stamped.repeat.set(2, 3)
stamped.offset.set(0.25, 0.5)
stamped.center.set(0.5, 0.5)
stamped.rotation = 0.75
stamped.flipY = false
stamped.colorSpace = THREE.SRGBColorSpace
stamped.updateMatrix()
stamped.userData.pascalTextureRef = {
v: 1,
kind: 'library-material',
src: `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/material/oak_basecolor_512.ktx2`,
map: 'basecolor',
colorSpace: 'srgb',
}
const unstamped = new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1)
root.add(
meshWithNodeMaterial(nodeMaterial({ map: stamped, normalMap: unstamped })),
meshWithNodeMaterial(nodeMaterial({ map: stamped })),
)
const { scene } = prepareSceneForExport(root, {}, { textures: 'reference' })
const material = (scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial
const placeholder = material.map as THREE.DataTexture
expect(placeholder).not.toBe(stamped)
expect(placeholder.isDataTexture).toBe(true)
expect(
(placeholder as THREE.DataTexture & { isCompressedTexture?: boolean }).isCompressedTexture,
).toBeUndefined()
expect(placeholder.image.width).toBe(1)
expect(placeholder.image.height).toBe(1)
expect(Array.from(placeholder.image.data!)).toEqual([255, 255, 255, 255])
expect(placeholder.wrapS).toBe(stamped.wrapS)
expect(placeholder.wrapT).toBe(stamped.wrapT)
expect(placeholder.repeat.toArray()).toEqual(stamped.repeat.toArray())
expect(placeholder.offset.toArray()).toEqual(stamped.offset.toArray())
expect(placeholder.center.toArray()).toEqual(stamped.center.toArray())
expect(placeholder.rotation).toBe(stamped.rotation)
expect(placeholder.flipY).toBe(stamped.flipY)
expect(placeholder.colorSpace).toBe(stamped.colorSpace)
expect(placeholder.userData.pascalTextureRef).toEqual(stamped.userData.pascalTextureRef)
expect(material.normalMap).toBe(unstamped)
const sharedMaterial = (scene.children[1] as THREE.Mesh).material as THREE.MeshStandardMaterial
expect(sharedMaterial.map).toBe(placeholder)
})
test('writes identical texture-reference extras to the texture and image definitions', () => {
const texture = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1)
const ref = {
v: 1,
kind: 'item-glb',
src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/models/chair.glb`,
imageIndex: 3,
map: 'normal',
colorSpace: 'linear',
}
texture.userData.pascalTextureRef = ref
const imageDef: { extras?: Record<string, unknown> } = {}
const textureDef: { source: number; extras?: Record<string, unknown> } = { source: 0 }
const writer = { json: { images: [imageDef] } } as unknown as GLTFWriter
writeTextureReferenceExtras(writer, texture, textureDef)
expect(textureDef.extras?.pascalTextureRef).toEqual(ref)
expect(imageDef.extras?.pascalTextureRef).toEqual(ref)
expect(textureDef.extras?.pascalTextureRef).toEqual(imageDef.extras?.pascalTextureRef)
})
test('strips editor overlays that live off the scene layer', () => { test('strips editor overlays that live off the scene layer', () => {
const root = new THREE.Group() const root = new THREE.Group()
const realMesh = meshWithNodeMaterial(nodeMaterial()) const realMesh = meshWithNodeMaterial(nodeMaterial())
+182 -7
View File
@@ -13,6 +13,7 @@ import {
type ZoneNode, type ZoneNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
getPascalTextureRef,
poseDoorMovingParts, poseDoorMovingParts,
poseWindowMovingParts, poseWindowMovingParts,
SCENE_LAYER, SCENE_LAYER,
@@ -20,7 +21,11 @@ import {
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import type { Object3D } from 'three' import type { Object3D } from 'three'
import * as THREE from 'three' import * as THREE from 'three'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' import {
GLTFExporter,
type GLTFExporterPlugin,
type GLTFWriter,
} from 'three/examples/jsm/exporters/GLTFExporter.js'
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
/** /**
@@ -41,6 +46,10 @@ export type GlbExport = {
animations: THREE.AnimationClip[] animations: THREE.AnimationClip[]
} }
export type GlbExportOptions = {
textures?: 'embed' | 'reference'
}
/** Resolve after the next couple of animation frames, giving React/R3F time to /** Resolve after the next couple of animation frames, giving React/R3F time to
* commit and mount export-only geometry (e.g. instanced kinds' real meshes) * commit and mount export-only geometry (e.g. instanced kinds' real meshes)
* before the exporter clones the scene graph. Callers must set * before the exporter clones the scene graph. Callers must set
@@ -51,10 +60,63 @@ export function nextFrames(): Promise<void> {
}) })
} }
type GltfExtrasDef = {
extras?: Record<string, unknown>
}
type TextureReferenceWriter = GLTFWriter & {
json: {
images?: GltfExtrasDef[]
}
}
function getExportedImageIndex(textureDef: Record<string, unknown>): number | null {
if (Number.isInteger(textureDef.source)) return textureDef.source as number
const extensions = textureDef.extensions as Record<string, { source?: unknown }> | undefined
const source = extensions?.EXT_texture_webp?.source ?? extensions?.EXT_texture_avif?.source
return Number.isInteger(source) ? (source as number) : null
}
export function writeTextureReferenceExtras(
writer: GLTFWriter,
texture: THREE.Texture,
textureDef: Record<string, unknown>,
) {
const ref = getPascalTextureRef(texture)
if (!ref) return
const imageIndex = getExportedImageIndex(textureDef)
const imageDef =
imageIndex === null ? undefined : (writer as TextureReferenceWriter).json.images?.[imageIndex]
if (!imageDef) {
throw new Error('GLTFExporter did not expose an image for a referenced Pascal texture')
}
const textureWithExtras = textureDef as GltfExtrasDef
textureWithExtras.extras = {
...textureWithExtras.extras,
pascalTextureRef: ref,
}
imageDef.extras = {
...imageDef.extras,
pascalTextureRef: ref,
}
}
function textureReferencePlugin(writer: GLTFWriter): GLTFExporterPlugin {
return {
writeTexture: (texture, textureDef) => {
writeTextureReferenceExtras(writer, texture, textureDef)
},
}
}
export async function exportSceneToGlb( export async function exportSceneToGlb(
sceneGroup: Object3D, sceneGroup: Object3D,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
options: GlbExportOptions = {},
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const textureMode = options.textures ?? 'embed'
emitter.emit('thumbnail:before-capture', undefined) emitter.emit('thumbnail:before-capture', undefined)
// Snap levels to their true stacked positions (like thumbnail capture) so the // Snap levels to their true stacked positions (like thumbnail capture) so the
// export always reflects the clean stacked building, regardless of the live // export always reflects the clean stacked building, regardless of the live
@@ -63,7 +125,10 @@ export async function exportSceneToGlb(
const restoreLevels = snapLevelsToTruePositions() const restoreLevels = snapLevelsToTruePositions()
let prepared: ReturnType<typeof prepareSceneForExport> let prepared: ReturnType<typeof prepareSceneForExport>
try { try {
prepared = prepareSceneForExport(sceneGroup, nodes) prepared =
textureMode === 'reference'
? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' })
: prepareSceneForExport(sceneGroup, nodes)
} finally { } finally {
restoreLevels() restoreLevels()
emitter.emit('thumbnail:after-capture', undefined) emitter.emit('thumbnail:after-capture', undefined)
@@ -71,6 +136,7 @@ export async function exportSceneToGlb(
const { scene: exportScene, animations } = prepared const { scene: exportScene, animations } = prepared
const exporter = new GLTFExporter() const exporter = new GLTFExporter()
if (textureMode === 'reference') exporter.register(textureReferencePlugin)
// Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read
// those directly. WebGPUTextureUtils blits each one to RGBA on its own // those directly. WebGPUTextureUtils blits each one to RGBA on its own
// offscreen renderer (passing the live renderer would resize/draw over the // offscreen renderer (passing the live renderer would resize/draw over the
@@ -112,6 +178,7 @@ export async function exportSceneToGlb(
export function prepareSceneForExport( export function prepareSceneForExport(
source: THREE.Object3D, source: THREE.Object3D,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
options: GlbExportOptions = {},
): GlbExport { ): GlbExport {
const scene = source.clone(true) const scene = source.clone(true)
const cloneByOriginal = pairClones(source, scene) const cloneByOriginal = pairClones(source, scene)
@@ -142,7 +209,7 @@ export function prepareSceneForExport(
pruneNonRenderableMeshes(scene, identityNodes) pruneNonRenderableMeshes(scene, identityNodes)
sanitizeMaterialGroups(scene, identityNodes) sanitizeMaterialGroups(scene, identityNodes)
convertMaterials(scene) convertMaterials(scene, options.textures ?? 'embed')
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes) const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
@@ -352,14 +419,31 @@ const STANDARD_MAP_SLOTS = [
'bumpMap', 'bumpMap',
] as const ] as const
function convertMaterials(root: THREE.Object3D) { const REFERENCE_MAP_SLOTS = [
...STANDARD_MAP_SLOTS,
'clearcoatMap',
'clearcoatNormalMap',
'clearcoatRoughnessMap',
'iridescenceMap',
'iridescenceThicknessMap',
'transmissionMap',
'thicknessMap',
'specularIntensityMap',
'specularColorMap',
'sheenRoughnessMap',
'sheenColorMap',
'anisotropyMap',
] as const
function convertMaterials(root: THREE.Object3D, textureMode: 'embed' | 'reference') {
const cache = new Map<THREE.Material, THREE.Material>() const cache = new Map<THREE.Material, THREE.Material>()
const placeholderCache = new Map<THREE.Texture, THREE.Texture>()
root.traverse((object) => { root.traverse((object) => {
const mesh = object as THREE.Mesh const mesh = object as THREE.Mesh
if (!mesh.isMesh) return if (!mesh.isMesh) return
const material = mesh.material const material = mesh.material
if (Array.isArray(material)) { if (Array.isArray(material)) {
mesh.material = material.map((m) => convertMaterial(m, cache)) mesh.material = material.map((m) => convertMaterial(m, cache, textureMode, placeholderCache))
return return
} }
// glTF has no BackSide — GLTFExporter renders the *front* face for any // glTF has no BackSide — GLTFExporter renders the *front* face for any
@@ -373,7 +457,7 @@ function convertMaterials(root: THREE.Object3D) {
) { ) {
mesh.geometry = flipGeometryWinding(mesh.geometry) mesh.geometry = flipGeometryWinding(mesh.geometry)
} }
mesh.material = convertMaterial(material, cache) mesh.material = convertMaterial(material, cache, textureMode, placeholderCache)
}) })
} }
@@ -423,8 +507,19 @@ function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeomet
function convertMaterial( function convertMaterial(
material: THREE.Material, material: THREE.Material,
cache: Map<THREE.Material, THREE.Material>, cache: Map<THREE.Material, THREE.Material>,
textureMode: 'embed' | 'reference',
placeholderCache: Map<THREE.Texture, THREE.Texture>,
): THREE.Material { ): THREE.Material {
if ((material as { isNodeMaterial?: boolean }).isNodeMaterial !== true) return material const isNodeMaterial = (material as { isNodeMaterial?: boolean }).isNodeMaterial === true
if (!isNodeMaterial) {
if (textureMode === 'embed') return material
const cached = cache.get(material)
if (cached) return cached
const target = material.clone()
replaceReferencedTextures(target, placeholderCache)
cache.set(material, target)
return target
}
const cached = cache.get(material) const cached = cache.get(material)
if (cached) return cached if (cached) return cached
@@ -465,10 +560,90 @@ function convertMaterial(
} }
} }
if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache)
cache.set(material, target) cache.set(material, target)
return target return target
} }
function replaceReferencedTextures(
material: THREE.Material,
placeholderCache: Map<THREE.Texture, THREE.Texture>,
) {
const textureMaterial = material as THREE.Material & Record<string, unknown>
for (const slot of REFERENCE_MAP_SLOTS) {
const texture = textureMaterial[slot]
if (!(texture instanceof THREE.Texture) || !getPascalTextureRef(texture)) continue
let placeholder = placeholderCache.get(texture)
if (!placeholder) {
placeholder = createReferencePlaceholder(texture)
placeholderCache.set(texture, placeholder)
}
textureMaterial[slot] = placeholder
}
}
/** GLTFExporter serializes images via canvas drawImage/createImageBitmap,
* which reject a DataTexture's raw `{data,width,height}` image — so in DOM
* environments the placeholder must be canvas-backed. The DataTexture branch
* covers non-DOM runs (bun tests), where the exporter itself never runs. */
function createPlaceholderCanvas(): OffscreenCanvas | HTMLCanvasElement | null {
const canvas =
typeof OffscreenCanvas !== 'undefined'
? new OffscreenCanvas(1, 1)
: typeof document !== 'undefined'
? Object.assign(document.createElement('canvas'), { width: 1, height: 1 })
: null
if (!canvas) return null
const ctx = canvas.getContext('2d') as
| OffscreenCanvasRenderingContext2D
| CanvasRenderingContext2D
| null
if (!ctx) return null
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, 1, 1)
return canvas
}
function createReferencePlaceholder(texture: THREE.Texture): THREE.Texture {
const ref = getPascalTextureRef(texture)
if (!ref) throw new Error('Cannot create a placeholder for an invalid Pascal texture reference')
const canvas = createPlaceholderCanvas()
const placeholder = canvas
? new THREE.Texture(canvas)
: new THREE.DataTexture(
new Uint8Array([255, 255, 255, 255]),
1,
1,
THREE.RGBAFormat,
THREE.UnsignedByteType,
)
placeholder.name = texture.name
placeholder.mapping = texture.mapping
placeholder.channel = texture.channel
placeholder.wrapS = texture.wrapS
placeholder.wrapT = texture.wrapT
placeholder.magFilter = texture.magFilter
placeholder.minFilter = texture.minFilter
placeholder.anisotropy = texture.anisotropy
placeholder.offset.copy(texture.offset)
placeholder.repeat.copy(texture.repeat)
placeholder.center.copy(texture.center)
placeholder.rotation = texture.rotation
placeholder.matrixAutoUpdate = texture.matrixAutoUpdate
placeholder.matrix.copy(texture.matrix)
placeholder.generateMipmaps = texture.generateMipmaps
placeholder.premultiplyAlpha = texture.premultiplyAlpha
placeholder.flipY = texture.flipY
placeholder.unpackAlignment = texture.unpackAlignment
placeholder.colorSpace = texture.colorSpace
placeholder.userData = { pascalTextureRef: ref }
placeholder.needsUpdate = true
return placeholder
}
// --- Animation clip baking ---------------------------------------------- // --- Animation clip baking ----------------------------------------------
function bakeAnimationClips( function bakeAnimationClips(
+77 -2
View File
@@ -29,6 +29,7 @@ import {
type RenderShading, type RenderShading,
resolveCdnUrl, resolveCdnUrl,
resolveMaterialRef, resolveMaterialRef,
stampPascalTextureRef,
useItemLightPool, useItemLightPool,
useNodeEvents, useNodeEvents,
useViewer, useViewer,
@@ -38,7 +39,7 @@ import { Clone } from '@react-three/drei/core/Clone'
import { useFrame, useLoader, useThree } from '@react-three/fiber' import { useFrame, useLoader, useThree } from '@react-three/fiber'
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three' import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
import { MathUtils } from 'three' import { MathUtils, Texture } from 'three'
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js' import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
@@ -225,11 +226,85 @@ type LoadedItemGltf = GLTF & {
nodes: Record<string, Object3D> nodes: Record<string, Object3D>
} }
const ITEM_TEXTURE_SLOTS = [
'map',
'normalMap',
'roughnessMap',
'metalnessMap',
'emissiveMap',
'aoMap',
'alphaMap',
'lightMap',
'bumpMap',
'displacementMap',
'clearcoatMap',
'clearcoatNormalMap',
'clearcoatRoughnessMap',
'iridescenceMap',
'iridescenceThicknessMap',
'transmissionMap',
'thicknessMap',
'specularIntensityMap',
'specularColorMap',
'sheenRoughnessMap',
'sheenColorMap',
'anisotropyMap',
] as const
function getItemTextureImageIndex(gltf: LoadedItemGltf, texture: Texture): number | null {
const association = gltf.parser?.associations.get(texture)
const textureIndex = association?.textures
if (!Number.isInteger(textureIndex)) return null
const textureDef = gltf.parser.json.textures?.[textureIndex as number]
const imageIndex =
textureDef?.extensions?.KHR_texture_basisu?.source ??
textureDef?.extensions?.EXT_texture_webp?.source ??
textureDef?.extensions?.EXT_texture_avif?.source ??
textureDef?.source
return Number.isInteger(imageIndex) && imageIndex >= 0 ? imageIndex : null
}
function stampItemTextureReferences(gltf: LoadedItemGltf, src: string) {
if (!gltf.parser?.associations) return
const stamped = new Set<Texture>()
gltf.scene.traverse((object) => {
const mesh = object as Mesh
if (!mesh.isMesh) return
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
for (const material of materials) {
const textureMaterial = material as Material & Record<string, unknown>
for (const slot of ITEM_TEXTURE_SLOTS) {
const texture = textureMaterial[slot]
if (!(texture instanceof Texture)) continue
if (stamped.has(texture)) continue
const imageIndex = getItemTextureImageIndex(gltf, texture)
if (imageIndex === null) continue
if (
stampPascalTextureRef(texture, {
kind: 'item-glb',
src,
slot,
imageIndex,
})
) {
stamped.add(texture)
}
}
}
})
}
const useItemGltf = (url: string): LoadedItemGltf => { const useItemGltf = (url: string): LoadedItemGltf => {
const renderer = useThree((state) => state.gl) const renderer = useThree((state) => state.gl)
return useLoader(ItemGLTFLoader, url, (loader) => const gltf = useLoader(ItemGLTFLoader, url, (loader) =>
configureItemModelLoader(loader, renderer), configureItemModelLoader(loader, renderer),
) as LoadedItemGltf ) as LoadedItemGltf
return useMemo(() => {
stampItemTextureReferences(gltf, url)
return gltf
}, [gltf, url])
} }
type DeferredUnavailableCleanup = { type DeferredUnavailableCleanup = {
+8
View File
@@ -119,6 +119,14 @@ export {
SCENE_THEMES, SCENE_THEMES,
type SceneTheme, type SceneTheme,
} from './lib/scene-themes' } 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 { packNormalToRGB, unpackRGBToNormal } from './lib/tsl-compat'
export { useItemLightPool } from './store/use-item-light-pool' export { useItemLightPool } from './store/use-item-light-pool'
export { applyCountryUnitDefault, default as useViewer } from './store/use-viewer' 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 { resolveCdnUrl } from './asset-url'
import { isKtx2Url, ktx2Loader, whenKtx2Ready } from './ktx2-loader' import { isKtx2Url, ktx2Loader, whenKtx2Ready } from './ktx2-loader'
import { getSceneTheme } from './scene-themes' import { getSceneTheme } from './scene-themes'
import { stampPascalTextureRef } from './texture-reference'
export type RenderShading = 'solid' | 'rendered' export type RenderShading = 'solid' | 'rendered'
export type ColorPreset = 'clay' | 'white' | 'mono' | 'blueprint' export type ColorPreset = 'clay' | 'white' | 'mono' | 'blueprint'
@@ -212,7 +213,10 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
const cached = textureCache.get(cacheKey) const cached = textureCache.get(cacheKey)
if (cached) return cached 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.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping texture.wrapT = THREE.RepeatWrapping
@@ -220,6 +224,11 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
texture.repeat.set(repeatX, repeatY) texture.repeat.set(repeatX, repeatY)
texture.updateMatrix() texture.updateMatrix()
texture.colorSpace = THREE.SRGBColorSpace texture.colorSpace = THREE.SRGBColorSpace
stampPascalTextureRef(texture, {
kind: 'project-asset',
src: resolvedUrl,
slot: 'map',
})
textureCache.set(cacheKey, texture) textureCache.set(cacheKey, texture)
return texture return texture
@@ -281,6 +290,11 @@ function getPresetTexture(
const texture = pickTextureLoader(resolvedPath).load(resolvedPath) const texture = pickTextureLoader(resolvedPath).load(resolvedPath)
applyTextureProperties(texture, props, slot) applyTextureProperties(texture, props, slot)
stampPascalTextureRef(texture, {
kind: 'material',
src: resolvedPath,
slot: slot ?? 'map',
})
setTextureCacheKey(texture, cacheKey) setTextureCacheKey(texture, cacheKey)
textureCache.set(cacheKey, texture) textureCache.set(cacheKey, texture)
return texture return texture
@@ -336,6 +350,11 @@ async function loadPresetTexture(
const promise = load const promise = load
.then((texture) => { .then((texture) => {
applyTextureProperties(texture, props, slot) applyTextureProperties(texture, props, slot)
stampPascalTextureRef(texture, {
kind: 'material',
src: resolvedPath,
slot: slot ?? 'map',
})
setTextureCacheKey(texture, cacheKey) setTextureCacheKey(texture, cacheKey)
textureCache.set(cacheKey, texture) textureCache.set(cacheKey, texture)
textureLoadPromises.delete(cacheKey) 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 }
}