Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/constants/code/Components/lanyardCode.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,27 @@ export const lanyard = {

<Lanyard position={[0, 0, 20]} gravity={[0, -40, 0]} />

// Pass custom images for the card's front/back faces and/or the lanyard band.
// frontImage and backImage render independently; imageFit keeps aspect ratio.
// lanyardWidth widens the band so a custom band image has more room.
<Lanyard
position={[0, 0, 20]}
gravity={[0, -40, 0]}
frontImage="/my-front.png"
backImage="/my-back.png"
imageFit="cover"
lanyardImage="/my-band.png"
lanyardWidth={1}
/>

/* IMPORTANT INFO BELOW

1. You MUST have the card.glb and lanyard.png files in your project and import them
- these can be downloaded from the repo's files, under src/assets/lanyard

2. You can edit your card.glb file in this online .glb editor and change the texture:
- https://modelviewer.dev/editor/
- alternatively, pass the "frontImage" / "backImage" props to swap the card's faces at runtime

4. The png file is the texture for the lanyard's band and can be edited in any image editor

Expand Down
103 changes: 96 additions & 7 deletions src/content/Components/Lanyard/Lanyard.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable react/no-unknown-property */
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Canvas, extend, useFrame } from '@react-three/fiber';
import { useGLTF, useTexture, Environment, Lightformer } from '@react-three/drei';
import { BallCollider, CuboidCollider, Physics, RigidBody, useRopeJoint, useSphericalJoint } from '@react-three/rapier';
Expand All @@ -15,7 +15,29 @@ import './Lanyard.css';

extend({ MeshLineGeometry, MeshLineMaterial });

export default function Lanyard({ position = [0, 0, 30], gravity = [0, -40, 0], fov = 20, transparent = true }) {
// 1x1 transparent pixel — lets useTexture be called unconditionally when a
// front/back image isn't supplied.
const BLANK_PIXEL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

// The card model's front face is UV-mapped to the LEFT half of the texture
// atlas and the back face to the RIGHT half (measured from card.glb). Each
// custom image is composited into its own half so the two faces render
// independently, aspect-preserving (no stretching).
const FRONT_UV_RECT = { x: 0, y: 0, w: 0.5, h: 0.755 };
const BACK_UV_RECT = { x: 0.5, y: 0, w: 0.5, h: 0.757 };

export default function Lanyard({
position = [0, 0, 30],
gravity = [0, -40, 0],
fov = 20,
transparent = true,
frontImage = null,
backImage = null,
imageFit = 'cover',
lanyardImage = null,
lanyardWidth = 1
}) {
const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768);

useEffect(() => {
Expand All @@ -34,7 +56,14 @@ export default function Lanyard({ position = [0, 0, 30], gravity = [0, -40, 0],
>
<ambientLight intensity={Math.PI} />
<Physics gravity={gravity} timeStep={isMobile ? 1 / 30 : 1 / 60}>
<Band isMobile={isMobile} />
<Band
isMobile={isMobile}
frontImage={frontImage}
backImage={backImage}
imageFit={imageFit}
lanyardImage={lanyardImage}
lanyardWidth={lanyardWidth}
/>
</Physics>
<Environment blur={0.75}>
<Lightformer
Expand Down Expand Up @@ -70,7 +99,16 @@ export default function Lanyard({ position = [0, 0, 30], gravity = [0, -40, 0],
</div>
);
}
function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }) {
function Band({
maxSpeed = 50,
minSpeed = 0,
isMobile = false,
frontImage = null,
backImage = null,
imageFit = 'cover',
lanyardImage = null,
lanyardWidth = 1
}) {
const band = useRef(),
fixed = useRef(),
j1 = useRef(),
Expand All @@ -83,7 +121,58 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }) {
dir = new THREE.Vector3();
const segmentProps = { type: 'dynamic', canSleep: true, colliders: false, angularDamping: 4, linearDamping: 4 };
const { nodes, materials } = useGLTF(cardGLB);
const texture = useTexture(lanyard);
const texture = useTexture(lanyardImage || lanyard);
// useTexture must be called unconditionally; use a blank pixel when an image
// isn't supplied for a given face, then skip compositing it below.
const frontTex = useTexture(frontImage || BLANK_PIXEL);
const backTex = useTexture(backImage || BLANK_PIXEL);

// Composite the front/back images into the card's texture atlas (front = left
// half, back = right half). Each image is drawn aspect-preserving (no stretch).
const cardMap = useMemo(() => {
const baseMap = materials.base.map;
if (!frontImage && !backImage) return baseMap;

const baseImg = baseMap.image;
const W = baseImg.width;
const H = baseImg.height;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
if (!ctx) return baseMap;
// Keep the original baked atlas for the card edges and any untouched face.
ctx.drawImage(baseImg, 0, 0, W, H);

const drawFitted = (img, rect) => {
const rx = rect.x * W;
const ry = rect.y * H;
const rw = rect.w * W;
const rh = rect.h * H;
const pick = imageFit === 'contain' ? Math.min : Math.max;
const scale = pick(rw / img.width, rh / img.height);
const dw = img.width * scale;
const dh = img.height * scale;
const dx = rx + (rw - dw) / 2;
const dy = ry + (rh - dh) / 2;
ctx.save();
ctx.beginPath();
ctx.rect(rx, ry, rw, rh);
ctx.clip();
ctx.drawImage(img, dx, dy, dw, dh);
ctx.restore();
};

if (frontImage && frontTex.image) drawFitted(frontTex.image, FRONT_UV_RECT);
if (backImage && backTex.image) drawFitted(backTex.image, BACK_UV_RECT);

const composite = new THREE.CanvasTexture(canvas);
composite.colorSpace = THREE.SRGBColorSpace;
composite.flipY = baseMap.flipY;
composite.anisotropy = 16;
composite.needsUpdate = true;
return composite;
}, [frontImage, backImage, imageFit, frontTex, backTex, materials.base.map]);
const [curve] = useState(
() =>
new THREE.CatmullRomCurve3([new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()])
Expand Down Expand Up @@ -165,7 +254,7 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }) {
>
<mesh geometry={nodes.card.geometry}>
<meshPhysicalMaterial
map={materials.base.map}
map={cardMap}
map-anisotropy={16}
clearcoat={isMobile ? 0 : 1}
clearcoatRoughness={0.15}
Expand All @@ -187,7 +276,7 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }) {
useMap
map={texture}
repeat={[-4, 1]}
lineWidth={1}
lineWidth={lanyardWidth}
/>
</mesh>
</>
Expand Down
30 changes: 30 additions & 0 deletions src/demo/Components/LanyardDemo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,36 @@ const LanyardDemo = () => {
type: 'boolean',
default: 'true',
description: 'Enables a transparent background for the canvas.'
},
{
name: 'frontImage',
type: 'string',
default: 'null',
description: "Custom image URL for the card's front face. Falls back to the model's built-in texture when not set."
},
{
name: 'backImage',
type: 'string',
default: 'null',
description: "Custom image URL for the card's back face, rendered independently from the front."
},
{
name: 'imageFit',
type: '"cover" | "contain"',
default: '"cover"',
description: "How a custom front/back image fits its face. Both preserve aspect ratio; 'cover' fills and crops, 'contain' letterboxes."
},
{
name: 'lanyardImage',
type: 'string',
default: 'null',
description: "Custom image URL for the lanyard band's repeating texture. Falls back to the default band texture when not set."
},
{
name: 'lanyardWidth',
type: 'number',
default: '1',
description: 'Width of the lanyard band (meshline lineWidth). Increase it to give a custom band image more room and reduce stretching.'
}
],
[]
Expand Down
103 changes: 96 additions & 7 deletions src/tailwind/Components/Lanyard/Lanyard.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable react/no-unknown-property */
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Canvas, extend, useFrame } from '@react-three/fiber';
import { useGLTF, useTexture, Environment, Lightformer } from '@react-three/drei';
import { BallCollider, CuboidCollider, Physics, RigidBody, useRopeJoint, useSphericalJoint } from '@react-three/rapier';
Expand All @@ -14,7 +14,29 @@ import * as THREE from 'three';

extend({ MeshLineGeometry, MeshLineMaterial });

export default function Lanyard({ position = [0, 0, 30], gravity = [0, -40, 0], fov = 20, transparent = true }) {
// 1x1 transparent pixel — lets useTexture be called unconditionally when a
// front/back image isn't supplied.
const BLANK_PIXEL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

// The card model's front face is UV-mapped to the LEFT half of the texture
// atlas and the back face to the RIGHT half (measured from card.glb). Each
// custom image is composited into its own half so the two faces render
// independently, aspect-preserving (no stretching).
const FRONT_UV_RECT = { x: 0, y: 0, w: 0.5, h: 0.755 };
const BACK_UV_RECT = { x: 0.5, y: 0, w: 0.5, h: 0.757 };

export default function Lanyard({
position = [0, 0, 30],
gravity = [0, -40, 0],
fov = 20,
transparent = true,
frontImage = null,
backImage = null,
imageFit = 'cover',
lanyardImage = null,
lanyardWidth = 1
}) {
const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768);

useEffect(() => {
Expand All @@ -33,7 +55,14 @@ export default function Lanyard({ position = [0, 0, 30], gravity = [0, -40, 0],
>
<ambientLight intensity={Math.PI} />
<Physics gravity={gravity} timeStep={isMobile ? 1 / 30 : 1 / 60}>
<Band isMobile={isMobile} />
<Band
isMobile={isMobile}
frontImage={frontImage}
backImage={backImage}
imageFit={imageFit}
lanyardImage={lanyardImage}
lanyardWidth={lanyardWidth}
/>
</Physics>
<Environment blur={0.75}>
<Lightformer
Expand Down Expand Up @@ -69,7 +98,16 @@ export default function Lanyard({ position = [0, 0, 30], gravity = [0, -40, 0],
</div>
);
}
function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }) {
function Band({
maxSpeed = 50,
minSpeed = 0,
isMobile = false,
frontImage = null,
backImage = null,
imageFit = 'cover',
lanyardImage = null,
lanyardWidth = 1
}) {
const band = useRef(),
fixed = useRef(),
j1 = useRef(),
Expand All @@ -82,7 +120,58 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }) {
dir = new THREE.Vector3();
const segmentProps = { type: 'dynamic', canSleep: true, colliders: false, angularDamping: 4, linearDamping: 4 };
const { nodes, materials } = useGLTF(cardGLB);
const texture = useTexture(lanyard);
const texture = useTexture(lanyardImage || lanyard);
// useTexture must be called unconditionally; use a blank pixel when an image
// isn't supplied for a given face, then skip compositing it below.
const frontTex = useTexture(frontImage || BLANK_PIXEL);
const backTex = useTexture(backImage || BLANK_PIXEL);

// Composite the front/back images into the card's texture atlas (front = left
// half, back = right half). Each image is drawn aspect-preserving (no stretch).
const cardMap = useMemo(() => {
const baseMap = materials.base.map;
if (!frontImage && !backImage) return baseMap;

const baseImg = baseMap.image;
const W = baseImg.width;
const H = baseImg.height;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
if (!ctx) return baseMap;
// Keep the original baked atlas for the card edges and any untouched face.
ctx.drawImage(baseImg, 0, 0, W, H);

const drawFitted = (img, rect) => {
const rx = rect.x * W;
const ry = rect.y * H;
const rw = rect.w * W;
const rh = rect.h * H;
const pick = imageFit === 'contain' ? Math.min : Math.max;
const scale = pick(rw / img.width, rh / img.height);
const dw = img.width * scale;
const dh = img.height * scale;
const dx = rx + (rw - dw) / 2;
const dy = ry + (rh - dh) / 2;
ctx.save();
ctx.beginPath();
ctx.rect(rx, ry, rw, rh);
ctx.clip();
ctx.drawImage(img, dx, dy, dw, dh);
ctx.restore();
};

if (frontImage && frontTex.image) drawFitted(frontTex.image, FRONT_UV_RECT);
if (backImage && backTex.image) drawFitted(backTex.image, BACK_UV_RECT);

const composite = new THREE.CanvasTexture(canvas);
composite.colorSpace = THREE.SRGBColorSpace;
composite.flipY = baseMap.flipY;
composite.anisotropy = 16;
composite.needsUpdate = true;
return composite;
}, [frontImage, backImage, imageFit, frontTex, backTex, materials.base.map]);
const [curve] = useState(
() =>
new THREE.CatmullRomCurve3([new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()])
Expand Down Expand Up @@ -164,7 +253,7 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }) {
>
<mesh geometry={nodes.card.geometry}>
<meshPhysicalMaterial
map={materials.base.map}
map={cardMap}
map-anisotropy={16}
clearcoat={isMobile ? 0 : 1}
clearcoatRoughness={0.15}
Expand All @@ -186,7 +275,7 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }) {
useMap
map={texture}
repeat={[-4, 1]}
lineWidth={1}
lineWidth={lanyardWidth}
/>
</mesh>
</>
Expand Down
Loading