Fix project structure
This commit is contained in:
BIN
card-harness/src/assets/hero.png
Normal file
BIN
card-harness/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
card-harness/src/assets/typescript.svg
Normal file
1
card-harness/src/assets/typescript.svg
Normal 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 |
1
card-harness/src/assets/vite.svg
Normal file
1
card-harness/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
93
card-harness/src/cardGeometry.ts
Normal file
93
card-harness/src/cardGeometry.ts
Normal 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
|
||||
}
|
||||
493
card-harness/src/cardMaterial.ts
Normal file
493
card-harness/src/cardMaterial.ts
Normal 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
|
||||
}
|
||||
146
card-harness/src/foilWrapper.ts
Normal file
146
card-harness/src/foilWrapper.ts
Normal 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
1476
card-harness/src/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
582
card-harness/src/packOpening.ts
Normal file
582
card-harness/src/packOpening.ts
Normal 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
496
card-harness/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user