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

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;
}
}