Fix project structure

This commit is contained in:
2026-09-08 13:39:03 -07:00
parent 2637189690
commit 21dd8fbdbf
249 changed files with 15679 additions and 143 deletions

24
card-harness/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,283 @@
# Sanctification Card Lab - Current Defaults
## Approved Runtime Look
- Revision: `runtime-look-v4-2026-09-07`
- Approved: September 7, 2026
- Scope: card geometry, front-face materials, substrate distinction, foil, holographic response, and current finish-mask policy
- Follow-up approval: rigid pack prototype and artifact-free individual card inspection; retained as the foundation for the foil iteration below
- Not yet approved: the new foil treatment/choreography, lighting presets, mobile performance, or wear
These values represent the current visual baseline. Changes should be deliberate and compared against this revision rather than treated as incidental shader cleanup.
## Geometry
The card is generated procedurally at runtime. Blender and GLB files are not required.
| Property | Physical value | Runtime value |
|---|---:|---:|
| Width | 63 mm | 2.8571 scene units |
| Height | 88.2 mm | 4 scene units |
| Thickness | 0.4 mm | 0.01814 scene units |
| Corner radius | 2.8 mm | 0.12698 scene units |
| Corner segments | - | 16 |
Front and back use normalized UV coordinates with no runtime mirroring. The front, back, and edge are separate material surfaces.
The edge extrusion renders only side-wall triangles; its generated end caps are
excluded from the draw range. Front and back sit exactly at half the physical
thickness, with no almost-coplanar cap underlay or artificial depth offset.
### Reveal-Safe Card Back
The universal card back is intentionally independent from the selected front finish, substrate, finish strength, surface detail, and condition. This prevents the hidden card identity or variant from being inferred during a pack reveal.
| Property | Fixed value |
|---|---:|
| Roughness | 0.42 |
| Metalness | 0 |
| Clear coat | 0.20 |
| Clear-coat roughness | 0.24 |
| Normal map | None |
The physical edge remains substrate-aware for close inspection. Pack-opening choreography should keep the thin edge from becoming a deliberate pre-reveal inspection surface.
## Renderer
| Setting | Default |
|---|---|
| Output color space | sRGB |
| Tone mapping | ACES Filmic |
| Exposure | 1.0 |
| Pixel ratio | `min(devicePixelRatio, 2)` |
| Scene background | `#0b0e14` |
| Environment | Generated 128 px studio cube texture |
## Camera and Reset Pose
| Setting | Default |
|---|---:|
| Perspective field of view | 34 degrees |
| Near / far planes | 0.1 / 100 |
| Camera position | `(0, 0, 8.2)` |
| Orbit target | `(0, 0, 0)` |
| Orbit distance | 5-12 |
| Card rotation | `(-0.06, 0.12, 0)` radians |
Available inspection poses:
| Pose | Card rotation |
|---|---|
| Front | `(-0.06, 0.12, 0)` |
| Grazing | `(-0.14, 52 degrees, 0)` |
| Edge | `(-0.05, 90 degrees, 0)` |
| Back | `(-0.06, 180 degrees + 0.12 radians, 0)` |
| Free | Current manually controlled rotation |
Reset restores the Front pose, David, Holographic finish, Paper substrate, default surface values, default camera, and default light.
## Light
| Setting | Default |
|---|---|
| Type | Point light |
| Color | `#fff0d0` |
| Position | `(2.2, 2.5, 4.4)` |
| Shader intensity | 3.0 |
| Three.js light intensity | 34 |
| Distance / decay | 20 / 1.7 |
| Environment intensity | 0.7 |
Lighting presets are available for repeatable evaluation but are not yet visually approved:
| Preset | Type | Color | Position | Intensity | Environment | Exposure |
|---|---|---|---|---:|---:|---:|
| Studio | Point | `#fff0d0` | `(2.2, 2.5, 4.4)` | 3.0 | 0.70 | 1.00 |
| Warm gallery | Spot | `#ffd29a` | `(-2.8, 3.1, 4.8)` | 4.0 | 0.52 | 1.04 |
| Cool window | Directional | `#bddcff` | `(-3.4, 4.2, 5.2)` | 2.7 | 0.78 | 1.00 |
| Dramatic spot | Spot | `#ffe1ad` | `(3.6, 1.9, 3.6)` | 4.8 | 0.28 | 0.92 |
| Flat review | Directional | `#ffffff` | `(0.6, 0.8, 5.5)` | 2.25 | 1.00 | 1.00 |
Changing a light type, color, position, intensity, environment contribution, or exposure marks the lighting configuration as Custom.
## User-Facing Surface Controls
| Control | Default | Available range |
|---|---:|---:|
| Finish | Holographic | Printed ink / Foil / Holographic |
| Material | Paper | Paper / Linen / Plastic / Metal / Wood |
| Finish strength | 0.60 | 0-1 |
| Roughness | 0.23 | 0.05-0.8 |
| Surface detail | 0.14 | 0-0.45 |
| Environment intensity | 0.70 | 0-1.5 |
| Condition | 1.0 (mint) | 0-1 |
| Imperfection seed | 81251 | 1-99999 |
Finish and material are independent concepts, but combinations should exist only when they have an intentional visual interpretation.
## Substrate Recipes
### Paper
- Roughness floor: `0.56`
- Fiber frequency: `230 x 322`
- Relief amplitude: `0.0014 * detail`
- Broad reflection strength: `0.045`
- Edge: `#9c8c69`, roughness `0.62`, metalness `0.02`, clear coat `0.02`
### Linen
- Roughness floor: `0.70`
- Interlaced thread frequency: `52 x 73`
- Relief amplitude: `0.005 * detail`
- Reflection strength: `0.025`
- Edge: `#a89772`, roughness `0.78`, metalness `0`, clear coat `0`
### Plastic
- Roughness ceiling: `0.25`
- Dielectric Fresnel base: `0.04`
- Reflected fraction cap: `0.32`
- Direct highlight cap: `0.50`
- Edge: `#d4cab0`, roughness `0.24`, metalness `0`, clear coat `0.72`
### Metal
- Roughness ceiling: `0.20`
- Artwork-driven etch: darker values are recessed more deeply
- Etch relief amplitude: `-0.0065 * depth * min(detail, 1.5)`
- Cavity shading: `0.12` on the metal underprint and `0.06` on the combined printed result
- Metal underprint blend: `0.42`
- Edge: `#a8adb7`, roughness `0.20`, metalness `0.86`, clear coat `0.18`
Metal + Foil is a special intentional combination:
- Preserves printed artwork colors
- Reduces etch depth beneath the foil mask by at most 25%
- Adds only `0.0005 * mask * detail` of raised relief
- Uses restrained champagne tint `#ffd694`
- Plating weight is capped at `0.34`
- Must not turn the illustration into opaque gold
### Wood
- Roughness floor: `0.48`
- Warped longitudinal grain frequency: `170`
- Relief amplitude: `0.007 * detail`
- Warm tint blend: `0.25`
- Reflection strength: `0.09`
- Edge: `#6b3f21`, roughness `0.56`, metalness `0`, clear coat `0.08`
## Finish Recipes
### Printed Ink
- Returns the substrate response without an additional coating.
- Substrate relief and reflection must remain visible.
### Foil
- Uses the finish mask as selective coverage.
- Effective finish strength: `finishStrength * 0.8`
- Maximum coating weight: `0.48`
- Coating response cap: `0.78`
- Reflection is warm and screened with the original artwork rather than replacing it.
- Reduced sheen applies only to Foil, not Holographic.
### Holographic
- Effective finish strength: `finishStrength`
- Maximum coating weight: `0.92`
- Coating response cap: `1.8`
- Spectral reflection blend: `0.72`
- Artwork-to-spectral coating blend: `0.48`
- The approved spectrum uses UV, view direction, and light direction so color shifts while the card moves.
- Do not weaken the holographic color response to solve mask problems; correct the mask instead.
## Finish Masks
### David
- Saturation threshold: `smoothstep(0.18, 0.62)`
- Value threshold: `smoothstep(0.015, 0.16)`
- Retains soft title-panel protection.
- Does not use circular or elliptical exclusions over the face, hand, or lamb.
### Timothy
- Saturation threshold: `smoothstep(0.08, 0.58)`
- Value threshold: `smoothstep(0.015, 0.16)`
All finish masks should:
- Protect critical text unless a treatment is intentionally designed for it.
- Follow compositional regions such as stained-glass panes, ornament, borders, garments, and environmental color.
- Avoid artificial holes, isolated skin spots, and recognizable geometric exclusions.
- Be reviewed while the card rotates under multiple lighting angles.
## Wear and Imperfections
Condition uses `1.0` for mint and `0.0` for the most worn supported appearance. The approved v4 material baseline remains unchanged at the mint default.
- Edge wear uses seeded irregular width and substrate-appropriate exposed colors.
- Three deterministic fine scratches and two deterministic scuff regions are generated from the imperfection seed.
- Scratches and scuffs perturb the surface normal slightly and reduce finish coverage where the coating is damaged.
- Paper and linen expose pale fibers, plastic becomes cloudy, metal exposes brighter raw metal, and wood exposes a lighter brown layer.
- Wear remains bounded so the card artwork stays readable even at condition `0.0`.
## 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.
## 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:
- Runtime-look revision and save time
- Reference fixture and custom asset filenames
- Finish, substrate, and surface controls
- Condition and imperfection seed
- Lighting preset, environment, type, color, position, intensity, and exposure
- Card rotation, camera position, and orbit target
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`.
## Source of Truth
- Geometry: `src/cardGeometry.ts`
- Runtime shader: `src/cardMaterial.ts`
- Scene and control defaults: `src/main.ts`
- Generated asset contract: `public/reference/manifest.json`
- Art policy: `../art-direction.md`
## Tear-Open Foil Pack (Next Review Iteration)
Pack is a third mode beside Inspect and Lab. It does not revise
`runtime-look-v4-2026-09-07`, saved experiments, or the reference asset contract.
- Controller: `src/packOpening.ts`; foil meshes: `src/foilWrapper.ts`; integration and lazy local-asset loading: `src/main.ts`.
- States: `sealed → opening → stackReady → lifting → lifted → revealing → inspecting → advancing → stackReady / complete`.
- Order: David / Printed ink / Linen; Timothy / Foil / Paper; David / Holographic / Metal. Surface defaults remain strength `0.60`, roughness `0.23`, detail `0.14`, mint condition and seed `81251`. Pack inherits the current lighting.
- Wrapper: two `64 × 80` subdivided pouch surfaces joined at their side/bottom boundaries and one `64 × 10` top ribbon, sharing horizontal samples along the attached seam. Total 21,760 triangles. Dimensions: width `3.36`, bottom `-2.24`, tear seam `1.97`, top `2.28`; center depth `-0.09`, inflated half-depth `0.37`. Surface folds are shallow deterministic UV-based displacements; contact dent is bounded to `0.045` units. Side and bottom crimps remain separated surfaces, with no overlapping cap/label meshes or transparent fades.
- Foil material: deep teal printed canvas, warm gold seam and restrained typography; standard material roughness `0.43`, metalness `0.68`, double-sided. The existing scene lighting/environment lights the foil. This is a wrapper-specific recipe, not a card-material revision.
- Opening lasts `3200 ms` from sealed (remaining duration scales with retained progress); lift `620 ms`, reveal `820 ms`, advance `650 ms`. Elapsed-time smoothstep interpolation and quaternion rotation remain unchanged for card transitions.
- Opening's eased progress intervals: tear `0–0.38`; strip detachment/curl-away `0.38–0.52`; mouth spread `0.50–0.62`; card extraction `0.62–0.826`; empty pouch exit `0.83–0.87`; stack settling `0.87–1`. Each successive card starts/ends extraction `0.018` progress later, staying at its established depth and scale. Extraction rises `4.4` units, clearing the entire pouch before its lateral exit. The wrapper never drops through the floor or crosses the inspecting card.
- Mouth lips spread in depth to a `0.64` half-opening envelope; the front lip bows down `0.28` units and the rear up `0.08`, exposing the opening without added rim layers. The released ribbon curls up/back around the moving tear front, then moves right and away before extraction begins.
- Seam input: a projected mouse/touch hit area at least `44 px` high; a rightward drag across `75%` of projected pack width (at least `80 px`) traverses a full tear. Progress is monotonic and survives partial release, pointer cancellation/lost capture and mode switches. A completed pull releases into the remaining choreography; otherwise **Finish opening** or another seam drag resumes. **Tear open** is the button/keyboard equivalent. The progress indicator exposes the tear percentage.
- Reduced motion: `70 ms` per transition, with identical resting states and event order.
- Input ownership: the Pack controller alone writes Pack transforms. Manual input pauses scripted motion without snapping; Continue resumes the elapsed transition. The seam gesture can take over a still-tearing animation at its current progress. A second touch cancels seam ownership in favor of pinch; wheel zoom releases captured gestures. Rotation is allowed only after revealing; zoom is available throughout. Press/release contact deformation is damped, except under reduced motion where it is immediate.
- Skip finishes one transition or one next resting-state step. Restart clears progress and event guards. Mode changes pause Pack while retaining the separate Inspect/Lab scene and assets.
- Hidden fronts are disabled, all backs share the universal fixed material, and hidden edges use the same mint paper recipe. Real substrate edges become visible only after the reveal finishes.
- Lift ownership: the existing stack card is the animated object, with the same root and mesh UUIDs throughout. There is no separate active-card renderer or stack placeholder. All unrevealed fronts remain disabled throughout the lift.
- Stack depth spacing: `0.055` scene units between roots at `0.88` scale, leaving approximately `0.039036` between neighboring surfaces. The current card lifts away while reserve cards ease to a visible lower-right position, preserving their depth spacing and face-down orientation.
- Inspection clearance: the full card corner-sweep radius is derived from the shared scene dimensions (approximately `2.458`). Lift finishes at local `z = max(3, radius + 0.5)` before reveal or manual rotation. With current dimensions, even the conservative full-rotation envelope stays over `0.61` units ahead of the reserve stack and over `0.60` above the floor. The opened wrapper is hidden and sits outside this envelope.
- Reserve cards remain visible through reveal, inspection, and exit. They are offset by `(1.05, -0.4, 0)` from their normal stack poses while inspecting, then recenter during advancing. The active card exits laterally on the same safe inspection plane, never diving back through the reserve stack.
- Pack uses a separate responsive 34-degree camera, fitted to the wrapper and compact bottom controls; pinch/wheel zoom spans `0.62–1.45` of its fitted distance. Opening-only framing retreats up to `8` units and raises its center `1.6` units during extraction, returning fully before stackReady. A `2.15`-unit camera retreat eases with the lift and reverses on exit to preserve the established card framing at the deeper inspection plane. One camera-update helper combines retreat, responsive fit, vertical framing, and user zoom; paused motion preserves them all. Inspect/Lab camera defaults are unchanged. Aggressive user zoom may crop the scene, as in the existing inspection flow.
No pack progress persistence, random rewards, backend, sound, haptics, particles,
cloth, or general timeline/editor are included. Deformation is an authored, reversible
evaluation of progress, not physical tearing or arbitrary cloth manipulation. The
tear itself cannot be reversed by dragging left; only Restart reseals the pack.
Wrapper art and the new foil choreography remain review-only. Desktop browser and
emulated mobile checks are not physical-device performance or touch-feel sign-off.

130
card-harness/README.md Normal file
View File

@@ -0,0 +1,130 @@
# Sanctification Card Lab
Bounded Three.js proof for the card-rendering harness plan.
The approved visual baseline is recorded in [`CURRENT-DEFAULTS.md`](CURRENT-DEFAULTS.md).
## Included proof scope
- David printed-ink, foil, and holographic presets
- Independently selectable paper, linen, plastic, metal, and wood substrates
- Timothy as the second-artwork fixture
- David color-based coverage and title-panel protection without face/hand/lamb cutouts; automatic saturation/value coverage for Timothy
- Procedural rounded-card geometry generated from shared physical dimensions
- Deterministic linen normal map
- Card manipulation, camera inspection, zoom, frame-rate-independent flip/reset, interruption-safe sweep, and movable lighting
- Repeatable front, grazing-angle, edge, and back inspection poses
- Point, directional, and spot lighting with repeatable studio, gallery, window, dramatic, and flat-review presets
- Live light color, position, intensity, environment contribution, and exposure controls
- Lab-mode loading of local artwork and finish-mask images without rebuilding
- Named browser experiment saves, versioned JSON import/export, and PNG capture
- Deterministic condition wear with substrate-aware edges, scratches, scuffs, and imperfection seeds
- Bare Inspect mode and small Lab-mode parameter panel
- Pack mode: a touch-responsive foil pouch with a draggable tear strip, reveal-safe ordered stack, and one-at-a-time inspection
- Drawing-buffer resolution plus median, p95, and worst frame-time display
Transform gizmos and a general timeline are intentionally deferred. The separate Flutter Scene comparison project is unchanged; Three.js remains the authoritative runtime.
## Tear-open foil pack prototype
Choose **Pack** in the Mode selector (expand **Controls** first on mobile).
Drag the gold top seam **to the right**, or use **Tear open** for the same authored opening without dragging:
**Sealed → progressive tear → strip curls away → mouth spreads → staggered card extraction → face-down stack → lifting → lifted → revealing → inspecting → advancing → next stack / complete.**
Lifting and flipping are separate actions so the universal back can be reviewed before revealing.
The authored order is always David / Printed ink / Linen, Timothy / Foil / Paper,
then David / Holographic / Metal. This is not a randomized reward system.
- The seam has a mouse/touch hit area at least 44 px tall. Drag distance drives tear progress monotonically: pulling back does not reseal it. A full pull completes the opening on release; a short pull holds its exact tear and **Finish opening** continues from there. The progress bar and percentage also expose the state without relying on the animation.
- Cancellation or lost pointer capture holds a partial tear; another seam pull, **Finish opening**, or **Skip** can continue it. A second finger relinquishes the tear gesture to pinch zoom. Restart and mode changes release all captured pointers.
- Pressing the pouch makes a small local dent that settles on release. Creases and seam crimps are deterministic, not continuously moving noise.
- Drag to rotate and pinch or wheel to zoom during inspection. Pinch/wheel zoom also works before revealing; hidden cards cannot be manually tilted. User zoom may intentionally crop the composition.
- Touching or zooming during a scripted transition pauses at the current pose. **Continue** resumes from that elapsed point, without snapping. A tap that interrupts motion does not also advance it.
- **Skip** finishes only the current transition, or performs one step immediately from a resting state. It never skips an unseen card's reveal. **Restart** restores the sealed wrapper and first card.
- Reduced-motion preference shortens each transition to 70 ms with the same state and reveal/completion ordering.
- Switching away pauses Pack and preserves the Inspect/Lab card, camera, materials, local images and experiment settings. Returning to Pack resumes its retained state via Continue.
The foil wrapper is two subdivided, joined pouch surfaces and a separate subdivided
top ribbon. Controlled vertex deformation creates shallow creases, a travelling tear
front, a curled detached strip, and spreading lips. Typography is printed directly
on the sheet materials, not on an almost-coplanar label layer. There is no rigid-panel
split, transparent cross-fade, cloth engine, or extra asset dependency.
The pouch stays above the floor while the three cards slide upward with a small
stagger. Only after all cards clear the mouth does the empty pouch move aside; the
cards then settle into the existing face-down stack. Opening-only camera framing
widens and recenters during extraction, then returns to the established stack framing.
Cards use the existing procedural geometry and approved runtime shader.
Before revealing, fronts are hidden, backs share the fixed universal material, and
edges use a neutral paper recipe. Authored substrate edges are enabled only after
the flip finishes. The active card lifts to a dedicated inspection plane before
flipping or free rotation. The reserve stack stays visible, offset to the lower
right and safely behind the active card's full corner-sweep volume. The exit
animation also stays on that safe plane while the reserve stack recenters.
Camera framing eases with the lift/exit so the deeper separation does not
unexpectedly enlarge the card. The edge mesh draws only extrusion side walls, not end caps beneath the
dedicated artwork faces, preventing depth-fighting bands at farther zoom distances.
Pack assets are loaded separately on first entry, with retry on failure.
There is no sound, haptics, particles, cloth simulation, backend, rewards persistence,
pack progress persistence, or timeline/editor. This is a choreography proof, not an
final pack-design or mobile performance sign-off. The approved rigid prototype and
artifact-free card inspection are the preserved foundation; this foil treatment is
the next review iteration. The canvas emits `packreveal`
(one-based index and variant) and `packcomplete` (count), once per card/pack per restart.
Finish and substrate are separate:
- Finish controls printed ink, foil, or holographic coating.
- Substrate controls surface roughness/microtexture, underprint response, and edge/core appearance.
Paper uses fine, irregular fibers and a broad, weak highlight. Linen uses raised
interlaced threads with small shaded recesses. Plastic has a smooth clear-coat
highlight even in Printed ink mode. Wood uses warped longitudinal grain with
surface relief and a restrained warm tint. Metal retains its reflective
underprint and adds a shallow luminance-driven etch, with darker artwork
recessed slightly more than lighter artwork. Procedural detail fades below
pixel resolution to limit shimmer.
The universal card back uses fixed neutral material properties so front finish
and substrate choices do not spoil a pack reveal.
The holo spectrum and coverage rules are independent of these substrate changes.
Metal combined with Foil preserves the printed colors while adding restrained,
slightly raised champagne-metal polishing over the etched steel base.
## Reference
`npm run assets` creates `public/reference/manifest.json`, copies the source artwork and approved reference renders, and generates the two masks and normal map. Card geometry is generated directly in Three.js from the dimensions recorded in the manifest; Blender is not required to build or run the harness. The current runtime reference revision is `runtime-look-v4-2026-09-07`, visually informed by:
- `blender_prototype/premium_finishes.py`
- `blender_prototype/card_finish_material.py`
- `blender_prototype/render_premium_review.py`
The runtime shader and geometry are runtime implementations, not a claim of pixel-identical Blender output.
Live review removed only David's face, hand, and lamb ellipse cutouts. Color-based coverage and title-panel protection remain. Holographic color and angle response are preserved; reduced sheen applies only to foil.
## Run
```sh
npm install
npm run dev
```
Production build:
```sh
npm run build
```
The asset-generation script uses cross-platform Node APIs and works on Windows and Linux. The retained `export:blender` script is an optional reference utility and is not part of the application pipeline.
## Baseline
Initial development machine:
- Windows
- Intel Arc Graphics, driver `32.0.101.6737`
- Node `24.14.1`
- Three.js version pinned by `package-lock.json`
The browser/version, viewport, drawing-buffer resolution, texture settings, and observation duration must be recorded during material approval. The on-screen frame-time display is diagnostic rather than a complete benchmark.

13
card-harness/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1016" />
<title>Sanctification Card Lab</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

@@ -0,0 +1,19 @@
{
"checks": [
"Body press produces bounded visible vertex dent",
"Contact deformation remains inside safe bound",
"Body drag does not accidentally open pack",
"Restart deterministically reconstructs original foil vertices",
"Lost pointer capture retains partial tear",
"Wheel pauses animation while zooming",
"Foil geometry/normals are finite and opaque",
"Attached ribbon shares seam samples and pouch surfaces do not cross",
"Inspect card pixels/pose remain identical after Pack inspection",
"Inspect/Lab selections preserved",
"Delayed pack-specific texture load exercised",
"Async pack completion respects mode ownership after switching away",
"Loaded Pack returns sealed and active",
"No runtime exceptions in lifecycle checks"
],
"errors": []
}

File diff suppressed because it is too large Load Diff

955
card-harness/package-lock.json generated Normal file
View File

@@ -0,0 +1,955 @@
{
"name": "card-harness",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "card-harness",
"version": "0.0.0",
"dependencies": {
"lil-gui": "^0.21.0",
"three": "^0.185.1"
},
"devDependencies": {
"@types/pngjs": "^6.0.5",
"@types/three": "^0.185.4",
"pngjs": "^7.0.0",
"typescript": "~6.0.2",
"vite": "^8.2.2"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha1-ezNl4d/cXNlXtFr+kgtKwGx804k=",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@oxc-project/types": {
"version": "0.147.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-project/types/-/types-0.147.0.tgz",
"integrity": "sha1-US5DGWBT20qZko41sodUmjImJos=",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
"integrity": "sha1-gbwyt5kC/NOHXf/fO+LMuj7415Q=",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
"integrity": "sha1-myCujYpbuXnqaqj6S34R9YxT93U=",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
"integrity": "sha1-EDHuBrUckTS6Wm2jpzEDiIdffdg=",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
"integrity": "sha1-Ec8xBAt3nbqOYdkyrjwO6B75SuA=",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
"integrity": "sha1-ctgFtFm6Sou/wRK8pKHHB0BRauk=",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
"integrity": "sha1-Q7d43QIfUBgqHv20FJzYnP+GyUI=",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
"integrity": "sha1-6fp6RpmZwTRtgnspFUBo0cgzxHQ=",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
"integrity": "sha1-XmjdbrhXl3RfSU3+jEkMK85CGKY=",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
"integrity": "sha1-Kg78FUN6Vd0sr1jwMY4SnRHZjuY=",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
"integrity": "sha1-vCsLYyXYquDxocBvsQ1mXIIlhUE=",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
"integrity": "sha1-5uUCL2E4/cDRTO+sxn3HvF3/ZDA=",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
"integrity": "sha1-yLRiYJgJI3u/YmVJGx541cJg7UY=",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
"integrity": "sha1-qiCHkWR7OwvSOzctXua9XtWYgRg=",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
"integrity": "sha1-oqXxqEVV0Ar+CsD/OHh4vPStpSo=",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
"integrity": "sha1-EWKQPiZO0Q20vor0C6jPE81KC0o=",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha1-4/zuCT+7XOdl4a0Ij/TeKIn2+b4=",
"dev": true,
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha1-7/AkVzXASpKLsZwCa1jCpWRgU50=",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.4.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-26.4.0.tgz",
"integrity": "sha1-TEygccQiQf5gJ0HQKZnxa05kxGg=",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/pngjs": {
"version": "6.0.5",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/pngjs/-/pngjs-6.0.5.tgz",
"integrity": "sha1-bewvfrgoRUPKTkI/PAmxGfqTnqM=",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha1-GTPl/xU6I8dmRIeDMZjWhcIueR4=",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.185.4",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/three/-/three-0.185.4.tgz",
"integrity": "sha1-rX8lIV5AHBwgOKwVDnozYTZrOKc=",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha1-c01dkNrcWAmlPkInJsYDN/ovSkQ=",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha1-aJxdzcGQDvVYOky59te0c3QgdK0=",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha1-7Sq5Z6MxreYvGNB32uGSaE1Q01A=",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha1-vCfY6zA0PU1RKrsDSAICzmXYJfw=",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha1-wIhn1xp5OFxuGQIU/XL+8+X5Xws=",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha1-mmhB+IrlD8g1ApA4krQa9BvCuQc=",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha1-wPLDHAv9GfpN0/GOlXofGhUgl9Y=",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha1-ywcFllrLU4xmg5Sc5pJfs833w2E=",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha1-djU4gosmurJoDa2vzITueLDrUCs=",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha1-aGLjF2ozGu297B7TUrTX0N0HhN4=",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha1-xqOi7RUUHa9r3CYokw+OOb30c6o=",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha1-f6EzSXH8goRfmCffbvigsgkUusY=",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha1-i5J4YuqMK7xoMaRlCSRLUNmTblU=",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha1-DFJbsHff2UQEwFnP5C2teX6Wrq8=",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha1-hQ7hED2smJz6tQ46wi0aaeOU5j0=",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha1-40OuFS7tNgncbhGUnRo785oclG8=",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lil-gui": {
"version": "0.21.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lil-gui/-/lil-gui-0.21.0.tgz",
"integrity": "sha1-xcvQdLafDiruO6E+B6/+Le8hrQk=",
"license": "MIT"
},
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha1-IMfQUNWb3FcxVIFPajfUfYmQjMo=",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha1-9mot4Rmf/eD88hyKXxMQaxwIGRM=",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha1-YxM2ADTMs2s9xh7L3/eBIfkP4h8=",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha1-qLdEYCDrvGrHOdtsVBWmXRcJDiY=",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha1-bnUTV4DH4Q3zQzvyJmxVLTXIxiA=",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rolldown": {
"version": "1.2.6",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rolldown/-/rolldown-1.2.6.tgz",
"integrity": "sha1-Al9cEZdcxwEp6px2xU813TWIF4Y=",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.147.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.6",
"@rolldown/binding-android-arm64": "1.2.6",
"@rolldown/binding-darwin-arm64": "1.2.6",
"@rolldown/binding-darwin-x64": "1.2.6",
"@rolldown/binding-freebsd-x64": "1.2.6",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
"@rolldown/binding-linux-arm64-gnu": "1.2.6",
"@rolldown/binding-linux-arm64-musl": "1.2.6",
"@rolldown/binding-linux-ppc64-gnu": "1.2.6",
"@rolldown/binding-linux-s390x-gnu": "1.2.6",
"@rolldown/binding-linux-x64-gnu": "1.2.6",
"@rolldown/binding-linux-x64-musl": "1.2.6",
"@rolldown/binding-openharmony-arm64": "1.2.6",
"@rolldown/binding-win32-arm64-msvc": "1.2.6",
"@rolldown/binding-win32-x64-msvc": "1.2.6"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha1-HOVlD93YerwJnto33P8CTCZnrkY=",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/three/-/three-0.185.1.tgz",
"integrity": "sha1-Y+niQaF7EB4hGWUSGgF7S02AVK4=",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha1-ViqabJ6ys7Ej05cZ+a9btE/NdjE=",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha1-kCUdwAeRbpcnhsuU100VsYVXfSE=",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha1-ROn8nzJEZIzeo15Pm7LWgelBCAk=",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.2.2",
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite/-/vite-8.2.2.tgz",
"integrity": "sha1-OZrvrTZWFFFFvhENE3oH6lu1UBQ=",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}

26
card-harness/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "card-harness",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"assets": "node scripts/generate-reference-assets.mjs",
"export:blender": "node scripts/export-card-mesh.mjs",
"predev": "npm run assets",
"dev": "vite --host 0.0.0.0",
"prebuild": "npm run assets",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"devDependencies": {
"@types/pngjs": "^6.0.5",
"@types/three": "^0.185.4",
"pngjs": "^7.0.0",
"typescript": "~6.0.2",
"vite": "^8.2.2"
},
"dependencies": {
"lil-gui": "^0.21.0",
"three": "^0.185.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,48 @@
{
"schemaVersion": 2,
"referenceRevision": "runtime-look-v4-2026-09-07",
"sourceMaterialReference": "blender_prototype/premium_finishes.py",
"genericMaterialReference": "blender_prototype/card_finish_material.py",
"geometry": {
"source": "runtime-procedural",
"widthMeters": 0.063,
"heightMeters": 0.0882,
"thicknessMeters": 0.0004,
"cornerRadiusMeters": 0.0028
},
"fixtures": {
"david": {
"artwork": "david-front.png",
"finishMask": "david-finish-mask.png",
"maskRule": "Premium v3 saturation/value and title-panel protection; face, hand, and lamb ellipse cutouts removed"
},
"timothy": {
"artwork": "timothy-front.png",
"finishMask": "timothy-finish-mask.png",
"maskRule": "Artwork-independent saturation/value coverage"
}
},
"normalMap": "linen-normal.png",
"referenceRenders": [
"renders/david_foil_closeup.png",
"renders/david_foil_reflection.png",
"renders/david_holographic_head_on.png",
"renders/david_holographic_spectrum_angle.png",
"renders/david_holographic_opposite_angle.png",
"renders/card_three_quarter_thickness.png"
],
"hashes": {
"david-front.png": "b1fcd29e82db42532ffdb8c5f5ff93a8a76666e48ef3a6514d85167fee5cb1be",
"david-finish-mask.png": "0d36e94b09251ec69f169ca37d618f9e0ce8aa4986a8d195e202e7951d8fb384",
"timothy-front.png": "6d1206ee8685af5d0e9a1cb774f845617da9a1011c13f9e7797ce2a3bbce17fb",
"timothy-finish-mask.png": "98619c47565712e0ffecc87742e0bc1f78577c4ed2195babd4ff2279f5bd9cf7",
"card-back.png": "837ee8dbad172db3ba64e97f29cb9d3580ea888fcd192859de217a16e62bd3e6",
"linen-normal.png": "ced7e644fe7518b17db1d3651af6bd58680e1adff64eb50a081ccbd874071c13",
"renders/david_foil_closeup.png": "cca86824f633c22e540bb8d57683b2d704f08aa8de2c9aff7087830b687bf6ef",
"renders/david_foil_reflection.png": "925b8e8c754fa695b975f421a52ea34c07e48f490a3388993972fc69bdf9f43f",
"renders/david_holographic_head_on.png": "fece3e5243917f3ee5e58ef04439cf6f657dc748927202cd4a137fdeb0934bc6",
"renders/david_holographic_spectrum_angle.png": "a445c9bb8d988bb42d16cd3ea99aff95594effbdb47e85198afd676e15bdcd98",
"renders/david_holographic_opposite_angle.png": "7fdd450d1351ce8014d3f552e29e162937bd2ea8a7f72bc4f819922b59af7efd",
"renders/card_three_quarter_thickness.png": "7a00e3188c24e2a02b9b9c5bf95e07e34ef3b9ebb355ca338e11c3065f0db9de"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

View File

@@ -0,0 +1,73 @@
import { access, mkdir } from 'node:fs/promises'
import { constants } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
const here = dirname(fileURLToPath(import.meta.url))
const harnessRoot = resolve(here, '..')
const projectRoot = resolve(harnessRoot, '..')
const blendFile = resolve(projectRoot, 'blender_prototype', 'sanctification_card_material_prototype.blend')
const pythonScript = resolve(here, 'export_card_mesh.py')
const outputFile = resolve(harnessRoot, 'public', 'reference', 'card-mesh.glb')
const explicit = process.env.BLENDER_EXECUTABLE
const platformCandidates = process.platform === 'win32'
? [
'C:\\Program Files\\Blender Foundation\\Blender 5.2\\blender.exe',
'C:\\Program Files\\Blender Foundation\\Blender 5.1\\blender.exe',
'C:\\Program Files\\Blender Foundation\\Blender 5.0\\blender.exe',
]
: [
'/usr/bin/blender',
'/usr/local/bin/blender',
'/snap/bin/blender',
]
async function exists(path) {
try {
await access(path, constants.X_OK)
return true
} catch {
return false
}
}
let blender = explicit
if (blender && !(await exists(blender))) {
throw new Error(`BLENDER_EXECUTABLE does not exist or is not executable: ${blender}`)
}
if (!blender) {
for (const candidate of platformCandidates) {
if (await exists(candidate)) {
blender = candidate
break
}
}
}
if (!blender) blender = 'blender'
await mkdir(dirname(outputFile), { recursive: true })
const args = [
'--background',
blendFile,
'--python',
pythonScript,
'--',
'--output',
outputFile,
]
console.log(`Exporting card mesh with ${blender}`)
const child = spawn(blender, args, { stdio: 'inherit', shell: false })
const exitCode = await new Promise((resolveExit, reject) => {
child.once('error', reject)
child.once('exit', (code) => resolveExit(code ?? 1))
})
if (exitCode !== 0) {
throw new Error(`Blender export failed with exit code ${exitCode}`)
}
console.log(`Card mesh exported to ${outputFile}`)

View File

@@ -0,0 +1,67 @@
import argparse
import sys
from pathlib import Path
import bpy
def script_args():
separator = sys.argv.index("--") if "--" in sys.argv else len(sys.argv)
parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True)
return parser.parse_args(sys.argv[separator + 1:])
def slot_material(name, color):
material = bpy.data.materials.get(name) or bpy.data.materials.new(name)
material.diffuse_color = (*color, 1.0)
return material
args = script_args()
output = Path(args.output).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
source = bpy.data.objects.get("CARD_David_Foil")
if source is None:
raise RuntimeError("Expected CARD_David_Foil in the Blender prototype")
bpy.ops.object.select_all(action="DESELECT")
export_object = source.copy()
export_object.data = source.data.copy()
export_object.name = "SanctificationCardMesh"
export_object.data.name = "SanctificationCardMesh"
export_object.animation_data_clear()
export_object.location = (0, 0, 0)
export_object.rotation_euler = (0, 0, 0)
export_object.scale = (1, 1, 1)
bpy.context.scene.collection.objects.link(export_object)
material_indices = [polygon.material_index for polygon in export_object.data.polygons]
export_object.data.materials.clear()
export_object.data.materials.append(slot_material("SLOT_FRONT", (0.8, 0.8, 0.8)))
export_object.data.materials.append(slot_material("SLOT_BACK", (0.25, 0.25, 0.25)))
export_object.data.materials.append(slot_material("SLOT_EDGE", (0.5, 0.42, 0.28)))
for polygon, material_index in zip(export_object.data.polygons, material_indices):
polygon.material_index = material_index
export_object.select_set(True)
bpy.context.view_layer.objects.active = export_object
bpy.ops.export_scene.gltf(
filepath=str(output),
export_format="GLB",
use_selection=True,
export_apply=True,
export_materials="EXPORT",
export_texcoords=True,
export_normals=True,
export_yup=True,
)
print({
"output": str(output),
"object": export_object.name,
"mesh": export_object.data.name,
"materials": [material.name for material in export_object.data.materials],
})

View File

@@ -0,0 +1,170 @@
import { createHash } from 'node:crypto'
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PNG } from 'pngjs'
const here = dirname(fileURLToPath(import.meta.url))
const harnessRoot = resolve(here, '..')
const projectRoot = resolve(harnessRoot, '..')
const outputRoot = resolve(harnessRoot, 'public', 'reference')
const renderRoot = resolve(projectRoot, 'blender_prototype', 'renders')
await mkdir(resolve(outputRoot, 'renders'), { recursive: true })
const sources = {
david: resolve(projectRoot, 'legendary.png'),
timothy: resolve(projectRoot, 'common.png'),
back: resolve(projectRoot, 'card-back.png'),
}
await Promise.all([
copyFile(sources.david, resolve(outputRoot, 'david-front.png')),
copyFile(sources.timothy, resolve(outputRoot, 'timothy-front.png')),
copyFile(sources.back, resolve(outputRoot, 'card-back.png')),
])
const referenceRenders = [
'david_foil_closeup.png',
'david_foil_reflection.png',
'david_holographic_head_on.png',
'david_holographic_spectrum_angle.png',
'david_holographic_opposite_angle.png',
'card_three_quarter_thickness.png',
]
await Promise.all(referenceRenders.map((name) =>
copyFile(resolve(renderRoot, name), resolve(outputRoot, 'renders', name)),
))
const smoothstep = (edge0, edge1, value) => {
const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)))
return t * t * (3 - 2 * t)
}
const imageHsv = (r, g, b) => {
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
return {
saturation: max === 0 ? 0 : (max - min) / max,
value: max,
}
}
async function generateMask(sourcePath, outputPath, kind) {
const source = PNG.sync.read(await readFile(sourcePath))
const mask = new PNG({ width: source.width, height: source.height })
for (let y = 0; y < source.height; y += 1) {
for (let x = 0; x < source.width; x += 1) {
const index = (y * source.width + x) * 4
const { saturation, value } = imageHsv(
source.data[index] / 255,
source.data[index + 1] / 255,
source.data[index + 2] / 255,
)
let coverage
if (kind === 'david-no-cutouts') {
const u = x / (source.width - 1)
const v = 1 - y / (source.height - 1)
coverage = smoothstep(0.18, 0.62, saturation) * smoothstep(0.015, 0.16, value)
const side = smoothstep(0.34, 0.41, Math.abs(u - 0.5))
const above = smoothstep(0.245, 0.29, v)
const below = 1 - smoothstep(0.07, 0.105, v)
coverage *= Math.max(side, above, below)
} else {
coverage = smoothstep(0.08, 0.58, saturation) * smoothstep(0.015, 0.16, value)
}
const byte = Math.round(Math.max(0, Math.min(1, coverage)) * 255)
mask.data[index] = byte
mask.data[index + 1] = byte
mask.data[index + 2] = byte
mask.data[index + 3] = 255
}
}
await writeFile(outputPath, PNG.sync.write(mask))
}
async function generateLinenNormal(outputPath) {
const size = 512
const png = new PNG({ width: size, height: size })
const heightAt = (x, y) => {
const weave = Math.sin(x * 0.27) * 0.55 + Math.sin(y * 0.31) * 0.45
const cross = Math.sin((x + y) * 0.075) * 0.16
return weave + cross
}
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const dx = heightAt(x + 1, y) - heightAt(x - 1, y)
const dy = heightAt(x, y + 1) - heightAt(x, y - 1)
const nx = -dx * 0.22
const ny = -dy * 0.22
const length = Math.hypot(nx, ny, 1)
const index = (y * size + x) * 4
png.data[index] = Math.round((nx / length * 0.5 + 0.5) * 255)
png.data[index + 1] = Math.round((ny / length * 0.5 + 0.5) * 255)
png.data[index + 2] = Math.round((1 / length * 0.5 + 0.5) * 255)
png.data[index + 3] = 255
}
}
await writeFile(outputPath, PNG.sync.write(png))
}
await Promise.all([
generateMask(sources.david, resolve(outputRoot, 'david-finish-mask.png'), 'david-no-cutouts'),
generateMask(sources.timothy, resolve(outputRoot, 'timothy-finish-mask.png'), 'automatic'),
generateLinenNormal(resolve(outputRoot, 'linen-normal.png')),
])
const generatedFiles = [
'david-front.png',
'david-finish-mask.png',
'timothy-front.png',
'timothy-finish-mask.png',
'card-back.png',
'linen-normal.png',
...referenceRenders.map((name) => `renders/${name}`),
]
const hashes = {}
for (const name of generatedFiles) {
hashes[name] = createHash('sha256')
.update(await readFile(resolve(outputRoot, name)))
.digest('hex')
}
await writeFile(resolve(outputRoot, 'manifest.json'), JSON.stringify({
schemaVersion: 2,
referenceRevision: 'runtime-look-v4-2026-09-07',
sourceMaterialReference: 'blender_prototype/premium_finishes.py',
genericMaterialReference: 'blender_prototype/card_finish_material.py',
geometry: {
source: 'runtime-procedural',
widthMeters: 0.063,
heightMeters: 0.0882,
thicknessMeters: 0.0004,
cornerRadiusMeters: 0.0028,
},
fixtures: {
david: {
artwork: 'david-front.png',
finishMask: 'david-finish-mask.png',
maskRule: 'Premium v3 saturation/value and title-panel protection; face, hand, and lamb ellipse cutouts removed',
},
timothy: {
artwork: 'timothy-front.png',
finishMask: 'timothy-finish-mask.png',
maskRule: 'Artwork-independent saturation/value coverage',
},
},
normalMap: 'linen-normal.png',
referenceRenders: referenceRenders.map((name) => `renders/${name}`),
hashes,
}, null, 2))
console.log(`Reference assets ready in ${outputRoot}`)

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,93 @@
import * as THREE from 'three'
export const cardDimensions = {
width: 0.063,
height: 0.0882,
thickness: 0.0004,
cornerRadius: 0.0028,
} as const
const sceneHeight = 4
const sceneScale = sceneHeight / cardDimensions.height
export const cardSceneDimensions = {
width: cardDimensions.width * sceneScale,
height: cardDimensions.height * sceneScale,
thickness: cardDimensions.thickness * sceneScale,
cornerRadius: cardDimensions.cornerRadius * sceneScale,
} as const
function createRoundedRectangle(width: number, height: number, radius: number) {
const halfWidth = width / 2
const halfHeight = height / 2
const shape = new THREE.Shape()
shape.moveTo(-halfWidth + radius, -halfHeight)
shape.lineTo(halfWidth - radius, -halfHeight)
shape.quadraticCurveTo(halfWidth, -halfHeight, halfWidth, -halfHeight + radius)
shape.lineTo(halfWidth, halfHeight - radius)
shape.quadraticCurveTo(halfWidth, halfHeight, halfWidth - radius, halfHeight)
shape.lineTo(-halfWidth + radius, halfHeight)
shape.quadraticCurveTo(-halfWidth, halfHeight, -halfWidth, halfHeight - radius)
shape.lineTo(-halfWidth, -halfHeight + radius)
shape.quadraticCurveTo(-halfWidth, -halfHeight, -halfWidth + radius, -halfHeight)
return shape
}
export function createCardGeometry(
frontMaterial: THREE.Material,
backMaterial: THREE.Material,
edgeMaterial: THREE.Material,
) {
const { width, height, thickness, cornerRadius } = cardSceneDimensions
const shape = createRoundedRectangle(width, height, cornerRadius)
const bodyGeometry = new THREE.ExtrudeGeometry(shape, {
curveSegments: 16,
depth: thickness,
steps: 1,
bevelEnabled: false,
})
bodyGeometry.translate(0, 0, -thickness / 2)
// Extrusion caps would duplicate the dedicated faces and z-fight at Pack's camera distances.
const sideWalls = bodyGeometry.groups.find((group) => group.materialIndex === 1)
if (!sideWalls) throw new Error('Card extrusion is missing its side-wall geometry')
bodyGeometry.setDrawRange(sideWalls.start, sideWalls.count)
const faceGeometry = new THREE.ShapeGeometry(shape, 16)
const positions = faceGeometry.getAttribute('position')
const uvs = faceGeometry.getAttribute('uv')
for (let index = 0; index < positions.count; index += 1) {
uvs.setXY(
index,
positions.getX(index) / width + 0.5,
positions.getY(index) / height + 0.5,
)
}
uvs.needsUpdate = true
const faceOffset = thickness / 2
const body = new THREE.Mesh(bodyGeometry, edgeMaterial)
body.name = 'CARD_EDGE'
const front = new THREE.Mesh(faceGeometry, frontMaterial)
front.name = 'CARD_FRONT'
front.position.z = faceOffset
const back = new THREE.Mesh(faceGeometry.clone(), backMaterial)
back.name = 'CARD_BACK'
back.position.z = -faceOffset
back.rotation.y = Math.PI
const card = new THREE.Group()
card.name = 'CARD_PROCEDURAL'
card.add(body, front, back)
for (const mesh of [body, front, back]) {
mesh.castShadow = true
mesh.receiveShadow = true
}
return card
}

View File

@@ -0,0 +1,493 @@
import * as THREE from 'three'
export type FinishName = 'Printed ink' | 'Foil' | 'Holographic'
export type SubstrateName = 'Paper' | 'Linen' | 'Plastic' | 'Metal' | 'Wood'
export interface MaterialControls {
finish: FinishName
substrate: SubstrateName
finishStrength: number
roughness: number
normalStrength: number
environmentIntensity: number
condition: number
imperfectionSeed: number
}
export function applyEdgeMaterialControls(
material: THREE.MeshPhysicalMaterial,
controls: Pick<MaterialControls, 'substrate' | 'condition'>,
) {
const edgeLooks: Record<SubstrateName, {
color: string
wornColor: string
roughness: number
metalness: number
clearcoat: number
}> = {
Paper: { color: '#9c8c69', wornColor: '#d0c29e', roughness: 0.62, metalness: 0.02, clearcoat: 0.02 },
Linen: { color: '#a89772', wornColor: '#c5b58f', roughness: 0.78, metalness: 0, clearcoat: 0 },
Plastic: { color: '#d4cab0', wornColor: '#c7ccd2', roughness: 0.24, metalness: 0, clearcoat: 0.72 },
Metal: { color: '#a8adb7', wornColor: '#d0d5dc', roughness: 0.2, metalness: 0.86, clearcoat: 0.18 },
Wood: { color: '#6b3f21', wornColor: '#a87847', roughness: 0.56, metalness: 0, clearcoat: 0.08 },
}
const look = edgeLooks[controls.substrate]
material.color.set(look.color)
material.color.lerp(
new THREE.Color(look.wornColor),
Math.pow(1 - controls.condition, 1.15) * 0.38,
)
material.roughness = look.roughness
material.metalness = look.metalness
material.clearcoat = look.clearcoat
material.needsUpdate = true
}
export function createStudioCubeTexture(
colors = ['#d7b77c', '#18243a', '#f4ead4', '#111722', '#6b7f9f', '#231718'],
) {
const canvases = colors.map((color, index) => {
const canvas = document.createElement('canvas')
canvas.width = 128
canvas.height = 128
const context = canvas.getContext('2d')!
const gradient = context.createLinearGradient(0, 0, 128, 128)
gradient.addColorStop(0, color)
gradient.addColorStop(0.48, index % 2 ? '#10151f' : '#fff4d6')
gradient.addColorStop(1, '#05070b')
context.fillStyle = gradient
context.fillRect(0, 0, 128, 128)
return canvas
})
const texture = new THREE.CubeTexture(canvases)
texture.colorSpace = THREE.SRGBColorSpace
texture.needsUpdate = true
return texture
}
const vertexShader = `
varying vec2 vUv;
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
varying vec3 vWorldTangent;
varying vec3 vWorldBitangent;
void main() {
vUv = uv;
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
mat3 modelRotation = mat3(modelMatrix);
vWorldPosition = worldPosition.xyz;
vWorldNormal = normalize(modelRotation * normal);
vWorldTangent = normalize(modelRotation * vec3(1.0, 0.0, 0.0));
vWorldBitangent = normalize(modelRotation * vec3(0.0, 1.0, 0.0));
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`
const fragmentShader = `
uniform sampler2D artwork;
uniform sampler2D finishMask;
uniform sampler2D normalMap;
uniform samplerCube studioEnvironment;
uniform vec3 lightPosition;
uniform vec3 lightColor;
uniform float lightIntensity;
uniform float environmentIntensity;
uniform float finishStrength;
uniform float roughness;
uniform float normalStrength;
uniform float condition;
uniform float imperfectionSeed;
uniform int finishMode;
uniform int substrateMode;
uniform int lightMode;
varying vec2 vUv;
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
varying vec3 vWorldTangent;
varying vec3 vWorldBitangent;
vec3 spectrum(float phase) {
return 0.52 + 0.48 * cos(6.2831853 * (phase + vec3(0.00, 0.33, 0.67)));
}
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float fiberNoise(vec2 p) {
vec2 cell = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(cell), hash(cell + vec2(1.0, 0.0)), f.x),
mix(hash(cell + vec2(0.0, 1.0)), hash(cell + vec2(1.0, 1.0)), f.x),
f.y
);
}
float filteredWave(float phase) {
float visibility = 1.0 - smoothstep(0.7, 3.0, fwidth(phase));
return sin(phase) * visibility;
}
float woodGrain(vec2 uv) {
float warp = fiberNoise(uv * vec2(4.0, 2.5));
float phase = uv.x * 170.0 + warp * 16.0 + sin(uv.y * 9.0) * 2.5;
return 0.5 + 0.5 * filteredWave(phase);
}
float scratchLine(vec2 uv, float index) {
float seed = imperfectionSeed * 0.001 + index * 19.17;
float angle = hash(vec2(seed, 1.3)) * 6.2831853;
vec2 direction = vec2(cos(angle), sin(angle));
vec2 perpendicular = vec2(-direction.y, direction.x);
vec2 center = vec2(
hash(vec2(seed, 4.7)),
hash(vec2(seed, 8.9))
);
vec2 offset = uv - center;
float along = dot(offset, direction);
float across = abs(dot(offset, perpendicular));
float halfLength = mix(0.08, 0.32, hash(vec2(seed, 13.1)));
float width = mix(0.00045, 0.0014, hash(vec2(seed, 17.3)));
float line = 1.0 - smoothstep(width, width * 2.4, across);
float ends = smoothstep(-halfLength, -halfLength * 0.78, along) *
(1.0 - smoothstep(halfLength * 0.78, halfLength, along));
return line * ends;
}
float scuffMark(vec2 uv, float index) {
float seed = imperfectionSeed * 0.001 + index * 31.73;
vec2 center = vec2(
hash(vec2(seed, 2.9)),
hash(vec2(seed, 7.1))
);
vec2 scale = vec2(
mix(4.0, 8.0, hash(vec2(seed, 11.7))),
mix(7.0, 14.0, hash(vec2(seed, 15.9)))
);
float distanceFromCenter = length((uv - center) * scale);
float irregularity = fiberNoise(uv * 38.0 + seed) * 0.24;
return 1.0 - smoothstep(0.34 + irregularity, 0.62 + irregularity, distanceFromCenter);
}
vec3 applyWearColor(
vec3 color,
float edgeWear,
float scratchWear,
float scuffWear
) {
vec3 wearColor = vec3(0.82, 0.76, 0.62);
if (substrateMode == 1) wearColor = vec3(0.76, 0.70, 0.57);
if (substrateMode == 2) wearColor = vec3(0.78, 0.80, 0.82);
if (substrateMode == 3) wearColor = vec3(0.72, 0.75, 0.80);
if (substrateMode == 4) wearColor = vec3(0.62, 0.39, 0.20);
float abrasion = clamp(
edgeWear * 0.55 + scratchWear * 0.30 + scuffWear * 0.22,
0.0,
0.62
);
return mix(color, wearColor, abrasion);
}
// Screen-space derivatives keep relief aligned to the face UVs rather than
// assuming that the card's local X/Y axes are always the surface tangents.
vec3 reliefNormal(vec3 baseNormal, float height) {
vec3 dx = dFdx(vWorldPosition);
vec3 dy = dFdy(vWorldPosition);
vec3 r1 = cross(dy, baseNormal);
vec3 r2 = cross(baseNormal, dx);
float determinant = dot(dx, r1);
vec3 gradient = sign(determinant) * (dFdx(height) * r1 + dFdy(height) * r2);
return normalize(abs(determinant) * baseNormal - gradient);
}
void main() {
vec2 artworkUv = vUv;
vec4 artworkSample = texture2D(artwork, artworkUv);
float mask = texture2D(finishMask, artworkUv).r;
float wearAmount = pow(clamp(1.0 - condition, 0.0, 1.0), 1.15);
float edgeDistance = min(
min(artworkUv.x, 1.0 - artworkUv.x),
min(artworkUv.y, 1.0 - artworkUv.y)
);
float edgeNoise = fiberNoise(artworkUv * vec2(43.0, 61.0) + imperfectionSeed * 0.0007);
float edgeWidth = mix(0.0015, 0.032, wearAmount) * mix(0.65, 1.25, edgeNoise);
float edgeWear = (1.0 - smoothstep(edgeWidth * 0.45, edgeWidth, edgeDistance)) * wearAmount;
float scratchWear = max(
scratchLine(artworkUv, 1.0),
max(scratchLine(artworkUv, 2.0), scratchLine(artworkUv, 3.0))
) * smoothstep(0.04, 0.48, wearAmount);
float scuffWear = max(
scuffMark(artworkUv, 1.0),
scuffMark(artworkUv, 2.0)
) * smoothstep(0.18, 0.82, wearAmount);
float wearDamage = max(edgeWear, max(scratchWear * 0.8, scuffWear * 0.55));
mask *= 1.0 - wearDamage * 0.82;
vec3 normalSample = texture2D(normalMap, artworkUv * 2.2).xyz * 2.0 - 1.0;
float detail = normalStrength / 0.14;
float surfaceHeight = 0.0;
float surfaceShade = 1.0;
float metalEtchDepth = 0.0;
float metalEtchExposure = 1.0;
if (substrateMode == 0) {
vec2 fiberUv = artworkUv * vec2(230.0, 322.0);
float visible = 1.0 - smoothstep(0.4, 1.2, max(fwidth(fiberUv.x), fwidth(fiberUv.y)));
float fibers = (fiberNoise(fiberUv) - 0.5) * visible;
surfaceHeight = fibers * 0.0014 * detail;
surfaceShade = 1.0 + fibers * 0.045 * min(detail, 1.5);
}
if (substrateMode == 1) {
vec2 thread = artworkUv * vec2(52.0, 73.0) * 6.2831853;
float warp = filteredWave(thread.x);
float weft = filteredWave(thread.y);
float overUnder = filteredWave(thread.x * 0.5) * filteredWave(thread.y * 0.5);
float weave = mix(warp, weft, 0.5 + 0.5 * overUnder);
surfaceHeight = weave * 0.005 * detail;
surfaceShade = 1.0 - (1.0 - weave) * 0.055 * min(detail, 1.5);
}
if (substrateMode == 3) {
float artworkLuminance = dot(artworkSample.rgb, vec3(0.2126, 0.7152, 0.0722));
metalEtchDepth = smoothstep(0.08, 0.92, 1.0 - artworkLuminance);
if (finishMode == 1) {
metalEtchExposure = 1.0 - mask * 0.25;
}
surfaceHeight = -metalEtchDepth * metalEtchExposure * 0.0065 * min(detail, 1.5);
if (finishMode == 1) {
surfaceHeight += mask * 0.0005 * min(detail, 1.5);
}
surfaceShade = 1.0 - metalEtchDepth * metalEtchExposure * 0.035;
}
if (substrateMode == 4) {
float grain = woodGrain(artworkUv);
surfaceHeight = grain * 0.007 * detail;
surfaceShade = 0.88 + grain * 0.12;
}
surfaceHeight -= scratchWear * 0.0018 + scuffWear * 0.0007;
float substrateNormalScale = substrateMode == 3 ? 0.06 : 0.12;
vec3 normal = normalize(
vWorldNormal +
vWorldTangent * normalSample.x * normalStrength * substrateNormalScale +
vWorldBitangent * normalSample.y * normalStrength * substrateNormalScale
);
normal = reliefNormal(normal, surfaceHeight);
vec3 viewDirection = normalize(cameraPosition - vWorldPosition);
vec3 lightVector = lightPosition - vWorldPosition;
float lightDistance = length(lightVector);
vec3 lightDirection = lightVector / max(lightDistance, 0.0001);
float attenuation = 1.0 / (1.0 + 0.032 * lightDistance * lightDistance);
if (lightMode == 1) {
lightDirection = normalize(lightPosition);
attenuation = 0.52;
}
if (lightMode == 2) {
vec3 spotForward = normalize(-lightPosition);
vec3 lightToSurface = normalize(vWorldPosition - lightPosition);
float spotAngle = dot(lightToSurface, spotForward);
attenuation *= smoothstep(0.82, 0.94, spotAngle);
}
vec3 halfVector = normalize(lightDirection + viewDirection);
float diffuse = max(dot(normal, lightDirection), 0.0);
float direct = diffuse * lightIntensity * attenuation;
vec3 reflected = reflect(-viewDirection, normal);
vec3 environment = textureCube(studioEnvironment, reflected).rgb;
float substrateRoughness = roughness;
if (substrateMode == 0) substrateRoughness = max(roughness, 0.56);
if (substrateMode == 1) substrateRoughness = max(roughness, 0.70);
if (substrateMode == 2) substrateRoughness = min(roughness, 0.25);
if (substrateMode == 3) substrateRoughness = min(roughness, 0.20);
if (substrateMode == 4) substrateRoughness = max(roughness, 0.48);
float nDotV = max(dot(normal, viewDirection), 0.0);
float dielectricFresnel = 0.04 + 0.96 * pow(1.0 - nDotV, 5.0);
float substrateSpecular = pow(
max(dot(normal, halfVector), 0.0),
mix(180.0, 8.0, substrateRoughness)
) * diffuse * lightIntensity * attenuation;
vec3 printedInk = artworkSample.rgb * surfaceShade * (0.32 + direct * lightColor);
// Substrate reflection is evaluated before the plain-ink return, so it
// remains visible with no foil or holo coating.
if (substrateMode == 0 || substrateMode == 1 || substrateMode == 4) {
float reflectionStrength = substrateMode == 1 ? 0.025 : (substrateMode == 4 ? 0.09 : 0.045);
printedInk += lightColor * substrateSpecular * reflectionStrength;
}
if (substrateMode == 2) {
float reflectedFraction = min(0.32, dielectricFresnel * environmentIntensity);
printedInk = printedInk * (1.0 - reflectedFraction) + environment * reflectedFraction;
printedInk += lightColor * min(0.50, substrateSpecular * 0.38);
}
if (substrateMode == 3) {
float visibleEtch = metalEtchDepth * metalEtchExposure;
float etchEdge = smoothstep(0.002, 0.045, fwidth(visibleEtch));
vec3 metalUnderprint = mix(
artworkSample.rgb * vec3(0.72, 0.76, 0.82),
environment,
0.36 + 0.24 * pow(1.0 - max(dot(normal, viewDirection), 0.0), 2.0)
);
metalUnderprint *= 1.0 - visibleEtch * 0.12;
metalUnderprint += lightColor * etchEdge * (0.035 + substrateSpecular * 0.04);
printedInk = mix(printedInk, metalUnderprint, 0.42);
printedInk *= 1.0 - visibleEtch * 0.06;
printedInk += lightColor * etchEdge * 0.025;
}
if (substrateMode == 4) {
float grain = woodGrain(artworkUv);
vec3 woodTint = mix(vec3(0.72, 0.40, 0.18), vec3(1.0, 0.86, 0.65), grain);
printedInk *= mix(vec3(1.0), woodTint, 0.25);
}
if (finishMode == 0) {
vec3 wornInk = applyWearColor(printedInk, edgeWear, scratchWear, scuffWear);
gl_FragColor = vec4(wornInk, artworkSample.a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
return;
}
float fresnel = pow(1.0 - max(dot(normal, viewDirection), 0.0), 3.0);
// A point-light Phong lobe must be broader than the equivalent microfacet
// roughness in Blender, otherwise foil only appears at a razor-thin angle.
float specularPower = mix(96.0, 10.0, substrateRoughness);
float directSpecular = pow(max(dot(normal, halfVector), 0.0), specularPower);
float environmentBrightness = dot(environment, vec3(0.2126, 0.7152, 0.0722));
if (substrateMode == 3 && finishMode == 1) {
float plateSpecular = pow(max(dot(normal, halfVector), 0.0), 150.0);
float plateEdge = smoothstep(0.003, 0.055, fwidth(mask));
vec3 champagne = vec3(1.0, 0.84, 0.58);
vec3 plateReflection = clamp(
environment * champagne * (0.55 + fresnel * 0.22) +
lightColor * champagne * plateSpecular * lightIntensity * attenuation * 0.22,
0.0,
1.25
);
vec3 screenedArtwork = 1.0 - (1.0 - artworkSample.rgb) * (1.0 - plateReflection * 0.42);
vec3 platedColor = mix(artworkSample.rgb, screenedArtwork, 0.38);
platedColor += lightColor * champagne * plateEdge * 0.008;
float plateCoverage = smoothstep(0.18, 0.88, mask);
float plateWeight = clamp(
plateCoverage * (0.18 + finishStrength * 0.22),
0.0,
0.34
);
vec3 plated = mix(printedInk, platedColor, plateWeight);
plated = applyWearColor(plated, edgeWear, scratchWear, scuffWear);
gl_FragColor = vec4(plated, artworkSample.a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
return;
}
float coatingResponse = clamp(
0.10 +
directSpecular * lightIntensity * attenuation * 0.46 +
(environmentBrightness * 0.34 + fresnel * 0.22) * environmentIntensity,
0.0,
0.78
);
if (finishMode == 2) {
coatingResponse = clamp(
0.16 +
directSpecular * lightIntensity * attenuation * 1.25 +
(environmentBrightness * 0.72 + fresnel * 0.48) * environmentIntensity,
0.0,
1.8
);
}
float phase = fract(
artworkUv.x * 0.65 +
artworkUv.y * 0.25 +
dot(viewDirection, vWorldTangent) * 2.8 +
dot(viewDirection, vWorldBitangent) * 1.6 +
dot(lightDirection, vWorldTangent) * 0.22 +
0.30
);
vec3 coatingColor;
if (finishMode == 2) {
vec3 spectralReflection = mix(environment, spectrum(phase), 0.72);
coatingColor = mix(artworkSample.rgb, spectralReflection, 0.48);
} else {
vec3 warmReflection = clamp(
environment * vec3(1.14, 1.02, 0.82) +
lightColor * directSpecular * 0.55,
0.0,
1.0
);
vec3 screenedInk = 1.0 - (1.0 - artworkSample.rgb) * (1.0 - warmReflection);
coatingColor = mix(artworkSample.rgb, screenedInk, 0.58);
}
float coatingStrength = finishMode == 2 ? finishStrength : finishStrength * 0.8;
float coatingLimit = finishMode == 2 ? 0.92 : 0.48;
float coatingWeight = clamp(mask * coatingStrength * coatingResponse, 0.0, coatingLimit);
vec3 coated = mix(printedInk, coatingColor, coatingWeight);
coated = applyWearColor(coated, edgeWear, scratchWear, scuffWear);
gl_FragColor = vec4(coated, artworkSample.a);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`
export function createCardMaterial(
artwork: THREE.Texture,
finishMask: THREE.Texture,
normalMap: THREE.Texture,
environment: THREE.CubeTexture,
lightPosition: THREE.Vector3,
) {
return new THREE.ShaderMaterial({
uniforms: {
artwork: { value: artwork },
finishMask: { value: finishMask },
normalMap: { value: normalMap },
studioEnvironment: { value: environment },
lightPosition: { value: lightPosition },
lightColor: { value: new THREE.Color('#fff0d0') },
lightIntensity: { value: 3.0 },
environmentIntensity: { value: 0.7 },
finishStrength: { value: 0.6 },
roughness: { value: 0.23 },
normalStrength: { value: 0.14 },
condition: { value: 1.0 },
imperfectionSeed: { value: 81251 },
finishMode: { value: 2 },
substrateMode: { value: 0 },
lightMode: { value: 0 },
},
vertexShader,
fragmentShader,
side: THREE.DoubleSide,
toneMapped: true,
})
}
export function applyMaterialControls(
material: THREE.ShaderMaterial,
controls: MaterialControls,
) {
const finishModes: Record<FinishName, number> = {
'Printed ink': 0,
Foil: 1,
Holographic: 2,
}
const substrateModes: Record<SubstrateName, number> = {
Paper: 0,
Linen: 1,
Plastic: 2,
Metal: 3,
Wood: 4,
}
material.uniforms.finishMode.value = finishModes[controls.finish]
material.uniforms.substrateMode.value = substrateModes[controls.substrate]
material.uniforms.finishStrength.value = controls.finishStrength
material.uniforms.roughness.value = controls.roughness
material.uniforms.normalStrength.value = controls.normalStrength
material.uniforms.environmentIntensity.value = controls.environmentIntensity
material.uniforms.condition.value = controls.condition
material.uniforms.imperfectionSeed.value = controls.imperfectionSeed
}

View File

@@ -0,0 +1,146 @@
import * as THREE from 'three'
const halfWidth = 1.68
const bottom = -2.24
const seam = 1.97
const top = 2.28
const midDepth = -0.09
const smooth = THREE.MathUtils.smoothstep
function wrapperTexture(back = false) {
const canvas = document.createElement('canvas')
canvas.width = 1024
canvas.height = 1400
const context = canvas.getContext('2d')!
context.fillStyle = '#263a40'
context.fillRect(0, 0, 1024, 1400)
const gradient = context.createLinearGradient(0, 0, 1024, 1400)
gradient.addColorStop(0, '#ffffff0c')
gradient.addColorStop(0.45, '#ffffff00')
gradient.addColorStop(1, '#00000022')
context.fillStyle = gradient
context.fillRect(0, 0, 1024, 1400)
context.strokeStyle = '#a59569'
context.lineWidth = 2
context.strokeRect(66, 155, 892, 1100)
context.strokeRect(80, 169, 864, 1072)
context.fillStyle = '#baa775'
context.fillRect(0, 0, 1024, 100)
context.fillRect(0, 1315, 1024, 85)
context.textAlign = 'center'
context.fillStyle = '#283339'
context.font = 'bold 19px sans-serif'
context.fillText('P U L L T O O P E N →', 512, 58)
context.fillStyle = '#d8c994'
context.save()
context.translate(512, 480)
context.rotate(Math.PI / 4)
context.strokeRect(-100, -100, 200, 200)
context.strokeRect(-80, -80, 160, 160)
context.restore()
context.font = '56px Georgia'
context.fillText('S', 512, 500)
context.font = '44px Georgia'
context.fillText('SANCTIFICATION', 512, 740)
context.font = '21px sans-serif'
context.fillText('C O L L E C T O R S E R I E S', 512, 795)
context.font = '19px sans-serif'
context.fillText(back ? 'AUTHORED EDITION / 001' : 'THREE CARDS / ONE COLLECTION', 512, 1175)
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
return texture
}
/**
* Two joined pouch surfaces and one sealed ribbon, never a label over a shell.
* All deformation is evaluated from immutable UVs, not accumulated frame deltas.
*/
export function createFoilWrapper() {
const root = new THREE.Group()
root.name = 'PACK_FOIL_WRAPPER'
const frontTexture = wrapperTexture()
const frontMaterial = new THREE.MeshStandardMaterial({
map: frontTexture, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide,
})
const backMaterial = frontMaterial.clone()
backMaterial.map = wrapperTexture(true)
const stripMaterial = frontMaterial.clone()
const sheets = [1, -1].map((side) => {
const geometry = new THREE.PlaneGeometry(halfWidth * 2, seam - bottom, 64, 80)
const mesh = new THREE.Mesh(geometry, side === 1 ? frontMaterial : backMaterial)
mesh.name = side === 1 ? 'FOIL_FRONT' : 'FOIL_BACK'
// UVs span the original, uncut printed sheet; geometry UVs never change.
const uv = geometry.getAttribute('uv')
for (let i = 0; i < uv.count; i++) uv.setY(i, uv.getY(i) * (seam - bottom) / (top - bottom))
root.add(mesh)
return { mesh, side }
})
// Match the pouch's horizontal tessellation so the attached tear boundary shares every sample.
const stripGeometry = new THREE.PlaneGeometry(halfWidth * 2, top - seam, 64, 10)
const stripUV = stripGeometry.getAttribute('uv')
for (let i = 0; i < stripUV.count; i++) {
stripUV.setY(i, (seam - bottom + stripUV.getY(i) * (top - seam)) / (top - bottom))
}
const strip = new THREE.Mesh(stripGeometry, stripMaterial)
strip.name = 'FOIL_TEAR_STRIP'
root.add(strip)
function tornEdge(u: number) {
return 0.011 * Math.sin(u * Math.PI * 96) + 0.005 * Math.sin(u * 173)
}
function deform(tear: number, detach: number, mouth: number, touch: THREE.Vector3, stripExit = 4.8) {
for (const { mesh, side } of sheets) {
const geometry = mesh.geometry
const positions = geometry.getAttribute('position')
const uv = geometry.getAttribute('uv')
for (let i = 0; i < positions.count; i++) {
const u = uv.getX(i)
const y = bottom + uv.getY(i) * (top - bottom)
const x = (u * 2 - 1) * halfWidth
const across = u === 0 || u === 1 ? 0 : Math.pow(Math.sin(u * Math.PI), 0.24)
const lower = smooth(y, bottom + 0.14, bottom + 0.47)
const neck = 1 - smooth(y, 1.65, seam)
const opening = mouth * smooth(y, 0.6, seam)
const envelope = across * lower
const diagonal = Math.sin(x * 13 + y * 7) * Math.sin(y * 3.1 - x * 2)
const folds = (0.009 * diagonal + 0.003 * Math.sin(x * 28 - y * 5))
* envelope * (0.35 + 0.65 * Math.pow(Math.abs(x / halfWidth), 1.5))
const crimp = 0.013 * (1 + 0.7 * Math.sin(x * 36))
* (1 - lower) * smooth(y, bottom, bottom + 0.1) * across
const dent = side === 1 ? -0.045 * touch.z
* Math.exp(-((x - touch.x) ** 2 + (y - touch.y) ** 2) * 5) * envelope * neck : 0
const depth = 0.37 * neck + 0.64 * opening
const lip = mouth * (side === 1 ? -0.28 : 0.08) * across * smooth(y, 1, seam)
positions.setXYZ(i, x, y + tornEdge(u) * smooth(y, 1.85, seam) + lip,
midDepth + side * (depth * envelope + folds * neck + crimp) + dent)
}
positions.needsUpdate = true
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
}
const positions = stripGeometry.getAttribute('position')
for (let i = 0; i < positions.count; i++) {
const u = stripUV.getX(i)
const v = (bottom + stripUV.getY(i) * (top - bottom) - seam) / (top - seam)
const length = Math.max(0, tear - u) * halfWidth * 2
const curvature = 0.55 + detach * 0.5
const angle = length * curvature
const anchor = (Math.max(tear, u) * 2 - 1) * halfWidth
const release = smooth(length, 0, 0.3)
// The released end rolls up around the moving tear front; the attached end stays sealed.
positions.setXYZ(i,
anchor - Math.sin(angle) / curvature + detach * stripExit,
seam + v * (top - seam) + tornEdge(u) * (1 - v)
+ 0.36 * (1 - Math.cos(angle)) / curvature + release * 0.035 + detach * 0.4,
midDepth + 0.012 * Math.sin((u * 2 - 1) * halfWidth * 36) * Math.sin(v * Math.PI)
- 0.85 * (1 - Math.cos(angle)) / curvature - detach * 0.65)
}
positions.needsUpdate = true
stripGeometry.computeVertexNormals()
stripGeometry.computeBoundingSphere()
strip.visible = detach < 1
}
deform(0, 0, 0, new THREE.Vector3())
return { root, deform }
}

1476
card-harness/src/main.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,582 @@
import * as THREE from 'three'
import { cardSceneDimensions, createCardGeometry } from './cardGeometry'
import { createFoilWrapper } from './foilWrapper'
import {
applyEdgeMaterialControls,
applyMaterialControls,
createCardMaterial,
type FinishName,
type SubstrateName,
} from './cardMaterial'
export type PackFixture = 'David' | 'Timothy'
export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted'
| 'revealing' | 'inspecting' | 'advancing' | 'complete'
export const packContents: readonly {
fixture: PackFixture
finish: FinishName
substrate: SubstrateName
}[] = [
{ fixture: 'David', finish: 'Printed ink', substrate: 'Linen' },
{ fixture: 'Timothy', finish: 'Foil', substrate: 'Paper' },
{ fixture: 'David', finish: 'Holographic', substrate: 'Metal' },
]
// Leaves ~0.039 scene units between the surfaces of adjacent 0.88-scale cards.
const stackDepthSpacing = 0.055
const cardSweepRadius = Math.hypot(
cardSceneDimensions.width, cardSceneDimensions.height, cardSceneDimensions.thickness,
) / 2
// Clearance is based on the full corner sweep, not just the card's thickness or front pose.
const inspectionDepth = Math.max(3, cardSweepRadius + 0.5)
const inspectionCameraRetreat = inspectionDepth - 0.85
interface PackOptions {
canvas: HTMLCanvasElement
textures: Record<PackFixture, { artwork: THREE.Texture; mask: THREE.Texture }>
backMaterial: THREE.Material
normalMap: THREE.Texture
environment: THREE.CubeTexture
lightPosition: THREE.Vector3
onChange: () => void
}
interface Pose {
position: THREE.Vector3
quaternion: THREE.Quaternion
scale: THREE.Vector3
}
interface Track {
object: THREE.Object3D
from: Pose
to: Pose
}
interface Motion {
tracks: Track[]
startedAt: number
elapsed: number
duration: number
finish: () => void
progress?: (value: number) => void
}
function pose(x: number, y: number, z: number, rx = 0, ry = 0, rz = 0, scale = 1): Pose {
return {
position: new THREE.Vector3(x, y, z),
quaternion: new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)),
scale: new THREE.Vector3(scale, scale, scale),
}
}
function setPose(object: THREE.Object3D, value: Pose) {
object.position.copy(value.position)
object.quaternion.copy(value.quaternion)
object.scale.copy(value.scale)
}
function track(object: THREE.Object3D, to: Pose): Track {
return {
object,
from: {
position: object.position.clone(),
quaternion: object.quaternion.clone(),
scale: object.scale.clone(),
},
to,
}
}
export class PackOpening {
readonly root = new THREE.Group()
readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
state: PackState = 'sealed'
paused = false
private readonly options: PackOptions
private readonly wrapper = createFoilWrapper()
private readonly cards
private readonly reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
private motion: Motion | undefined
private active = false
private index = 0
private readonly revealed = new Set<number>()
private completed = false
private zoom = 1
private fitDistance = 9.6
private cameraRetreat = 0
private cameraTargetY = 0
private openingProgress = 0
private seamDrag: { id: number; x: number; progress: number; travel: number } | undefined
private readonly touch = new THREE.Vector3()
private touchTarget = 0
private lastTick: number | undefined
private readonly pointers = new Map<number, THREE.Vector2>()
private tap: { id: number; x: number; y: number; at: number } | undefined
private pinchDistance: number | undefined
constructor(options: PackOptions) {
this.options = options
this.root.name = 'PACK_PROTOTYPE'
this.root.position.y = 0.22
this.root.visible = false
this.root.add(this.wrapper.root)
this.cards = packContents.map((spec) => {
const textures = options.textures[spec.fixture]
const material = createCardMaterial(
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
)
applyMaterialControls(material, {
...spec, finishStrength: 0.6, roughness: 0.23, normalStrength: 0.14,
environmentIntensity: 0.7, condition: 1, imperfectionSeed: 81251,
})
const edge = new THREE.MeshPhysicalMaterial()
const object = createCardGeometry(material, options.backMaterial, edge)
const front = object.getObjectByName('CARD_FRONT')!
this.root.add(object)
return { object, material, edge, front }
})
this.restart()
}
get view() {
const number = `${this.index + 1} / ${this.cards.length}`
const spec = packContents[this.index]
const views: Record<PackState, { action: string; status: string; hint: string }> = {
sealed: { action: 'Tear open', status: 'Sealed · three cards', hint: 'Drag the gold top seam to the right · or use Tear open' },
opening: {
action: this.motion && !this.paused ? 'Opening…' : 'Finish opening',
status: this.openingProgress < 0.38
? `Tearing seal · ${Math.round(this.tearProgress * 100)}%`
: this.openingProgress < 0.52 ? 'Strip curling away'
: this.openingProgress < 0.62 ? 'Spreading the foil mouth'
: this.openingProgress < 0.87 ? 'Sliding out three cards' : 'Settling the face-down stack',
hint: this.openingProgress < 0.38
? 'Pull right along the seam · release to hold · Finish opening resumes'
: 'Touch or wheel pauses motion · Skip finishes this step',
},
stackReady: { action: 'Lift card', status: `Card ${number} · face-down stack`, hint: 'Tap or Lift card · identities stay hidden until the flip' },
lifting: { action: 'Lifting…', status: `Card ${number} · lifting face down`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
lifted: { action: 'Reveal card', status: `Card ${number} · ready to turn`, hint: 'Tap or Reveal card to flip the front into view' },
revealing: { action: 'Revealing…', status: `Card ${number} · turning over`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
inspecting: {
action: this.index === this.cards.length - 1 ? 'Finish pack' : 'Next card',
status: `${number} · ${spec.fixture} · ${spec.substrate} · ${spec.finish}`,
hint: 'Drag to rotate · pinch / wheel to zoom · tap to continue',
},
advancing: { action: 'Advancing…', status: 'Setting the revealed card aside', hint: 'Touch or wheel pauses motion · Skip finishes this step' },
complete: { action: 'Pack complete', status: 'Complete · all three cards inspected', hint: 'Restart to replay the same authored order' },
}
return this.paused && this.motion
? { ...views[this.state], action: 'Continue', hint: 'Motion paused in place · Continue resumes · Skip finishes this step' }
: views[this.state]
}
get tearProgress() { return Math.min(1, this.openingProgress / 0.38) }
syncLighting(source: THREE.ShaderMaterial) {
for (const { material } of this.cards) {
for (const name of ['studioEnvironment', 'environmentIntensity', 'lightMode', 'lightIntensity']) {
material.uniforms[name].value = source.uniforms[name].value
}
material.uniforms.lightColor.value.copy(source.uniforms.lightColor.value)
}
}
setActive(active: boolean) {
if (!active) this.pause(performance.now())
this.clearPointers()
this.active = active
this.root.visible = active
this.options.onChange()
}
resize(width: number, height: number) {
if (width <= 0 || height <= 0) return
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
const tangent = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2))
this.fitDistance = Math.max(9.6, 3.95 / (2 * tangent * this.camera.aspect))
this.updateCamera()
}
private updateCamera() {
this.camera.position.set(0, this.cameraTargetY, this.fitDistance * this.zoom + this.cameraRetreat)
this.camera.lookAt(0, this.cameraTargetY, 0)
}
private stackPose(offset: number) {
return pose(offset * 0.045, -offset * 0.045, -0.08 - offset * stackDepthSpacing, 0, Math.PI, 0, 0.88)
}
private reservePose(offset: number) {
const target = this.stackPose(offset)
target.position.x += 1.05
target.position.y -= 0.4
return target
}
restart() {
this.clearPointers()
this.motion = undefined
this.paused = false
this.index = 0
this.revealed.clear()
this.completed = false
this.zoom = 1
this.cameraRetreat = 0
this.cameraTargetY = 0
this.openingProgress = 0
this.touch.set(0, 0, 0)
this.touchTarget = 0
this.lastTick = undefined
this.updateCamera()
this.wrapper.root.visible = true
setPose(this.wrapper.root, pose(0, 0, 0))
this.wrapper.deform(0, 0, 0, this.touch)
this.cards.forEach((card, index) => {
card.object.visible = true
card.front.visible = false
applyEdgeMaterialControls(card.edge, { substrate: 'Paper', condition: 1 })
setPose(card.object, this.stackPose(index))
})
this.setState('sealed')
}
private shapeWrapper() {
const stripExit = (this.camera.position.z + 4)
* Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect + 2
this.wrapper.deform(this.tearProgress,
THREE.MathUtils.smoothstep(this.openingProgress, 0.38, 0.52),
THREE.MathUtils.smoothstep(this.openingProgress, 0.5, 0.62), this.touch, stripExit)
}
private setOpeningProgress(value: number) {
this.openingProgress = value
this.shapeWrapper()
const smooth = THREE.MathUtils.smoothstep
const settling = smooth(value, 0.87, 1)
const framing = smooth(value, 0.52, 0.76) * (1 - settling)
this.cameraRetreat = 8 * framing
this.cameraTargetY = 1.6 * framing
this.updateCamera()
this.cards.forEach((card, index) => {
const target = this.stackPose(index)
// All cards clear the mouth before the pouch moves aside. Depth spacing never changes.
target.position.y += 4.4 * smooth(value, 0.62 + index * 0.018, 0.79 + index * 0.018) * (1 - settling)
setPose(card.object, target)
})
const aside = smooth(value, 0.83, 0.87)
const halfViewWidth = this.camera.position.z
* Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect
this.wrapper.root.position.x = -(halfViewWidth + 2) * aside
this.wrapper.root.visible = value < 0.87
this.options.onChange()
}
private open() {
const start = this.openingProgress
this.begin('opening', [], 3200 * (1 - start), () => {
this.wrapper.root.visible = false
this.setState('stackReady')
}, (value) => this.setOpeningProgress(THREE.MathUtils.lerp(start, 1, value)))
}
private setState(state: PackState) {
this.state = state
const revealPhase = state === 'revealing' || state === 'inspecting' || state === 'advancing'
this.cards.forEach((card, index) => {
card.object.visible = state !== 'complete' && index >= this.index
card.front.visible = revealPhase && index === this.index
})
if (state === 'inspecting' && !this.revealed.has(this.index)) {
this.revealed.add(this.index)
applyEdgeMaterialControls(this.cards[this.index].edge, { ...packContents[this.index], condition: 1 })
this.options.canvas.dispatchEvent(new CustomEvent('packreveal', {
detail: { index: this.index + 1, ...packContents[this.index] },
}))
}
if (state === 'complete' && !this.completed) {
this.completed = true
this.options.canvas.dispatchEvent(new CustomEvent('packcomplete', { detail: { count: this.revealed.size } }))
}
this.options.onChange()
}
private begin(
state: PackState, tracks: Track[], duration: number, finish: () => void,
progress?: (value: number) => void,
) {
this.paused = false
this.motion = {
tracks, duration: this.reducedMotion.matches ? 70 : duration,
startedAt: performance.now(), elapsed: 0, finish, progress,
}
this.setState(state)
}
primary() {
if (!this.active) return
this.clearPointers()
if (this.motion) {
if (this.paused) {
this.paused = false
this.motion.startedAt = performance.now()
this.options.onChange()
}
return
}
const card = this.cards[this.index]
switch (this.state) {
case 'sealed':
case 'opening':
this.open()
break
case 'stackReady': {
// This is the stack card itself, not a second lift actor or a cloned placeholder.
const tracks = [track(card.object, pose(0, 0.14, inspectionDepth, 0, Math.PI))]
for (let next = this.index + 1; next < this.cards.length; next++) {
tracks.push(track(this.cards[next].object, this.reservePose(next - this.index - 1)))
}
const startRetreat = this.cameraRetreat
this.begin('lifting', tracks, 620, () => this.setState('lifted'), (value) => {
// Camera framing follows the lift; updateCamera remains the sole camera transform writer.
this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, inspectionCameraRetreat, value)
this.updateCamera()
})
break
}
case 'lifted':
this.begin('revealing', [track(card.object, pose(0, 0.14, inspectionDepth, -0.06, Math.PI * 2 - 0.12))], 820,
() => this.setState('inspecting'))
break
case 'inspecting': {
const halfViewWidth = (this.camera.position.z - inspectionDepth)
* Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect
const exitX = -Math.max(4.8, halfViewWidth + cardSweepRadius + 0.3)
// Stay on the safe plane even when advancing from an arbitrary manually rotated pose.
const tracks = [track(card.object, pose(exitX, 0.3, inspectionDepth, 0.1, -0.5, -0.18, 0.85))]
for (let next = this.index + 1; next < this.cards.length; next++) {
tracks.push(track(this.cards[next].object, this.stackPose(next - this.index - 1)))
}
const startRetreat = this.cameraRetreat
this.begin('advancing', tracks, 650, () => {
if (this.index === this.cards.length - 1) {
this.setState('complete')
} else {
this.index++
this.setState('stackReady')
}
}, (value) => {
this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, 0, value)
this.updateCamera()
})
break
}
default:
break
}
}
skip() {
if (!this.active) return
this.clearPointers()
if (!this.motion) this.primary()
if (this.motion) this.finishMotion()
}
private finishMotion() {
const motion = this.motion
if (!motion) return
for (const item of motion.tracks) setPose(item.object, item.to)
motion.progress?.(1)
this.motion = undefined
this.paused = false
motion.finish()
}
tick(now: number) {
const delta = Math.min(0.05, Math.max(0, (now - (this.lastTick ?? now)) / 1000))
this.lastTick = now
if (!this.active) return
if (Math.abs(this.touch.z - this.touchTarget) > 0.001) {
this.touch.z = this.reducedMotion.matches ? this.touchTarget
: THREE.MathUtils.lerp(this.touch.z, this.touchTarget, 1 - Math.exp(-delta * 18))
this.shapeWrapper()
}
const motion = this.motion
if (!motion || this.paused) return
const progress = THREE.MathUtils.clamp((motion.elapsed + now - motion.startedAt) / motion.duration, 0, 1)
if (progress >= 1) {
this.finishMotion()
return
}
const eased = progress * progress * (3 - 2 * progress)
for (const { object, from, to } of motion.tracks) {
object.position.lerpVectors(from.position, to.position, eased)
object.quaternion.slerpQuaternions(from.quaternion, to.quaternion, eased)
object.scale.lerpVectors(from.scale, to.scale, eased)
}
motion.progress?.(eased)
}
private pause(now: number) {
const interrupted = this.motion !== undefined
this.tick(now)
if (this.motion && !this.paused) {
this.motion.elapsed += now - this.motion.startedAt
this.paused = true
this.options.onChange()
}
return interrupted
}
pointerDown(event: PointerEvent) {
if (!this.active || event.button !== 0) return
const interrupted = this.pause(performance.now())
this.pointers.set(event.pointerId, new THREE.Vector2(event.clientX, event.clientY))
this.options.canvas.setPointerCapture(event.pointerId)
this.tap = this.pointers.size === 1 && !interrupted
? { id: event.pointerId, x: event.clientX, y: event.clientY, at: performance.now() }
: undefined
this.pinchDistance = this.getPinchDistance()
if (this.pointers.size > 1) {
this.endSeamDrag()
this.touchTarget = 0
return
}
if (this.state === 'sealed' || (this.state === 'opening' && this.openingProgress < 0.38)) {
const local = this.wrapperPoint(event.clientX, event.clientY)
if (local && Math.abs(local.x) < 1.8 && local.y > -2.24 && local.y < 2.5) {
this.touch.set(local.x, Math.min(local.y, 1.7), this.touch.z)
this.touchTarget = 1
}
const seam = this.seamScreenBounds()
if (event.clientX >= seam.left - 18 && event.clientX <= seam.right + 18
&& Math.abs(event.clientY - seam.y) <= Math.max(22, seam.height)) {
this.motion = undefined
this.seamDrag = { id: event.pointerId, x: event.clientX,
progress: this.openingProgress, travel: Math.max(80, (seam.right - seam.left) * 0.75) }
this.paused = false
}
}
}
pointerMove(event: PointerEvent) {
const pointer = this.pointers.get(event.pointerId)
if (!pointer) return
const dx = event.clientX - pointer.x
const dy = event.clientY - pointer.y
pointer.set(event.clientX, event.clientY)
if (this.tap && Math.hypot(event.clientX - this.tap.x, event.clientY - this.tap.y) > 6) {
this.tap = undefined
}
if (this.pointers.size >= 2) {
const distance = this.getPinchDistance()
if (distance && this.pinchDistance) this.setZoom(this.zoom * this.pinchDistance / distance)
this.pinchDistance = distance
} else if (this.seamDrag?.id === event.pointerId) {
const drag = this.seamDrag
const progress = THREE.MathUtils.clamp(drag.progress
+ Math.max(0, event.clientX - drag.x) / drag.travel * 0.38, this.openingProgress, 0.38)
if (progress > this.openingProgress && !this.tap) {
if (this.state === 'sealed') this.setState('opening')
this.setOpeningProgress(progress)
}
} else if (this.state === 'inspecting') {
const object = this.cards[this.index].object
object.rotation.y += dx * 0.008
object.rotation.x = THREE.MathUtils.clamp(object.rotation.x + dy * 0.008, -1.25, 1.25)
} else if (this.touchTarget) {
const local = this.wrapperPoint(event.clientX, event.clientY)
if (local) {
this.touch.x = THREE.MathUtils.clamp(local.x, -1.68, 1.68)
this.touch.y = THREE.MathUtils.clamp(local.y, -2.24, 1.7)
this.shapeWrapper()
}
}
}
pointerUp(event: PointerEvent, cancelled = false) {
if (!this.pointers.has(event.pointerId)) return
const tearing = this.seamDrag?.id === event.pointerId
if (tearing) this.endSeamDrag()
this.touchTarget = 0
const tap = this.tap
this.tap = undefined
this.pointers.delete(event.pointerId)
this.pinchDistance = this.getPinchDistance()
if (this.options.canvas.hasPointerCapture(event.pointerId)) {
this.options.canvas.releasePointerCapture(event.pointerId)
}
if (tearing && !cancelled && this.tearProgress >= 1) {
this.primary()
return
}
if (!cancelled && tap?.id === event.pointerId && performance.now() - tap.at < 600
&& Math.hypot(event.clientX - tap.x, event.clientY - tap.y) <= 6) this.primary()
}
wheel(event: WheelEvent) {
event.preventDefault()
this.pause(performance.now())
this.clearPointers()
const pixels = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 240 : 1)
this.setZoom(this.zoom * Math.exp(pixels * 0.001))
}
private setZoom(zoom: number) {
this.zoom = THREE.MathUtils.clamp(zoom, 0.62, 1.45)
this.updateCamera()
}
private getPinchDistance() {
const pointers = [...this.pointers.values()]
return pointers.length < 2 ? undefined : pointers[0].distanceTo(pointers[1])
}
private clearPointers() {
this.endSeamDrag()
this.touchTarget = 0
this.tap = undefined
const ids = [...this.pointers.keys()]
this.pointers.clear()
for (const id of ids) {
if (this.options.canvas.hasPointerCapture(id)) this.options.canvas.releasePointerCapture(id)
}
this.pinchDistance = undefined
}
private endSeamDrag() {
if (!this.seamDrag) return
this.seamDrag = undefined
if (this.state === 'opening' && !this.motion) this.paused = true
this.options.onChange()
}
private seamScreenBounds() {
const rect = this.options.canvas.getBoundingClientRect()
this.root.updateMatrixWorld(true)
this.camera.updateMatrixWorld(true)
const screen = (x: number, y: number) => {
const point = this.wrapper.root.localToWorld(new THREE.Vector3(x, y, -0.09)).project(this.camera)
return { x: rect.left + (point.x + 1) * rect.width / 2,
y: rect.top + (1 - point.y) * rect.height / 2 }
}
const left = screen(-1.68, 2.12)
const right = screen(1.68, 2.12)
return { left: left.x, right: right.x, y: left.y, height: Math.abs(screen(0, 1.97).y - screen(0, 2.28).y) }
}
private wrapperPoint(x: number, y: number) {
const rect = this.options.canvas.getBoundingClientRect()
this.camera.updateMatrixWorld(true)
this.root.updateMatrixWorld(true)
const ray = new THREE.Raycaster()
ray.setFromCamera(new THREE.Vector2((x - rect.left) / rect.width * 2 - 1,
1 - (y - rect.top) / rect.height * 2), this.camera)
const point = ray.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 0, 1), -0.28), new THREE.Vector3())
return point ? this.wrapper.root.worldToLocal(point) : undefined
}
}

496
card-harness/src/style.css Normal file
View File

@@ -0,0 +1,496 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f3efe5;
background: #080a0e;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
button,
select {
color: inherit;
font: inherit;
}
.app-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: 100%;
height: 100%;
background:
radial-gradient(circle at 50% -20%, rgba(108, 127, 160, 0.18), transparent 45%),
#080a0e;
}
.topbar {
z-index: 3;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 82px;
padding: 14px 20px;
border-bottom: 1px solid rgba(227, 215, 181, 0.16);
background: rgba(11, 14, 20, 0.92);
backdrop-filter: blur(18px);
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.controls-toggle {
display: none;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.controls-toggle-icon {
width: 12px;
color: #d6b860;
font-size: 16px;
line-height: 1;
}
.eyebrow {
margin: 0 0 3px;
color: #b8a979;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(20px, 2.2vw, 30px);
font-weight: 500;
letter-spacing: 0.01em;
}
.toolbar {
display: flex;
flex: 1 1 720px;
flex-wrap: wrap;
min-width: 0;
align-items: flex-end;
justify-content: flex-end;
gap: 8px;
}
.toolbar label {
display: grid;
flex: 1 1 110px;
min-width: 100px;
gap: 4px;
color: #9da3ad;
font-size: 11px;
letter-spacing: 0.04em;
}
select,
button {
height: 34px;
border: 1px solid rgba(227, 215, 181, 0.2);
border-radius: 7px;
background: #171b23;
outline: none;
}
select {
width: 100%;
min-width: 0;
padding: 0 28px 0 10px;
}
button {
padding: 0 13px;
cursor: pointer;
}
button:hover,
select:hover {
border-color: rgba(218, 186, 102, 0.62);
background: #202630;
}
button:focus-visible,
select:focus-visible {
outline: 2px solid #d6b860;
outline-offset: 2px;
}
.viewport-shell {
position: relative;
min-height: 0;
}
#scene {
display: block;
width: 100%;
height: 100%;
cursor: grab;
touch-action: none;
}
#scene:active {
cursor: grabbing;
}
#scene.camera-target {
cursor: move;
}
.status-panel,
.hint,
.loading {
position: absolute;
pointer-events: none;
border: 1px solid rgba(227, 215, 181, 0.14);
background: rgba(10, 13, 18, 0.76);
backdrop-filter: blur(12px);
}
.status-panel {
top: 16px;
left: 16px;
display: grid;
gap: 4px;
max-width: calc(100% - 32px);
padding: 9px 12px;
border-radius: 8px;
color: #d8d2c2;
font-size: 12px;
}
#performance {
color: #89909b;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 10px;
}
.hint {
bottom: 16px;
left: 50%;
padding: 8px 12px;
border-radius: 999px;
color: #aeb4bc;
font-size: 12px;
transform: translateX(-50%);
white-space: nowrap;
}
.loading {
z-index: 4;
top: 50%;
left: 50%;
padding: 12px 16px;
border-radius: 9px;
transform: translate(-50%, -50%);
}
footer {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 8px 16px;
border-top: 1px solid rgba(227, 215, 181, 0.12);
color: #777f8a;
background: #0b0e14;
font-size: 11px;
}
footer a {
color: #bba96e;
}
.lil-gui.root {
--background-color: rgba(16, 20, 27, 0.94);
--widget-color: #252c37;
--hover-color: #303946;
--focus-color: #3b4655;
--text-color: #ede8dc;
--number-color: #e1c56e;
top: 98px;
right: 16px;
}
.pack-controls[hidden] {
display: none;
}
.pack-controls {
position: absolute;
bottom: max(12px, env(safe-area-inset-bottom));
left: 50%;
display: grid;
gap: 8px;
width: min(440px, calc(100% - 24px));
padding: 10px 12px;
border: 1px solid rgba(227, 215, 181, 0.22);
border-radius: 12px;
background: rgba(10, 13, 18, 0.9);
backdrop-filter: blur(12px);
transform: translateX(-50%);
text-align: center;
}
#pack-status {
color: #d8d2c2;
font-size: 12px;
}
#pack-tear {
appearance: none;
width: 100%;
height: 3px;
border: 0;
border-radius: 2px;
overflow: hidden;
background: #293039;
accent-color: #bba36a;
}
#pack-tear::-webkit-progress-bar {
background: #293039;
}
#pack-tear::-webkit-progress-value {
background: #bba36a;
}
#pack-tear::-moz-progress-bar {
background: #bba36a;
}
.pack-actions {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
}
.pack-actions button {
min-height: 44px;
height: auto;
padding: 8px 12px;
}
#pack-primary {
border-color: #9b8750;
background: #393222;
color: #f5e5b4;
font-weight: 600;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.pack-mode .toolbar > :not(:first-child) {
display: none;
}
.pack-mode .toolbar {
flex: 0 1 180px;
}
.pack-mode .hint {
bottom: 118px;
max-width: calc(100% - 24px);
text-align: center;
white-space: normal;
}
.pack-mode .status-panel {
max-width: min(440px, calc(100% - 32px));
}
@media (max-width: 1120px) {
.topbar {
align-items: flex-start;
}
.toolbar {
flex-basis: 100%;
justify-content: flex-start;
}
}
@media (max-width: 880px) {
.topbar {
gap: 10px;
padding: 10px 12px;
}
.title-row {
width: 100%;
}
.controls-toggle {
display: inline-flex;
}
.toolbar {
width: 100%;
justify-content: flex-start;
}
.topbar.controls-collapsed {
min-height: 0;
}
.topbar.controls-collapsed .toolbar {
display: none;
}
.toolbar label {
flex-basis: calc(33.333% - 6px);
}
.toolbar button {
flex: 1 1 120px;
}
.hint {
bottom: 10px;
max-width: calc(100% - 24px);
overflow: hidden;
text-overflow: ellipsis;
}
.lil-gui.root {
top: 210px;
}
.compact-controls .lil-gui.root {
top: 74px;
}
}
@media (max-width: 560px) {
.topbar {
gap: 8px;
}
.toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.toolbar label {
width: 100%;
min-width: 0;
}
.toolbar button {
width: 100%;
min-width: 0;
padding-inline: 8px;
}
.lil-gui.root {
top: 288px;
}
.pack-mode .toolbar {
display: flex;
}
.pack-mode .status-panel {
top: 8px;
left: 12px;
padding: 6px 9px;
font-size: 10px;
}
.pack-mode #performance {
font-size: 9px;
}
.pack-mode .hint {
bottom: 114px;
padding: 5px 9px;
font-size: 10px;
}
.pack-controls {
gap: 6px;
padding: 8px;
}
#pack-status {
font-size: 11px;
}
.pack-actions {
gap: 6px;
}
.pack-actions button {
padding-inline: 10px;
font-size: 12px;
}
}
@media (max-height: 540px) and (min-aspect-ratio: 4/3) {
.pack-controls {
right: 12px;
left: auto;
width: min(280px, 32vw);
padding: 8px;
transform: none;
}
.pack-actions {
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.pack-actions button {
padding-inline: 8px;
font-size: 12px;
}
#pack-primary {
grid-row: 1;
grid-column: 1 / -1;
}
#pack-status {
font-size: 11px;
}
.pack-mode .hint {
right: 12px;
bottom: 170px;
left: auto;
width: min(280px, 32vw);
font-size: 10px;
transform: none;
}
.pack-mode .status-panel {
max-width: 28vw;
font-size: 10px;
}
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"lib": ["ES2023", "DOM"],
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}