Improved harness - with card selection

This commit is contained in:
2026-09-15 06:08:43 -07:00
parent 97ed7f05c8
commit 786f63a042
29 changed files with 1398 additions and 494 deletions

View File

@@ -82,7 +82,7 @@ Available inspection poses:
| Back | `(-0.06, 180 degrees + 0.12 radians, 0)` |
| Free | Current manually controlled rotation |
Reset restores the Front pose, David, Holographic finish, Paper substrate, default surface values, default camera, and default light.
Reset restores the Front pose, BP-002 David, High/Normal, Holographic finish, Paper substrate, repository surface defaults, default camera, and default light.
## Light
@@ -114,9 +114,9 @@ Changing a light type, color, position, intensity, environment contribution, or
|---|---:|---:|
| Finish | Holographic | Printed ink / Foil / Holographic / Gold leaf / Frosted glass |
| Material | Paper | Paper / Linen / Metal / Wood / Leather |
| Finish strength | 0.60 | 0-1 |
| Roughness | 0.23 | 0.05-0.8 |
| Surface detail | 0.14 (Leather selection: 0.45) | 0-1.0 |
| Finish strength | Per finish: Holographic `0.33`, Foil `1.00`, others `0.60` | 0-1 |
| Roughness | Per material; initially `0.23` | 0.05-0.8 |
| Surface detail | Paper/Wood `0.14`, Linen `0.05`, Metal `1.00`, Leather `0.45` | 0-1.0 |
| Horizontal metal grain (Lab only) | Off (Vertical) | Off / On (Horizontal) |
| Environment intensity | 0.70 | 0-1.5 |
| Condition | 1.0 (mint) | 0-1 |
@@ -128,10 +128,12 @@ September 12 material tuning (pending visual review): Roughness now contributes
75% of effective substrate roughness, with a 25% material bias. Every step across
`0.05-0.8` changes highlight width; the former substrate floors/ceilings are removed.
Surface detail accepts `0-1.0` in Lab and saved experiment imports. Selecting a
material restores its detail default: Leather `0.45`, all others `0.14`. Imported
experiments retain their explicit detail value (clamped only to `0-1.0`); Reset
restores Paper with detail `0.14`. Existing relief caps remain to bound parallax
and prevent extreme recesses. Random Leather pack cards also use `0.45`.
finish or material applies its entry from `config/surface-defaults.json`; Lab can
reapply or save the current finish/material values to that repository file while
the development server is running. Imported experiments retain their explicit
values (clamped only to supported ranges); Reset resolves Holographic/Paper from
the same file. Existing relief caps remain to bound parallax and prevent extreme
recesses. New pack cards also resolve their rolled finish/material through the file.
Leather, Gold leaf, and Frosted glass are experimental
@@ -304,16 +306,16 @@ Condition uses `1.0` for mint and `0.0` for the most worn supported appearance.
## Local Asset Evaluation
Lab mode can replace the active artwork or finish mask with a local image through the browser file picker. Local files remain in browser memory, are not copied into the project, and are discarded when the page reloads or the selected fixture is restored. Reset restores the David reference artwork and mask.
Lab mode can replace the active artwork or finish mask with a local image through the browser file picker. Local files remain in browser memory, are not copied into the project, and are discarded when the page reloads or the selected card is restored. Reset restores BP-002 David at High/Normal.
## Experiment Snapshots
Lab mode can save named experiments in browser storage, export or import a versioned JSON snapshot, and capture the current canvas as PNG. Snapshot schema version 2 records:
Lab mode can save named experiments in browser storage, export or import a versioned JSON snapshot, and capture the current canvas as PNG. Snapshot schema version 3 records the stable card ID, accepted revision, resolution, and printing in addition to:
- `metalBrushHorizontal`: boolean (`false` = Vertical, `true` = Horizontal), including browser saves and JSON exports; omitted fields in older experiments restore Horizontal. Reset and all pack cards use Vertical.
- Runtime-look revision and save time
- Reference fixture and custom asset filenames
- Card selection and custom asset filenames
- Finish, substrate, and surface controls
- Condition and imperfection seed
- Lighting preset, environment, type, color, position, intensity, and exposure
@@ -321,13 +323,13 @@ Lab mode can save named experiments in browser storage, export or import a versi
Local image contents are intentionally not embedded in snapshots. When a snapshot records custom artwork or a custom mask, those files must be loaded again after restoring the configuration.
Schema version 1 snapshots remain loadable and restore as mint condition with seed `81251`.
Schema versions 1 and 2 remain loadable through the legacy fixture-name mapping; version 1 restores mint condition with seed `81251`.
## Source of Truth
- Geometry: `src/cardGeometry.ts`
- Runtime shader: `src/cardMaterial.ts`
- Scene and control defaults: `src/main.ts`
- Surface defaults: `config/surface-defaults.json`; scene/control behavior: `src/main.ts`
- Generated asset contract: `public/reference/manifest.json`
- Art policy: `../art-direction.md`
@@ -338,7 +340,7 @@ Pack is a third mode beside Inspect and Lab. It does not revise
- Controller: `src/packOpening.ts`; foil meshes: `src/foilWrapper.ts`; integration and lazy local-asset loading: `src/main.ts`.
- States: `sealed → opening → stackReady → lifting → lifted → revealing → inspecting → advancing → stackReady / complete`.
- Order: David / Printed ink / Linen; Timothy / Foil / Paper; David / Holographic / Metal. Surface defaults remain strength `0.60`, roughness `0.23`, detail `0.14`, mint condition and seed `81251`. Pack inherits the current lighting.
- Each ten-card pack rolls accepted card IDs, finishes, and materials independently. It uses the selected resolution/printing (High/Normal by default), resolves surface presets from `config/surface-defaults.json`, and inherits current lighting. Only current/next fronts are acquired; an outgoing texture lease is released after its advance completes.
- Wrapper: two `64 × 80` subdivided pouch surfaces joined at their side/bottom boundaries and one `64 × 10` top ribbon, sharing horizontal samples along the attached seam. Total 21,760 triangles. Dimensions: width `3.36`, bottom `-2.24`, tear seam `1.97`, top `2.28`; center depth `-0.09`, inflated half-depth `0.37`. Surface folds are shallow deterministic UV-based displacements; contact dent is bounded to `0.045` units. Side and bottom crimps remain separated surfaces, with no overlapping cap/label meshes or transparent fades.
- Foil material: deep teal printed canvas, warm gold seam and restrained typography; standard material roughness `0.43`, metalness `0.68`, double-sided. The existing scene lighting/environment lights the foil. This is a wrapper-specific recipe, not a card-material revision.
- Opening lasts `3200 ms` from sealed (remaining duration scales with retained progress); lift `620 ms`, reveal `820 ms`, advance `650 ms`. Elapsed-time smoothstep interpolation and quaternion rotation remain unchanged for card transitions.

View File

@@ -6,10 +6,10 @@ The approved visual baseline is recorded in [`CURRENT-DEFAULTS.md`](CURRENT-DEFA
## Included proof scope
- David printed-ink, foil, and holographic presets
- Independently selectable paper, linen, metal, and wood substrates
- Timothy as the second-artwork fixture
- David color-based coverage and title-panel protection; automatic saturation/value coverage for Timothy
- Searchable accepted-card library sourced directly from `artifacts/cards`
- Low, Med, and High resolution plus Normal, Borderless, Textless, and Boundless printing controls
- Independently selectable finishes and paper, linen, metal, wood, and leather substrates
- Per-finish and per-material defaults editable from Lab and versioned in the repository
- Procedural rounded-card geometry generated from shared physical dimensions
- Deterministic linen normal map
- Card manipulation, camera inspection, zoom, frame-rate-independent flip/reset, interruption-safe sweep, and movable lighting
@@ -190,11 +190,19 @@ Timothy regression artwork and universal card back from the project root, and
generates the two finish masks and linen normal map. Startup and production builds
run this automatically. No spike assets are required.
Inspect/Lab and Pack load artwork and masks from `public/card-art`; the generated
David and Timothy fixtures are retained for shader regression checks. The app
uses the generated universal back and linen normal map. Card geometry is generated
Inspect/Lab and Pack discover approved card packages directly from `../artifacts/cards`.
The development server exposes only allowlisted runtime PNGs through a contained
asset route; no copies are required in `public/card-art`. The generated David and
Timothy files in `public/reference` remain shader-regression fixtures. The app uses
the generated universal back and linen normal map. Card geometry is generated
directly in Three.js from the dimensions recorded in the manifest.
The toolbar defaults to High and exposes Low, Med, High plus all four printings.
The Card button opens a searchable, rarity-filterable accepted-card drawer whose
lazy thumbnails always use Low/Normal. Pack supports the same resolution and
printing selection. It streams only the current and next front, sharing duplicate
assets and releasing outgoing textures; hidden High fronts are not loaded en masse.
The current runtime reference revision is `runtime-look-v4-2026-09-07`. The approved
linen, metal, and wood depth follow-ups change runtime shader behavior, not the
source artwork or generated normal map. The asset manifest therefore retains its
@@ -270,11 +278,11 @@ The browser/version, viewport, drawing-buffer resolution, texture settings, and
## Optional text masks
Place `[image-name]-text-mask.png` next to `[image-name].png` and the required
`[image-name]-mask.png` in `public/card-art`. It must match artwork dimensions
and be opaque grayscale: **white protects lettering, black retains the material**.
Generate it from the rendered text layer with a small, recorded feathered margin;
it is separate from finish coverage and card silhouette alpha.
Accepted Normal and Borderless packages provide `text-mask.png` beside `card.png`
and `finish-mask.png` in each resolution/printing directory. It must match artwork
dimensions and be opaque grayscale: **white protects lettering, black retains the
material**. Generate it from the rendered text layer with a small, recorded feathered
margin; it is separate from finish coverage and card silhouette alpha.
The catalog exposes `textMask` only when the optional file exists, validates it,
and includes its bytes in cache revisions. Inspect/Lab and selected pack cards load
@@ -318,6 +326,9 @@ motion timings; idle gaps are excluded. Timing windows reset on mode
changes. ResizeObserver handles canvas layout changes. Pack cards share two
geometry buffers while retaining their individual transforms and materials.
Diagnostic readouts and timing-recording controls are hidden by default. **Show diagnostics**
in the toolbar reveals them; the current card/material status remains visible either way.
The diagnostic display also reports median CPU scene-update/render-submission time,
draw calls, resident renderer texture count, and shader program count. CPU submission
time can include driver waits; it is not a direct GPU timing measurement. Resource
@@ -332,9 +343,9 @@ Card faces now cull inward-facing triangles; the wrapper remains double-sided fo
its curls/opening. Uniform-only edge changes reuse the material shader version.
The overlay no longer blurs the animated canvas beneath it.
Pack resources stay resident after switching modes to preserve its state
and avoid repeating preparation. Catalog entries alone do not load artwork:
Inspect/Lab load the current fixture, and Pack loads its selected artwork only.
Pack wrapper, geometry, and placeholder resources stay resident after switching
modes to preserve state. Catalog entries alone do not load artwork: Inspect/Lab load
the selected variant, while Pack keeps at most the current and next front acquired.
For a useful comparison, keep viewport, card, pose, lighting, and motion identical;
allow at least four seconds for the 240-frame window to turn over at 60 Hz (longer

View File

@@ -0,0 +1,43 @@
{
"schemaVersion": 1,
"finishes": {
"Printed ink": {
"finishStrength": 0.6
},
"Foil": {
"finishStrength": 1.0
},
"Holographic": {
"finishStrength": 0.33
},
"Gold leaf": {
"finishStrength": 0.6
},
"Frosted glass": {
"finishStrength": 0.6
}
},
"materials": {
"Paper": {
"surfaceDetail": 0.14,
"roughness": 0.23
},
"Linen": {
"surfaceDetail": 0.05,
"roughness": 0.23
},
"Metal": {
"surfaceDetail": 1.0,
"roughness": 0.23,
"metalBrushHorizontal": false
},
"Wood": {
"surfaceDetail": 0.14,
"roughness": 0.23
},
"Leather": {
"surfaceDetail": 0.45,
"roughness": 0.23
}
}
}

View File

@@ -1,21 +0,0 @@
# Card Art Drop Folder
Add each card as one PNG artwork and one matching PNG finish mask:
```text
david.png
david-mask.png
```
The filename before `.png` is the card name shown in the harness. Names are
case-sensitive. Artwork and mask must:
- have identical dimensions;
- use a 5:7 aspect ratio;
- be fully opaque;
- use an opaque grayscale mask.
During `npm run dev`, adding or replacing either PNG refreshes the searchable
card catalog automatically. Reopening or focusing the Card field also forces a
rescan. Production builds generate `manifest.json` from this folder and fail if
any pair is missing or invalid.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -1,33 +0,0 @@
{
"schemaVersion": 1,
"cards": [
{
"name": "david",
"artwork": "/card-art/david.png?v=cc45a552b6516d2b",
"mask": "/card-art/david-mask.png?v=cc45a552b6516d2b",
"textMask": "/card-art/david-text-mask.png?v=cc45a552b6516d2b",
"revision": "cc45a552b6516d2b",
"width": 1000,
"height": 1400
},
{
"name": "the-fall",
"artwork": "/card-art/the-fall.png?v=ff92f7967bf1217b",
"mask": "/card-art/the-fall-mask.png?v=ff92f7967bf1217b",
"textMask": "/card-art/the-fall-text-mask.png?v=ff92f7967bf1217b",
"revision": "ff92f7967bf1217b",
"width": 1000,
"height": 1400
},
{
"name": "timothy",
"artwork": "/card-art/timothy.png?v=04a57871358d4c6d",
"mask": "/card-art/timothy-mask.png?v=04a57871358d4c6d",
"textMask": "/card-art/timothy-text-mask.png?v=04a57871358d4c6d",
"revision": "04a57871358d4c6d",
"width": 2000,
"height": 2800
}
],
"errors": []
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

View File

@@ -1,129 +1,104 @@
import { createHash } from 'node:crypto'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { readFile, readdir, realpath, stat } from 'node:fs/promises'
import { dirname, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PNG } from 'pngjs'
const here = dirname(fileURLToPath(import.meta.url))
export const harnessRoot = resolve(here, '..')
export const cardArtRoot = resolve(harnessRoot, 'public', 'card-art')
export const cardCatalogPath = resolve(cardArtRoot, 'manifest.json')
export const acceptedCardsRoot = resolve(harnessRoot, '..', 'artifacts', 'cards')
export const resolutions = { low: [500, 700], med: [1000, 1400], high: [2000, 2800] }
export const printings = ['normal', 'borderless', 'textless', 'boundless']
const runtimeFiles = new Set(['card.png', 'finish-mask.png', 'text-mask.png'])
function parsePng(name, bytes) {
try {
return PNG.sync.read(bytes)
} catch (error) {
throw new Error(`${name} is not a readable PNG: ${error instanceof Error ? error.message : String(error)}`)
async function json(path) { return JSON.parse(await readFile(path, 'utf8')) }
function assetUrl(folderName, resolution, printing, filename, sha256) {
return `/__card_asset/${encodeURIComponent(folderName)}/${resolution}/${printing}/${filename}?v=${sha256.slice(0, 16)}`
}
function fileRecord(files, key, dimensions) {
const record = files[key]
if (!record || typeof record.sha256 !== 'string') throw new Error(`Missing manifest entry ${key}`)
if (key.endsWith('.png') && (!Array.isArray(record.dimensions) ||
record.dimensions[0] !== dimensions[0] || record.dimensions[1] !== dimensions[1])) {
throw new Error(`Wrong manifest dimensions for ${key}`)
}
function validateDataMask(filename, bytes, artwork) {
const mask = parsePng(filename, bytes)
if (mask.width !== artwork.width || mask.height !== artwork.height) {
throw new Error(`${filename} must match artwork dimensions; found ` +
`${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`)
return record
}
for (let offset = 0; offset < mask.data.length; offset += 4) {
if (mask.data[offset] !== mask.data[offset + 1] ||
mask.data[offset] !== mask.data[offset + 2] || mask.data[offset + 3] !== 255) {
throw new Error(`${filename} must be an opaque grayscale mask`)
async function ensureFile(path, label) {
if (!(await stat(path)).isFile()) throw new Error(`${label} is not a file`)
}
async function scanCard(directory) {
const [manifest, content] = await Promise.all([json(resolve(directory, 'manifest.json')), json(resolve(directory, 'card.json'))])
if (manifest.stage !== 'approved') throw new Error('Card package is not approved')
if (manifest.cardId !== content.cardId || manifest.folderName !== content.folderName) throw new Error('Package identity differs between manifests')
if (typeof manifest.revision !== 'string' || typeof content.title !== 'string' || typeof content.rarity !== 'string') {
throw new Error('Package is missing accepted revision, title, or rarity metadata')
}
if (!directory.endsWith(`${sep}${manifest.folderName}`)) throw new Error('folderName differs from directory')
const variants = {}
for (const [resolutionName, dimensions] of Object.entries(resolutions)) {
const resolutionManifest = await json(resolve(directory, resolutionName, 'manifest.json'))
if (resolutionManifest.dimensions?.[0] !== dimensions[0] || resolutionManifest.dimensions?.[1] !== dimensions[1]) {
throw new Error(`${resolutionName} manifest dimensions are invalid`)
}
function validatePair(name, artworkBytes, maskBytes, textMaskBytes) {
const artwork = parsePng(`${name}.png`, artworkBytes)
const mask = parsePng(`${name}-mask.png`, maskBytes)
if (artwork.width * 7 !== artwork.height * 5) {
throw new Error(`${name}.png must use a 5:7 aspect ratio; found ${artwork.width}x${artwork.height}`)
}
if (mask.width !== artwork.width || mask.height !== artwork.height) {
throw new Error(
`${name}-mask.png must match ${name}.png dimensions; found ` +
`${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`,
)
}
for (let offset = 0; offset < artwork.data.length; offset += 4) {
if (artwork.data[offset + 3] !== 255) {
throw new Error(`${name}.png must be fully opaque for the runtime card surface`)
}
if (
mask.data[offset] !== mask.data[offset + 1] ||
mask.data[offset] !== mask.data[offset + 2] ||
mask.data[offset + 3] !== 255
) {
throw new Error(`${name}-mask.png must be an opaque grayscale finish mask`)
}
}
if (textMaskBytes) validateDataMask(`${name}-text-mask.png`, textMaskBytes, artwork)
return { width: artwork.width, height: artwork.height }
}
export async function scanCardCatalog(root = cardArtRoot) {
await mkdir(root, { recursive: true })
const files = (await readdir(root, { withFileTypes: true }))
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.png'))
.map(entry => entry.name)
const fileSet = new Set(files)
const artworkFiles = files.filter(name => !name.toLowerCase().endsWith('-mask.png'))
const maskFiles = files.filter(name => name.toLowerCase().endsWith('-mask.png'))
const cards = []
const errors = []
for (const artworkFile of artworkFiles) {
const name = artworkFile.slice(0, -4)
const maskFile = `${name}-mask.png`
const textMaskFile = `${name}-text-mask.png`
const hasTextMask = fileSet.has(textMaskFile)
if (!fileSet.has(maskFile)) {
errors.push(`Missing ${maskFile} for ${artworkFile}`)
continue
}
try {
const [artworkBytes, maskBytes, textMaskBytes] = await Promise.all([
readFile(resolve(root, artworkFile)),
readFile(resolve(root, maskFile)),
hasTextMask ? readFile(resolve(root, textMaskFile)) : undefined,
variants[resolutionName] = {}
for (const printing of printings) {
const artworkKey = `${printing}/card.png`, finishKey = `${printing}/finish-mask.png`, textKey = `${printing}/text-mask.png`
const artwork = fileRecord(resolutionManifest.files, artworkKey, dimensions)
const finishMask = fileRecord(resolutionManifest.files, finishKey, dimensions)
const textMask = printing === 'normal' || printing === 'borderless'
? fileRecord(resolutionManifest.files, textKey, dimensions) : undefined
await Promise.all([
ensureFile(resolve(directory, resolutionName, artworkKey), artworkKey),
ensureFile(resolve(directory, resolutionName, finishKey), finishKey),
...(textMask ? [ensureFile(resolve(directory, resolutionName, textKey), textKey)] : []),
])
const dimensions = validatePair(name, artworkBytes, maskBytes, textMaskBytes)
const hash = createHash('sha256').update(artworkBytes).update(maskBytes)
if (textMaskBytes) hash.update(textMaskBytes)
const revision = hash.digest('hex').slice(0, 16)
cards.push({
name,
artwork: `/card-art/${encodeURIComponent(artworkFile)}?v=${revision}`,
mask: `/card-art/${encodeURIComponent(maskFile)}?v=${revision}`,
...(hasTextMask ? { textMask: `/card-art/${encodeURIComponent(textMaskFile)}?v=${revision}` } : {}),
revision,
...dimensions,
})
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error))
const revision = createHash('sha256').update(artwork.sha256).update(finishMask.sha256)
.update(textMask?.sha256 ?? '').digest('hex').slice(0, 16)
variants[resolutionName][printing] = {
artwork: assetUrl(manifest.folderName, resolutionName, printing, 'card.png', artwork.sha256),
mask: assetUrl(manifest.folderName, resolutionName, printing, 'finish-mask.png', finishMask.sha256),
...(textMask ? { textMask: assetUrl(manifest.folderName, resolutionName, printing, 'text-mask.png', textMask.sha256) } : {}),
revision, width: dimensions[0], height: dimensions[1],
}
}
for (const maskFile of maskFiles) {
const suffixLength = maskFile.toLowerCase().endsWith('-text-mask.png') ? 14 : 9
const artworkFile = `${maskFile.slice(0, -suffixLength)}.png`
if (!fileSet.has(artworkFile)) errors.push(`Missing ${artworkFile} for ${maskFile}`)
}
cards.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
return { cardId: manifest.cardId, title: content.title, rarity: content.rarity,
folderName: manifest.folderName, acceptedRevision: manifest.revision, variants }
}
export async function scanCardCatalog(root = acceptedCardsRoot) {
const entries = (await readdir(root, { withFileTypes: true }))
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
const cards = [], errors = []
let cursor = 0
await Promise.all(Array.from({ length: Math.min(12, entries.length) }, async () => {
while (cursor < entries.length) {
const entry = entries[cursor++]
try { cards.push(await scanCard(resolve(root, entry.name))) }
catch (error) { errors.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`) }
}
}))
cards.sort((a, b) => a.cardId.localeCompare(b.cardId, undefined, { numeric: true, sensitivity: 'base' }))
errors.sort()
return { schemaVersion: 1, cards, errors }
return { schemaVersion: 2, cards, errors }
}
export async function writeCardCatalog(root = cardArtRoot, output = cardCatalogPath) {
const catalog = await scanCardCatalog(root)
await writeFile(output, `${JSON.stringify(catalog, null, 2)}\n`)
return catalog
export async function resolveCardAssetRequest(pathname, root = acceptedCardsRoot) {
const prefix = '/__card_asset/'
if (!pathname.startsWith(prefix)) return undefined
let parts
try { parts = pathname.slice(prefix.length).split('/').map(decodeURIComponent) } catch { return undefined }
if (parts.length !== 4) return undefined
const [folderName, resolutionName, printing, filename] = parts
if (!/^[A-Z]+-[0-9]{3,}-[a-z0-9-]+$/.test(folderName) || !(resolutionName in resolutions) ||
!printings.includes(printing) || !runtimeFiles.has(filename)) return undefined
const target = resolve(root, folderName, resolutionName, printing, filename), relativeTarget = relative(root, target)
if (!relativeTarget || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`)) return undefined
const [realRoot, realTarget] = await Promise.all([realpath(root), realpath(target)]), realRelative = relative(realRoot, realTarget)
if (!realRelative || realRelative === '..' || realRelative.startsWith(`..${sep}`)) return undefined
return realTarget
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const catalog = await writeCardCatalog()
console.log(`Card catalog ready: ${catalog.cards.length} cards, ${catalog.errors.length} errors`)
if (catalog.errors.length) {
const catalog = await scanCardCatalog()
console.log(`Accepted card catalog: ${catalog.cards.length} cards, ${catalog.errors.length} errors`)
for (const error of catalog.errors) console.error(`- ${error}`)
process.exitCode = 1
}
if (!catalog.cards.length || catalog.errors.length) process.exitCode = 1
}

View File

@@ -1,96 +1,78 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
import { PNG } from 'pngjs'
import { scanCardCatalog } from './card-catalog.mjs'
import { printings, resolutions, resolveCardAssetRequest, scanCardCatalog } from './card-catalog.mjs'
function png(width, height, pixel) {
const image = new PNG({ width, height })
for (let offset = 0; offset < image.data.length; offset += 4) {
image.data.set(pixel, offset)
async function createCard(root, options = {}) {
const folderName = options.folderName ?? 'BP-002-david'
const directory = resolve(root, folderName)
await mkdir(directory, { recursive: true })
await writeFile(resolve(directory, 'card.json'), JSON.stringify({ cardId: 'BP-002', folderName, title: 'David', rarity: 'Blessed' }))
await writeFile(resolve(directory, 'manifest.json'), JSON.stringify({
stage: options.stage ?? 'approved', cardId: 'BP-002', folderName, revision: 'accepted-revision',
}))
for (const [resolutionName, dimensions] of Object.entries(resolutions)) {
const files = {}
for (const printing of printings) {
const target = resolve(directory, resolutionName, printing)
await mkdir(target, { recursive: true })
for (const filename of ['card.png', 'finish-mask.png', ...(['normal', 'borderless'].includes(printing) ? ['text-mask.png'] : [])]) {
const key = `${printing}/${filename}`
files[key] = { sha256: `${resolutionName}-${printing}-${filename}`, dimensions }
await writeFile(resolve(target, filename), key)
}
return PNG.sync.write(image)
}
await writeFile(resolve(directory, resolutionName, 'manifest.json'), JSON.stringify({ dimensions, files }))
}
return directory
}
test('catalog pairs filename-based card art with validated masks', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-'))
test('catalog exposes every accepted resolution and printing from artifact manifests', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'accepted-card-catalog-'))
t.after(() => rm(root, { recursive: true }))
await Promise.all([
writeFile(resolve(root, 'david.png'), png(10, 14, [20, 40, 60, 255])),
writeFile(resolve(root, 'david-mask.png'), png(10, 14, [128, 128, 128, 255])),
])
await createCard(root)
const catalog = await scanCardCatalog(root)
assert.deepEqual(catalog.errors, [])
assert.equal(catalog.schemaVersion, 2)
assert.equal(catalog.cards.length, 1)
assert.equal(catalog.cards[0].name, 'david')
assert.match(catalog.cards[0].artwork, /^\/card-art\/david\.png\?v=[0-9a-f]{16}$/)
assert.equal(catalog.cards[0].width, 10)
assert.equal(catalog.cards[0].height, 14)
const card = catalog.cards[0]
assert.equal(card.cardId, 'BP-002')
assert.equal(card.title, 'David')
assert.equal(card.acceptedRevision, 'accepted-revision')
assert.equal(card.variants.high.normal.width, 2000)
assert.match(card.variants.high.normal.artwork, /^\/__card_asset\/BP-002-david\/high\/normal\/card\.png\?v=/)
assert.match(card.variants.low.normal.textMask, /text-mask\.png\?v=/)
assert.equal(card.variants.low.textless.textMask, undefined)
})
test('catalog reports missing pairs and invalid runtime images explicitly', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-invalid-'))
test('catalog rejects unapproved and identity-mismatched packages without hiding errors', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'invalid-accepted-card-catalog-'))
t.after(() => rm(root, { recursive: true }))
await Promise.all([
writeFile(resolve(root, 'orphan.png'), png(10, 14, [20, 40, 60, 255])),
writeFile(resolve(root, 'mask-only-mask.png'), png(10, 14, [0, 0, 0, 255])),
writeFile(resolve(root, 'bad.png'), png(10, 14, [20, 40, 60, 254])),
writeFile(resolve(root, 'bad-mask.png'), png(10, 14, [20, 21, 20, 255])),
])
await createCard(root, { folderName: 'BP-002-draft', stage: 'candidate' })
const bad = await createCard(root, { folderName: 'BP-002-wrong-folder' })
await writeFile(resolve(bad, 'manifest.json'), JSON.stringify({
stage: 'approved', cardId: 'BP-999', folderName: 'BP-002-wrong-folder', revision: 'x',
}))
const catalog = await scanCardCatalog(root)
assert.deepEqual(catalog.cards, [])
assert.ok(catalog.errors.some(error => error.includes('Missing orphan-mask.png')))
assert.ok(catalog.errors.some(error => error.includes('Missing mask-only.png')))
assert.ok(catalog.errors.some(error => error.includes('bad.png must be fully opaque')))
assert.ok(catalog.errors.some(error => error.includes('not approved')))
assert.ok(catalog.errors.some(error => error.includes('identity differs')))
})
test('optional text masks are discovered without becoming artwork or orphan finish masks', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'card-text-mask-'))
test('artifact asset routes allow only real runtime files inside the accepted root', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'accepted-card-route-'))
t.after(() => rm(root, { recursive: true }))
const artwork = png(10, 14, [20, 40, 60, 255])
const finish = png(10, 14, [128, 128, 128, 255])
await Promise.all([
writeFile(resolve(root, 'Card name.png'), artwork),
writeFile(resolve(root, 'Card name-mask.png'), finish),
])
const legacy = await scanCardCatalog(root)
assert.equal(legacy.cards[0].textMask, undefined)
const { createHash } = await import('node:crypto')
assert.equal(legacy.cards[0].revision,
createHash('sha256').update(artwork).update(finish).digest('hex').slice(0, 16))
await writeFile(resolve(root, 'Card name-text-mask.png'), png(10, 14, [255, 255, 255, 255]))
const masked = await scanCardCatalog(root)
assert.deepEqual(masked.errors, [])
assert.equal(masked.cards.length, 1)
assert.match(masked.cards[0].textMask, /^\/card-art\/Card%20name-text-mask\.png\?v=[0-9a-f]{16}$/)
assert.notEqual(masked.cards[0].revision, legacy.cards[0].revision)
await writeFile(resolve(root, 'Card name-text-mask.png'), png(10, 14, [0, 0, 0, 255]))
assert.notEqual((await scanCardCatalog(root)).cards[0].revision, masked.cards[0].revision)
await rm(resolve(root, 'Card name-text-mask.png'))
assert.deepEqual(await scanCardCatalog(root), legacy)
})
test('invalid optional text masks reject the card and orphan masks name the correct missing artwork', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'invalid-card-text-mask-'))
t.after(() => rm(root, { recursive: true }))
await Promise.all([
writeFile(resolve(root, 'card.png'), png(10, 14, [20, 40, 60, 255])),
writeFile(resolve(root, 'card-mask.png'), png(10, 14, [128, 128, 128, 255])),
writeFile(resolve(root, 'orphan-text-mask.png'), png(10, 14, [0, 0, 0, 255])),
])
for (const [bytes, error] of [
[png(5, 7, [0, 0, 0, 255]), 'must match artwork dimensions'],
[png(10, 14, [0, 1, 0, 255]), 'must be an opaque grayscale mask'],
[png(10, 14, [0, 0, 0, 254]), 'must be an opaque grayscale mask'],
[Buffer.from('broken PNG'), 'is not a readable PNG'],
]) {
await writeFile(resolve(root, 'card-text-mask.png'), bytes)
const catalog = await scanCardCatalog(root)
assert.deepEqual(catalog.cards, [])
assert.ok(catalog.errors.some(message => message.includes('card-text-mask.png') && message.includes(error)))
assert.ok(catalog.errors.includes('Missing orphan.png for orphan-text-mask.png'))
assert.ok(!catalog.errors.some(message => message.includes('orphan-text.png')))
}
await createCard(root)
const path = await resolveCardAssetRequest('/__card_asset/BP-002-david/high/normal/card.png', root)
assert.equal(path, resolve(root, 'BP-002-david/high/normal/card.png'))
assert.equal(await resolveCardAssetRequest('/__card_asset/BP-002-david/high/normal/manifest.json', root), undefined)
assert.equal(await resolveCardAssetRequest('/__card_asset/..%2Foutside/high/normal/card.png', root), undefined)
const outside = resolve(root, '..', `outside-${Date.now()}.png`)
await writeFile(outside, 'outside')
t.after(() => rm(outside))
await rm(resolve(root, 'BP-002-david/high/normal/card.png'))
await symlink(outside, resolve(root, 'BP-002-david/high/normal/card.png'))
assert.equal(await resolveCardAssetRequest('/__card_asset/BP-002-david/high/normal/card.png', root), undefined)
})

View File

@@ -63,12 +63,30 @@ test('advertised optional-mask failures reject loading and clean up every succes
}
})
test('runtime catalog validation accepts missing/valid optional URLs and rejects malformed text-mask fields', () => {
const catalog = { schemaVersion: 1, cards: [asset], errors: [] }
test('runtime catalog validation accepts complete artifact variants and rejects malformed text-mask fields', () => {
const artifactAsset = {
artwork: '/__card_asset/BP-002-david/high/normal/card.png',
mask: '/__card_asset/BP-002-david/high/normal/finish-mask.png',
revision: 'test', width: 2000, height: 2800,
}
const resolutions = ['low', 'med', 'high']
const printings = ['normal', 'borderless', 'textless', 'boundless']
const variants = Object.fromEntries(resolutions.map(resolution => [resolution,
Object.fromEntries(printings.map(printing => [printing, { ...artifactAsset,
artwork: `/__card_asset/BP-002-david/${resolution}/${printing}/card.png`,
mask: `/__card_asset/BP-002-david/${resolution}/${printing}/finish-mask.png`,
}]))
]))
const card = { cardId: 'BP-002', title: 'David', rarity: 'Blessed', folderName: 'BP-002-david',
acceptedRevision: 'accepted', variants }
const catalog = { schemaVersion: 2, cards: [card], errors: [] }
assert.deepEqual(validateCardCatalog(catalog), catalog)
const masked = { ...catalog, cards: [{ ...asset, textMask: '/card-art/test-text-mask.png?v=revision' }] }
const masked = structuredClone(catalog)
masked.cards[0].variants.high.normal.textMask = '/__card_asset/BP-002-david/high/normal/text-mask.png'
assert.deepEqual(validateCardCatalog(masked), masked)
for (const textMask of [null, 1, false, '', '/reference/text.png', 'https://example.com/text.png']) {
assert.throws(() => validateCardCatalog({ ...catalog, cards: [{ ...asset, textMask }] }), /invalid format/)
const malformed = structuredClone(catalog)
malformed.cards[0].variants.high.normal.textMask = textMask
assert.throws(() => validateCardCatalog(malformed), /invalid format/)
}
})

View File

@@ -541,3 +541,59 @@ test('pack frames report motion and touch settling but settled and hidden scenes
pack.setActive(false)
assert.equal(pack.tick(performance.now()), false)
})
test('streamed packs acquire current and next fronts and release outgoing leases', async (t) => {
installDOM()
let now = 1000
t.mock.method(performance, 'now', () => now)
const canvas = new TestCanvas()
const acquired = []
const released = []
const placeholder = { artwork: new THREE.Texture(), mask: new THREE.Texture() }
const contents = [
{ fixture: 'one', finish: 'Holographic', substrate: 'Linen' },
{ fixture: 'two', finish: 'Foil', substrate: 'Metal' },
{ fixture: 'three', finish: 'Printed ink', substrate: 'Paper' },
]
const pack = new PackOpening({
canvas, contents, placeholderTextures: placeholder,
async acquireTextures(spec) {
acquired.push(spec.fixture)
return { textures: { artwork: new THREE.Texture(), mask: new THREE.Texture() },
release: () => released.push(spec.fixture) }
},
resolveSurfaceDefaults(finish, substrate) {
return { finishStrength: finish === 'Holographic' ? 0.33 : 1,
roughness: 0.23, normalStrength: substrate === 'Linen' ? 0.05 : 1,
metalBrushHorizontal: false }
},
backMaterial: new THREE.MeshPhysicalMaterial(), normalMap: new THREE.Texture(),
environment: new THREE.CubeTexture(), lightPosition: new THREE.Vector3(), onChange() {},
})
pack.setActive(true)
pack.skip()
now += 3200
pack.tick(now)
await Promise.resolve()
await Promise.resolve()
assert.deepEqual(acquired, ['one'])
assert.ok(pack.cards.every(card => !card.front.visible))
assert.equal(pack.cards[0].material.uniforms.finishStrength.value, 0.33)
assert.equal(pack.cards[0].material.uniforms.normalStrength.value, 0.05)
pack.skip()
assert.equal(pack.state, 'lifted')
pack.skip()
assert.equal(pack.state, 'inspecting')
await Promise.resolve()
await Promise.resolve()
assert.deepEqual(acquired, ['one', 'two'])
assert.equal(pack.cards[0].front.visible, true)
pack.skip()
assert.equal(pack.state, 'stackReady')
assert.deepEqual(released, ['one'])
pack.dispose()
assert.deepEqual(released, ['one', 'two'])
})

View File

@@ -0,0 +1,76 @@
import { createHash } from 'node:crypto'
import { readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
export const surfaceDefaultsPath = resolve(here, '..', 'config', 'surface-defaults.json')
const finishNames = ['Printed ink', 'Foil', 'Holographic', 'Gold leaf', 'Frosted glass']
const materialNames = ['Paper', 'Linen', 'Metal', 'Wood', 'Leather']
function number(value, min, max, label) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
throw new Error(`${label} must be between ${min} and ${max}`)
}
}
export function validateSurfaceDefaults(value) {
if (!value || typeof value !== 'object' || value.schemaVersion !== 1 ||
!value.finishes || typeof value.finishes !== 'object' ||
!value.materials || typeof value.materials !== 'object') {
throw new Error('Surface defaults use an unsupported format')
}
for (const name of finishNames) {
const item = value.finishes[name]
if (!item || typeof item !== 'object') throw new Error(`Missing defaults for finish ${name}`)
if (Object.keys(item).some(key => key !== 'finishStrength')) throw new Error(`Unsupported setting for finish ${name}`)
number(item.finishStrength, 0, 1, `${name} finish strength`)
}
for (const name of materialNames) {
const item = value.materials[name]
if (!item || typeof item !== 'object') throw new Error(`Missing defaults for material ${name}`)
if (Object.keys(item).some(key => !['surfaceDetail', 'roughness', 'metalBrushHorizontal'].includes(key))) {
throw new Error(`Unsupported setting for material ${name}`)
}
number(item.surfaceDetail, 0, 1, `${name} surface detail`)
number(item.roughness, 0.05, 0.8, `${name} roughness`)
if (item.metalBrushHorizontal !== undefined && typeof item.metalBrushHorizontal !== 'boolean') {
throw new Error(`${name} metal grain orientation must be a boolean`)
}
}
return value
}
function revision(bytes) {
return createHash('sha256').update(bytes).digest('hex').slice(0, 16)
}
export async function readSurfaceDefaults(writable = false, path = surfaceDefaultsPath) {
const bytes = await readFile(path)
return { defaults: validateSurfaceDefaults(JSON.parse(bytes)), revision: revision(bytes), writable }
}
export async function updateSurfaceDefaults(update, path = surfaceDefaultsPath) {
const current = await readSurfaceDefaults(true, path)
if (!update || update.revision !== current.revision) {
const error = new Error('Surface defaults changed since this page loaded; refresh and try again')
error.statusCode = 409
throw error
}
const next = structuredClone(current.defaults)
if (update.group === 'finish' && finishNames.includes(update.name)) {
next.finishes[update.name] = update.values
} else if (update.group === 'material' && materialNames.includes(update.name)) {
next.materials[update.name] = update.values
} else {
const error = new Error('Unknown surface-default group or selection')
error.statusCode = 400
throw error
}
validateSurfaceDefaults(next)
const bytes = `${JSON.stringify(next, null, 2)}\n`
const temporary = `${path}.tmp`
await writeFile(temporary, bytes)
await rename(temporary, path)
return { defaults: next, revision: revision(bytes), writable: true }
}

View File

@@ -0,0 +1,45 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
import { readSurfaceDefaults, updateSurfaceDefaults, validateSurfaceDefaults } from './surface-defaults.mjs'
const initial = {
schemaVersion: 1,
finishes: {
'Printed ink': { finishStrength: 0.6 }, Foil: { finishStrength: 1 },
Holographic: { finishStrength: 0.33 }, 'Gold leaf': { finishStrength: 0.6 },
'Frosted glass': { finishStrength: 0.6 },
},
materials: {
Paper: { surfaceDetail: 0.14, roughness: 0.23 },
Linen: { surfaceDetail: 0.05, roughness: 0.23 },
Metal: { surfaceDetail: 1, roughness: 0.23, metalBrushHorizontal: false },
Wood: { surfaceDetail: 0.14, roughness: 0.23 },
Leather: { surfaceDetail: 0.45, roughness: 0.23 },
},
}
test('surface defaults validate requested finish/material strengths and reject unknown settings', () => {
assert.equal(validateSurfaceDefaults(structuredClone(initial)).finishes.Holographic.finishStrength, 0.33)
const malformed = structuredClone(initial)
malformed.materials.Linen.shaderSecret = 1
assert.throws(() => validateSurfaceDefaults(malformed), /Unsupported setting/)
})
test('surface default saves are atomic, scoped, and revision checked', async (t) => {
const directory = await mkdtemp(resolve(tmpdir(), 'surface-defaults-'))
t.after(() => rm(directory, { recursive: true }))
const path = resolve(directory, 'defaults.json')
await writeFile(path, `${JSON.stringify(initial, null, 2)}\n`)
const before = await readSurfaceDefaults(true, path)
const after = await updateSurfaceDefaults({ revision: before.revision, group: 'finish',
name: 'Holographic', values: { finishStrength: 0.41 } }, path)
assert.equal(after.defaults.finishes.Holographic.finishStrength, 0.41)
assert.deepEqual(after.defaults.materials, initial.materials)
assert.notEqual(after.revision, before.revision)
await assert.rejects(updateSurfaceDefaults({ revision: before.revision, group: 'material',
name: 'Linen', values: { surfaceDetail: 0.08, roughness: 0.23 } }, path), /changed since/)
assert.doesNotMatch(await readFile(path, 'utf8'), /\.tmp/)
})

View File

@@ -1,5 +1,9 @@
export const resolutionNames = ['low', 'med', 'high'] as const
export const printingNames = ['normal', 'borderless', 'textless', 'boundless'] as const
export type CardResolution = typeof resolutionNames[number]
export type CardPrinting = typeof printingNames[number]
export interface CardAsset {
name: string
artwork: string
mask: string
textMask?: string
@@ -7,56 +11,57 @@ export interface CardAsset {
width: number
height: number
}
export interface CardCatalog {
schemaVersion: 1
cards: CardAsset[]
errors: string[]
export interface CatalogCard {
cardId: string
title: string
rarity: string
folderName: string
acceptedRevision: string
variants: Record<CardResolution, Record<CardPrinting, CardAsset>>
}
export interface CardCatalog { schemaVersion: 2; cards: CatalogCard[]; errors: string[] }
function isCardAsset(value: unknown): value is CardAsset {
function isAsset(value: unknown): value is CardAsset {
if (typeof value !== 'object' || value === null) return false
const card = value as Record<string, unknown>
return typeof card.name === 'string' && card.name.length > 0 &&
typeof card.artwork === 'string' && card.artwork.startsWith('/card-art/') &&
typeof card.mask === 'string' && card.mask.startsWith('/card-art/') &&
(card.textMask === undefined || (typeof card.textMask === 'string' && card.textMask.startsWith('/card-art/'))) &&
typeof card.revision === 'string' &&
Number.isInteger(card.width) && Number.isInteger(card.height)
const asset = value as Record<string, unknown>
return typeof asset.artwork === 'string' && asset.artwork.startsWith('/__card_asset/') &&
typeof asset.mask === 'string' && asset.mask.startsWith('/__card_asset/') &&
(asset.textMask === undefined || (typeof asset.textMask === 'string' && asset.textMask.startsWith('/__card_asset/'))) &&
typeof asset.revision === 'string' && Number.isInteger(asset.width) && Number.isInteger(asset.height)
}
export async function fetchCardCatalog(): Promise<CardCatalog> {
const urls = import.meta.env.DEV
? ['/__card_catalog', '/card-art/manifest.json']
: ['/card-art/manifest.json']
const failures: string[] = []
for (const url of urls) {
try {
const response = await fetch(url, { cache: 'no-store' })
if (!response.ok) throw new Error(`request failed (${response.status})`)
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
throw new Error(`expected JSON but received ${contentType || 'an unknown content type'}`)
function isCard(value: unknown): value is CatalogCard {
if (typeof value !== 'object' || value === null) return false
const card = value as Record<string, unknown>, variants = card.variants as Record<string, unknown> | undefined
return typeof card.cardId === 'string' && typeof card.title === 'string' && typeof card.rarity === 'string' &&
typeof card.folderName === 'string' && typeof card.acceptedRevision === 'string' && !!variants &&
resolutionNames.every(resolution => {
const group = variants[resolution] as Record<string, unknown> | undefined
return !!group && printingNames.every(printing => isAsset(group[printing]))
})
}
return validateCardCatalog(await response.json())
} catch (error) {
failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(`Card catalog unavailable: ${failures.join('; ')}`)
}
export function validateCardCatalog(value: unknown): CardCatalog {
if (typeof value !== 'object' || value === null) throw new Error('Card catalog is not an object')
const catalog = value as Record<string, unknown>
if (
catalog.schemaVersion !== 1 ||
!Array.isArray(catalog.cards) ||
!catalog.cards.every(isCardAsset) ||
!Array.isArray(catalog.errors) ||
!catalog.errors.every(error => typeof error === 'string')
) {
if (catalog.schemaVersion !== 2 || !Array.isArray(catalog.cards) || !catalog.cards.every(isCard) ||
!Array.isArray(catalog.errors) || !catalog.errors.every(error => typeof error === 'string')) {
throw new Error('Card catalog has an unsupported or invalid format')
}
return catalog as unknown as CardCatalog
}
export async function fetchCardCatalog(): Promise<CardCatalog> {
const response = await fetch('/__card_catalog', { cache: 'no-store' })
if (!response.ok) throw new Error(`Card catalog request failed (${response.status})`)
const type = response.headers.get('content-type') ?? ''
if (!type.includes('application/json')) throw new Error(`Card catalog returned ${type || 'unknown content'}`)
return validateCardCatalog(await response.json())
}
export function resolveCardAsset(card: CatalogCard, resolution: CardResolution, printing: CardPrinting) {
return card.variants[resolution][printing]
}
export function cardLabel(card: CatalogCard) { return `${card.title} · ${card.cardId}` }
export function findLegacyCard(cards: readonly CatalogCard[], value: string) {
const normalized = value.trim().toLocaleLowerCase()
return cards.find(card => card.cardId.toLocaleLowerCase() === normalized ||
card.title.toLocaleLowerCase() === normalized || card.folderName.toLocaleLowerCase() === normalized ||
card.folderName.slice(card.cardId.length + 1).toLocaleLowerCase() === normalized)
}

View File

@@ -0,0 +1,123 @@
import { cardLabel, type CatalogCard } from './cardCatalog'
type SelectCard = (card: CatalogCard) => void
export class CardPicker {
private cards: readonly CatalogCard[] = []
private selectedCardId = ''
private visibleCount = 60
private readonly dialog = document.createElement('dialog')
private readonly search = document.createElement('input')
private readonly rarity = document.createElement('select')
private readonly count = document.createElement('span')
private readonly results = document.createElement('div')
private readonly onSelect: SelectCard
constructor(cards: readonly CatalogCard[], onSelect: SelectCard) {
this.onSelect = onSelect
this.dialog.className = 'card-picker'
this.dialog.setAttribute('aria-labelledby', 'card-picker-title')
this.search.type = 'search'
this.search.placeholder = 'Search title or card ID'
this.search.autocomplete = 'off'
this.search.spellcheck = false
this.search.setAttribute('aria-label', 'Search accepted cards')
this.rarity.setAttribute('aria-label', 'Filter by rarity')
this.results.className = 'card-picker-results'
this.results.addEventListener('scroll', () => {
if (this.results.scrollTop + this.results.clientHeight >= this.results.scrollHeight - 320) {
this.visibleCount += 60
this.render()
}
})
this.dialog.innerHTML = `
<div class="card-picker-header">
<div><p class="eyebrow">Accepted card library</p><h2 id="card-picker-title">Choose a card</h2></div>
<button class="card-picker-close" type="button" aria-label="Close card library">×</button>
</div>
<div class="card-picker-filters"></div>
`
this.dialog.querySelector('.card-picker-filters')!.append(this.search, this.rarity, this.count)
this.dialog.append(this.results)
document.body.append(this.dialog)
this.dialog.querySelector<HTMLButtonElement>('.card-picker-close')!.addEventListener('click', () => this.close())
this.dialog.addEventListener('click', event => { if (event.target === this.dialog) this.close() })
this.dialog.addEventListener('cancel', event => { event.preventDefault(); this.close() })
this.search.addEventListener('input', () => { this.visibleCount = 60; this.render() })
this.rarity.addEventListener('change', () => { this.visibleCount = 60; this.render() })
this.update(cards)
}
update(cards: readonly CatalogCard[]) {
this.cards = cards
const selectedRarity = this.rarity.value
const rarities = [...new Set(cards.map(card => card.rarity))].sort()
this.rarity.replaceChildren(
new Option('All rarities', ''),
...rarities.map(value => new Option(value, value)),
)
if (rarities.includes(selectedRarity)) this.rarity.value = selectedRarity
this.render()
}
open(selectedCardId: string) {
this.selectedCardId = selectedCardId
this.visibleCount = 60
this.render()
this.dialog.showModal()
requestAnimationFrame(() => this.search.focus())
}
close() { if (this.dialog.open) this.dialog.close() }
adjacent(selectedCardId: string, delta: -1 | 1) {
const index = this.cards.findIndex(card => card.cardId === selectedCardId)
if (index < 0 || !this.cards.length) return undefined
return this.cards[(index + delta + this.cards.length) % this.cards.length]
}
private filteredCards() {
const words = this.search.value.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
return this.cards.filter(card => {
if (this.rarity.value && card.rarity !== this.rarity.value) return false
const haystack = `${card.title} ${card.cardId} ${card.rarity}`.toLocaleLowerCase()
return words.every(word => haystack.includes(word))
})
}
private render() {
const filtered = this.filteredCards()
this.count.textContent = `${filtered.length} card${filtered.length === 1 ? '' : 's'}`
const fragment = document.createDocumentFragment()
for (const card of filtered.slice(0, this.visibleCount)) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'card-picker-result'
button.classList.toggle('selected', card.cardId === this.selectedCardId)
button.setAttribute('aria-label', `Choose ${cardLabel(card)}, ${card.rarity}`)
const image = document.createElement('img')
image.src = card.variants.low.normal.artwork
image.alt = ''
image.loading = 'lazy'
image.decoding = 'async'
const copy = document.createElement('span')
copy.innerHTML = `<strong></strong><small></small>`
copy.querySelector('strong')!.textContent = card.title
copy.querySelector('small')!.textContent = `${card.cardId} · ${card.rarity}`
button.append(image, copy)
button.addEventListener('click', () => {
this.selectedCardId = card.cardId
this.close()
this.onSelect(card)
})
fragment.append(button)
}
this.results.replaceChildren(fragment)
if (!filtered.length) {
const empty = document.createElement('p')
empty.className = 'card-picker-empty'
empty.textContent = 'No accepted cards match this search.'
this.results.append(empty)
}
}
}

View File

@@ -3,6 +3,7 @@ import {
finishNames, substrateNames, surfaceDetailMax,
type FinishName, type SubstrateName, type MaterialControls,
} from './cardMaterial'
import { printingNames, resolutionNames, type CardPrinting, type CardResolution } from './cardCatalog'
type FixtureName = string
type LightingPreset = 'Studio' | 'Warm gallery' | 'Cool window' | 'Dramatic spot' | 'Flat review' | 'Custom'
@@ -10,11 +11,15 @@ type EnvironmentName = 'Studio' | 'Warm' | 'Cool' | 'Dark' | 'Neutral'
type LightType = 'Point' | 'Directional' | 'Spot'
export interface ExperimentSnapshot {
schemaVersion: 1 | 2
schemaVersion: 1 | 2 | 3
runtimeRevision: 'runtime-look-v4-2026-09-07'
name: string
savedAt: string
fixture: FixtureName
fixture?: FixtureName
cardId?: string
acceptedRevision?: string
resolution?: CardResolution
printing?: CardPrinting
customArtworkName?: string
customMaskName?: string
finish: FinishName
@@ -52,7 +57,11 @@ function isVectorTuple(value: unknown): value is [number, number, number] {
export function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot {
if (typeof value !== 'object' || value === null) return false
const snapshot = value as Record<string, unknown>
const fixtureValid = typeof snapshot.fixture === 'string' && snapshot.fixture.length > 0
const legacyFixtureValid = typeof snapshot.fixture === 'string' && snapshot.fixture.length > 0
const cardSelectionValid = typeof snapshot.cardId === 'string' && snapshot.cardId.length > 0 &&
typeof snapshot.acceptedRevision === 'string' &&
resolutionNames.some(name => name === snapshot.resolution) &&
printingNames.some(name => name === snapshot.printing)
const finishValid = finishNames.some(name => name === snapshot.finish)
const substrateValid = snapshot.substrate === 'Plastic' ||
substrateNames.some(name => name === snapshot.substrate)
@@ -77,7 +86,8 @@ export function isExperimentSnapshot(value: unknown): value is ExperimentSnapsho
(snapshot.customArtworkName === undefined || typeof snapshot.customArtworkName === 'string') &&
(snapshot.customMaskName === undefined || typeof snapshot.customMaskName === 'string')
const schemaValid = snapshot.schemaVersion === 1 || snapshot.schemaVersion === 2
const schemaValid = snapshot.schemaVersion === 1 || snapshot.schemaVersion === 2 || snapshot.schemaVersion === 3
const selectionValid = snapshot.schemaVersion === 3 ? cardSelectionValid : legacyFixtureValid
const wearValid = snapshot.schemaVersion === 1 ||
(isFiniteNumber(snapshot.condition) && isFiniteNumber(snapshot.imperfectionSeed))
@@ -85,7 +95,7 @@ export function isExperimentSnapshot(value: unknown): value is ExperimentSnapsho
snapshot.runtimeRevision === 'runtime-look-v4-2026-09-07' &&
typeof snapshot.name === 'string' &&
typeof snapshot.savedAt === 'string' &&
fixtureValid &&
selectionValid &&
optionalNamesValid &&
finishValid &&
substrateValid &&

View File

@@ -8,7 +8,6 @@ import {
applyMaterialControls,
createCardMaterial,
createStudioCubeTexture,
defaultSurfaceDetail,
surfaceDetailMax,
finishNames,
substrateNames,
@@ -21,10 +20,15 @@ import {
isExperimentSnapshot, type ExperimentSnapshot,
} from './experimentSnapshot'
import { createCardGeometry } from './cardGeometry'
import { PackOpening, packSize, rollPackContents } from './packOpening'
import { PackOpening, packSize, rollPackContents, type CardTextureLease, type PackCardSpec } from './packOpening'
import { defaultPackStyle, packStyles } from './packDesigns'
import { fetchCardCatalog, type CardAsset, type CardCatalog } from './cardCatalog'
import { loadCardTextures, disposeCardTextures } from './cardTextures'
import {
cardLabel, fetchCardCatalog, findLegacyCard, printingNames, resolutionNames, resolveCardAsset,
type CardCatalog, type CardPrinting, type CardResolution,
} from './cardCatalog'
import { CardPicker } from './cardPicker'
import { SurfaceDefaultsStore } from './surfaceDefaults'
import { loadCardTextures, disposeCardTextures, type CardTextures } from './cardTextures'
import { RenderCadence } from './renderCadence'
import { PerformanceRecording } from './performanceRecording'
import { drawingPixelRatio, FramePacing, GpuTimer, median, rendererIdentity } from './renderDiagnostics'
@@ -48,9 +52,13 @@ let displayedIdle = false
let observedPixelRatio = window.devicePixelRatio
let cardCatalog = await fetchCardCatalog()
if (!cardCatalog.cards.length) throw new Error('The card-art folder contains no valid artwork/mask pairs')
let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.name, card]))
let defaultFixture = fixtureAssets.has('David') ? 'David' : cardCatalog.cards[0].name
if (!cardCatalog.cards.length) throw new Error('The accepted artifacts catalog contains no valid cards')
let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.cardId, card]))
let defaultFixture = fixtureAssets.has('BP-002') ? 'BP-002' : cardCatalog.cards[0].cardId
let selectedResolution: CardResolution = 'high'
let selectedPrinting: CardPrinting = 'normal'
const defaultsStore = new SurfaceDefaultsStore()
await defaultsStore.refresh()
function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) {
texture.colorSpace = colorSpace
@@ -89,10 +97,23 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
${packStyles.map(style => `<option${style === defaultPackStyle ? ' selected' : ''}>${style}</option>`).join('')}
</select>
</label>
<label>Card
<label class="card-control">Card
<span class="catalog-state" id="catalog-state" aria-live="polite"></span>
<input id="fixture" list="fixture-options" autocomplete="off" spellcheck="false">
<datalist id="fixture-options"></datalist>
<span class="card-select-row">
<button id="card-previous" type="button" aria-label="Previous card">‹</button>
<button id="card-picker-open" type="button"></button>
<button id="card-next" type="button" aria-label="Next card">›</button>
</span>
</label>
<label class="variant-control">Resolution
<select id="resolution">
${resolutionNames.map(name => `<option${name === 'high' ? ' selected' : ''}>${name}</option>`).join('')}
</select>
</label>
<label class="variant-control">Printing
<select id="printing">
${printingNames.map(name => `<option>${name}</option>`).join('')}
</select>
</label>
<label>Finish
<select id="finish">
@@ -133,9 +154,10 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<button id="flip" type="button">Flip</button>
<button id="reset" type="button">Reset</button>
<button id="sweep" type="button">Play sweep</button>
<button id="record-timings" type="button" title="Record 30 seconds: stay idle, then rotate/sweep or open a pack">Record timings</button>
<button id="export-timings" type="button" disabled>Export timings</button>
<span id="timing-record-status" role="status"></span>
<button class="diagnostics-toggle" id="diagnostics-toggle" type="button" aria-pressed="false">Show diagnostics</button>
<button class="diagnostic-control" id="record-timings" type="button" title="Record 30 seconds: stay idle, then rotate/sweep or open a pack">Record timings</button>
<button class="diagnostic-control" id="export-timings" type="button" disabled>Export timings</button>
<span class="diagnostic-control" id="timing-record-status" role="status"></span>
</div>
</header>
@@ -144,8 +166,8 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div class="loading" id="loading" hidden>Loading card…</div>
<div class="status-panel">
<span id="status">Preparing reference assets…</span>
<span id="performance">Frame time —</span>
<span id="render-diagnostics" style="font-size: 10px; color: #89909b"></span>
<span class="diagnostic-readout" id="performance">Frame time —</span>
<span class="diagnostic-readout" id="render-diagnostics"></span>
</div>
<div class="hint" id="hint">Drag the card · pinch or wheel to zoom · flip to inspect the back</div>
<div class="pack-controls" id="pack-controls" aria-label="Pack opening controls" hidden>
@@ -173,6 +195,7 @@ const canvas = document.querySelector<HTMLCanvasElement>('#scene')!
const statusElement = document.querySelector<HTMLSpanElement>('#status')!
const performanceElement = document.querySelector<HTMLSpanElement>('#performance')!
const renderDiagnosticsElement = document.querySelector<HTMLSpanElement>('#render-diagnostics')!
const diagnosticsToggle = document.querySelector<HTMLButtonElement>('#diagnostics-toggle')!
const recordTimingsButton = document.querySelector<HTMLButtonElement>('#record-timings')!
const exportTimingsButton = document.querySelector<HTMLButtonElement>('#export-timings')!
const timingRecordStatus = document.querySelector<HTMLSpanElement>('#timing-record-status')!
@@ -181,8 +204,11 @@ const hintElement = document.querySelector<HTMLDivElement>('#hint')!
const topbar = document.querySelector<HTMLElement>('.topbar')!
const controlsToggle = document.querySelector<HTMLButtonElement>('#controls-toggle')!
const modeSelect = document.querySelector<HTMLSelectElement>('#mode')!
const fixtureSelect = document.querySelector<HTMLInputElement>('#fixture')!
const fixtureOptions = document.querySelector<HTMLDataListElement>('#fixture-options')!
const cardPickerButton = document.querySelector<HTMLButtonElement>('#card-picker-open')!
const previousCardButton = document.querySelector<HTMLButtonElement>('#card-previous')!
const nextCardButton = document.querySelector<HTMLButtonElement>('#card-next')!
const resolutionSelect = document.querySelector<HTMLSelectElement>('#resolution')!
const printingSelect = document.querySelector<HTMLSelectElement>('#printing')!
const catalogStateElement = document.querySelector<HTMLSpanElement>('#catalog-state')!
const finishSelect = document.querySelector<HTMLSelectElement>('#finish')!
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')!
@@ -203,12 +229,16 @@ const packRestartButton = document.querySelector<HTMLButtonElement>('#pack-resta
const packSkipButton = document.querySelector<HTMLButtonElement>('#pack-skip')!
const packStyleSelect = document.querySelector<HTMLSelectElement>('#pack-style')!
function setDiagnosticsEnabled(enabled: boolean) {
document.documentElement.classList.toggle('diagnostics-enabled', enabled)
diagnosticsToggle.setAttribute('aria-pressed', String(enabled))
diagnosticsToggle.textContent = enabled ? 'Hide diagnostics' : 'Show diagnostics'
renderCadence.invalidate()
}
setDiagnosticsEnabled(false)
function renderCardCatalog(catalog: CardCatalog) {
fixtureOptions.replaceChildren(...catalog.cards.map(card => {
const option = document.createElement('option')
option.value = card.name
return option
}))
catalogStateElement.textContent = catalog.errors.length
? `${catalog.cards.length} cards · ${catalog.errors.length} errors`
: `${catalog.cards.length} cards`
@@ -216,8 +246,13 @@ function renderCardCatalog(catalog: CardCatalog) {
catalogStateElement.title = catalog.errors.join('\n')
}
function updateCardButton(cardId = activeFixture) {
const card = fixtureAssets.get(cardId)
cardPickerButton.textContent = card ? cardLabel(card) : cardId
cardPickerButton.title = card ? `${card.rarity} · accepted ${card.acceptedRevision.slice(0, 10)}` : ''
}
renderCardCatalog(cardCatalog)
fixtureSelect.value = defaultFixture
function setControlsCollapsed(collapsed: boolean) {
topbar.classList.toggle('controls-collapsed', collapsed)
@@ -292,11 +327,56 @@ const backTexture = await loader.loadAsync('/reference/card-back.png')
backTexture.colorSpace = THREE.SRGBColorSpace
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
const initialAsset = fixtureAssets.get(defaultFixture)!
const initialAsset = resolveCardAsset(fixtureAssets.get(defaultFixture)!, selectedResolution, selectedPrinting)
const initialTextures = await loadCardTextures(loader, initialAsset, renderer.capabilities.getMaxAnisotropy())
const initialArtwork = initialTextures.artwork
const initialMask = initialTextures.mask
function solidTexture(r: number, g: number, b: number, colorSpace: THREE.ColorSpace) {
const texture = new THREE.DataTexture(new Uint8Array([r, g, b, 255]), 1, 1, THREE.RGBAFormat)
texture.colorSpace = colorSpace
texture.needsUpdate = true
return texture
}
const packPlaceholderTextures: CardTextures = {
artwork: solidTexture(26, 29, 35, THREE.SRGBColorSpace),
mask: solidTexture(0, 0, 0, THREE.NoColorSpace),
}
class PackTextureCache {
private readonly entries = new Map<string, { promise: Promise<CardTextures>; references: number }>()
async acquire(spec: PackCardSpec): Promise<CardTextureLease> {
const card = fixtureAssets.get(spec.fixture)
if (!card) throw new Error(`Card "${spec.fixture}" disappeared from the catalog`)
const asset = resolveCardAsset(card, selectedResolution, selectedPrinting)
const key = asset.revision
let entry = this.entries.get(key)
if (!entry) {
entry = { promise: loadCardTextures(loader, asset, renderer.capabilities.getMaxAnisotropy()), references: 0 }
this.entries.set(key, entry)
}
entry.references++
try {
const textures = await entry.promise
let released = false
return { textures, release: () => {
if (released) return
released = true
entry!.references--
if (entry!.references === 0) {
this.entries.delete(key)
disposeCardTextures(textures)
}
} }
} catch (error) {
entry.references--
if (entry.references === 0) this.entries.delete(key)
throw error
}
}
}
const lightPosition = new THREE.Vector3(2.2, 2.5, 4.4)
const frontMaterial = createCardMaterial(
initialArtwork,
@@ -341,6 +421,8 @@ floor.position.y = -2.7
floor.receiveShadow = true
scene.add(floor)
const initialSurfaceDefaults = defaultsStore.resolved('Holographic', 'Paper')
const controls: MaterialControls & {
lightX: number
lightY: number
@@ -354,10 +436,7 @@ const controls: MaterialControls & {
} = {
finish: 'Holographic',
substrate: 'Paper',
finishStrength: 0.6,
roughness: 0.23,
normalStrength: defaultSurfaceDetail('Paper'),
metalBrushHorizontal: false,
...initialSurfaceDefaults,
environmentIntensity: 0.7,
condition: 1,
imperfectionSeed: 81251,
@@ -435,13 +514,15 @@ const surfaceFolder = gui.addFolder('Surface')
surfaceFolder.add(controls, 'finish', [...finishNames]).name('Finish').onChange((value: FinishName) => {
pauseScriptedMotion()
finishSelect.value = value
defaultsStore.applyFinish(controls, value)
gui.controllersRecursive().forEach(controller => controller.updateDisplay())
updateMaterial()
updateStatus()
})
surfaceFolder.add(controls, 'substrate', [...substrateNames]).name('Material').onChange((value: SubstrateName) => {
pauseScriptedMotion()
substrateSelect.value = value
controls.normalStrength = defaultSurfaceDetail(value)
defaultsStore.applyMaterial(controls, value)
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
updateMaterial()
updateEdgeMaterial()
@@ -469,6 +550,21 @@ surfaceFolder.add(controls, 'environmentIntensity', 0, 1.5, 0.01).name('Environm
updateMaterial()
})
const surfaceDefaultActions = {
reapplyFinish: () => { defaultsStore.applyFinish(controls, controls.finish); syncSurfaceControls() },
reapplyMaterial: () => { defaultsStore.applyMaterial(controls, controls.substrate); syncSurfaceControls() },
saveFinish: () => { void saveCurrentFinishDefault() },
saveMaterial: () => { void saveCurrentMaterialDefault() },
}
surfaceFolder.add(surfaceDefaultActions, 'reapplyFinish').name('Reapply finish default')
surfaceFolder.add(surfaceDefaultActions, 'reapplyMaterial').name('Reapply material default')
const saveFinishDefaultController = surfaceFolder.add(surfaceDefaultActions, 'saveFinish').name('Save finish as default')
const saveMaterialDefaultController = surfaceFolder.add(surfaceDefaultActions, 'saveMaterial').name('Save material as default')
if (!defaultsStore.writable) {
saveFinishDefaultController.disable()
saveMaterialDefaultController.disable()
}
const wearFolder = gui.addFolder('Wear')
wearFolder.add(controls, 'condition', 0, 1, 0.001).name('Condition (1=mint)').onChange(() => {
pauseScriptedMotion()
@@ -546,7 +642,7 @@ const assetActions = {
loadMask: () => maskFileInput.click(),
restoreFixture: () => {
pauseScriptedMotion()
void loadFixture(fixtureSelect.value)
void loadFixture(activeFixture)
},
}
const assetFolder = gui.addFolder('Assets')
@@ -578,6 +674,11 @@ gui.hide()
let mode: AppMode = 'Inspect'
let textureRequest = 0
let activeFixture: FixtureName = defaultFixture
const cardPicker = new CardPicker(cardCatalog.cards, card => {
pauseScriptedMotion()
void loadFixture(card.cardId)
})
updateCardButton()
let customArtworkName: string | undefined
let customMaskName: string | undefined
let dragging = false
@@ -600,9 +701,43 @@ let pack: PackOpening | undefined
let packLoading = false
let packUIDirty = false
let packError: string | undefined
let packTextures: THREE.Texture[] = []
let selectedPackStyle = defaultPackStyle
function syncSurfaceControls() {
finishSelect.value = controls.finish
substrateSelect.value = controls.substrate
gui.controllersRecursive().forEach(controller => controller.updateDisplay())
updateMaterial()
updateEdgeMaterial()
updateStatus()
}
async function saveCurrentFinishDefault() {
try {
await defaultsStore.saveFinish(controls.finish, { finishStrength: controls.finishStrength })
statusElement.textContent = `Saved ${controls.finish} finish default at ${controls.finishStrength.toFixed(2)}.`
} catch (error) {
statusElement.textContent = `Could not save finish default: ${error instanceof Error ? error.message : String(error)}`
}
}
async function saveCurrentMaterialDefault() {
if (controls.substrate === 'Plastic') {
statusElement.textContent = 'Plastic is a legacy experiment material and has no repository default.'
return
}
try {
await defaultsStore.saveMaterial(controls.substrate, {
surfaceDetail: controls.normalStrength,
roughness: controls.roughness,
metalBrushHorizontal: controls.metalBrushHorizontal,
})
statusElement.textContent = `Saved ${controls.substrate} material defaults.`
} catch (error) {
statusElement.textContent = `Could not save material default: ${error instanceof Error ? error.message : String(error)}`
}
}
function updateMaterial() {
renderCadence.invalidate()
applyMaterialControls(frontMaterial, controls)
@@ -725,7 +860,7 @@ function updateStatus() {
updatePackUI()
return
}
const artworkLabel = customArtworkName ? `Custom: ${customArtworkName}` : activeFixture
const artworkLabel = customArtworkName ? `Custom: ${customArtworkName}` : cardLabel(fixtureAssets.get(activeFixture)!)
const maskLabel = customMaskName ? ` · mask: ${customMaskName}` : ''
statusElement.textContent = `${artworkLabel} · ${controls.substrate} · ${controls.finish}${maskLabel}`
}
@@ -759,7 +894,7 @@ function setIfChanged<T, K extends keyof T>(target: T, key: K, value: T[K]) {
if (target[key] !== value) target[key] = value
}
async function preparePack(newRoll = false) {
async function preparePack(newRoll = false, retainedContents?: readonly PackCardSpec[]) {
if (pack && !newRoll) {
pack.syncLighting(frontMaterial)
pack.setActive(true)
@@ -769,46 +904,27 @@ async function preparePack(newRoll = false) {
if (packLoading) return
packLoading = true
packError = undefined
if (newRoll && pack) {
if (pack) {
selectedPackStyle = pack.style
pack.dispose()
pack = undefined
for (const texture of packTextures) texture.dispose()
packTextures = []
}
updatePackUI()
let preparedPack: PackOpening | undefined
let textures: THREE.Texture[] = []
try {
const contents = rollPackContents([...fixtureAssets.keys()])
const selectedNames = [...new Set(contents.map(spec => spec.fixture))]
const selectedAssets = selectedNames.map(name => {
const asset = fixtureAssets.get(name)
if (!asset) throw new Error(`Card "${name}" disappeared from the catalog while rolling`)
return asset
})
// Only cards selected for this pack are loaded; a large catalog does not consume GPU memory.
const results = await Promise.allSettled(selectedAssets.map(asset =>
loadCardTextures(loader, asset, renderer.capabilities.getMaxAnisotropy())))
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') {
for (const result of results) {
if (result.status === 'fulfilled') disposeCardTextures(result.value)
}
throw failure.reason
}
const selectedTextures = new Map(selectedAssets.map((asset, index) => {
const result = results[index]
if (result.status !== 'fulfilled') throw new Error(`Missing pack card: ${asset.name}`)
const loaded = result.value
textures.push(loaded.artwork, loaded.mask)
if (loaded.textMask) textures.push(loaded.textMask)
return [asset.name, loaded] as const
}))
const contents = retainedContents?.map(spec => ({ ...spec })) ?? rollPackContents([...fixtureAssets.keys()])
const textureCache = new PackTextureCache()
preparedPack = new PackOpening({
canvas,
contents,
textures: selectedTextures,
placeholderTextures: packPlaceholderTextures,
acquireTextures: spec => textureCache.acquire(spec),
prepareTextures: textures => {
renderer.initTexture(textures.artwork)
renderer.initTexture(textures.mask)
if (textures.textMask) renderer.initTexture(textures.textMask)
},
resolveSurfaceDefaults: (finish, substrate) => defaultsStore.resolved(finish, substrate),
backMaterial,
normalMap,
environment: activeEnvironment,
@@ -830,10 +946,8 @@ async function preparePack(newRoll = false) {
preparedPack.setActive(mode === 'Pack')
scene.add(preparedPack.root)
pack = preparedPack
packTextures = textures
} catch (error) {
preparedPack?.dispose()
for (const texture of textures) texture.dispose()
packError = `Pack unavailable: ${error instanceof Error ? error.message : String(error)}`
console.error('Could not prepare pack prototype', error)
} finally {
@@ -855,11 +969,14 @@ function normalizedExperimentName() {
function createExperimentSnapshot(name: string): ExperimentSnapshot {
pauseScriptedMotion()
return {
schemaVersion: 2,
schemaVersion: 3,
runtimeRevision: 'runtime-look-v4-2026-09-07',
name,
savedAt: new Date().toISOString(),
fixture: activeFixture,
cardId: activeFixture,
acceptedRevision: fixtureAssets.get(activeFixture)!.acceptedRevision,
resolution: selectedResolution,
printing: selectedPrinting,
customArtworkName,
customMaskName,
...captureExperimentMaterialControls(controls),
@@ -971,11 +1088,19 @@ function captureExperimentPng() {
async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
pauseScriptedMotion()
if (!fixtureAssets.has(snapshot.fixture)) {
throw new Error(`Experiment card "${snapshot.fixture}" is not in the card-art catalog`)
const experimentCard = snapshot.schemaVersion === 3
? fixtureAssets.get(snapshot.cardId!)
: findLegacyCard(cardCatalog.cards, snapshot.fixture!)
if (!experimentCard) {
throw new Error(`Experiment card "${snapshot.cardId ?? snapshot.fixture}" is not in the accepted artifact catalog`)
}
fixtureSelect.value = snapshot.fixture
await loadFixture(snapshot.fixture)
if (snapshot.schemaVersion === 3) {
selectedResolution = snapshot.resolution!
selectedPrinting = snapshot.printing!
resolutionSelect.value = selectedResolution
printingSelect.value = selectedPrinting
}
await loadFixture(experimentCard.cardId)
Object.assign(controls, restoreExperimentMaterialControls(snapshot))
controls.lightingPreset = snapshot.lightingPreset
controls.lightType = snapshot.lightType
@@ -1019,7 +1144,10 @@ async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
const customAssetNote = snapshot.customArtworkName || snapshot.customMaskName
? ' Local artwork or masks must be loaded again.'
: ''
if (mode !== 'Pack') statusElement.textContent = `Loaded experiment "${snapshot.name}".${customAssetNote}`
const revisionNote = snapshot.schemaVersion === 3 && snapshot.acceptedRevision !== experimentCard.acceptedRevision
? ` Card acceptance changed from ${snapshot.acceptedRevision?.slice(0, 10)} to ${experimentCard.acceptedRevision.slice(0, 10)}.`
: ''
if (mode !== 'Pack') statusElement.textContent = `Loaded experiment "${snapshot.name}".${revisionNote}${customAssetNote}`
}
function syncLightControls() {
@@ -1060,16 +1188,16 @@ function resetView() {
orbit.update()
modeSelect.value = 'Inspect'
controls.target = 'Card'
fixtureSelect.value = defaultFixture
selectedResolution = 'high'
selectedPrinting = 'normal'
resolutionSelect.value = selectedResolution
printingSelect.value = selectedPrinting
finishSelect.value = 'Holographic'
substrateSelect.value = 'Paper'
poseSelect.value = 'Front'
controls.finish = 'Holographic'
controls.substrate = 'Paper'
controls.finishStrength = 0.6
controls.roughness = 0.23
controls.normalStrength = defaultSurfaceDetail(controls.substrate)
controls.metalBrushHorizontal = false
Object.assign(controls, defaultsStore.resolved(controls.finish, controls.substrate))
controls.condition = 1
controls.imperfectionSeed = 81251
applyLightingPreset('Studio')
@@ -1087,14 +1215,14 @@ function resetView() {
async function loadFixture(nextFixture: FixtureName) {
const request = ++textureRequest
loadingElement.hidden = false
statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…`
const asset = fixtureAssets.get(nextFixture)
if (!asset) {
const card = fixtureAssets.get(nextFixture)
if (!card) {
loadingElement.hidden = true
fixtureSelect.value = activeFixture
statusElement.textContent = `Card "${nextFixture}" is not in the card-art catalog.`
statusElement.textContent = `Card "${nextFixture}" is not in the accepted artifact catalog.`
return
}
statusElement.textContent = `Loading ${cardLabel(card)} · ${selectedResolution} · ${selectedPrinting}…`
const asset = resolveCardAsset(card, selectedResolution, selectedPrinting)
try {
const loaded = await loadCardTextures(loader, asset, renderer.capabilities.getMaxAnisotropy())
@@ -1112,52 +1240,44 @@ async function loadFixture(nextFixture: FixtureName) {
activeFixture = nextFixture
customArtworkName = undefined
customMaskName = undefined
updateCardButton()
previousArtwork.dispose()
previousMask.dispose()
previousTextMask?.dispose()
updateStatus()
} catch (error) {
if (request === textureRequest) {
fixtureSelect.value = activeFixture
if (mode !== 'Pack') {
statusElement.textContent = `Could not load ${nextFixture}: ${error instanceof Error ? error.message : String(error)}`
}
if (request === textureRequest && mode !== 'Pack') {
statusElement.textContent = `Could not load ${cardLabel(card)}: ${error instanceof Error ? error.message : String(error)}`
}
} finally {
if (request === textureRequest) loadingElement.hidden = true
}
}
function resolveFixtureName(value: string) {
if (fixtureAssets.has(value)) return value
const normalized = value.toLocaleLowerCase()
return [...fixtureAssets.keys()].find(name => name.toLocaleLowerCase() === normalized)
}
async function refreshCardCatalog() {
try {
const nextCatalog = await fetchCardCatalog()
if (!nextCatalog.cards.length) {
throw new Error(nextCatalog.errors.join('; ') || 'No valid artwork/mask pairs found')
}
const previousAsset: CardAsset | undefined = fixtureAssets.get(activeFixture)
const nextAssets = new Map(nextCatalog.cards.map(card => [card.name, card]))
const nextAsset = nextAssets.get(activeFixture)
if (!nextCatalog.cards.length) throw new Error(nextCatalog.errors.join('; ') || 'No accepted cards found')
const previousCard = fixtureAssets.get(activeFixture)
const previousAsset = previousCard && resolveCardAsset(previousCard, selectedResolution, selectedPrinting)
const nextAssets = new Map(nextCatalog.cards.map(card => [card.cardId, card]))
const nextCard = nextAssets.get(activeFixture)
cardCatalog = nextCatalog
fixtureAssets = nextAssets
defaultFixture = fixtureAssets.has('David') ? 'David' : nextCatalog.cards[0].name
defaultFixture = fixtureAssets.has('BP-002') ? 'BP-002' : nextCatalog.cards[0].cardId
renderCardCatalog(nextCatalog)
if (nextCatalog.errors.length) {
statusElement.textContent = `Card catalog: ${nextCatalog.errors.join('; ')}`
}
if (
nextAsset &&
previousAsset?.revision !== nextAsset.revision &&
customArtworkName === undefined &&
customMaskName === undefined
) {
cardPicker.update(nextCatalog.cards)
if (!nextCard) {
activeFixture = defaultFixture
await loadFixture(defaultFixture)
} else {
updateCardButton()
const nextAsset = resolveCardAsset(nextCard, selectedResolution, selectedPrinting)
if (previousAsset?.revision !== nextAsset.revision && !customArtworkName && !customMaskName) {
await loadFixture(activeFixture)
}
}
if (nextCatalog.errors.length) statusElement.textContent = `Card catalog: ${nextCatalog.errors.join('; ')}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
catalogStateElement.textContent = 'Catalog error'
@@ -1366,30 +1486,35 @@ modeSelect.addEventListener('change', () => {
pauseScriptedMotion()
updateMode()
})
function selectFixtureFromPicker() {
const fixture = resolveFixtureName(fixtureSelect.value.trim())
if (!fixture) {
statusElement.textContent = `No card named "${fixtureSelect.value.trim()}" exists in the card-art folder.`
fixtureSelect.value = activeFixture
return
}
fixtureSelect.value = fixture
pauseScriptedMotion()
void loadFixture(fixture)
}
fixtureSelect.addEventListener('change', selectFixtureFromPicker)
fixtureSelect.addEventListener('focus', () => { void refreshCardCatalog() })
fixtureSelect.addEventListener('keydown', event => {
if (event.key === 'Enter') selectFixtureFromPicker()
cardPickerButton.addEventListener('click', () => {
void refreshCardCatalog()
cardPicker.open(activeFixture)
})
previousCardButton.addEventListener('click', () => {
const card = cardPicker.adjacent(activeFixture, -1)
if (card) void loadFixture(card.cardId)
})
nextCardButton.addEventListener('click', () => {
const card = cardPicker.adjacent(activeFixture, 1)
if (card) void loadFixture(card.cardId)
})
async function changeVariant() {
selectedResolution = resolutionSelect.value as CardResolution
selectedPrinting = printingSelect.value as CardPrinting
if (mode === 'Pack') await preparePack(true, pack?.contents)
else await loadFixture(activeFixture)
}
resolutionSelect.addEventListener('change', () => { void changeVariant() })
printingSelect.addEventListener('change', () => { void changeVariant() })
window.addEventListener('focus', () => { void refreshCardCatalog() })
if (import.meta.hot) {
import.meta.hot.on('card-catalog:update', () => { void refreshCardCatalog() })
import.meta.hot.on('surface-defaults:update', () => { void defaultsStore.refresh() })
}
substrateSelect.addEventListener('change', () => {
pauseScriptedMotion()
controls.substrate = substrateSelect.value as SubstrateName
controls.normalStrength = defaultSurfaceDetail(controls.substrate)
defaultsStore.applyMaterial(controls, controls.substrate)
updateMaterial()
updateEdgeMaterial()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
@@ -1398,6 +1523,7 @@ substrateSelect.addEventListener('change', () => {
finishSelect.addEventListener('change', () => {
pauseScriptedMotion()
controls.finish = finishSelect.value as FinishName
defaultsStore.applyFinish(controls, controls.finish)
updateMaterial()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
updateStatus()
@@ -1465,6 +1591,9 @@ flipButton.addEventListener('click', () => {
(normalized > Math.PI / 2 && normalized < Math.PI * 1.5 ? -Math.PI : Math.PI)
})
resetButton.addEventListener('click', resetView)
diagnosticsToggle.addEventListener('click', () => {
setDiagnosticsEnabled(diagnosticsToggle.getAttribute('aria-pressed') !== 'true')
})
sweepButton.addEventListener('click', startSweep)
controlsToggle.addEventListener('click', () => {
setControlsCollapsed(!topbar.classList.contains('controls-collapsed'))

View File

@@ -4,6 +4,7 @@ import { createFoilWrapper } from './foilWrapper'
import type { PackStyle } from './packDesigns'
import type { CardTextures } from './cardTextures'
import {
applyCardTextMask,
applyEdgeMaterialControls,
applyMaterialControls,
createCardMaterial,
@@ -56,10 +57,24 @@ const cardSweepRadius = Math.hypot(
const inspectionDepth = Math.max(3, cardSweepRadius + 0.5)
const inspectionCameraRetreat = inspectionDepth - 0.85
export interface CardTextureLease {
textures: CardTextures
release: () => void
}
interface PackOptions {
canvas: HTMLCanvasElement
contents: readonly PackCardSpec[]
textures: ReadonlyMap<string, CardTextures>
textures?: ReadonlyMap<string, CardTextures>
placeholderTextures?: CardTextures
acquireTextures?: (spec: PackCardSpec) => Promise<CardTextureLease>
prepareTextures?: (textures: CardTextures) => Promise<void> | void
resolveSurfaceDefaults?: (finish: FinishName, substrate: SubstrateName) => {
finishStrength: number
roughness: number
normalStrength: number
metalBrushHorizontal: boolean
}
backMaterial: THREE.Material
normalMap: THREE.Texture
environment: THREE.CubeTexture
@@ -143,6 +158,7 @@ export class PackOpening {
private readonly pointers = new Map<number, THREE.Vector2>()
private tap: { id: number; x: number; y: number; at: number } | undefined
private pinchDistance: number | undefined
private disposed = false
constructor(options: PackOptions) {
if (!options.contents.length) throw new Error('Pack must contain at least one card')
@@ -154,22 +170,26 @@ export class PackOpening {
this.root.add(this.wrapper.root)
const buffers = createCardBuffers()
this.cards = this.contents.map((spec) => {
const textures = options.textures.get(spec.fixture)
if (!textures) throw new Error(`Missing loaded textures for pack card "${spec.fixture}"`)
const staticTextures = options.textures?.get(spec.fixture)
const textures = staticTextures ?? options.placeholderTextures
if (!textures) throw new Error(`Missing textures or placeholder for pack card "${spec.fixture}"`)
const material = createCardMaterial(
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
textures.textMask ?? null,
)
applyMaterialControls(material, {
...spec, finishStrength: 0.6, roughness: 0.23, normalStrength: defaultSurfaceDetail(spec.substrate),
const defaults = options.resolveSurfaceDefaults?.(spec.finish, spec.substrate) ?? {
finishStrength: 0.6, roughness: 0.23, normalStrength: defaultSurfaceDetail(spec.substrate),
metalBrushHorizontal: false,
environmentIntensity: 0.7, condition: 1, imperfectionSeed: 81251,
}
applyMaterialControls(material, {
...spec, ...defaults, environmentIntensity: 0.7, condition: 1, imperfectionSeed: 81251,
})
const edge = new THREE.MeshPhysicalMaterial()
const object = createCardGeometry(material, options.backMaterial, edge, buffers)
const front = object.getObjectByName('CARD_FRONT')!
this.root.add(object)
return { object, material, edge, front }
return { object, material, edge, front, ready: !!staticTextures, lease: undefined as CardTextureLease | undefined,
loading: undefined as Promise<void> | undefined, error: undefined as string | undefined }
})
this.restart()
}
@@ -192,7 +212,11 @@ export class PackOpening {
},
stackReady: { action: 'Lift card', status: `Card ${number} · face-down stack`, hint: 'Tap or Lift card · identities stay hidden until the flip' },
lifting: { action: 'Lifting…', status: `Card ${number} · lifting face down`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
lifted: { action: 'Reveal card', status: `Card ${number} · ready to turn`, hint: 'Tap or Reveal card to flip the front into view' },
lifted: this.cards[this.index].loading
? { action: 'Loading…', status: `Card ${number} · loading ${spec.fixture}`, hint: 'Preparing only this card front before the reveal' }
: this.cards[this.index].error
? { action: 'Retry card', status: `Card ${number} · ${this.cards[this.index].error}`, hint: 'Retry loads this card front again' }
: { action: 'Reveal card', status: `Card ${number} · ready to turn`, hint: 'Tap or Reveal card to flip the front into view' },
revealing: { action: 'Revealing…', status: `Card ${number} · turning over`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
inspecting: {
action: this.index === this.cards.length - 1 ? 'Finish pack' : 'Next card',
@@ -257,6 +281,8 @@ export class PackOpening {
}
dispose() {
this.disposed = true
this.cards.forEach((_, index) => this.releaseTextures(index))
this.clearPointers()
this.active = false
this.root.visible = false
@@ -307,6 +333,7 @@ export class PackOpening {
}
restart() {
if (this.options.acquireTextures) this.cards.forEach((_, index) => this.releaseTextures(index))
this.clearPointers()
this.motion = undefined
this.paused = false
@@ -378,6 +405,8 @@ export class PackOpening {
card.object.visible = state !== 'complete' && index >= this.index
card.front.visible = revealPhase && index === this.index
})
if (state === 'stackReady') void this.ensureTextures(this.index).catch(() => {})
if (state === 'inspecting' && this.index + 1 < this.cards.length) void this.ensureTextures(this.index + 1).catch(() => {})
if (state === 'inspecting' && !this.revealed.has(this.index)) {
this.revealed.add(this.index)
applyEdgeMaterialControls(this.cards[this.index].edge, { ...this.contents[this.index], condition: 1 })
@@ -392,6 +421,56 @@ export class PackOpening {
this.options.onChange()
}
private async ensureTextures(index: number) {
const card = this.cards[index]
if (card.ready || !this.options.acquireTextures) return
if (card.loading) return card.loading
card.error = undefined
const loading = (async () => {
const lease = await this.options.acquireTextures!(this.contents[index])
if (this.disposed) {
lease.release()
return
}
await this.options.prepareTextures?.(lease.textures)
if (this.disposed) {
lease.release()
return
}
card.lease = lease
card.material.uniforms.artwork.value = lease.textures.artwork
card.material.uniforms.finishMask.value = lease.textures.mask
applyCardTextMask(card.material, lease.textures.textMask ?? null)
card.ready = true
})()
card.loading = loading
this.options.onChange()
try {
await loading
} catch (error) {
card.error = error instanceof Error ? error.message : String(error)
throw error
} finally {
card.loading = undefined
this.options.onChange()
}
}
private releaseTextures(index: number) {
const card = this.cards[index]
const lease = card.lease
if (!lease) return
const placeholder = this.options.placeholderTextures
if (placeholder) {
card.material.uniforms.artwork.value = placeholder.artwork
card.material.uniforms.finishMask.value = placeholder.mask
applyCardTextMask(card.material, placeholder.textMask ?? null)
}
card.lease = undefined
card.ready = false
lease.release()
}
private begin(
state: PackState, tracks: Track[], duration: number, finish: () => void,
progress?: (value: number) => void,
@@ -436,6 +515,12 @@ export class PackOpening {
break
}
case 'lifted':
if (!card.ready) {
void this.ensureTextures(this.index).then(() => {
if (this.active && this.state === 'lifted' && !this.motion) this.primary()
}).catch(() => {})
break
}
this.begin('revealing', [track(card.object, pose(0, 0.14, inspectionDepth, -0.06, Math.PI * 2 - 0.12))], 820,
() => this.setState('inspecting'))
break
@@ -450,12 +535,14 @@ export class PackOpening {
}
const startRetreat = this.cameraRetreat
this.begin('advancing', tracks, 650, () => {
const previousIndex = this.index
if (this.index === this.cards.length - 1) {
this.setState('complete')
} else {
this.index++
this.setState('stackReady')
}
this.releaseTextures(previousIndex)
}, (value) => {
this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, 0, value)
this.updateCamera()

View File

@@ -100,8 +100,8 @@ h1 {
.toolbar label {
display: grid;
flex: 1 1 110px;
min-width: 100px;
flex: 0 1 124px;
min-width: 104px;
gap: 4px;
color: #9da3ad;
font-size: 11px;
@@ -329,8 +329,56 @@ button:disabled {
cursor: default;
}
.card-control { flex: 1 1 300px !important; min-width: min(300px, 100%); max-width: 440px; }
.card-select-row { display: grid; grid-template-columns: 32px minmax(150px, 1fr) 32px; gap: 5px; }
.card-select-row button { min-width: 0; padding-inline: 7px; }
#card-picker-open { overflow: hidden; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.card-picker {
position: fixed; inset: 0 0 0 auto; width: min(520px, 94vw); max-width: none;
height: 100dvh; max-height: none; margin: 0; padding: 0; border: 0;
border-left: 1px solid #363d49; background: #11151d; color: #edf0f5;
box-shadow: -16px 0 50px #0009;
}
.card-picker::backdrop { background: #05070bbb; }
.card-picker[open] { display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.card-picker-header {
display: flex; align-items: center; gap: 10px; padding: 16px; border-bottom: 1px solid #2b313c;
}
.card-picker-filters {
display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center;
gap: 10px; padding: 16px; border-bottom: 1px solid #2b313c;
}
.card-picker-header { justify-content: space-between; }
.card-picker-header h2 { margin: 2px 0 0; font-size: 20px; }
.card-picker-header .eyebrow { margin: 0; }
.card-picker-close { width: 40px; min-width: 40px; font-size: 24px; }
.card-picker-filters input { grid-column: 1 / -1; min-width: 0; }
.card-picker-filters select { width: min(240px, 65vw); }
.card-picker-filters span { justify-self: end; color: #9fa8b8; font-size: 12px; white-space: nowrap; }
.card-picker-results {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: start;
gap: 10px; overflow: auto; padding: 14px;
}
.card-picker-result {
display: grid; grid-template-columns: 62px minmax(0, 1fr); align-items: center;
gap: 10px; min-width: 0; height: auto; padding: 7px; text-align: left;
}
.card-picker-result.selected { border-color: #b69a51; background: #332d20; }
.card-picker-result img { width: 62px; aspect-ratio: 5 / 7; border-radius: 3px; object-fit: cover; background: #080a0e; }
.card-picker-result span { display: grid; gap: 4px; min-width: 0; }
.card-picker-result strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-picker-result small { color: #a3abba; line-height: 1.35; }
.card-picker-empty { grid-column: 1 / -1; color: #a3abba; text-align: center; }
.diagnostic-readout,
.diagnostic-control { display: none; }
.diagnostics-enabled .diagnostic-readout { display: block; }
.diagnostics-enabled .toolbar .diagnostic-control { display: inline-block; }
#render-diagnostics { color: #89909b; font-size: 10px; }
.toolbar .pack-style,
.pack-mode .toolbar > :not(:first-child):not(.pack-style) {
.pack-mode .toolbar > :not(:first-child):not(.pack-style):not(.variant-control):not(.diagnostics-toggle) {
display: none;
}
@@ -339,6 +387,8 @@ button:disabled {
flex-basis: 200px;
}
.pack-mode .toolbar .variant-control { display: grid; }
.pack-mode .toolbar {
flex: 0 1 400px;
}
@@ -354,7 +404,7 @@ button:disabled {
max-width: min(440px, calc(100% - 32px));
}
@media (max-width: 1120px) {
@media (max-width: 1500px) {
.topbar {
align-items: flex-start;
}
@@ -393,7 +443,13 @@ button:disabled {
}
.toolbar label {
flex-basis: calc(33.333% - 6px);
flex: 1 1 calc(33.333% - 6px);
}
.toolbar .card-control {
flex: 2 1 calc(66.666% - 6px) !important;
max-width: none;
min-width: 0;
}
.toolbar button {
@@ -479,6 +535,10 @@ button:disabled {
padding-inline: 10px;
font-size: 12px;
}
.card-control { grid-column: 1 / -1; }
.card-picker { width: 100vw; }
.card-picker-results { grid-template-columns: 1fr; }
}
@media (max-height: 540px) and (min-aspect-ratio: 4/3) {

View File

@@ -0,0 +1,132 @@
import bundledDefaults from '../config/surface-defaults.json'
import {
finishNames, substrateNames, surfaceDetailMax,
type FinishName, type MaterialControls, type SubstrateName,
} from './cardMaterial'
export interface FinishDefaults { finishStrength: number }
export interface SubstrateDefaults {
surfaceDetail: number
roughness: number
metalBrushHorizontal?: boolean
}
export interface SurfaceDefaults {
schemaVersion: 1
finishes: Record<FinishName, FinishDefaults>
materials: Record<Exclude<SubstrateName, 'Plastic'>, SubstrateDefaults>
}
interface SurfaceDefaultsResponse {
defaults: SurfaceDefaults
revision: string
writable: boolean
}
function finiteRange(value: unknown, min: number, max: number, label: string) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
throw new Error(`${label} must be between ${min} and ${max}`)
}
return value
}
export function validateSurfaceDefaults(value: unknown): SurfaceDefaults {
if (typeof value !== 'object' || value === null) throw new Error('Surface defaults are not an object')
const root = value as Record<string, unknown>
if (root.schemaVersion !== 1 || typeof root.finishes !== 'object' || root.finishes === null ||
typeof root.materials !== 'object' || root.materials === null) {
throw new Error('Surface defaults use an unsupported format')
}
const finishes = root.finishes as Record<string, unknown>
const materials = root.materials as Record<string, unknown>
for (const name of finishNames) {
const item = finishes[name]
if (typeof item !== 'object' || item === null) throw new Error(`Missing defaults for finish ${name}`)
finiteRange((item as Record<string, unknown>).finishStrength, 0, 1, `${name} finish strength`)
}
for (const name of substrateNames) {
const item = materials[name]
if (typeof item !== 'object' || item === null) throw new Error(`Missing defaults for material ${name}`)
const record = item as Record<string, unknown>
finiteRange(record.surfaceDetail, 0, surfaceDetailMax, `${name} surface detail`)
finiteRange(record.roughness, 0.05, 0.8, `${name} roughness`)
if (record.metalBrushHorizontal !== undefined && typeof record.metalBrushHorizontal !== 'boolean') {
throw new Error(`${name} metal grain orientation must be a boolean`)
}
}
return value as SurfaceDefaults
}
const fallbackDefaults = validateSurfaceDefaults(bundledDefaults)
export class SurfaceDefaultsStore {
defaults: SurfaceDefaults = structuredClone(fallbackDefaults)
revision = 'bundled'
writable = false
async refresh() {
try {
const response = await fetch('/__surface_defaults', { cache: 'no-store' })
if (!response.ok) throw new Error(`request failed (${response.status})`)
const body = await response.json() as Partial<SurfaceDefaultsResponse>
this.defaults = validateSurfaceDefaults(body.defaults)
this.revision = typeof body.revision === 'string' ? body.revision : 'unknown'
this.writable = body.writable === true
} catch {
this.defaults = structuredClone(fallbackDefaults)
this.revision = 'bundled'
this.writable = false
}
}
finish(name: FinishName) { return this.defaults.finishes[name] }
material(name: SubstrateName) {
if (name === 'Plastic') return { surfaceDetail: 0.14, roughness: 0.23, metalBrushHorizontal: true }
return this.defaults.materials[name]
}
applyFinish(controls: MaterialControls, name: FinishName) {
controls.finish = name
controls.finishStrength = this.finish(name).finishStrength
}
applyMaterial(controls: MaterialControls, name: SubstrateName) {
const preset = this.material(name)
controls.substrate = name
controls.normalStrength = preset.surfaceDetail
controls.roughness = preset.roughness
controls.metalBrushHorizontal = preset.metalBrushHorizontal ?? false
}
resolved(finish: FinishName, substrate: SubstrateName) {
const finishPreset = this.finish(finish)
const materialPreset = this.material(substrate)
return {
finishStrength: finishPreset.finishStrength,
roughness: materialPreset.roughness,
normalStrength: materialPreset.surfaceDetail,
metalBrushHorizontal: materialPreset.metalBrushHorizontal ?? false,
}
}
async saveFinish(name: FinishName, values: FinishDefaults) {
return this.save('finish', name, values)
}
async saveMaterial(name: Exclude<SubstrateName, 'Plastic'>, values: SubstrateDefaults) {
return this.save('material', name, values)
}
private async save(group: 'finish' | 'material', name: string, values: FinishDefaults | SubstrateDefaults) {
if (!this.writable) throw new Error('Repository defaults can only be saved from the development server')
const response = await fetch('/__surface_defaults', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ revision: this.revision, group, name, values }),
})
const body = await response.json() as Partial<SurfaceDefaultsResponse> & { error?: string }
if (!response.ok) throw new Error(body.error ?? `save failed (${response.status})`)
this.defaults = validateSurfaceDefaults(body.defaults)
this.revision = typeof body.revision === 'string' ? body.revision : this.revision
this.writable = body.writable === true
}
}

View File

@@ -1,41 +1,94 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { defineConfig } from 'vite'
import { cardArtRoot, scanCardCatalog, writeCardCatalog } from './scripts/card-catalog.mjs'
import { acceptedCardsRoot, resolveCardAssetRequest, scanCardCatalog } from './scripts/card-catalog.mjs'
import { readSurfaceDefaults, surfaceDefaultsPath, updateSurfaceDefaults } from './scripts/surface-defaults.mjs'
function cardCatalogPlugin() {
return {
name: 'card-catalog',
async buildStart() {
const catalog = await writeCardCatalog()
if (!catalog.cards.length || catalog.errors.length) {
this.error(`Invalid card catalog:\n${catalog.errors.join('\n') || 'No valid card pairs found'}`)
}
},
configureServer(server) {
server.middlewares.use('/__card_catalog', async (_request, response) => {
try {
const catalog = await scanCardCatalog()
response.statusCode = 200
function json(response, status, value) {
response.statusCode = status
response.setHeader('Content-Type', 'application/json')
response.setHeader('Cache-Control', 'no-store')
response.end(JSON.stringify(catalog))
response.end(JSON.stringify(value))
}
function readJsonBody(request) {
return new Promise((resolve, reject) => {
let body = ''
request.setEncoding('utf8')
request.on('data', chunk => {
body += chunk
if (body.length > 65536) reject(new Error('Request body is too large'))
})
request.on('end', () => {
try { resolve(JSON.parse(body)) } catch { reject(new Error('Request body is not valid JSON')) }
})
request.on('error', reject)
})
}
function installRoutes(server, writable) {
let cachedCatalog
let cachedAssetPaths
let catalogPromise
const catalog = async () => {
if (cachedCatalog) return cachedCatalog
catalogPromise ??= scanCardCatalog().then(value => {
cachedCatalog = value
cachedAssetPaths = new Set(value.cards.flatMap(card => Object.values(card.variants).flatMap(group =>
Object.values(group).flatMap(asset => [asset.artwork, asset.mask, asset.textMask].filter(Boolean)
.map(assetUrl => new URL(assetUrl, 'http://card-harness.local').pathname)))))
return value
}).finally(() => { catalogPromise = undefined })
return catalogPromise
}
server.middlewares.use(async (request, response, next) => {
const url = new URL(request.url ?? '/', 'http://card-harness.local')
try {
if (url.pathname === '/__card_catalog') return json(response, 200, await catalog())
if (url.pathname === '/__surface_defaults') {
if (request.method === 'GET') return json(response, 200, await readSurfaceDefaults(writable))
if (request.method === 'POST' && writable) return json(response, 200, await updateSurfaceDefaults(await readJsonBody(request)))
return json(response, 405, { error: 'Surface defaults are read-only in this server mode' })
}
if (url.pathname.startsWith('/__card_asset/')) {
await catalog()
if (!cachedAssetPaths?.has(url.pathname)) return json(response, 404, { error: 'Unknown card asset' })
const path = await resolveCardAssetRequest(url.pathname)
if (!path) return json(response, 404, { error: 'Unknown card asset' })
const metadata = await stat(path)
response.statusCode = 200
response.setHeader('Content-Type', 'image/png')
response.setHeader('Content-Length', String(metadata.size))
response.setHeader('Cache-Control', url.searchParams.has('v') ? 'public, max-age=31536000, immutable' : 'no-cache')
return createReadStream(path).pipe(response)
}
next()
} catch (error) {
response.statusCode = 500
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify({
error: error instanceof Error ? error.message : String(error),
}))
const status = Number.isInteger(error?.statusCode) ? error.statusCode : 500
json(response, status, { error: error instanceof Error ? error.message : String(error) })
}
})
server.watcher.add(cardArtRoot)
if (server.watcher) {
server.watcher.add([acceptedCardsRoot, surfaceDefaultsPath])
server.watcher.on('all', (_event, path) => {
if (path.startsWith(cardArtRoot) && path.toLowerCase().endsWith('.png')) {
if (path.startsWith(acceptedCardsRoot)) {
cachedCatalog = undefined
cachedAssetPaths = undefined
server.ws.send({ type: 'custom', event: 'card-catalog:update' })
}
if (path === surfaceDefaultsPath) server.ws.send({ type: 'custom', event: 'surface-defaults:update' })
})
}
}
function cardLibraryPlugin() {
return {
name: 'card-library',
async buildStart() {
const catalog = await scanCardCatalog()
if (!catalog.cards.length || catalog.errors.length) {
this.error(`Invalid accepted card catalog:\n${catalog.errors.join('\n') || 'No approved cards found'}`)
}
},
configureServer(server) { installRoutes(server, true) },
configurePreviewServer(server) { installRoutes(server, false) },
}
}
export default defineConfig({
plugins: [cardCatalogPlugin()],
})
export default defineConfig({ plugins: [cardLibraryPlugin()] })

View File

@@ -0,0 +1,151 @@
# Card harness: accepted library, browsing, and surface defaults
Status: implementation plan, September 14, 2026. This document records the requested changes; it does not apply runtime settings or change approved artwork.
## Outcome
Inspect accepted cards directly from `artifacts/cards`, with a searchable visual picker, independent resolution and printing controls, and editable defaults for individual finishes and materials. Support roughly 500 card identities without loading their full images into browser or GPU memory at startup.
## 1. Accepted cards are the catalogue
- Discover packages under `artifacts/cards/[folderName]/` using `card.json`, the accepted `manifest.json`, and resolution manifests. Include only approved packages; skip migration reports and history.
- Key selections by catalogue `cardId`, never display title or a reconstructed title slug. Display title, ID, rarity, and accepted revision.
- Represent each card once, with variants indexed by resolution (`low`, `med`, `high`) and printing (`normal`, `borderless`, `textless`, `boundless`). Each variant identifies artwork, finish mask, optional text mask, dimensions, and recorded hashes.
- Use manifest metadata and file-existence checks for normal catalogue refreshes. Full image decoding and hash verification belong in explicit validation, with cached results where appropriate. Opening the picker must not decode every production PNG.
- Report invalid or incomplete packages with useful card/variant errors while keeping valid cards browseable. Show a useful empty-library state. Never silently substitute another printing or resolution.
- Cache catalogue discovery, coalesce refresh requests, and invalidate affected entries when accepted manifests or assets change. Recheck when opening the picker or returning to the page, without repeatedly scanning unchanged image bytes.
### Serving and builds
- Replace `public/card-art` discovery with a Vite catalogue endpoint and a read-only asset endpoint backed directly by `artifacts/cards`.
- Resolve asset requests through known catalogue entries and an allowlist of runtime PNGs. Check resolved paths, including symlinks, remain within the accepted asset root. Source/build/review files are not exposed by this route.
- Use recorded content hashes in URLs/cache validation so replacing an accepted revision cannot leave stale textures on screen.
- Support the same asset contract in development and Vite preview. A local built harness depends on the repository's accepted library being available to its server.
- A standalone static deployment will require a separately served artifact library or an explicit export step. Do not implicitly duplicate the entire 500-card library into tracked `public` files.
- Retire the old drop-folder catalogue and copies after verifying all consumers use the new catalogue. Keep `public/reference` for the shared back, normal map, and existing renderer regression fixtures.
## 2. Card selection and inspection
Proposed primary controls:
```text
Card [ David · BP-002 — Browse… ]
Resolution [ High ] Printing [ Normal ]
Finish [ Holographic ] Material [ Paper ]
```
- Default resolution is High (2000 × 2800); Medium is 1000 × 1400 and Low is 500 × 700. This is texture resolution, distinct from the renderer's drawing-buffer settings.
- Default printing is Normal. Printing and finish remain independent controls.
- Open a right-side drawer on desktop and a full-screen dialog on small screens. Use a native dialog where suitable, with labelled controls, visible focus, Escape, keyboard selection, and focus restoration to the Browse button.
- Include search by title, ID, and rarity, a rarity filter, result count, and stable ID/title sorting.
- Results show a low Normal thumbnail, title, ID, rarity, and accepted revision. Picker thumbnails stay low resolution regardless of the inspection setting.
- Load thumbnails only near the visible area; use a bounded visible grid or incremental loading so scrolling 500 cards does not retain hundreds of decoded images. Preserve search and scroll position when reopening.
- Selecting a card loads it into inspection and closes the picker. Keep resolution, printing, material, finish, lighting, and pose when changing card. Add Previous/Next navigation through the filtered result set for repeated inspection.
- Show loading/error state and keep the prior complete card visible until all new textures are ready. Swap artwork and matching masks together; discard stale results from rapid changes and dispose unused GPU textures.
- Continue supporting local artwork/mask experiments in Lab. Restore selected card reloads the exact selected identity, resolution, and printing.
## 3. High-resolution packs with bounded texture loading
High is available and is the default in Pack as well. Hidden fronts should produce no draw calls; their high-resolution textures also should not all be decoded/uploaded in advance. The current Pack implementation prepares every selected front, so both loading and GPU preparation need to change.
- Keep the shared back and lightweight card geometry for the face-down stack. Hidden fronts use placeholders until their textures are needed.
- Load the first front before revealing it; prefetch the next card during inspection.
- Keep a small bounded cache for the current card, next card, and any outgoing card still visible during animation. Release texture sets when no visible or imminent card needs them. A duplicate far later in a pack must not pin a texture indefinitely.
- Cache by ID, accepted asset hash/revision, resolution, and printing. Share matching textures safely without disposing them while another card uses them.
- Prepare the required shader/texture resources before allowing a reveal. If assets are still loading, pause at the face-down reveal boundary with a loading indicator and retry on failure.
- Cancel stale loading on pack replacement; preserve reveal order, interruption behavior, shared-back appearance, and material-dependent edges.
- Expose resolution and printing in Pack. Proposed first implementation: a change applies to a new pack, with clear wording before discarding an opening in progress. A later enhancement can preserve contents and position while replacing textures.
- Test memory during rapid New pack, mode switches, and repeated reveals. Invisible fronts alone do not prove that texture memory has been released.
## 4. Editable defaults per finish and material
### Shared settings file
Proposed canonical file: `card-harness/config/surface-defaults.json`, versioned in Git. A shared validated resolver supplies defaults to Inspect, Lab, Reset, and Pack. It replaces duplicated selection/startup constants, including Pack's current hard-coded finish strength.
Initial requested values for implementation:
| Group | Selection | Setting | Default |
|---|---|---|---:|
| Finish | Holographic | Finish strength | 0.33 |
| Finish | Foil | Finish strength | 1.00 |
| Material | Metal | Surface detail | 1.00 |
| Material | Linen | Surface detail | 0.05 |
Interpretation: the user's material strength examples refer to the existing **Surface detail** control (`normalStrength` internally). These are proposed harness defaults; recording them here does not alter the shader or claim a new visual approval.
Suggested structure (excerpt; implementation includes every supported selection):
```json
{
"schemaVersion": 1,
"finishes": {
"Holographic": { "finishStrength": 0.33 },
"Foil": { "finishStrength": 1.00 }
},
"materials": {
"Metal": {
"surfaceDetail": 1.00,
"roughness": 0.23,
"metalBrushHorizontal": false
},
"Linen": { "surfaceDetail": 0.05, "roughness": 0.23 }
}
}
```
- Other finishes/materials start with their existing values until deliberately changed. Preserve support for legacy experiment values through the existing compatibility path.
- Finish presets own finish strength. Material presets own surface detail, roughness, and applicable material-specific controls such as Metal grain orientation.
- Lighting/environment, pose, card selection, condition, and seed remain session/experiment settings; saving a surface default does not capture them.
- Use extensible parameter objects with explicit supported fields and range validation. New exposed shader controls can join the appropriate group without creating another defaults table. Reject unknown/invalid fields with actionable messages instead of silently ignoring them.
- Keep shader formulas and internal relief caps separate from these editable starting values. Setting Surface detail to 1.00 does not remove existing relief limits.
- This is a Three.js harness; the requested renderer-setting workflow does not imply automatic Blender synchronization. If Blender settings are meant literally, map specific equivalents in a separate scoped change.
### Lab workflow and persistence
- Add **Save finish default** and **Save material defaults** actions near the respective controls. Labels identify the selection being saved, e.g. “Save Holographic default”.
- Moving a slider immediately previews it but does not save it automatically. Show when the current settings differ from the saved preset, and provide **Reapply saved defaults** for each group.
- The save action writes only the selected group's supported values to the canonical settings file during local development, then updates all controls using that resolver.
- Implement a narrow development-only write endpoint for this one settings file: validate values server-side, require same-origin requests, reject arbitrary paths, write atomically, and detect stale saves to avoid overwriting a newer edit from another tab/editor.
- Direct file edits should refresh the settings catalogue. Preserve currently tweaked values until the user reapplies a preset, changes the relevant selection, or resets.
- For preview/static use, bundle/read the defaults and offer explicit JSON export/import as needed. Show repository saving as unavailable where no development write endpoint exists; never report a browser-only save as a repository update.
### Application rules
1. Startup and Reset resolve the current default finish and material through the settings file.
2. Changing finish applies only the new finish preset; changing material applies only the new material preset. Keep toolbar and Lab controls synchronized.
3. Changing card, printing, or resolution preserves current surface tweaks.
4. Manual adjustments override presets for the current session until selection change, explicit reapply, or Reset. Saving persists them for future selections and sessions.
5. Loading an experiment applies its exact stored surface values after selecting its assets. Updated defaults must not overwrite a saved experiment's values.
6. New packs resolve defaults independently for each rolled finish/material. Snapshot those resolved settings for the pack; changing the defaults file does not alter cards halfway through an opening.
7. Save failure leaves the preview intact and clearly reports that the default was not persisted.
## 5. Selection and experiment compatibility
- Introduce experiment schema version 3 with `cardId`, accepted revision/hash, resolution, and printing alongside the explicit material/light/camera values.
- Read older versions using an explicit mapping for known fixture names. Reject ambiguous/unavailable identities with an explanation.
- An old fixture or accepted revision may differ visually from the current accepted card. Warn when loading a replacement; never claim an exact historical reproduction when those assets are unavailable.
- Keep defaults configuration distinct from experiment snapshots: a preset is a reusable starting point, while an experiment records one exact set of choices.
## 6. Implementation sequence
1. Implement the accepted-card catalogue, safe asset routes, cache invalidation, and development/preview contract.
2. Add the visual picker and resolution/printing state; integrate texture swapping and experiment migration.
3. Add the shared surface-defaults schema/resolver and initial requested values; wire every selection path and Reset through it.
4. Add Lab preset-save/reapply actions, validated persistence, and file-edit refresh handling.
5. Refactor Pack texture loading/preparation for High, bounded caching, and per-card resolved surface presets.
6. Verify consumers and retire obsolete drop-folder assets/references. Update the harness README and current-defaults documentation to describe the implemented behavior.
Keep picker/filtering, catalogue/variant resolution, defaults resolution/persistence, and pack texture ownership in focused modules rather than expanding `main.ts` with every responsibility.
## 7. Acceptance checks
- The five current accepted cards appear automatically with their available 12 resolution/printing variants; adding an approved package requires no manual copying or manifest editing in `public`.
- A synthetic 500-card catalogue can be searched and scrolled while thumbnail requests and decoded-image memory remain bounded. Opening the picker does not load high-resolution textures.
- Card switching preserves surface/light/pose choices, handles missing variants and rapid changes correctly, and releases obsolete textures.
- All asset routes reject traversal and unavailable files. Development and local preview use the same catalogue contract.
- Selecting Holographic/Foil starts at 0.33/1.00 finish strength; Metal/Linen starts at 1.00/0.05 surface detail. Test both toolbar and Lab paths, Reset, and new packs.
- Saving one group's defaults leaves the other group and lighting untouched; values survive a restart through the repository settings file. Invalid values, stale writes, and failed saves are handled visibly.
- Changing presets does not overwrite imported experiment values or an active pack's captured settings.
- High-resolution packs load only a bounded number of front texture sets, gate reveals on readiness, preserve animations and hidden-card appearance, and clean up correctly after failure, mode switches, and new packs.
- Run relevant catalogue, texture ownership, experiment, and Pack tests plus TypeScript/build checks. Browser checks cover drawer accessibility, mobile layout, real texture loading, and repeated pack/card switching; report actual GPU/memory observations separately from CPU tests.