diff --git a/packages/viewer/src/lib/materials.test.ts b/packages/viewer/src/lib/materials.test.ts new file mode 100644 index 00000000..57d170eb --- /dev/null +++ b/packages/viewer/src/lib/materials.test.ts @@ -0,0 +1,32 @@ +// @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 type { MaterialSchema } from '@pascal-app/core' +import { getTextureKey, resolveTextureRepeat } from './materials' + +function materialWithRepeat(repeat: unknown): MaterialSchema { + return { + texture: { + url: 'https://example.com/texture.png', + repeat, + }, + } as unknown as MaterialSchema +} + +describe('legacy texture repeat values', () => { + test('normalizes tuple, scalar, and Vector2-shaped repeats', () => { + expect(resolveTextureRepeat([2, 3], undefined)).toEqual([2, 3]) + expect(resolveTextureRepeat(2, undefined)).toEqual([2, 2]) + expect(resolveTextureRepeat({ x: 2, y: 3 }, undefined)).toEqual([2, 3]) + }) + + test('falls back to scale for malformed repeats', () => { + expect(resolveTextureRepeat({ width: 2 }, 4)).toEqual([4, 4]) + }) + + test('keeps distinct Vector2-shaped repeats in distinct cache entries', () => { + expect(getTextureKey(materialWithRepeat({ x: 2, y: 3 }))).not.toBe( + getTextureKey(materialWithRepeat({ x: 4, y: 5 })), + ) + }) +}) diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index c150139d..504e3034 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -180,12 +180,28 @@ function getCacheKey(props: MaterialProperties, shading: RenderShading): string return `${shading}-${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}` } -function getTextureKey(material?: MaterialSchema): string { +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +export function resolveTextureRepeat(repeat: unknown, scale: unknown): [number, number] { + const fallback = isFiniteNumber(scale) ? scale : 1 + if (Array.isArray(repeat) && isFiniteNumber(repeat[0]) && isFiniteNumber(repeat[1])) { + return [repeat[0], repeat[1]] + } + if (isFiniteNumber(repeat)) return [repeat, repeat] + if (repeat && typeof repeat === 'object' && 'x' in repeat && 'y' in repeat) { + const { x, y } = repeat + if (isFiniteNumber(x) && isFiniteNumber(y)) return [x, y] + } + return [fallback, fallback] +} + +export function getTextureKey(material?: MaterialSchema): string { const texture = material?.texture if (!texture) return 'none' - const repeat = texture.repeat?.join('x') ?? 'default' - const scale = texture.scale ?? 'default' - return `${texture.url}-${repeat}-${scale}` + const [repeatX, repeatY] = resolveTextureRepeat(texture.repeat, texture.scale) + return `${texture.url}-${repeatX}x${repeatY}` } function getTexture(material?: MaterialSchema): THREE.Texture | undefined { @@ -200,8 +216,7 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined { texture.wrapS = THREE.RepeatWrapping texture.wrapT = THREE.RepeatWrapping - const repeatX = textureConfig.repeat?.[0] ?? textureConfig.scale ?? 1 - const repeatY = textureConfig.repeat?.[1] ?? textureConfig.scale ?? 1 + const [repeatX, repeatY] = resolveTextureRepeat(textureConfig.repeat, textureConfig.scale) texture.repeat.set(repeatX, repeatY) texture.updateMatrix() texture.colorSpace = THREE.SRGBColorSpace