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). */}
+
+
+
);
}