Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/editor/src/components/editor/bake-exporter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 80 additions & 1 deletion packages/editor/src/lib/glb-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<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', () => {
const root = new THREE.Group()
const realMesh = meshWithNodeMaterial(nodeMaterial())
Expand Down
189 changes: 182 additions & 7 deletions packages/editor/src/lib/glb-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@ import {
type ZoneNode,
} from '@pascal-app/core'
import {
getPascalTextureRef,
poseDoorMovingParts,
poseWindowMovingParts,
SCENE_LAYER,
snapLevelsToTruePositions,
} 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'

/**
Expand All @@ -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
Expand All @@ -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,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared image extras overwritten

Medium Severity

In reference mode, writeTextureReferenceExtras writes pascalTextureRef onto the shared glTF images[] entry for every stamped map. Identical 1×1 placeholder pixels are often deduplicated to one image index, so each writeTexture overwrites imageDef.extras and only the last ref remains while earlier textures[] entries may still point at that image.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 472db40. Configure here.

}

function textureReferencePlugin(writer: GLTFWriter): GLTFExporterPlugin {
return {
writeTexture: (texture, textureDef) => {
writeTextureReferenceExtras(writer, texture, textureDef)
},
}
}

export async function exportSceneToGlb(
sceneGroup: Object3D,
nodes: Record<string, AnyNode>,
options: GlbExportOptions = {},
): Promise<ArrayBuffer> {
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
Expand All @@ -63,14 +125,18 @@ export async function exportSceneToGlb(
const restoreLevels = snapLevelsToTruePositions()
let prepared: ReturnType<typeof prepareSceneForExport>
try {
prepared = prepareSceneForExport(sceneGroup, nodes)
prepared =
textureMode === 'reference'
? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' })
: prepareSceneForExport(sceneGroup, nodes)
} finally {
restoreLevels()
emitter.emit('thumbnail:after-capture', undefined)
}
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
Expand Down Expand Up @@ -112,6 +178,7 @@ export async function exportSceneToGlb(
export function prepareSceneForExport(
source: THREE.Object3D,
nodes: Record<string, AnyNode>,
options: GlbExportOptions = {},
): GlbExport {
const scene = source.clone(true)
const cloneByOriginal = pairClones(source, scene)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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<THREE.Material, THREE.Material>()
const placeholderCache = new Map<THREE.Texture, THREE.Texture>()
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
Expand All @@ -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)
})
}

Expand Down Expand Up @@ -423,8 +507,19 @@ function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeomet
function convertMaterial(
material: THREE.Material,
cache: Map<THREE.Material, THREE.Material>,
textureMode: 'embed' | 'reference',
placeholderCache: Map<THREE.Texture, THREE.Texture>,
): 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
Expand Down Expand Up @@ -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<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 ----------------------------------------------

function bakeAnimationClips(
Expand Down
Loading
Loading