diff --git a/apps/web/src/components/viewers/CardViewer.tsx b/apps/web/src/components/viewers/CardViewer.tsx index 6fe1529..9cbd754 100644 --- a/apps/web/src/components/viewers/CardViewer.tsx +++ b/apps/web/src/components/viewers/CardViewer.tsx @@ -28,7 +28,12 @@ import type { ViewerProps } from '../viewers'; export default function CardViewer({ object, fill }: ViewerProps) { return ( - + {/* Cards are authored standing (face in XY, thickness along Z); lay them + flat on the ground so the face points up like a card on a table, which + is also what the contact shadow needs to project. */} + + + ); } diff --git a/apps/web/src/components/viewers/DeckViewer.tsx b/apps/web/src/components/viewers/DeckViewer.tsx index 9a4817d..d587e00 100644 --- a/apps/web/src/components/viewers/DeckViewer.tsx +++ b/apps/web/src/components/viewers/DeckViewer.tsx @@ -63,6 +63,7 @@ export default function DeckViewer({ object, fill }: ViewerProps) { fit={false} autoRotate={false} fill={fill} + shadow={false} overlay={ hasCards ? ( @@ -90,7 +91,11 @@ export default function DeckViewer({ object, fill }: ViewerProps) { /> ) : ( - + // No contained cards: show the deck object itself, laid flat so the + // shared scene's contact shadow has a surface to project onto. + + + )} ); @@ -187,10 +192,69 @@ function CarouselCard({ return ( + {/* A soft radial shadow under this card; it inherits the card group's + position/rotation/scale, so it tweens with the card. Card meshes are + laid flat (face up), so the plane sits just below the card's bottom. */} + ); } +// A soft elliptical drop shadow under a single carousel card. The card mesh is +// ~2 world-units tall (centered), so its bottom sits at local y = -1; we put a +// radial-gradient plane just below it. I can't use `ContactShadows` here +// because the card is a thin vertical panel — a top-down capture would only +// catch a line. Instead we fake a soft ambient contact shadow with a gradient. +const CARD_HALF_HEIGHT = 1; +const SHADOW_SCALE = 2.6; + +function CardShadow() { + // A soft radial gradient, dark at the center fading to transparent — reads as + // a soft blob of shadow rather than a hard cast shadow. + const texture = useMemo(makeSoftShadowTexture, []); + return ( + + + + + ); +} + +/** Build a radial-gradient texture: dark center fading out to transparent. */ +function makeSoftShadowTexture(): THREE.Texture { + const size = 256; + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return new THREE.Texture(); + const g = ctx.createRadialGradient( + size / 2, + size / 2, + 0, + size / 2, + size / 2, + size / 2, + ); + const stops: [number, number][] = [ + [0, 0.55], + [0.45, 0.35], + [0.75, 0.12], + [1, 0], + ]; + for (const [t, a] of stops) { + g.addColorStop(t, `rgba(0, 0, 0, ${a})`); + } + ctx.fillStyle = g; + ctx.fillRect(0, 0, size, size); + const texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + return texture; +} + interface Slots { x: number; z: number; diff --git a/apps/web/src/components/viewers/Scene.tsx b/apps/web/src/components/viewers/Scene.tsx index a22482f..e3efd56 100644 --- a/apps/web/src/components/viewers/Scene.tsx +++ b/apps/web/src/components/viewers/Scene.tsx @@ -1,9 +1,10 @@ -import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react'; +import { Suspense, useEffect, useRef, useState, type RefObject, type ReactNode } from 'react'; import { Icon } from '@iconify/react'; -import { Canvas } from '@react-three/fiber'; +import { Canvas, useFrame } from '@react-three/fiber'; import { Bounds, ContactShadows, Environment, Lightformer, OrbitControls, useProgress } from '@react-three/drei'; import { BrightnessContrast, EffectComposer, ToneMapping, Vignette } from '@react-three/postprocessing'; import { ToneMappingMode } from 'postprocessing'; +import * as THREE from 'three'; /** * Shared 3D scene wrapper for object viewers. Provides a consistent camera, @@ -39,6 +40,8 @@ export default function Scene({ overlay, shadowScale = 22, maxPolarAngle = Math.PI, + shadowBlur = 0.012, + shadow = true, }: { children: ReactNode; autoRotate?: boolean; @@ -52,11 +55,18 @@ export default function Scene({ overlay?: ReactNode; /** Contact shadow plane size in world units; defaults to a generous 22. */ shadowScale?: number; + /** Contact shadow blur as a fraction of the plane size; a larger value is softer. */ + shadowBlur?: number; + /** Whether to render a scene-level contact shadow under the content (default true). */ + shadow?: boolean; /** Max camera polar angle in radians; defaults to unrestricted (π). */ maxPolarAngle?: number; }) { const containerRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); + // Points at the wrapped children so the shadow can measure the real content + // bounding box (independent of drei's `Bounds` camera fit). + const contentRef = useRef(null); useEffect(() => { const onChange = () => setIsFullscreen(!!document.fullscreenElement); @@ -100,19 +110,15 @@ export default function Scene({ - - {children} - + {shadow && ( + + )} - + + + {children} + + ); } + +/** + * A contact shadow sized and placed to the scene's actual content bounds. + * + * A fixed-size shadow plane baked for a 2-unit token is invisible to a whole + * card or model ten times larger, so the plane is (re)sized to the bounding + * box of whatever is wrapped in the scene, measured each frame from a ref. + * Keeping it scene-level (instead of per mesh) keeps it world-horizontal at + * the table and costs one shadow render per frame even when there are many + * meshes, which per-mesh planes would multiply. + * + * The plane sits on the very bottom (y minimum) of the content, centered on + * its footprint, and spans a bit wider so the shadow edge doesn't fall inside + * the object. `far` covers the full content height so tall objects aren't + * clipped. Falls back to `shadowScale` while the bounds are still empty. + */ +function AdaptiveContactShadow({ + shadowScale, + shadowBlur, + contentRef, +}: { + shadowScale: number; + shadowBlur: number; + contentRef: RefObject; +}) { + // Current computed shadow config, kept in a ref so it's stable between frames + // and only recomputed when the content bounds actually change. + const state = useRef({ scale: 0, posX: 0, posY: 0, posZ: 0, far: 0 }); + // Scratch box, reused each frame to avoid allocations. + const box = useRef(new THREE.Box3()).current; + + useFrame(() => { + const content = contentRef.current; + if (!content) return; + content.updateWorldMatrix(true, true); + box.setFromObject(content); + if (box.isEmpty()) return; + + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + // The shadow plane rests on the very bottom of the content, centered on its + // footprint. `far` covers the full content height (plus margin) so the + // capture doesn't clip tall objects; `scale` covers the footprint. + const scale = Math.max(size.x, size.z) * SHADOW_PAD; + const posY = box.min.y; + const far = size.y + SHADOW_HEIGHT_MARGIN; + const s = state.current; + if ( + scale !== s.scale || + center.x !== s.posX || + posY !== s.posY || + center.z !== s.posZ || + far !== s.far + ) { + Object.assign(s, { scale, posX: center.x, posY, posZ: center.z, far }); + } + }); + + const s = state.current; + const scale = s.scale || shadowScale; + // Sit just below the lowest point of the content so it rests on it without + // intersecting; the plane uses a (fixed) world-horizontal orientation. + const position = [s.posX, s.posY - 0.01, s.posZ] as [number, number, number]; + + return ( + + ); +} + +/** How far the shadow plane extends past the content footprint, as a multiple. */ +const SHADOW_PAD = 1.1; +/** Extra capture depth beyond the content height, so the falloff edge isn't clipped. */ +const SHADOW_HEIGHT_MARGIN = 0.5; diff --git a/apps/web/src/components/viewers/TileViewer.tsx b/apps/web/src/components/viewers/TileViewer.tsx index 5d921da..3d99e8d 100644 --- a/apps/web/src/components/viewers/TileViewer.tsx +++ b/apps/web/src/components/viewers/TileViewer.tsx @@ -14,7 +14,10 @@ import type { ViewerProps } from '../viewers'; export default function TileViewer({ object, fill }: ViewerProps) { return ( - + {/* Lay the tile flat on the ground (the mesh is authored standing). */} + + + ); } diff --git a/apps/web/src/components/viewers/TokenViewer.tsx b/apps/web/src/components/viewers/TokenViewer.tsx index e58de47..6ef691e 100644 --- a/apps/web/src/components/viewers/TokenViewer.tsx +++ b/apps/web/src/components/viewers/TokenViewer.tsx @@ -12,7 +12,10 @@ import type { ViewerProps } from '../viewers'; export default function TokenViewer({ object, fill }: ViewerProps) { return ( - + {/* Lay the token flat on the ground (see CardViewer). */} + + + ); }