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)` | | Back | `(-0.06, 180 degrees + 0.12 radians, 0)` |
| Free | Current manually controlled rotation | | 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 ## 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 | | Finish | Holographic | Printed ink / Foil / Holographic / Gold leaf / Frosted glass |
| Material | Paper | Paper / Linen / Metal / Wood / Leather | | Material | Paper | Paper / Linen / Metal / Wood / Leather |
| Finish strength | 0.60 | 0-1 | | Finish strength | Per finish: Holographic `0.33`, Foil `1.00`, others `0.60` | 0-1 |
| Roughness | 0.23 | 0.05-0.8 | | Roughness | Per material; initially `0.23` | 0.05-0.8 |
| Surface detail | 0.14 (Leather selection: 0.45) | 0-1.0 | | 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) | | Horizontal metal grain (Lab only) | Off (Vertical) | Off / On (Horizontal) |
| Environment intensity | 0.70 | 0-1.5 | | Environment intensity | 0.70 | 0-1.5 |
| Condition | 1.0 (mint) | 0-1 | | 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 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. `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 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 finish or material applies its entry from `config/surface-defaults.json`; Lab can
experiments retain their explicit detail value (clamped only to `0-1.0`); Reset reapply or save the current finish/material values to that repository file while
restores Paper with detail `0.14`. Existing relief caps remain to bound parallax the development server is running. Imported experiments retain their explicit
and prevent extreme recesses. Random Leather pack cards also use `0.45`. 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 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 ## 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 ## 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. - `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 - Runtime-look revision and save time
- Reference fixture and custom asset filenames - Card selection and custom asset filenames
- Finish, substrate, and surface controls - Finish, substrate, and surface controls
- Condition and imperfection seed - Condition and imperfection seed
- Lighting preset, environment, type, color, position, intensity, and exposure - 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. 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 ## Source of Truth
- Geometry: `src/cardGeometry.ts` - Geometry: `src/cardGeometry.ts`
- Runtime shader: `src/cardMaterial.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` - Generated asset contract: `public/reference/manifest.json`
- Art policy: `../art-direction.md` - 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`. - 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`. - 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. - 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. - 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. - 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 ## Included proof scope
- David printed-ink, foil, and holographic presets - Searchable accepted-card library sourced directly from `artifacts/cards`
- Independently selectable paper, linen, metal, and wood substrates - Low, Med, and High resolution plus Normal, Borderless, Textless, and Boundless printing controls
- Timothy as the second-artwork fixture - Independently selectable finishes and paper, linen, metal, wood, and leather substrates
- David color-based coverage and title-panel protection; automatic saturation/value coverage for Timothy - Per-finish and per-material defaults editable from Lab and versioned in the repository
- Procedural rounded-card geometry generated from shared physical dimensions - Procedural rounded-card geometry generated from shared physical dimensions
- Deterministic linen normal map - Deterministic linen normal map
- Card manipulation, camera inspection, zoom, frame-rate-independent flip/reset, interruption-safe sweep, and movable lighting - 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 generates the two finish masks and linen normal map. Startup and production builds
run this automatically. No spike assets are required. run this automatically. No spike assets are required.
Inspect/Lab and Pack load artwork and masks from `public/card-art`; the generated Inspect/Lab and Pack discover approved card packages directly from `../artifacts/cards`.
David and Timothy fixtures are retained for shader regression checks. The app The development server exposes only allowlisted runtime PNGs through a contained
uses the generated universal back and linen normal map. Card geometry is generated 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. 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 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 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 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 ## Optional text masks
Place `[image-name]-text-mask.png` next to `[image-name].png` and the required Accepted Normal and Borderless packages provide `text-mask.png` beside `card.png`
`[image-name]-mask.png` in `public/card-art`. It must match artwork dimensions and `finish-mask.png` in each resolution/printing directory. It must match artwork
and be opaque grayscale: **white protects lettering, black retains the material**. dimensions and be opaque grayscale: **white protects lettering, black retains the
Generate it from the rendered text layer with a small, recorded feathered margin; material**. Generate it from the rendered text layer with a small, recorded feathered
it is separate from finish coverage and card silhouette alpha. margin; it is separate from finish coverage and card silhouette alpha.
The catalog exposes `textMask` only when the optional file exists, validates it, 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 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 changes. ResizeObserver handles canvas layout changes. Pack cards share two
geometry buffers while retaining their individual transforms and materials. 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, The diagnostic display also reports median CPU scene-update/render-submission time,
draw calls, resident renderer texture count, and shader program count. CPU submission 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 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. its curls/opening. Uniform-only edge changes reuse the material shader version.
The overlay no longer blurs the animated canvas beneath it. The overlay no longer blurs the animated canvas beneath it.
Pack resources stay resident after switching modes to preserve its state Pack wrapper, geometry, and placeholder resources stay resident after switching
and avoid repeating preparation. Catalog entries alone do not load artwork: modes to preserve state. Catalog entries alone do not load artwork: Inspect/Lab load
Inspect/Lab load the current fixture, and Pack loads its selected artwork only. 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; 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 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 { createHash } from 'node:crypto'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { readFile, readdir, realpath, stat } from 'node:fs/promises'
import { dirname, resolve } from 'node:path' import { dirname, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { PNG } from 'pngjs'
const here = dirname(fileURLToPath(import.meta.url)) const here = dirname(fileURLToPath(import.meta.url))
export const harnessRoot = resolve(here, '..') export const harnessRoot = resolve(here, '..')
export const cardArtRoot = resolve(harnessRoot, 'public', 'card-art') export const acceptedCardsRoot = resolve(harnessRoot, '..', 'artifacts', 'cards')
export const cardCatalogPath = resolve(cardArtRoot, 'manifest.json') 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) { async function json(path) { return JSON.parse(await readFile(path, 'utf8')) }
try { function assetUrl(folderName, resolution, printing, filename, sha256) {
return PNG.sync.read(bytes) return `/__card_asset/${encodeURIComponent(folderName)}/${resolution}/${printing}/${filename}?v=${sha256.slice(0, 16)}`
} catch (error) {
throw new Error(`${name} is not a readable PNG: ${error instanceof Error ? error.message : String(error)}`)
} }
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}`)
} }
return record
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}`)
} }
for (let offset = 0; offset < mask.data.length; offset += 4) { async function ensureFile(path, label) {
if (mask.data[offset] !== mask.data[offset + 1] || if (!(await stat(path)).isFile()) throw new Error(`${label} is not a file`)
mask.data[offset] !== mask.data[offset + 2] || mask.data[offset + 3] !== 255) {
throw new Error(`${filename} must be an opaque grayscale mask`)
} }
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`)
} }
variants[resolutionName] = {}
function validatePair(name, artworkBytes, maskBytes, textMaskBytes) { for (const printing of printings) {
const artwork = parsePng(`${name}.png`, artworkBytes) const artworkKey = `${printing}/card.png`, finishKey = `${printing}/finish-mask.png`, textKey = `${printing}/text-mask.png`
const mask = parsePng(`${name}-mask.png`, maskBytes) const artwork = fileRecord(resolutionManifest.files, artworkKey, dimensions)
if (artwork.width * 7 !== artwork.height * 5) { const finishMask = fileRecord(resolutionManifest.files, finishKey, dimensions)
throw new Error(`${name}.png must use a 5:7 aspect ratio; found ${artwork.width}x${artwork.height}`) const textMask = printing === 'normal' || printing === 'borderless'
} ? fileRecord(resolutionManifest.files, textKey, dimensions) : undefined
if (mask.width !== artwork.width || mask.height !== artwork.height) { await Promise.all([
throw new Error( ensureFile(resolve(directory, resolutionName, artworkKey), artworkKey),
`${name}-mask.png must match ${name}.png dimensions; found ` + ensureFile(resolve(directory, resolutionName, finishKey), finishKey),
`${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`, ...(textMask ? [ensureFile(resolve(directory, resolutionName, textKey), textKey)] : []),
)
}
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,
]) ])
const dimensions = validatePair(name, artworkBytes, maskBytes, textMaskBytes) const revision = createHash('sha256').update(artwork.sha256).update(finishMask.sha256)
const hash = createHash('sha256').update(artworkBytes).update(maskBytes) .update(textMask?.sha256 ?? '').digest('hex').slice(0, 16)
if (textMaskBytes) hash.update(textMaskBytes) variants[resolutionName][printing] = {
const revision = hash.digest('hex').slice(0, 16) artwork: assetUrl(manifest.folderName, resolutionName, printing, 'card.png', artwork.sha256),
cards.push({ mask: assetUrl(manifest.folderName, resolutionName, printing, 'finish-mask.png', finishMask.sha256),
name, ...(textMask ? { textMask: assetUrl(manifest.folderName, resolutionName, printing, 'text-mask.png', textMask.sha256) } : {}),
artwork: `/card-art/${encodeURIComponent(artworkFile)}?v=${revision}`, revision, width: dimensions[0], height: dimensions[1],
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))
} }
} }
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}`)
} }
return { cardId: manifest.cardId, title: content.title, rarity: content.rarity,
cards.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) 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() errors.sort()
return { schemaVersion: 1, cards, errors } return { schemaVersion: 2, cards, errors }
} }
export async function resolveCardAssetRequest(pathname, root = acceptedCardsRoot) {
export async function writeCardCatalog(root = cardArtRoot, output = cardCatalogPath) { const prefix = '/__card_asset/'
const catalog = await scanCardCatalog(root) if (!pathname.startsWith(prefix)) return undefined
await writeFile(output, `${JSON.stringify(catalog, null, 2)}\n`) let parts
return catalog 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)) { if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const catalog = await writeCardCatalog() const catalog = await scanCardCatalog()
console.log(`Card catalog ready: ${catalog.cards.length} cards, ${catalog.errors.length} errors`) console.log(`Accepted card catalog: ${catalog.cards.length} cards, ${catalog.errors.length} errors`)
if (catalog.errors.length) {
for (const error of catalog.errors) console.error(`- ${error}`) 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 { test } from 'node:test'
import assert from 'node:assert/strict' 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 { tmpdir } from 'node:os'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { PNG } from 'pngjs' import { printings, resolutions, resolveCardAssetRequest, scanCardCatalog } from './card-catalog.mjs'
import { scanCardCatalog } from './card-catalog.mjs'
function png(width, height, pixel) { async function createCard(root, options = {}) {
const image = new PNG({ width, height }) const folderName = options.folderName ?? 'BP-002-david'
for (let offset = 0; offset < image.data.length; offset += 4) { const directory = resolve(root, folderName)
image.data.set(pixel, offset) 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) => { test('catalog exposes every accepted resolution and printing from artifact manifests', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-')) const root = await mkdtemp(resolve(tmpdir(), 'accepted-card-catalog-'))
t.after(() => rm(root, { recursive: true })) t.after(() => rm(root, { recursive: true }))
await Promise.all([ await createCard(root)
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])),
])
const catalog = await scanCardCatalog(root) const catalog = await scanCardCatalog(root)
assert.deepEqual(catalog.errors, []) assert.deepEqual(catalog.errors, [])
assert.equal(catalog.schemaVersion, 2)
assert.equal(catalog.cards.length, 1) assert.equal(catalog.cards.length, 1)
assert.equal(catalog.cards[0].name, 'david') const card = catalog.cards[0]
assert.match(catalog.cards[0].artwork, /^\/card-art\/david\.png\?v=[0-9a-f]{16}$/) assert.equal(card.cardId, 'BP-002')
assert.equal(catalog.cards[0].width, 10) assert.equal(card.title, 'David')
assert.equal(catalog.cards[0].height, 14) 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) => { test('catalog rejects unapproved and identity-mismatched packages without hiding errors', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'card-catalog-invalid-')) const root = await mkdtemp(resolve(tmpdir(), 'invalid-accepted-card-catalog-'))
t.after(() => rm(root, { recursive: true })) t.after(() => rm(root, { recursive: true }))
await Promise.all([ await createCard(root, { folderName: 'BP-002-draft', stage: 'candidate' })
writeFile(resolve(root, 'orphan.png'), png(10, 14, [20, 40, 60, 255])), const bad = await createCard(root, { folderName: 'BP-002-wrong-folder' })
writeFile(resolve(root, 'mask-only-mask.png'), png(10, 14, [0, 0, 0, 255])), await writeFile(resolve(bad, 'manifest.json'), JSON.stringify({
writeFile(resolve(root, 'bad.png'), png(10, 14, [20, 40, 60, 254])), stage: 'approved', cardId: 'BP-999', folderName: 'BP-002-wrong-folder', revision: 'x',
writeFile(resolve(root, 'bad-mask.png'), png(10, 14, [20, 21, 20, 255])), }))
])
const catalog = await scanCardCatalog(root) const catalog = await scanCardCatalog(root)
assert.deepEqual(catalog.cards, []) assert.deepEqual(catalog.cards, [])
assert.ok(catalog.errors.some(error => error.includes('Missing orphan-mask.png'))) assert.ok(catalog.errors.some(error => error.includes('not approved')))
assert.ok(catalog.errors.some(error => error.includes('Missing mask-only.png'))) assert.ok(catalog.errors.some(error => error.includes('identity differs')))
assert.ok(catalog.errors.some(error => error.includes('bad.png must be fully opaque')))
}) })
test('optional text masks are discovered without becoming artwork or orphan finish masks', async (t) => { test('artifact asset routes allow only real runtime files inside the accepted root', async (t) => {
const root = await mkdtemp(resolve(tmpdir(), 'card-text-mask-')) const root = await mkdtemp(resolve(tmpdir(), 'accepted-card-route-'))
t.after(() => rm(root, { recursive: true })) t.after(() => rm(root, { recursive: true }))
const artwork = png(10, 14, [20, 40, 60, 255]) await createCard(root)
const finish = png(10, 14, [128, 128, 128, 255]) const path = await resolveCardAssetRequest('/__card_asset/BP-002-david/high/normal/card.png', root)
await Promise.all([ assert.equal(path, resolve(root, 'BP-002-david/high/normal/card.png'))
writeFile(resolve(root, 'Card name.png'), artwork), assert.equal(await resolveCardAssetRequest('/__card_asset/BP-002-david/high/normal/manifest.json', root), undefined)
writeFile(resolve(root, 'Card name-mask.png'), finish), assert.equal(await resolveCardAssetRequest('/__card_asset/..%2Foutside/high/normal/card.png', root), undefined)
]) const outside = resolve(root, '..', `outside-${Date.now()}.png`)
const legacy = await scanCardCatalog(root) await writeFile(outside, 'outside')
assert.equal(legacy.cards[0].textMask, undefined) t.after(() => rm(outside))
const { createHash } = await import('node:crypto') await rm(resolve(root, 'BP-002-david/high/normal/card.png'))
assert.equal(legacy.cards[0].revision, await symlink(outside, resolve(root, 'BP-002-david/high/normal/card.png'))
createHash('sha256').update(artwork).update(finish).digest('hex').slice(0, 16)) assert.equal(await resolveCardAssetRequest('/__card_asset/BP-002-david/high/normal/card.png', root), undefined)
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')))
}
}) })

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', () => { test('runtime catalog validation accepts complete artifact variants and rejects malformed text-mask fields', () => {
const catalog = { schemaVersion: 1, cards: [asset], errors: [] } 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) 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) assert.deepEqual(validateCardCatalog(masked), masked)
for (const textMask of [null, 1, false, '', '/reference/text.png', 'https://example.com/text.png']) { 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) pack.setActive(false)
assert.equal(pack.tick(performance.now()), 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 { export interface CardAsset {
name: string
artwork: string artwork: string
mask: string mask: string
textMask?: string textMask?: string
@@ -7,56 +11,57 @@ export interface CardAsset {
width: number width: number
height: number height: number
} }
export interface CatalogCard {
export interface CardCatalog { cardId: string
schemaVersion: 1 title: string
cards: CardAsset[] rarity: string
errors: 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 if (typeof value !== 'object' || value === null) return false
const card = value as Record<string, unknown> const asset = value as Record<string, unknown>
return typeof card.name === 'string' && card.name.length > 0 && return typeof asset.artwork === 'string' && asset.artwork.startsWith('/__card_asset/') &&
typeof card.artwork === 'string' && card.artwork.startsWith('/card-art/') && typeof asset.mask === 'string' && asset.mask.startsWith('/__card_asset/') &&
typeof card.mask === 'string' && card.mask.startsWith('/card-art/') && (asset.textMask === undefined || (typeof asset.textMask === 'string' && asset.textMask.startsWith('/__card_asset/'))) &&
(card.textMask === undefined || (typeof card.textMask === 'string' && card.textMask.startsWith('/card-art/'))) && typeof asset.revision === 'string' && Number.isInteger(asset.width) && Number.isInteger(asset.height)
typeof card.revision === 'string' &&
Number.isInteger(card.width) && Number.isInteger(card.height)
} }
function isCard(value: unknown): value is CatalogCard {
export async function fetchCardCatalog(): Promise<CardCatalog> { if (typeof value !== 'object' || value === null) return false
const urls = import.meta.env.DEV const card = value as Record<string, unknown>, variants = card.variants as Record<string, unknown> | undefined
? ['/__card_catalog', '/card-art/manifest.json'] return typeof card.cardId === 'string' && typeof card.title === 'string' && typeof card.rarity === 'string' &&
: ['/card-art/manifest.json'] typeof card.folderName === 'string' && typeof card.acceptedRevision === 'string' && !!variants &&
const failures: string[] = [] resolutionNames.every(resolution => {
for (const url of urls) { const group = variants[resolution] as Record<string, unknown> | undefined
try { return !!group && printingNames.every(printing => isAsset(group[printing]))
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'}`)
} }
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 { export function validateCardCatalog(value: unknown): CardCatalog {
if (typeof value !== 'object' || value === null) throw new Error('Card catalog is not an object') if (typeof value !== 'object' || value === null) throw new Error('Card catalog is not an object')
const catalog = value as Record<string, unknown> const catalog = value as Record<string, unknown>
if ( if (catalog.schemaVersion !== 2 || !Array.isArray(catalog.cards) || !catalog.cards.every(isCard) ||
catalog.schemaVersion !== 1 || !Array.isArray(catalog.errors) || !catalog.errors.every(error => typeof error === 'string')) {
!Array.isArray(catalog.cards) ||
!catalog.cards.every(isCardAsset) ||
!Array.isArray(catalog.errors) ||
!catalog.errors.every(error => typeof error === 'string')
) {
throw new Error('Card catalog has an unsupported or invalid format') throw new Error('Card catalog has an unsupported or invalid format')
} }
return catalog as unknown as CardCatalog 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, finishNames, substrateNames, surfaceDetailMax,
type FinishName, type SubstrateName, type MaterialControls, type FinishName, type SubstrateName, type MaterialControls,
} from './cardMaterial' } from './cardMaterial'
import { printingNames, resolutionNames, type CardPrinting, type CardResolution } from './cardCatalog'
type FixtureName = string type FixtureName = string
type LightingPreset = 'Studio' | 'Warm gallery' | 'Cool window' | 'Dramatic spot' | 'Flat review' | 'Custom' 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' type LightType = 'Point' | 'Directional' | 'Spot'
export interface ExperimentSnapshot { export interface ExperimentSnapshot {
schemaVersion: 1 | 2 schemaVersion: 1 | 2 | 3
runtimeRevision: 'runtime-look-v4-2026-09-07' runtimeRevision: 'runtime-look-v4-2026-09-07'
name: string name: string
savedAt: string savedAt: string
fixture: FixtureName fixture?: FixtureName
cardId?: string
acceptedRevision?: string
resolution?: CardResolution
printing?: CardPrinting
customArtworkName?: string customArtworkName?: string
customMaskName?: string customMaskName?: string
finish: FinishName finish: FinishName
@@ -52,7 +57,11 @@ function isVectorTuple(value: unknown): value is [number, number, number] {
export function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot { export function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot {
if (typeof value !== 'object' || value === null) return false if (typeof value !== 'object' || value === null) return false
const snapshot = value as Record<string, unknown> 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 finishValid = finishNames.some(name => name === snapshot.finish)
const substrateValid = snapshot.substrate === 'Plastic' || const substrateValid = snapshot.substrate === 'Plastic' ||
substrateNames.some(name => name === snapshot.substrate) 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.customArtworkName === undefined || typeof snapshot.customArtworkName === 'string') &&
(snapshot.customMaskName === undefined || typeof snapshot.customMaskName === '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 || const wearValid = snapshot.schemaVersion === 1 ||
(isFiniteNumber(snapshot.condition) && isFiniteNumber(snapshot.imperfectionSeed)) (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' && snapshot.runtimeRevision === 'runtime-look-v4-2026-09-07' &&
typeof snapshot.name === 'string' && typeof snapshot.name === 'string' &&
typeof snapshot.savedAt === 'string' && typeof snapshot.savedAt === 'string' &&
fixtureValid && selectionValid &&
optionalNamesValid && optionalNamesValid &&
finishValid && finishValid &&
substrateValid && substrateValid &&

View File

@@ -8,7 +8,6 @@ import {
applyMaterialControls, applyMaterialControls,
createCardMaterial, createCardMaterial,
createStudioCubeTexture, createStudioCubeTexture,
defaultSurfaceDetail,
surfaceDetailMax, surfaceDetailMax,
finishNames, finishNames,
substrateNames, substrateNames,
@@ -21,10 +20,15 @@ import {
isExperimentSnapshot, type ExperimentSnapshot, isExperimentSnapshot, type ExperimentSnapshot,
} from './experimentSnapshot' } from './experimentSnapshot'
import { createCardGeometry } from './cardGeometry' 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 { defaultPackStyle, packStyles } from './packDesigns'
import { fetchCardCatalog, type CardAsset, type CardCatalog } from './cardCatalog' import {
import { loadCardTextures, disposeCardTextures } from './cardTextures' 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 { RenderCadence } from './renderCadence'
import { PerformanceRecording } from './performanceRecording' import { PerformanceRecording } from './performanceRecording'
import { drawingPixelRatio, FramePacing, GpuTimer, median, rendererIdentity } from './renderDiagnostics' import { drawingPixelRatio, FramePacing, GpuTimer, median, rendererIdentity } from './renderDiagnostics'
@@ -48,9 +52,13 @@ let displayedIdle = false
let observedPixelRatio = window.devicePixelRatio let observedPixelRatio = window.devicePixelRatio
let cardCatalog = await fetchCardCatalog() let cardCatalog = await fetchCardCatalog()
if (!cardCatalog.cards.length) throw new Error('The card-art folder contains no valid artwork/mask pairs') if (!cardCatalog.cards.length) throw new Error('The accepted artifacts catalog contains no valid cards')
let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.name, card])) let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.cardId, card]))
let defaultFixture = fixtureAssets.has('David') ? 'David' : cardCatalog.cards[0].name 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) { function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) {
texture.colorSpace = colorSpace texture.colorSpace = colorSpace
@@ -89,10 +97,23 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
${packStyles.map(style => `<option${style === defaultPackStyle ? ' selected' : ''}>${style}</option>`).join('')} ${packStyles.map(style => `<option${style === defaultPackStyle ? ' selected' : ''}>${style}</option>`).join('')}
</select> </select>
</label> </label>
<label>Card <label class="card-control">Card
<span class="catalog-state" id="catalog-state" aria-live="polite"></span> <span class="catalog-state" id="catalog-state" aria-live="polite"></span>
<input id="fixture" list="fixture-options" autocomplete="off" spellcheck="false"> <span class="card-select-row">
<datalist id="fixture-options"></datalist> <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>
<label>Finish <label>Finish
<select id="finish"> <select id="finish">
@@ -133,9 +154,10 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<button id="flip" type="button">Flip</button> <button id="flip" type="button">Flip</button>
<button id="reset" type="button">Reset</button> <button id="reset" type="button">Reset</button>
<button id="sweep" type="button">Play sweep</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 class="diagnostics-toggle" id="diagnostics-toggle" type="button" aria-pressed="false">Show diagnostics</button>
<button id="export-timings" type="button" disabled>Export timings</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>
<span id="timing-record-status" role="status"></span> <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> </div>
</header> </header>
@@ -144,8 +166,8 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div class="loading" id="loading" hidden>Loading card…</div> <div class="loading" id="loading" hidden>Loading card…</div>
<div class="status-panel"> <div class="status-panel">
<span id="status">Preparing reference assets…</span> <span id="status">Preparing reference assets…</span>
<span id="performance">Frame time —</span> <span class="diagnostic-readout" id="performance">Frame time —</span>
<span id="render-diagnostics" style="font-size: 10px; color: #89909b"></span> <span class="diagnostic-readout" id="render-diagnostics"></span>
</div> </div>
<div class="hint" id="hint">Drag the card · pinch or wheel to zoom · flip to inspect the back</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> <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 statusElement = document.querySelector<HTMLSpanElement>('#status')!
const performanceElement = document.querySelector<HTMLSpanElement>('#performance')! const performanceElement = document.querySelector<HTMLSpanElement>('#performance')!
const renderDiagnosticsElement = document.querySelector<HTMLSpanElement>('#render-diagnostics')! const renderDiagnosticsElement = document.querySelector<HTMLSpanElement>('#render-diagnostics')!
const diagnosticsToggle = document.querySelector<HTMLButtonElement>('#diagnostics-toggle')!
const recordTimingsButton = document.querySelector<HTMLButtonElement>('#record-timings')! const recordTimingsButton = document.querySelector<HTMLButtonElement>('#record-timings')!
const exportTimingsButton = document.querySelector<HTMLButtonElement>('#export-timings')! const exportTimingsButton = document.querySelector<HTMLButtonElement>('#export-timings')!
const timingRecordStatus = document.querySelector<HTMLSpanElement>('#timing-record-status')! 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 topbar = document.querySelector<HTMLElement>('.topbar')!
const controlsToggle = document.querySelector<HTMLButtonElement>('#controls-toggle')! const controlsToggle = document.querySelector<HTMLButtonElement>('#controls-toggle')!
const modeSelect = document.querySelector<HTMLSelectElement>('#mode')! const modeSelect = document.querySelector<HTMLSelectElement>('#mode')!
const fixtureSelect = document.querySelector<HTMLInputElement>('#fixture')! const cardPickerButton = document.querySelector<HTMLButtonElement>('#card-picker-open')!
const fixtureOptions = document.querySelector<HTMLDataListElement>('#fixture-options')! 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 catalogStateElement = document.querySelector<HTMLSpanElement>('#catalog-state')!
const finishSelect = document.querySelector<HTMLSelectElement>('#finish')! const finishSelect = document.querySelector<HTMLSelectElement>('#finish')!
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')! 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 packSkipButton = document.querySelector<HTMLButtonElement>('#pack-skip')!
const packStyleSelect = document.querySelector<HTMLSelectElement>('#pack-style')! 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) { 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 catalogStateElement.textContent = catalog.errors.length
? `${catalog.cards.length} cards · ${catalog.errors.length} errors` ? `${catalog.cards.length} cards · ${catalog.errors.length} errors`
: `${catalog.cards.length} cards` : `${catalog.cards.length} cards`
@@ -216,8 +246,13 @@ function renderCardCatalog(catalog: CardCatalog) {
catalogStateElement.title = catalog.errors.join('\n') 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) renderCardCatalog(cardCatalog)
fixtureSelect.value = defaultFixture
function setControlsCollapsed(collapsed: boolean) { function setControlsCollapsed(collapsed: boolean) {
topbar.classList.toggle('controls-collapsed', collapsed) topbar.classList.toggle('controls-collapsed', collapsed)
@@ -292,11 +327,56 @@ const backTexture = await loader.loadAsync('/reference/card-back.png')
backTexture.colorSpace = THREE.SRGBColorSpace backTexture.colorSpace = THREE.SRGBColorSpace
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy() 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 initialTextures = await loadCardTextures(loader, initialAsset, renderer.capabilities.getMaxAnisotropy())
const initialArtwork = initialTextures.artwork const initialArtwork = initialTextures.artwork
const initialMask = initialTextures.mask 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 lightPosition = new THREE.Vector3(2.2, 2.5, 4.4)
const frontMaterial = createCardMaterial( const frontMaterial = createCardMaterial(
initialArtwork, initialArtwork,
@@ -341,6 +421,8 @@ floor.position.y = -2.7
floor.receiveShadow = true floor.receiveShadow = true
scene.add(floor) scene.add(floor)
const initialSurfaceDefaults = defaultsStore.resolved('Holographic', 'Paper')
const controls: MaterialControls & { const controls: MaterialControls & {
lightX: number lightX: number
lightY: number lightY: number
@@ -354,10 +436,7 @@ const controls: MaterialControls & {
} = { } = {
finish: 'Holographic', finish: 'Holographic',
substrate: 'Paper', substrate: 'Paper',
finishStrength: 0.6, ...initialSurfaceDefaults,
roughness: 0.23,
normalStrength: defaultSurfaceDetail('Paper'),
metalBrushHorizontal: false,
environmentIntensity: 0.7, environmentIntensity: 0.7,
condition: 1, condition: 1,
imperfectionSeed: 81251, imperfectionSeed: 81251,
@@ -435,13 +514,15 @@ const surfaceFolder = gui.addFolder('Surface')
surfaceFolder.add(controls, 'finish', [...finishNames]).name('Finish').onChange((value: FinishName) => { surfaceFolder.add(controls, 'finish', [...finishNames]).name('Finish').onChange((value: FinishName) => {
pauseScriptedMotion() pauseScriptedMotion()
finishSelect.value = value finishSelect.value = value
defaultsStore.applyFinish(controls, value)
gui.controllersRecursive().forEach(controller => controller.updateDisplay())
updateMaterial() updateMaterial()
updateStatus() updateStatus()
}) })
surfaceFolder.add(controls, 'substrate', [...substrateNames]).name('Material').onChange((value: SubstrateName) => { surfaceFolder.add(controls, 'substrate', [...substrateNames]).name('Material').onChange((value: SubstrateName) => {
pauseScriptedMotion() pauseScriptedMotion()
substrateSelect.value = value substrateSelect.value = value
controls.normalStrength = defaultSurfaceDetail(value) defaultsStore.applyMaterial(controls, value)
gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
updateMaterial() updateMaterial()
updateEdgeMaterial() updateEdgeMaterial()
@@ -469,6 +550,21 @@ surfaceFolder.add(controls, 'environmentIntensity', 0, 1.5, 0.01).name('Environm
updateMaterial() 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') const wearFolder = gui.addFolder('Wear')
wearFolder.add(controls, 'condition', 0, 1, 0.001).name('Condition (1=mint)').onChange(() => { wearFolder.add(controls, 'condition', 0, 1, 0.001).name('Condition (1=mint)').onChange(() => {
pauseScriptedMotion() pauseScriptedMotion()
@@ -546,7 +642,7 @@ const assetActions = {
loadMask: () => maskFileInput.click(), loadMask: () => maskFileInput.click(),
restoreFixture: () => { restoreFixture: () => {
pauseScriptedMotion() pauseScriptedMotion()
void loadFixture(fixtureSelect.value) void loadFixture(activeFixture)
}, },
} }
const assetFolder = gui.addFolder('Assets') const assetFolder = gui.addFolder('Assets')
@@ -578,6 +674,11 @@ gui.hide()
let mode: AppMode = 'Inspect' let mode: AppMode = 'Inspect'
let textureRequest = 0 let textureRequest = 0
let activeFixture: FixtureName = defaultFixture let activeFixture: FixtureName = defaultFixture
const cardPicker = new CardPicker(cardCatalog.cards, card => {
pauseScriptedMotion()
void loadFixture(card.cardId)
})
updateCardButton()
let customArtworkName: string | undefined let customArtworkName: string | undefined
let customMaskName: string | undefined let customMaskName: string | undefined
let dragging = false let dragging = false
@@ -600,9 +701,43 @@ let pack: PackOpening | undefined
let packLoading = false let packLoading = false
let packUIDirty = false let packUIDirty = false
let packError: string | undefined let packError: string | undefined
let packTextures: THREE.Texture[] = []
let selectedPackStyle = defaultPackStyle 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() { function updateMaterial() {
renderCadence.invalidate() renderCadence.invalidate()
applyMaterialControls(frontMaterial, controls) applyMaterialControls(frontMaterial, controls)
@@ -725,7 +860,7 @@ function updateStatus() {
updatePackUI() updatePackUI()
return return
} }
const artworkLabel = customArtworkName ? `Custom: ${customArtworkName}` : activeFixture const artworkLabel = customArtworkName ? `Custom: ${customArtworkName}` : cardLabel(fixtureAssets.get(activeFixture)!)
const maskLabel = customMaskName ? ` · mask: ${customMaskName}` : '' const maskLabel = customMaskName ? ` · mask: ${customMaskName}` : ''
statusElement.textContent = `${artworkLabel} · ${controls.substrate} · ${controls.finish}${maskLabel}` 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 if (target[key] !== value) target[key] = value
} }
async function preparePack(newRoll = false) { async function preparePack(newRoll = false, retainedContents?: readonly PackCardSpec[]) {
if (pack && !newRoll) { if (pack && !newRoll) {
pack.syncLighting(frontMaterial) pack.syncLighting(frontMaterial)
pack.setActive(true) pack.setActive(true)
@@ -769,46 +904,27 @@ async function preparePack(newRoll = false) {
if (packLoading) return if (packLoading) return
packLoading = true packLoading = true
packError = undefined packError = undefined
if (newRoll && pack) { if (pack) {
selectedPackStyle = pack.style selectedPackStyle = pack.style
pack.dispose() pack.dispose()
pack = undefined pack = undefined
for (const texture of packTextures) texture.dispose()
packTextures = []
} }
updatePackUI() updatePackUI()
let preparedPack: PackOpening | undefined let preparedPack: PackOpening | undefined
let textures: THREE.Texture[] = []
try { try {
const contents = rollPackContents([...fixtureAssets.keys()]) const contents = retainedContents?.map(spec => ({ ...spec })) ?? rollPackContents([...fixtureAssets.keys()])
const selectedNames = [...new Set(contents.map(spec => spec.fixture))] const textureCache = new PackTextureCache()
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
}))
preparedPack = new PackOpening({ preparedPack = new PackOpening({
canvas, canvas,
contents, 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, backMaterial,
normalMap, normalMap,
environment: activeEnvironment, environment: activeEnvironment,
@@ -830,10 +946,8 @@ async function preparePack(newRoll = false) {
preparedPack.setActive(mode === 'Pack') preparedPack.setActive(mode === 'Pack')
scene.add(preparedPack.root) scene.add(preparedPack.root)
pack = preparedPack pack = preparedPack
packTextures = textures
} catch (error) { } catch (error) {
preparedPack?.dispose() preparedPack?.dispose()
for (const texture of textures) texture.dispose()
packError = `Pack unavailable: ${error instanceof Error ? error.message : String(error)}` packError = `Pack unavailable: ${error instanceof Error ? error.message : String(error)}`
console.error('Could not prepare pack prototype', error) console.error('Could not prepare pack prototype', error)
} finally { } finally {
@@ -855,11 +969,14 @@ function normalizedExperimentName() {
function createExperimentSnapshot(name: string): ExperimentSnapshot { function createExperimentSnapshot(name: string): ExperimentSnapshot {
pauseScriptedMotion() pauseScriptedMotion()
return { return {
schemaVersion: 2, schemaVersion: 3,
runtimeRevision: 'runtime-look-v4-2026-09-07', runtimeRevision: 'runtime-look-v4-2026-09-07',
name, name,
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
fixture: activeFixture, cardId: activeFixture,
acceptedRevision: fixtureAssets.get(activeFixture)!.acceptedRevision,
resolution: selectedResolution,
printing: selectedPrinting,
customArtworkName, customArtworkName,
customMaskName, customMaskName,
...captureExperimentMaterialControls(controls), ...captureExperimentMaterialControls(controls),
@@ -971,11 +1088,19 @@ function captureExperimentPng() {
async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) { async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
pauseScriptedMotion() pauseScriptedMotion()
if (!fixtureAssets.has(snapshot.fixture)) { const experimentCard = snapshot.schemaVersion === 3
throw new Error(`Experiment card "${snapshot.fixture}" is not in the card-art catalog`) ? 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 if (snapshot.schemaVersion === 3) {
await loadFixture(snapshot.fixture) selectedResolution = snapshot.resolution!
selectedPrinting = snapshot.printing!
resolutionSelect.value = selectedResolution
printingSelect.value = selectedPrinting
}
await loadFixture(experimentCard.cardId)
Object.assign(controls, restoreExperimentMaterialControls(snapshot)) Object.assign(controls, restoreExperimentMaterialControls(snapshot))
controls.lightingPreset = snapshot.lightingPreset controls.lightingPreset = snapshot.lightingPreset
controls.lightType = snapshot.lightType controls.lightType = snapshot.lightType
@@ -1019,7 +1144,10 @@ async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
const customAssetNote = snapshot.customArtworkName || snapshot.customMaskName const customAssetNote = snapshot.customArtworkName || snapshot.customMaskName
? ' Local artwork or masks must be loaded again.' ? ' 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() { function syncLightControls() {
@@ -1060,16 +1188,16 @@ function resetView() {
orbit.update() orbit.update()
modeSelect.value = 'Inspect' modeSelect.value = 'Inspect'
controls.target = 'Card' controls.target = 'Card'
fixtureSelect.value = defaultFixture selectedResolution = 'high'
selectedPrinting = 'normal'
resolutionSelect.value = selectedResolution
printingSelect.value = selectedPrinting
finishSelect.value = 'Holographic' finishSelect.value = 'Holographic'
substrateSelect.value = 'Paper' substrateSelect.value = 'Paper'
poseSelect.value = 'Front' poseSelect.value = 'Front'
controls.finish = 'Holographic' controls.finish = 'Holographic'
controls.substrate = 'Paper' controls.substrate = 'Paper'
controls.finishStrength = 0.6 Object.assign(controls, defaultsStore.resolved(controls.finish, controls.substrate))
controls.roughness = 0.23
controls.normalStrength = defaultSurfaceDetail(controls.substrate)
controls.metalBrushHorizontal = false
controls.condition = 1 controls.condition = 1
controls.imperfectionSeed = 81251 controls.imperfectionSeed = 81251
applyLightingPreset('Studio') applyLightingPreset('Studio')
@@ -1087,14 +1215,14 @@ function resetView() {
async function loadFixture(nextFixture: FixtureName) { async function loadFixture(nextFixture: FixtureName) {
const request = ++textureRequest const request = ++textureRequest
loadingElement.hidden = false loadingElement.hidden = false
statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…` const card = fixtureAssets.get(nextFixture)
const asset = fixtureAssets.get(nextFixture) if (!card) {
if (!asset) {
loadingElement.hidden = true loadingElement.hidden = true
fixtureSelect.value = activeFixture statusElement.textContent = `Card "${nextFixture}" is not in the accepted artifact catalog.`
statusElement.textContent = `Card "${nextFixture}" is not in the card-art catalog.`
return return
} }
statusElement.textContent = `Loading ${cardLabel(card)} · ${selectedResolution} · ${selectedPrinting}…`
const asset = resolveCardAsset(card, selectedResolution, selectedPrinting)
try { try {
const loaded = await loadCardTextures(loader, asset, renderer.capabilities.getMaxAnisotropy()) const loaded = await loadCardTextures(loader, asset, renderer.capabilities.getMaxAnisotropy())
@@ -1112,52 +1240,44 @@ async function loadFixture(nextFixture: FixtureName) {
activeFixture = nextFixture activeFixture = nextFixture
customArtworkName = undefined customArtworkName = undefined
customMaskName = undefined customMaskName = undefined
updateCardButton()
previousArtwork.dispose() previousArtwork.dispose()
previousMask.dispose() previousMask.dispose()
previousTextMask?.dispose() previousTextMask?.dispose()
updateStatus() updateStatus()
} catch (error) { } catch (error) {
if (request === textureRequest) { if (request === textureRequest && mode !== 'Pack') {
fixtureSelect.value = activeFixture statusElement.textContent = `Could not load ${cardLabel(card)}: ${error instanceof Error ? error.message : String(error)}`
if (mode !== 'Pack') {
statusElement.textContent = `Could not load ${nextFixture}: ${error instanceof Error ? error.message : String(error)}`
}
} }
} finally { } finally {
if (request === textureRequest) loadingElement.hidden = true 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() { async function refreshCardCatalog() {
try { try {
const nextCatalog = await fetchCardCatalog() const nextCatalog = await fetchCardCatalog()
if (!nextCatalog.cards.length) { if (!nextCatalog.cards.length) throw new Error(nextCatalog.errors.join('; ') || 'No accepted cards found')
throw new Error(nextCatalog.errors.join('; ') || 'No valid artwork/mask pairs found') const previousCard = fixtureAssets.get(activeFixture)
} const previousAsset = previousCard && resolveCardAsset(previousCard, selectedResolution, selectedPrinting)
const previousAsset: CardAsset | undefined = fixtureAssets.get(activeFixture) const nextAssets = new Map(nextCatalog.cards.map(card => [card.cardId, card]))
const nextAssets = new Map(nextCatalog.cards.map(card => [card.name, card])) const nextCard = nextAssets.get(activeFixture)
const nextAsset = nextAssets.get(activeFixture)
cardCatalog = nextCatalog cardCatalog = nextCatalog
fixtureAssets = nextAssets fixtureAssets = nextAssets
defaultFixture = fixtureAssets.has('David') ? 'David' : nextCatalog.cards[0].name defaultFixture = fixtureAssets.has('BP-002') ? 'BP-002' : nextCatalog.cards[0].cardId
renderCardCatalog(nextCatalog) renderCardCatalog(nextCatalog)
if (nextCatalog.errors.length) { cardPicker.update(nextCatalog.cards)
statusElement.textContent = `Card catalog: ${nextCatalog.errors.join('; ')}` if (!nextCard) {
} activeFixture = defaultFixture
if ( await loadFixture(defaultFixture)
nextAsset && } else {
previousAsset?.revision !== nextAsset.revision && updateCardButton()
customArtworkName === undefined && const nextAsset = resolveCardAsset(nextCard, selectedResolution, selectedPrinting)
customMaskName === undefined if (previousAsset?.revision !== nextAsset.revision && !customArtworkName && !customMaskName) {
) {
await loadFixture(activeFixture) await loadFixture(activeFixture)
} }
}
if (nextCatalog.errors.length) statusElement.textContent = `Card catalog: ${nextCatalog.errors.join('; ')}`
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
catalogStateElement.textContent = 'Catalog error' catalogStateElement.textContent = 'Catalog error'
@@ -1366,30 +1486,35 @@ modeSelect.addEventListener('change', () => {
pauseScriptedMotion() pauseScriptedMotion()
updateMode() updateMode()
}) })
function selectFixtureFromPicker() { cardPickerButton.addEventListener('click', () => {
const fixture = resolveFixtureName(fixtureSelect.value.trim()) void refreshCardCatalog()
if (!fixture) { cardPicker.open(activeFixture)
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()
}) })
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() }) window.addEventListener('focus', () => { void refreshCardCatalog() })
if (import.meta.hot) { if (import.meta.hot) {
import.meta.hot.on('card-catalog:update', () => { void refreshCardCatalog() }) import.meta.hot.on('card-catalog:update', () => { void refreshCardCatalog() })
import.meta.hot.on('surface-defaults:update', () => { void defaultsStore.refresh() })
} }
substrateSelect.addEventListener('change', () => { substrateSelect.addEventListener('change', () => {
pauseScriptedMotion() pauseScriptedMotion()
controls.substrate = substrateSelect.value as SubstrateName controls.substrate = substrateSelect.value as SubstrateName
controls.normalStrength = defaultSurfaceDetail(controls.substrate) defaultsStore.applyMaterial(controls, controls.substrate)
updateMaterial() updateMaterial()
updateEdgeMaterial() updateEdgeMaterial()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
@@ -1398,6 +1523,7 @@ substrateSelect.addEventListener('change', () => {
finishSelect.addEventListener('change', () => { finishSelect.addEventListener('change', () => {
pauseScriptedMotion() pauseScriptedMotion()
controls.finish = finishSelect.value as FinishName controls.finish = finishSelect.value as FinishName
defaultsStore.applyFinish(controls, controls.finish)
updateMaterial() updateMaterial()
gui.controllersRecursive().forEach((controller) => controller.updateDisplay()) gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
updateStatus() updateStatus()
@@ -1465,6 +1591,9 @@ flipButton.addEventListener('click', () => {
(normalized > Math.PI / 2 && normalized < Math.PI * 1.5 ? -Math.PI : Math.PI) (normalized > Math.PI / 2 && normalized < Math.PI * 1.5 ? -Math.PI : Math.PI)
}) })
resetButton.addEventListener('click', resetView) resetButton.addEventListener('click', resetView)
diagnosticsToggle.addEventListener('click', () => {
setDiagnosticsEnabled(diagnosticsToggle.getAttribute('aria-pressed') !== 'true')
})
sweepButton.addEventListener('click', startSweep) sweepButton.addEventListener('click', startSweep)
controlsToggle.addEventListener('click', () => { controlsToggle.addEventListener('click', () => {
setControlsCollapsed(!topbar.classList.contains('controls-collapsed')) setControlsCollapsed(!topbar.classList.contains('controls-collapsed'))

View File

@@ -4,6 +4,7 @@ import { createFoilWrapper } from './foilWrapper'
import type { PackStyle } from './packDesigns' import type { PackStyle } from './packDesigns'
import type { CardTextures } from './cardTextures' import type { CardTextures } from './cardTextures'
import { import {
applyCardTextMask,
applyEdgeMaterialControls, applyEdgeMaterialControls,
applyMaterialControls, applyMaterialControls,
createCardMaterial, createCardMaterial,
@@ -56,10 +57,24 @@ const cardSweepRadius = Math.hypot(
const inspectionDepth = Math.max(3, cardSweepRadius + 0.5) const inspectionDepth = Math.max(3, cardSweepRadius + 0.5)
const inspectionCameraRetreat = inspectionDepth - 0.85 const inspectionCameraRetreat = inspectionDepth - 0.85
export interface CardTextureLease {
textures: CardTextures
release: () => void
}
interface PackOptions { interface PackOptions {
canvas: HTMLCanvasElement canvas: HTMLCanvasElement
contents: readonly PackCardSpec[] 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 backMaterial: THREE.Material
normalMap: THREE.Texture normalMap: THREE.Texture
environment: THREE.CubeTexture environment: THREE.CubeTexture
@@ -143,6 +158,7 @@ export class PackOpening {
private readonly pointers = new Map<number, THREE.Vector2>() private readonly pointers = new Map<number, THREE.Vector2>()
private tap: { id: number; x: number; y: number; at: number } | undefined private tap: { id: number; x: number; y: number; at: number } | undefined
private pinchDistance: number | undefined private pinchDistance: number | undefined
private disposed = false
constructor(options: PackOptions) { constructor(options: PackOptions) {
if (!options.contents.length) throw new Error('Pack must contain at least one card') 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) this.root.add(this.wrapper.root)
const buffers = createCardBuffers() const buffers = createCardBuffers()
this.cards = this.contents.map((spec) => { this.cards = this.contents.map((spec) => {
const textures = options.textures.get(spec.fixture) const staticTextures = options.textures?.get(spec.fixture)
if (!textures) throw new Error(`Missing loaded textures for pack card "${spec.fixture}"`) const textures = staticTextures ?? options.placeholderTextures
if (!textures) throw new Error(`Missing textures or placeholder for pack card "${spec.fixture}"`)
const material = createCardMaterial( const material = createCardMaterial(
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition, textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
textures.textMask ?? null, textures.textMask ?? null,
) )
applyMaterialControls(material, { const defaults = options.resolveSurfaceDefaults?.(spec.finish, spec.substrate) ?? {
...spec, finishStrength: 0.6, roughness: 0.23, normalStrength: defaultSurfaceDetail(spec.substrate), finishStrength: 0.6, roughness: 0.23, normalStrength: defaultSurfaceDetail(spec.substrate),
metalBrushHorizontal: false, 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 edge = new THREE.MeshPhysicalMaterial()
const object = createCardGeometry(material, options.backMaterial, edge, buffers) const object = createCardGeometry(material, options.backMaterial, edge, buffers)
const front = object.getObjectByName('CARD_FRONT')! const front = object.getObjectByName('CARD_FRONT')!
this.root.add(object) 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() 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' }, 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' }, 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' }, revealing: { action: 'Revealing…', status: `Card ${number} · turning over`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
inspecting: { inspecting: {
action: this.index === this.cards.length - 1 ? 'Finish pack' : 'Next card', action: this.index === this.cards.length - 1 ? 'Finish pack' : 'Next card',
@@ -257,6 +281,8 @@ export class PackOpening {
} }
dispose() { dispose() {
this.disposed = true
this.cards.forEach((_, index) => this.releaseTextures(index))
this.clearPointers() this.clearPointers()
this.active = false this.active = false
this.root.visible = false this.root.visible = false
@@ -307,6 +333,7 @@ export class PackOpening {
} }
restart() { restart() {
if (this.options.acquireTextures) this.cards.forEach((_, index) => this.releaseTextures(index))
this.clearPointers() this.clearPointers()
this.motion = undefined this.motion = undefined
this.paused = false this.paused = false
@@ -378,6 +405,8 @@ export class PackOpening {
card.object.visible = state !== 'complete' && index >= this.index card.object.visible = state !== 'complete' && index >= this.index
card.front.visible = revealPhase && 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)) { if (state === 'inspecting' && !this.revealed.has(this.index)) {
this.revealed.add(this.index) this.revealed.add(this.index)
applyEdgeMaterialControls(this.cards[this.index].edge, { ...this.contents[this.index], condition: 1 }) applyEdgeMaterialControls(this.cards[this.index].edge, { ...this.contents[this.index], condition: 1 })
@@ -392,6 +421,56 @@ export class PackOpening {
this.options.onChange() 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( private begin(
state: PackState, tracks: Track[], duration: number, finish: () => void, state: PackState, tracks: Track[], duration: number, finish: () => void,
progress?: (value: number) => void, progress?: (value: number) => void,
@@ -436,6 +515,12 @@ export class PackOpening {
break break
} }
case 'lifted': 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.begin('revealing', [track(card.object, pose(0, 0.14, inspectionDepth, -0.06, Math.PI * 2 - 0.12))], 820,
() => this.setState('inspecting')) () => this.setState('inspecting'))
break break
@@ -450,12 +535,14 @@ export class PackOpening {
} }
const startRetreat = this.cameraRetreat const startRetreat = this.cameraRetreat
this.begin('advancing', tracks, 650, () => { this.begin('advancing', tracks, 650, () => {
const previousIndex = this.index
if (this.index === this.cards.length - 1) { if (this.index === this.cards.length - 1) {
this.setState('complete') this.setState('complete')
} else { } else {
this.index++ this.index++
this.setState('stackReady') this.setState('stackReady')
} }
this.releaseTextures(previousIndex)
}, (value) => { }, (value) => {
this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, 0, value) this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, 0, value)
this.updateCamera() this.updateCamera()

View File

@@ -100,8 +100,8 @@ h1 {
.toolbar label { .toolbar label {
display: grid; display: grid;
flex: 1 1 110px; flex: 0 1 124px;
min-width: 100px; min-width: 104px;
gap: 4px; gap: 4px;
color: #9da3ad; color: #9da3ad;
font-size: 11px; font-size: 11px;
@@ -329,8 +329,56 @@ button:disabled {
cursor: default; 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, .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; display: none;
} }
@@ -339,6 +387,8 @@ button:disabled {
flex-basis: 200px; flex-basis: 200px;
} }
.pack-mode .toolbar .variant-control { display: grid; }
.pack-mode .toolbar { .pack-mode .toolbar {
flex: 0 1 400px; flex: 0 1 400px;
} }
@@ -354,7 +404,7 @@ button:disabled {
max-width: min(440px, calc(100% - 32px)); max-width: min(440px, calc(100% - 32px));
} }
@media (max-width: 1120px) { @media (max-width: 1500px) {
.topbar { .topbar {
align-items: flex-start; align-items: flex-start;
} }
@@ -393,7 +443,13 @@ button:disabled {
} }
.toolbar label { .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 { .toolbar button {
@@ -479,6 +535,10 @@ button:disabled {
padding-inline: 10px; padding-inline: 10px;
font-size: 12px; 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) { @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 { 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() { function json(response, status, value) {
return { response.statusCode = status
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
response.setHeader('Content-Type', 'application/json') response.setHeader('Content-Type', 'application/json')
response.setHeader('Cache-Control', 'no-store') 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) { } catch (error) {
response.statusCode = 500 const status = Number.isInteger(error?.statusCode) ? error.statusCode : 500
response.setHeader('Content-Type', 'application/json') json(response, status, { error: error instanceof Error ? error.message : String(error) })
response.end(JSON.stringify({
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) => { 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' }) 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: [cardLibraryPlugin()] })
export default defineConfig({
plugins: [cardCatalogPlugin()],
})

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.