wow
This commit is contained in:
commit
794dce869c
16 changed files with 5430 additions and 0 deletions
378
frontend/pass.jsx
Normal file
378
frontend/pass.jsx
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import * as THREE from "three";
|
||||
import { Canvas, extend, useFrame, useThree } from "@react-three/fiber";
|
||||
import { Environment, Lightformer, useGLTF } from "@react-three/drei";
|
||||
import {
|
||||
BallCollider,
|
||||
CuboidCollider,
|
||||
Physics,
|
||||
RigidBody,
|
||||
useRopeJoint,
|
||||
useSphericalJoint,
|
||||
} from "@react-three/rapier";
|
||||
import { MeshLineGeometry, MeshLineMaterial } from "meshline";
|
||||
import "./pass.css";
|
||||
|
||||
extend({ MeshLineGeometry, MeshLineMaterial });
|
||||
|
||||
const MODEL_URL = "/static/assets/tag.glb";
|
||||
|
||||
useGLTF.preload(MODEL_URL);
|
||||
|
||||
function fitText(ctx, text, maxWidth, initialSize) {
|
||||
let size = initialSize;
|
||||
while (size > 30) {
|
||||
ctx.font = `600 ${size}px Arial, sans-serif`;
|
||||
if (ctx.measureText(text).width <= maxWidth) break;
|
||||
size -= 2;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
function createBadgeTexture(pass) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 912;
|
||||
canvas.height = 1288;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const stateColors = {
|
||||
VALID: "#b9f8d3",
|
||||
"NOT ACTIVE": "#e8c986",
|
||||
EXPIRED: "#df9298",
|
||||
};
|
||||
const accent = stateColors[pass.status] || "#ffffff";
|
||||
|
||||
ctx.fillStyle = "#050505";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const sheen = ctx.createLinearGradient(0, 0, 912, 970);
|
||||
sheen.addColorStop(0, "#ffffff09");
|
||||
sheen.addColorStop(0.45, "transparent");
|
||||
sheen.addColorStop(1, "#ffffff05");
|
||||
ctx.fillStyle = sheen;
|
||||
ctx.fillRect(0, 0, 912, 970);
|
||||
|
||||
ctx.strokeStyle = "#ffffff24";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(30, 30, 852, 925);
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(58, 88);
|
||||
ctx.lineTo(79, 52);
|
||||
ctx.lineTo(100, 88);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.font = "700 29px Arial, sans-serif";
|
||||
ctx.fillText("PASS", 122, 79);
|
||||
ctx.fillStyle = "#8f8f8f";
|
||||
ctx.fillText("PORT", 204, 79);
|
||||
ctx.font = "500 13px monospace";
|
||||
ctx.fillText("DIGITAL ACCESS SYSTEM", 122, 105);
|
||||
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillStyle = accent;
|
||||
ctx.beginPath();
|
||||
ctx.arc(700, 77, 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.font = "600 16px monospace";
|
||||
ctx.fillText(pass.status, 852, 83);
|
||||
ctx.textAlign = "left";
|
||||
|
||||
ctx.strokeStyle = "#ffffff2d";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(58, 145);
|
||||
ctx.lineTo(854, 145);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.075;
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 8;
|
||||
ctx.font = "800 350px Arial, sans-serif";
|
||||
ctx.strokeText("P", 590, 465);
|
||||
for (let row = 0; row < 9; row += 1) {
|
||||
for (let column = 0; column < 9; column += 1) {
|
||||
const radius = Math.max(1.5, 5 - (row + column) * 0.18);
|
||||
ctx.beginPath();
|
||||
ctx.arc(620 + column * 25, 220 + row * 25, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.fillStyle = "#929292";
|
||||
ctx.font = "500 14px monospace";
|
||||
ctx.fillText("TEMPORARY / GUEST", 58, 208);
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`NO. ${pass.credentialId}`, 854, 208);
|
||||
ctx.textAlign = "left";
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "700 86px Arial, sans-serif";
|
||||
ctx.fillText("ACCESS", 52, 322);
|
||||
ctx.font = "300 86px Arial, sans-serif";
|
||||
ctx.fillText("PASS", 52, 402);
|
||||
|
||||
ctx.fillStyle = "#858585";
|
||||
ctx.font = "500 14px monospace";
|
||||
ctx.fillText("PASS HOLDER", 58, 493);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
const holderSize = fitText(ctx, pass.holder, 796, 66);
|
||||
ctx.font = `600 ${holderSize}px Arial, sans-serif`;
|
||||
ctx.fillText(pass.holder, 55, 560);
|
||||
|
||||
ctx.strokeStyle = "#ffffff2d";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(58, 610);
|
||||
ctx.lineTo(854, 610);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "#858585";
|
||||
ctx.font = "500 14px monospace";
|
||||
ctx.fillText("AUTHORIZED LOCATION", 58, 657);
|
||||
ctx.fillText("VALID FROM", 520, 657);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
const locationSize = fitText(ctx, pass.location, 400, 32);
|
||||
ctx.font = `500 ${locationSize}px Arial, sans-serif`;
|
||||
ctx.fillText(pass.location, 58, 699);
|
||||
ctx.font = "500 20px monospace";
|
||||
ctx.fillText(pass.validFrom, 520, 697);
|
||||
|
||||
ctx.fillStyle = "#858585";
|
||||
ctx.font = "500 14px monospace";
|
||||
ctx.fillText("VALID UNTIL", 520, 757);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "500 20px monospace";
|
||||
ctx.fillText(pass.validUntil, 520, 797);
|
||||
|
||||
ctx.strokeStyle = "#ffffff2d";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(58, 840);
|
||||
ctx.lineTo(854, 840);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = accent;
|
||||
ctx.beginPath();
|
||||
ctx.arc(70, 893, 13, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#050505";
|
||||
ctx.font = "700 14px Arial, sans-serif";
|
||||
ctx.fillText("✓", 65, 898);
|
||||
ctx.fillStyle = "#9a9a9a";
|
||||
ctx.font = "500 13px monospace";
|
||||
ctx.fillText("DIGITALLY SIGNED ACCESS CREDENTIAL", 100, 890);
|
||||
ctx.fillText("ISSUER / PASSPORT", 100, 915);
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(803, 876, 51, 51);
|
||||
ctx.fillStyle = "#050505";
|
||||
ctx.font = "700 26px Arial, sans-serif";
|
||||
ctx.fillText("P", 838, 910);
|
||||
ctx.textAlign = "left";
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.anisotropy = 16;
|
||||
texture.flipY = false;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function Band({ pass, maxSpeed = 50, minSpeed = 10 }) {
|
||||
const band = useRef();
|
||||
const fixed = useRef();
|
||||
const joint1 = useRef();
|
||||
const joint2 = useRef();
|
||||
const joint3 = useRef();
|
||||
const card = useRef();
|
||||
const { nodes, materials } = useGLTF(MODEL_URL);
|
||||
const bandTexture = useTexture(BAND_URL);
|
||||
const badgeTexture = useMemo(() => createBadgeTexture(pass), [pass]);
|
||||
const { width, height } = useThree((state) => state.size);
|
||||
const curve = useMemo(
|
||||
() => new THREE.CatmullRomCurve3([
|
||||
new THREE.Vector3(),
|
||||
new THREE.Vector3(),
|
||||
new THREE.Vector3(),
|
||||
new THREE.Vector3(),
|
||||
]),
|
||||
[],
|
||||
);
|
||||
const vec = useMemo(() => new THREE.Vector3(), []);
|
||||
const angularVelocity = useMemo(() => new THREE.Vector3(), []);
|
||||
const rotation = useMemo(() => new THREE.Vector3(), []);
|
||||
const direction = useMemo(() => new THREE.Vector3(), []);
|
||||
const [dragged, setDragged] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const segmentProps = {
|
||||
canSleep: true,
|
||||
colliders: false,
|
||||
angularDamping: 2,
|
||||
linearDamping: 2,
|
||||
};
|
||||
|
||||
useRopeJoint(fixed, joint1, [[0, 0, 0], [0, 0, 0], 1]);
|
||||
useRopeJoint(joint1, joint2, [[0, 0, 0], [0, 0, 0], 1]);
|
||||
useRopeJoint(joint2, joint3, [[0, 0, 0], [0, 0, 0], 1]);
|
||||
useSphericalJoint(joint3, card, [[0, 0, 0], [0, 1.45, 0]]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.cursor = hovered ? (dragged ? "grabbing" : "grab") : "auto";
|
||||
return () => { document.body.style.cursor = "auto"; };
|
||||
}, [hovered, dragged]);
|
||||
|
||||
useEffect(() => () => badgeTexture.dispose(), [badgeTexture]);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
if (dragged) {
|
||||
vec.set(state.pointer.x, state.pointer.y, 0.5).unproject(state.camera);
|
||||
direction.copy(vec).sub(state.camera.position).normalize();
|
||||
vec.add(direction.multiplyScalar(state.camera.position.length()));
|
||||
[card, joint1, joint2, joint3, fixed].forEach((ref) => ref.current?.wakeUp());
|
||||
card.current?.setNextKinematicTranslation({
|
||||
x: vec.x - dragged.x,
|
||||
y: vec.y - dragged.y,
|
||||
z: vec.z - dragged.z,
|
||||
});
|
||||
}
|
||||
|
||||
if (fixed.current) {
|
||||
[joint1, joint2].forEach((ref) => {
|
||||
if (!ref.current.lerped) {
|
||||
ref.current.lerped = new THREE.Vector3().copy(ref.current.translation());
|
||||
}
|
||||
const distance = ref.current.lerped.distanceTo(ref.current.translation());
|
||||
const clampedDistance = Math.max(0.1, Math.min(1, distance));
|
||||
ref.current.lerped.lerp(
|
||||
ref.current.translation(),
|
||||
delta * (minSpeed + clampedDistance * (maxSpeed - minSpeed)),
|
||||
);
|
||||
});
|
||||
|
||||
curve.points[0].copy(joint3.current.translation());
|
||||
curve.points[1].copy(joint2.current.lerped);
|
||||
curve.points[2].copy(joint1.current.lerped);
|
||||
curve.points[3].copy(fixed.current.translation());
|
||||
band.current.geometry.setPoints(curve.getPoints(32));
|
||||
|
||||
angularVelocity.copy(card.current.angvel());
|
||||
rotation.copy(card.current.rotation());
|
||||
card.current.setAngvel({
|
||||
x: angularVelocity.x,
|
||||
y: angularVelocity.y - rotation.y * 0.25,
|
||||
z: angularVelocity.z,
|
||||
}, false);
|
||||
}
|
||||
});
|
||||
|
||||
curve.curveType = "chordal";
|
||||
bandTexture.wrapS = bandTexture.wrapT = THREE.RepeatWrapping;
|
||||
|
||||
return (
|
||||
<>
|
||||
<group position={[0, 4, 0]}>
|
||||
<RigidBody ref={fixed} {...segmentProps} type="fixed" />
|
||||
<RigidBody ref={joint1} position={[0.5, 0, 0]} {...segmentProps}>
|
||||
<BallCollider args={[0.1]} />
|
||||
</RigidBody>
|
||||
<RigidBody ref={joint2} position={[1, 0, 0]} {...segmentProps}>
|
||||
<BallCollider args={[0.1]} />
|
||||
</RigidBody>
|
||||
<RigidBody ref={joint3} position={[1.5, 0, 0]} {...segmentProps}>
|
||||
<BallCollider args={[0.1]} />
|
||||
</RigidBody>
|
||||
<RigidBody
|
||||
ref={card}
|
||||
position={[2, 0, 0]}
|
||||
{...segmentProps}
|
||||
type={dragged ? "kinematicPosition" : "dynamic"}
|
||||
>
|
||||
<CuboidCollider args={[0.8, 1.125, 0.01]} />
|
||||
<group
|
||||
scale={2.25}
|
||||
position={[0, -1.2, -0.05]}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
onPointerUp={(event) => {
|
||||
event.target.releasePointerCapture(event.pointerId);
|
||||
setDragged(false);
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.target.setPointerCapture(event.pointerId);
|
||||
setDragged(event.point.clone().sub(vec.copy(card.current.translation())));
|
||||
}}
|
||||
>
|
||||
<mesh geometry={nodes.card.geometry}>
|
||||
<meshPhysicalMaterial
|
||||
map={badgeTexture}
|
||||
map-anisotropy={16}
|
||||
clearcoat={1}
|
||||
clearcoatRoughness={0.15}
|
||||
iridescence={1}
|
||||
iridescenceIOR={1}
|
||||
iridescenceThicknessRange={[0, 2400]}
|
||||
metalness={0.5}
|
||||
roughness={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh geometry={nodes.clip.geometry} material={materials.metal} material-roughness={0.3} />
|
||||
<mesh geometry={nodes.clamp.geometry} material={materials.metal} />
|
||||
</group>
|
||||
</RigidBody>
|
||||
</group>
|
||||
<mesh ref={band}>
|
||||
<meshLineGeometry />
|
||||
<meshLineMaterial
|
||||
color="white"
|
||||
depthTest={false}
|
||||
resolution={[width, height]}
|
||||
useMap
|
||||
map={bandTexture}
|
||||
repeat={[-3, 1]}
|
||||
lineWidth={1}
|
||||
/>
|
||||
</mesh>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PassScene({ pass }) {
|
||||
return (
|
||||
<Canvas camera={{ position: [0, 0, 13], fov: 25 }} dpr={[1, 2]}>
|
||||
<ambientLight intensity={Math.PI} />
|
||||
<Physics interpolate gravity={[0, -40, 0]} timeStep={1 / 60}>
|
||||
<Band pass={pass} />
|
||||
</Physics>
|
||||
<Environment background blur={0.75}>
|
||||
<color attach="background" args={["black"]} />
|
||||
<Lightformer intensity={2} color="white" position={[0, -1, 5]} rotation={[0, 0, Math.PI / 3]} scale={[100, 0.1, 1]} />
|
||||
<Lightformer intensity={3} color="white" position={[-1, -1, 1]} rotation={[0, 0, Math.PI / 3]} scale={[100, 0.1, 1]} />
|
||||
<Lightformer intensity={3} color="white" position={[1, 1, 1]} rotation={[0, 0, Math.PI / 3]} scale={[100, 0.1, 1]} />
|
||||
<Lightformer intensity={10} color="white" position={[-10, 0, 14]} rotation={[0, Math.PI / 2, Math.PI / 3]} scale={[100, 10, 1]} />
|
||||
</Environment>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const element = document.getElementById("pass-root");
|
||||
const pass = useMemo(() => ({
|
||||
holder: element.dataset.holder,
|
||||
location: element.dataset.location,
|
||||
status: element.dataset.status,
|
||||
validFrom: element.dataset.validFrom,
|
||||
validUntil: element.dataset.validUntil,
|
||||
credentialId: element.dataset.credentialId,
|
||||
}), [element]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div className="loading">Loading credential</div>}>
|
||||
<PassScene pass={pass} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("pass-root")).render(<App />);
|
||||
Loading…
Reference in a new issue