From 472db4098106ff141ce11d63a9e6150149363e54 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 16:30:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(export):=20texture=20references=20?= =?UTF-8?q?=E2=80=94=20stamp=20sources=20at=20load,=20reference=20mode=20i?= =?UTF-8?q?n=20GLB=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/editor/bake-exporter.tsx | 4 +- packages/editor/src/lib/glb-export.test.ts | 81 +++++++- packages/editor/src/lib/glb-export.ts | 189 +++++++++++++++++- packages/nodes/src/item/renderer.tsx | 79 +++++++- packages/viewer/src/index.ts | 8 + packages/viewer/src/lib/materials.ts | 21 +- .../viewer/src/lib/texture-reference.test.ts | 102 ++++++++++ packages/viewer/src/lib/texture-reference.ts | 185 +++++++++++++++++ 8 files changed, 657 insertions(+), 12 deletions(-) create mode 100644 packages/viewer/src/lib/texture-reference.test.ts create mode 100644 packages/viewer/src/lib/texture-reference.ts diff --git a/packages/editor/src/components/editor/bake-exporter.tsx b/packages/editor/src/components/editor/bake-exporter.tsx index d58c4cbee..e84302cf0 100644 --- a/packages/editor/src/components/editor/bake-exporter.tsx +++ b/packages/editor/src/components/editor/bake-exporter.tsx @@ -29,7 +29,9 @@ export function BakeExporter({ await nextFrames() const sceneGroup = scene.getObjectByName('scene-renderer') 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) } catch (err) { // The bake worker relays page console output into the job's error diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 94c0e7de7..c9557bf9e 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -2,7 +2,13 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core' import { buildDoorPreviewMesh } from '@pascal-app/viewer' 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(() => { sceneRegistry.clear() @@ -62,6 +68,79 @@ describe('prepareSceneForExport', () => { 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 } = {} + const textureDef: { source: number; extras?: Record } = { 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', () => { const root = new THREE.Group() const realMesh = meshWithNodeMaterial(nodeMaterial()) diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index e6b67b5b5..ed0e40631 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -13,6 +13,7 @@ import { type ZoneNode, } from '@pascal-app/core' import { + getPascalTextureRef, poseDoorMovingParts, poseWindowMovingParts, SCENE_LAYER, @@ -20,7 +21,11 @@ import { } from '@pascal-app/viewer' import type { Object3D } 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' /** @@ -41,6 +46,10 @@ export type GlbExport = { animations: THREE.AnimationClip[] } +export type GlbExportOptions = { + textures?: 'embed' | 'reference' +} + /** 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) * before the exporter clones the scene graph. Callers must set @@ -51,10 +60,63 @@ export function nextFrames(): Promise { }) } +type GltfExtrasDef = { + extras?: Record +} + +type TextureReferenceWriter = GLTFWriter & { + json: { + images?: GltfExtrasDef[] + } +} + +function getExportedImageIndex(textureDef: Record): number | null { + if (Number.isInteger(textureDef.source)) return textureDef.source as number + const extensions = textureDef.extensions as Record | 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, +) { + 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( sceneGroup: Object3D, nodes: Record, + options: GlbExportOptions = {}, ): Promise { + const textureMode = options.textures ?? 'embed' emitter.emit('thumbnail:before-capture', undefined) // Snap levels to their true stacked positions (like thumbnail capture) so the // export always reflects the clean stacked building, regardless of the live @@ -63,7 +125,10 @@ export async function exportSceneToGlb( const restoreLevels = snapLevelsToTruePositions() let prepared: ReturnType try { - prepared = prepareSceneForExport(sceneGroup, nodes) + prepared = + textureMode === 'reference' + ? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' }) + : prepareSceneForExport(sceneGroup, nodes) } finally { restoreLevels() emitter.emit('thumbnail:after-capture', undefined) @@ -71,6 +136,7 @@ export async function exportSceneToGlb( const { scene: exportScene, animations } = prepared const exporter = new GLTFExporter() + if (textureMode === 'reference') exporter.register(textureReferencePlugin) // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read // those directly. WebGPUTextureUtils blits each one to RGBA on its own // offscreen renderer (passing the live renderer would resize/draw over the @@ -112,6 +178,7 @@ export async function exportSceneToGlb( export function prepareSceneForExport( source: THREE.Object3D, nodes: Record, + options: GlbExportOptions = {}, ): GlbExport { const scene = source.clone(true) const cloneByOriginal = pairClones(source, scene) @@ -142,7 +209,7 @@ export function prepareSceneForExport( pruneNonRenderableMeshes(scene, identityNodes) sanitizeMaterialGroups(scene, identityNodes) - convertMaterials(scene) + convertMaterials(scene, options.textures ?? 'embed') const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes) @@ -352,14 +419,31 @@ const STANDARD_MAP_SLOTS = [ 'bumpMap', ] 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() + const placeholderCache = new Map() root.traverse((object) => { const mesh = object as THREE.Mesh if (!mesh.isMesh) return const material = mesh.material if (Array.isArray(material)) { - mesh.material = material.map((m) => convertMaterial(m, cache)) + mesh.material = material.map((m) => convertMaterial(m, cache, textureMode, placeholderCache)) return } // 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.material = convertMaterial(material, cache) + mesh.material = convertMaterial(material, cache, textureMode, placeholderCache) }) } @@ -423,8 +507,19 @@ function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeomet function convertMaterial( material: THREE.Material, cache: Map, + textureMode: 'embed' | 'reference', + placeholderCache: Map, ): 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) if (cached) return cached @@ -465,10 +560,90 @@ function convertMaterial( } } + if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache) + cache.set(material, target) return target } +function replaceReferencedTextures( + material: THREE.Material, + placeholderCache: Map, +) { + const textureMaterial = material as THREE.Material & Record + 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 ---------------------------------------------- function bakeAnimationClips( diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 53b654559..b3e718469 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -29,6 +29,7 @@ import { type RenderShading, resolveCdnUrl, resolveMaterialRef, + stampPascalTextureRef, useItemLightPool, useNodeEvents, useViewer, @@ -38,7 +39,7 @@ import { Clone } from '@react-three/drei/core/Clone' import { useFrame, useLoader, useThree } from '@react-three/fiber' import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' 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 { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js' import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' @@ -225,11 +226,85 @@ type LoadedItemGltf = GLTF & { nodes: Record } +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() + 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 + 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 renderer = useThree((state) => state.gl) - return useLoader(ItemGLTFLoader, url, (loader) => + const gltf = useLoader(ItemGLTFLoader, url, (loader) => configureItemModelLoader(loader, renderer), ) as LoadedItemGltf + return useMemo(() => { + stampItemTextureReferences(gltf, url) + return gltf + }, [gltf, url]) } type DeferredUnavailableCleanup = { diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 8408cd88d..4a9dee64b 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -108,6 +108,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' diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 5504153d4..b4ecb3347 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -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) diff --git a/packages/viewer/src/lib/texture-reference.test.ts b/packages/viewer/src/lib/texture-reference.test.ts new file mode 100644 index 000000000..bb45b6121 --- /dev/null +++ b/packages/viewer/src/lib/texture-reference.test.ts @@ -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', + }) + }) +}) diff --git a/packages/viewer/src/lib/texture-reference.ts b/packages/viewer/src/lib/texture-reference.ts new file mode 100644 index 000000000..a11cbbda7 --- /dev/null +++ b/packages/viewer/src/lib/texture-reference.ts @@ -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([ + '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 + 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 } +}