diff --git a/src/constants/code/Components/lanyardCode.js b/src/constants/code/Components/lanyardCode.js index fc3a54cf9..f5208f940 100644 --- a/src/constants/code/Components/lanyardCode.js +++ b/src/constants/code/Components/lanyardCode.js @@ -10,6 +10,19 @@ export const lanyard = { +// 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. + + /* IMPORTANT INFO BELOW 1. You MUST have the card.glb and lanyard.png files in your project and import them @@ -17,6 +30,7 @@ export const 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 diff --git a/src/content/Components/Lanyard/Lanyard.jsx b/src/content/Components/Lanyard/Lanyard.jsx index 4c010ede4..86dae4652 100644 --- a/src/content/Components/Lanyard/Lanyard.jsx +++ b/src/content/Components/Lanyard/Lanyard.jsx @@ -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'; @@ -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(() => { @@ -34,7 +56,14 @@ export default function Lanyard({ position = [0, 0, 30], gravity = [0, -40, 0], > - + ); } -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(), @@ -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()]) @@ -165,7 +254,7 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }) { > diff --git a/src/demo/Components/LanyardDemo.jsx b/src/demo/Components/LanyardDemo.jsx index 98c45fab2..41d5e7590 100644 --- a/src/demo/Components/LanyardDemo.jsx +++ b/src/demo/Components/LanyardDemo.jsx @@ -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.' } ], [] diff --git a/src/tailwind/Components/Lanyard/Lanyard.jsx b/src/tailwind/Components/Lanyard/Lanyard.jsx index fdb62506b..76e23af8a 100644 --- a/src/tailwind/Components/Lanyard/Lanyard.jsx +++ b/src/tailwind/Components/Lanyard/Lanyard.jsx @@ -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'; @@ -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(() => { @@ -33,7 +55,14 @@ export default function Lanyard({ position = [0, 0, 30], gravity = [0, -40, 0], > - + ); } -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(), @@ -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()]) @@ -164,7 +253,7 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }) { > diff --git a/src/ts-default/Components/Lanyard/Lanyard.tsx b/src/ts-default/Components/Lanyard/Lanyard.tsx index 661c015b8..41235a7e2 100644 --- a/src/ts-default/Components/Lanyard/Lanyard.tsx +++ b/src/ts-default/Components/Lanyard/Lanyard.tsx @@ -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 { @@ -23,18 +23,40 @@ import './Lanyard.css'; extend({ MeshLineGeometry, MeshLineMaterial }); +// 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 }; + interface LanyardProps { position?: [number, number, number]; gravity?: [number, number, number]; fov?: number; transparent?: boolean; + frontImage?: string | null; + backImage?: string | null; + imageFit?: 'cover' | 'contain'; + lanyardImage?: string | null; + lanyardWidth?: number; } export default function Lanyard({ position = [0, 0, 30], gravity = [0, -40, 0], fov = 20, - transparent = true + transparent = true, + frontImage = null, + backImage = null, + imageFit = 'cover', + lanyardImage = null, + lanyardWidth = 1 }: LanyardProps) { const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768); @@ -54,7 +76,14 @@ export default function Lanyard({ > - + (null); const fixed = useRef(null); @@ -120,7 +163,58 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }: BandProps) { }; const { nodes, materials } = useGLTF(cardGLB) as any; - 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 as THREE.Texture; + if (!frontImage && !backImage) return baseMap; + + const baseImg = baseMap.image as any; + 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: any, rect: typeof FRONT_UV_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()]) @@ -216,7 +310,7 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }: BandProps) { > diff --git a/src/ts-tailwind/Components/Lanyard/Lanyard.tsx b/src/ts-tailwind/Components/Lanyard/Lanyard.tsx index a0760da06..bf2a8fdc6 100644 --- a/src/ts-tailwind/Components/Lanyard/Lanyard.tsx +++ b/src/ts-tailwind/Components/Lanyard/Lanyard.tsx @@ -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 { @@ -21,18 +21,40 @@ import lanyard from './lanyard.png'; extend({ MeshLineGeometry, MeshLineMaterial }); +// 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 }; + interface LanyardProps { position?: [number, number, number]; gravity?: [number, number, number]; fov?: number; transparent?: boolean; + frontImage?: string | null; + backImage?: string | null; + imageFit?: 'cover' | 'contain'; + lanyardImage?: string | null; + lanyardWidth?: number; } export default function Lanyard({ position = [0, 0, 30], gravity = [0, -40, 0], fov = 20, - transparent = true + transparent = true, + frontImage = null, + backImage = null, + imageFit = 'cover', + lanyardImage = null, + lanyardWidth = 1 }: LanyardProps) { const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768); @@ -52,7 +74,14 @@ export default function Lanyard({ > - + (null); const fixed = useRef(null); @@ -118,7 +161,58 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }: BandProps) { }; const { nodes, materials } = useGLTF(cardGLB) as any; - 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 as THREE.Texture; + if (!frontImage && !backImage) return baseMap; + + const baseImg = baseMap.image as any; + 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: any, rect: typeof FRONT_UV_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()]) @@ -214,7 +308,7 @@ function Band({ maxSpeed = 50, minSpeed = 0, isMobile = false }: BandProps) { >