Improved harness - with card selection
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
43
card-harness/config/surface-defaults.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
|
||||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
@@ -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": []
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 4.9 MiB |
@@ -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) {
|
||||||
function validateDataMask(filename, bytes, artwork) {
|
const record = files[key]
|
||||||
const mask = parsePng(filename, bytes)
|
if (!record || typeof record.sha256 !== 'string') throw new Error(`Missing manifest entry ${key}`)
|
||||||
if (mask.width !== artwork.width || mask.height !== artwork.height) {
|
if (key.endsWith('.png') && (!Array.isArray(record.dimensions) ||
|
||||||
throw new Error(`${filename} must match artwork dimensions; found ` +
|
record.dimensions[0] !== dimensions[0] || record.dimensions[1] !== dimensions[1])) {
|
||||||
`${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`)
|
throw new Error(`Wrong manifest dimensions for ${key}`)
|
||||||
}
|
|
||||||
for (let offset = 0; offset < mask.data.length; offset += 4) {
|
|
||||||
if (mask.data[offset] !== mask.data[offset + 1] ||
|
|
||||||
mask.data[offset] !== mask.data[offset + 2] || mask.data[offset + 3] !== 255) {
|
|
||||||
throw new Error(`${filename} must be an opaque grayscale mask`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return record
|
||||||
}
|
}
|
||||||
|
async function ensureFile(path, label) {
|
||||||
function validatePair(name, artworkBytes, maskBytes, textMaskBytes) {
|
if (!(await stat(path)).isFile()) throw new Error(`${label} is not a file`)
|
||||||
const artwork = parsePng(`${name}.png`, artworkBytes)
|
|
||||||
const mask = parsePng(`${name}-mask.png`, maskBytes)
|
|
||||||
if (artwork.width * 7 !== artwork.height * 5) {
|
|
||||||
throw new Error(`${name}.png must use a 5:7 aspect ratio; found ${artwork.width}x${artwork.height}`)
|
|
||||||
}
|
|
||||||
if (mask.width !== artwork.width || mask.height !== artwork.height) {
|
|
||||||
throw new Error(
|
|
||||||
`${name}-mask.png must match ${name}.png dimensions; found ` +
|
|
||||||
`${mask.width}x${mask.height} and ${artwork.width}x${artwork.height}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
for (let offset = 0; offset < artwork.data.length; offset += 4) {
|
|
||||||
if (artwork.data[offset + 3] !== 255) {
|
|
||||||
throw new Error(`${name}.png must be fully opaque for the runtime card surface`)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
mask.data[offset] !== mask.data[offset + 1] ||
|
|
||||||
mask.data[offset] !== mask.data[offset + 2] ||
|
|
||||||
mask.data[offset + 3] !== 255
|
|
||||||
) {
|
|
||||||
throw new Error(`${name}-mask.png must be an opaque grayscale finish mask`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (textMaskBytes) validateDataMask(`${name}-text-mask.png`, textMaskBytes, artwork)
|
|
||||||
return { width: artwork.width, height: artwork.height }
|
|
||||||
}
|
}
|
||||||
|
async function scanCard(directory) {
|
||||||
export async function scanCardCatalog(root = cardArtRoot) {
|
const [manifest, content] = await Promise.all([json(resolve(directory, 'manifest.json')), json(resolve(directory, 'card.json'))])
|
||||||
await mkdir(root, { recursive: true })
|
if (manifest.stage !== 'approved') throw new Error('Card package is not approved')
|
||||||
const files = (await readdir(root, { withFileTypes: true }))
|
if (manifest.cardId !== content.cardId || manifest.folderName !== content.folderName) throw new Error('Package identity differs between manifests')
|
||||||
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.png'))
|
if (typeof manifest.revision !== 'string' || typeof content.title !== 'string' || typeof content.rarity !== 'string') {
|
||||||
.map(entry => entry.name)
|
throw new Error('Package is missing accepted revision, title, or rarity metadata')
|
||||||
const fileSet = new Set(files)
|
}
|
||||||
const artworkFiles = files.filter(name => !name.toLowerCase().endsWith('-mask.png'))
|
if (!directory.endsWith(`${sep}${manifest.folderName}`)) throw new Error('folderName differs from directory')
|
||||||
const maskFiles = files.filter(name => name.toLowerCase().endsWith('-mask.png'))
|
const variants = {}
|
||||||
const cards = []
|
for (const [resolutionName, dimensions] of Object.entries(resolutions)) {
|
||||||
const errors = []
|
const resolutionManifest = await json(resolve(directory, resolutionName, 'manifest.json'))
|
||||||
|
if (resolutionManifest.dimensions?.[0] !== dimensions[0] || resolutionManifest.dimensions?.[1] !== dimensions[1]) {
|
||||||
for (const artworkFile of artworkFiles) {
|
throw new Error(`${resolutionName} manifest dimensions are invalid`)
|
||||||
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 {
|
variants[resolutionName] = {}
|
||||||
const [artworkBytes, maskBytes, textMaskBytes] = await Promise.all([
|
for (const printing of printings) {
|
||||||
readFile(resolve(root, artworkFile)),
|
const artworkKey = `${printing}/card.png`, finishKey = `${printing}/finish-mask.png`, textKey = `${printing}/text-mask.png`
|
||||||
readFile(resolve(root, maskFile)),
|
const artwork = fileRecord(resolutionManifest.files, artworkKey, dimensions)
|
||||||
hasTextMask ? readFile(resolve(root, textMaskFile)) : undefined,
|
const finishMask = fileRecord(resolutionManifest.files, finishKey, dimensions)
|
||||||
|
const textMask = printing === 'normal' || printing === 'borderless'
|
||||||
|
? fileRecord(resolutionManifest.files, textKey, dimensions) : undefined
|
||||||
|
await Promise.all([
|
||||||
|
ensureFile(resolve(directory, resolutionName, artworkKey), artworkKey),
|
||||||
|
ensureFile(resolve(directory, resolutionName, finishKey), finishKey),
|
||||||
|
...(textMask ? [ensureFile(resolve(directory, resolutionName, textKey), textKey)] : []),
|
||||||
])
|
])
|
||||||
const dimensions = validatePair(name, artworkBytes, maskBytes, textMaskBytes)
|
const 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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { cardId: manifest.cardId, title: content.title, rarity: content.rarity,
|
||||||
for (const maskFile of maskFiles) {
|
folderName: manifest.folderName, acceptedRevision: manifest.revision, variants }
|
||||||
const suffixLength = maskFile.toLowerCase().endsWith('-text-mask.png') ? 14 : 9
|
}
|
||||||
const artworkFile = `${maskFile.slice(0, -suffixLength)}.png`
|
export async function scanCardCatalog(root = acceptedCardsRoot) {
|
||||||
if (!fileSet.has(artworkFile)) errors.push(`Missing ${artworkFile} for ${maskFile}`)
|
const entries = (await readdir(root, { withFileTypes: true }))
|
||||||
}
|
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
|
||||||
|
const cards = [], errors = []
|
||||||
cards.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
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}`)
|
if (!catalog.cards.length || catalog.errors.length) process.exitCode = 1
|
||||||
process.exitCode = 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeFile(resolve(directory, resolutionName, 'manifest.json'), JSON.stringify({ dimensions, files }))
|
||||||
}
|
}
|
||||||
return PNG.sync.write(image)
|
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')))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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/)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
|||||||
76
card-harness/scripts/surface-defaults.mjs
Normal 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 }
|
||||||
|
}
|
||||||
45
card-harness/scripts/surface-defaults.test.mjs
Normal 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/)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
123
card-harness/src/cardPicker.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
132
card-harness/src/surfaceDefaults.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
response.setHeader('Content-Type', 'application/json')
|
||||||
async buildStart() {
|
response.setHeader('Cache-Control', 'no-store')
|
||||||
const catalog = await writeCardCatalog()
|
response.end(JSON.stringify(value))
|
||||||
if (!catalog.cards.length || catalog.errors.length) {
|
}
|
||||||
this.error(`Invalid card catalog:\n${catalog.errors.join('\n') || 'No valid card pairs found'}`)
|
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/')) {
|
||||||
configureServer(server) {
|
await catalog()
|
||||||
server.middlewares.use('/__card_catalog', async (_request, response) => {
|
if (!cachedAssetPaths?.has(url.pathname)) return json(response, 404, { error: 'Unknown card asset' })
|
||||||
try {
|
const path = await resolveCardAssetRequest(url.pathname)
|
||||||
const catalog = await scanCardCatalog()
|
if (!path) return json(response, 404, { error: 'Unknown card asset' })
|
||||||
response.statusCode = 200
|
const metadata = await stat(path)
|
||||||
response.setHeader('Content-Type', 'application/json')
|
response.statusCode = 200
|
||||||
response.setHeader('Cache-Control', 'no-store')
|
response.setHeader('Content-Type', 'image/png')
|
||||||
response.end(JSON.stringify(catalog))
|
response.setHeader('Content-Length', String(metadata.size))
|
||||||
} catch (error) {
|
response.setHeader('Cache-Control', url.searchParams.has('v') ? 'public, max-age=31536000, immutable' : 'no-cache')
|
||||||
response.statusCode = 500
|
return createReadStream(path).pipe(response)
|
||||||
response.setHeader('Content-Type', 'application/json')
|
}
|
||||||
response.end(JSON.stringify({
|
next()
|
||||||
error: error instanceof Error ? error.message : String(error),
|
} catch (error) {
|
||||||
}))
|
const status = Number.isInteger(error?.statusCode) ? error.statusCode : 500
|
||||||
}
|
json(response, status, { error: error instanceof Error ? error.message : String(error) })
|
||||||
})
|
}
|
||||||
server.watcher.add(cardArtRoot)
|
})
|
||||||
server.watcher.on('all', (_event, path) => {
|
if (server.watcher) {
|
||||||
if (path.startsWith(cardArtRoot) && path.toLowerCase().endsWith('.png')) {
|
server.watcher.add([acceptedCardsRoot, surfaceDefaultsPath])
|
||||||
server.ws.send({ type: 'custom', event: 'card-catalog:update' })
|
server.watcher.on('all', (_event, path) => {
|
||||||
}
|
if (path.startsWith(acceptedCardsRoot)) {
|
||||||
})
|
cachedCatalog = undefined
|
||||||
},
|
cachedAssetPaths = undefined
|
||||||
|
server.ws.send({ type: 'custom', event: 'card-catalog:update' })
|
||||||
|
}
|
||||||
|
if (path === surfaceDefaultsPath) server.ws.send({ type: 'custom', event: 'surface-defaults:update' })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function cardLibraryPlugin() {
|
||||||
export default defineConfig({
|
return {
|
||||||
plugins: [cardCatalogPlugin()],
|
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()] })
|
||||||
|
|||||||
151
docs/card-harness-library-plan.md
Normal 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.
|
||||||