feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)

Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:04 +02:00
1 parent ab45d6e24e
commit 1a57de8573
123 files changed
+7639 -355

No files matched your search

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { cameraBasis, cameraPosition, fitDistance, focusFrame, overlayMargin } from "./camera-fit";
import { cameraBasis, cameraPosition, fitDistance, focusFrame, insetView } from "./camera-fit";
describe("cameraBasis", () => {
it("looks from 20° above and 30° to the side, with orthonormal axes", () => {
@@ -35,12 +35,26 @@ describe("fitDistance", () => {
});
});
describe("overlayMargin", () => {
it("leaves more room on a short card than on a tall one, capped at 2", () => {
expect(overlayMargin(1000, 150)).toBeCloseTo(1000 / 850, 9);
expect(overlayMargin(460, 150)).toBeCloseTo(460 / 310, 9);
expect(overlayMargin(200, 150)).toBe(2);
expect(overlayMargin(0)).toBe(1);
describe("insetView", () => {
it("fits into the band between the overlays and centres the model in it", () => {
const view = insetView(460, { top: 96, bottom: 56 });
// 12 px gap next to each overlay.
expect(view.fraction).toBeCloseTo((460 - 108 - 68) / 460, 9);
// More covered at the top: the view moves the model down (negative offset).
expect(view.offsetY).toBeCloseTo(-(108 - 68) / 2, 9);
});
it("is the whole card without overlays and never less than 40 %", () => {
expect(insetView(800, { top: 0, bottom: 0 })).toEqual({ fraction: 1, offsetY: -0 });
expect(insetView(200, { top: 120, bottom: 60 }).fraction).toBeCloseTo(0.4, 9);
expect(insetView(0, { top: 10, bottom: 10 })).toEqual({ fraction: 1, offsetY: 0 });
});
});
describe("fitDistance with a vertical fraction", () => {
it("moves the camera back when only part of the height is free", () => {
const size: [number, number, number] = [5.25, 9, 4.3];
expect(fitDistance(size, 0.8, 1, undefined, undefined, 0.6)).toBeGreaterThan(fitDistance(size, 0.8, 1));
});
});
@@ -7,11 +7,11 @@ export const CAMERA_ELEVATION_DEG = 20;
export const CAMERA_AZIMUTH_DEG = 30;
export const CAMERA_FOV_DEG = 30;
/** Space around the model, as a factor on its projected size. */
export const FIT_MARGIN = 1.05;
export const FIT_MARGIN = 1.08;
/** Focus mode: the frame is pulled toward the ghost hive (focusFrame), so less extra room. */
export const FOCUS_FIT_MARGIN = 0.95;
/** Card pixels taken by the frosted overlays (title or focus bar on top, buttons below). */
export const OVERLAY_RESERVE_PX = 150;
export const FOCUS_FIT_MARGIN = 1.0;
/** Breathing room between the model and the overlays, in pixels. */
export const OVERLAY_GAP_PX = 12;
/** Share of the way from the focused part toward the ghost hive the view centre moves (x, y, z). */
export const FOCUS_PULL: Vec3 = [0.3, 0.42, 0.3];
@@ -45,13 +45,34 @@ export function cameraBasis(
return { back, right, up };
}
export interface OverlayInsets {
/** Pixels covered at the top (title panel or focus bar). */
top: number;
/** Pixels covered at the bottom (button bar). */
bottom: number;
}
export interface InsetView {
/** Share of the card height left free for the model (0.4 … 1). */
fraction: number;
/** Vertical view offset in pixels that centres the model in the free band (camera.setViewOffset). */
offsetY: number;
}
/**
* Extra margin so the model fits the part of the card the overlays leave free:
* about 1.2 on a tall desktop card, about 1.5 on a phone. Never more than 2.
* The band of the card the frosted overlays leave free: the camera fits the
* model into it and shifts the view so the model sits in its middle (so the
* facts panel never covers the roof on a phone).
*/
export function overlayMargin(heightPx: number, reservePx: number = OVERLAY_RESERVE_PX): number {
if (!(heightPx > 0)) return 1;
return heightPx / Math.max(heightPx - reservePx, heightPx / 2);
export function insetView(heightPx: number, insets: OverlayInsets): InsetView {
if (!(heightPx > 0)) return { fraction: 1, offsetY: 0 };
const top = Math.max(0, insets.top) + (insets.top > 0 ? OVERLAY_GAP_PX : 0);
const bottom = Math.max(0, insets.bottom) + (insets.bottom > 0 ? OVERLAY_GAP_PX : 0);
const free = Math.max(heightPx - top - bottom, heightPx * 0.4);
const scale = free / Math.max(heightPx - top - bottom, 1e-6);
// When the overlays leave less than 40 %, shrink both proportionally.
const shift = scale > 1 ? (top - bottom) / (2 * scale) : (top - bottom) / 2;
return { fraction: free / heightPx, offsetY: -shift };
}
/** Half extent of an axis-aligned box of `size` projected on a unit axis. */
@@ -66,13 +87,15 @@ export function fitDistance(
margin: number = FIT_MARGIN,
fovDeg: number = CAMERA_FOV_DEG,
basis: CameraBasis = cameraBasis(),
verticalFraction = 1,
): number {
const tanHalf = Math.tan(toRad(fovDeg) / 2);
const safeAspect = aspect > 0 && Number.isFinite(aspect) ? aspect : 1;
const halfWidth = halfExtent(size, basis.right);
const halfHeight = halfExtent(size, basis.up);
const halfDepth = halfExtent(size, basis.back);
const distance = Math.max(halfHeight / tanHalf, halfWidth / (tanHalf * safeAspect)) * margin;
const fraction = verticalFraction > 0 ? verticalFraction : 1;
const distance = Math.max(halfHeight / (tanHalf * fraction), halfWidth / (tanHalf * safeAspect)) * margin;
// The near face of the box sits half its depth closer to the camera.
return Math.max(distance + halfDepth, 1);
}
@@ -3,9 +3,10 @@
import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { HiveLayout, Vec3 } from "@/lib/hive/layout";
import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, overlayMargin } from "./camera-fit";
import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, insetView } from "./camera-fit";
import type { OverlayInsets } from "./camera-fit";
import { useSceneKit } from "./scene-kit";
import { placeCamera } from "./scene-mutations";
import { offsetView, placeCamera, setFocusPoint } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { restBounds, stepToward } from "./use-hive-motion";
import type { FocusInfo } from "./use-hive-motion";
@@ -19,10 +20,12 @@ export interface CameraRigProps {
focus: FocusInfo | null;
/** Receives the current look-at point every frame; the model rig turns around it. */
onPivot: (center: Vec3) => void;
/** Card pixels covered by the frosted overlays. */
insets: OverlayInsets;
}
export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
const { motion } = useSceneKit();
export function CameraRig({ layout, focus, onPivot, insets }: CameraRigProps) {
const { motion, signals } = useSceneKit();
const camera = useThree((state) => state.camera);
const width = useThree((state) => state.size.width);
const height = useThree((state) => state.size.height);
@@ -30,14 +33,15 @@ export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
const goal = useMemo(() => {
const aspect = height > 0 ? width / height : 1;
const room = overlayMargin(height);
const band = insetView(height, insets);
const bounds = restBounds(layout);
const fit = (size: Vec3, margin: number) => fitDistance(size, aspect, margin, undefined, undefined, band.fraction);
if (focus) {
const frame = focusFrame(focus.center, focus.size, bounds.center);
return { center: frame.center, distance: fitDistance(frame.size, aspect, FOCUS_FIT_MARGIN * room) };
return { center: frame.center, distance: fit(frame.size, FOCUS_FIT_MARGIN), offsetY: band.offsetY };
}
return { center: bounds.center, distance: fitDistance(bounds.size, aspect, FIT_MARGIN * room) };
}, [focus, layout, width, height]);
return { center: bounds.center, distance: fit(bounds.size, FIT_MARGIN), offsetY: band.offsetY };
}, [focus, layout, width, height, insets]);
const current = useRef<{ center: Vec3; distance: number } | null>(null);
useEffect(() => {
@@ -60,7 +64,9 @@ export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
}
const { center, distance } = current.current;
onPivot(center);
setFocusPoint(signals, focus ? focus.center : null);
placeCamera(camera, center, distance);
offsetView(camera, width, height, goal.offsetY);
const settled =
distance === goal.distance && center.every((value, axis) => value === goal.center[axis]);
if (!settled) invalidate();
@@ -2,10 +2,11 @@
import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { BoxGeometry, CylinderGeometry, MeshStandardMaterial } from "three";
import type { Mesh } from "three";
import { BoxGeometry, CanvasTexture, CylinderGeometry, MeshStandardMaterial, PlaneGeometry, RepeatWrapping, SRGBColorSpace } from "three";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
import type { Color, Mesh } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { createLiquidMaterial } from "./materials";
import { createCausticMaterial, createLiquidMaterial } from "./materials";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion";
import { noRaycast, useSceneKit } from "./scene-kit";
@@ -19,6 +20,8 @@ const WOBBLE = 0.02;
export type FillKind = "honey" | "acid" | "fondant";
type FillColor = Color;
export interface FillVolumeProps {
/** Centre and size of the full space (local to the part). */
center: Vec3;
@@ -35,31 +38,35 @@ export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVol
const mesh = useRef<Mesh>(null);
const shown = useRef(ratio);
const geometry = useMemo(
() => (shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12)),
[shape],
);
const geometry = useMemo(() => {
if (kind === "fondant") return new RoundedBoxGeometry(1, 1, 1, 3, 0.1);
return shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12);
}, [shape, kind]);
const surface = useMemo(() => (kind === "fondant" ? fondantTexture() : null), [kind]);
const material = useMemo(() => {
if (kind === "fondant") {
// Feed paste: a soft sugary block with a fine grain (map and relief from one noise texture).
return new MeshStandardMaterial({
color: theme.fondant,
roughness: 0.55,
map: surface,
bumpMap: surface,
bumpScale: 2.5,
roughness: 0.62,
emissive: theme.fondant,
emissiveIntensity: 0.18,
transparent: true,
opacity: 0.94,
emissiveIntensity: 0.16,
});
}
return kind === "acid"
? createLiquidMaterial({ color: theme.acid, deep: theme.rim, glow: 1.05, alpha: 0.55 })
: createLiquidMaterial({ color: theme.honey, deep: theme.honeyDeep, glow: 1.2, alpha: 0.78 });
}, [kind, theme]);
}, [kind, theme, surface]);
useEffect(
() => () => {
geometry.dispose();
material.dispose();
surface?.dispose();
},
[geometry, material],
[geometry, material, surface],
);
useEffect(() => {
invalidate();
@@ -79,5 +86,72 @@ export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVol
if (shown.current !== ratio) invalidate();
});
return <mesh ref={mesh} geometry={geometry} material={material} raycast={noRaycast} renderOrder={3} />;
return (
<>
<mesh ref={mesh} geometry={geometry} material={material} raycast={noRaycast} renderOrder={3} />
{kind !== "fondant" && shape === "box" && ratio > 0 ? (
<Caustics center={center} size={size} color={kind === "acid" ? theme.acid : theme.honey} />
) : null}
</>
);
}
/** Fine sugary grain for the feed paste; null without a 2D canvas (tests). */
function fondantTexture(): CanvasTexture | null {
if (typeof document === "undefined") return null;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) return null;
let seed = 97;
const next = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
context.fillStyle = "#d8d8d8";
context.fillRect(0, 0, 128, 128);
for (let dot = 0; dot < 900; dot += 1) {
const light = Math.round(180 + next() * 75);
context.fillStyle = `rgb(${light}, ${light}, ${light})`;
context.beginPath();
context.arc(next() * 128, next() * 128, 0.6 + next() * 1.6, 0, Math.PI * 2);
context.fill();
}
const texture = new CanvasTexture(canvas);
texture.colorSpace = SRGBColorSpace;
texture.wrapS = RepeatWrapping;
texture.wrapT = RepeatWrapping;
texture.repeat.set(2, 2);
return texture;
}
/** Soft caustics on the floor of the space a liquid fills, moving only at `full`. */
function Caustics({ center, size, color }: { center: Vec3; size: Vec3; color: FillColor }) {
const { motion } = useSceneKit();
const geometry = useMemo(() => {
const plane = new PlaneGeometry(size[0] * 0.98, size[2] * 0.98);
plane.rotateX(-Math.PI / 2);
return plane;
}, [size]);
const material = useMemo(() => createCausticMaterial(color, [size[0] * 3, size[2] * 3]), [color, size]);
useEffect(
() => () => {
geometry.dispose();
material.dispose();
},
[geometry, material],
);
useFrame((state) => {
if (motion === "full") setUniform(material, "uTime", state.clock.elapsedTime);
});
return (
<mesh
geometry={geometry}
material={material}
position={[center[0], center[1] - size[1] / 2 + 0.006, center[2]]}
raycast={noRaycast}
renderOrder={2}
/>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { MeshStandardMaterial } from "three";
import { Color, MeshStandardMaterial } from "three";
import type { Group, ShaderMaterial } from "three";
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
import { HoloLabel } from "./hive-labels";
@@ -9,7 +9,9 @@ import { createEdgeMaterial, createGlassMaterial } from "./materials";
import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry";
import type { PartShapeSpec } from "./part-geometry";
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
import { setMaterialOpacity, setUniform } from "./scene-mutations";
import { setMaterialOpacity, setUniform, setUniformMix } from "./scene-mutations";
import { useLivingEdges } from "./use-scene-effects";
import { frameRimColor } from "./frame-textures";
import type { SceneLabel } from "./scene-model";
import type { NodeHandlers } from "./stack-part";
import { useAnimatedNode } from "./use-animated-node";
@@ -52,7 +54,7 @@ export function FrameHints({
);
return (
<>
<mesh geometry={geometries.glass} material={glass} raycast={noRaycast} renderOrder={1} />
<mesh name={`frame-hints:${frames[0]?.boxId ?? ""}`} geometry={geometries.glass} material={glass} raycast={noRaycast} renderOrder={1} />
<mesh geometry={geometries.edges} material={edges} raycast={noRaycast} renderOrder={4} />
</>
);
@@ -69,7 +71,7 @@ export interface FramePlateProps {
const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 };
export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) {
const { theme, geometries, textures, motion } = useSceneKit();
const { theme, geometries, textures, motion, env, tier } = useSceneKit();
const group = useRef<Group>(null);
const spec = useMemo(() => frameShapeSpec(frame.size, frame.type), [frame.size, frame.type]);
const sizeKey = frame.size.map((value) => value.toFixed(4)).join("x");
@@ -88,18 +90,23 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
}))
: null;
const texture = textures.get(frame.type);
const rimHex = frameRimColor(frame.type);
const rim = useMemo(() => (rimHex ? new Color(rimHex) : theme.edgeStrong), [rimHex, theme]);
const materials = useMemo(
() => ({
bars: createGlassMaterial(theme, { frost: 0.28 }),
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }),
bars: createGlassMaterial(theme, { frost: 0.28, env, dispersion: tier === "high" ? 0.03 : 0 }),
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38, env, dispersion: 0 }),
edges: createEdgeMaterial(theme),
typed: texture
? new MeshStandardMaterial({
map: texture,
// The comb pattern as relief: cells read as depth under the studio light.
bumpMap: texture,
bumpScale: 4,
emissiveMap: texture,
emissive: "#ffffff",
emissiveIntensity: 0.95,
emissiveIntensity: 0.8,
roughness: frame.type === "HONEY" ? 0.2 : 0.45,
metalness: 0.05,
transparent: true,
@@ -107,7 +114,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
})
: null,
}),
[theme, texture, frame.type],
[theme, texture, frame.type, env, tier],
);
useEffect(
() => () => {
@@ -128,6 +135,8 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
setUniform(materials.comb, "uGlow", glow);
setUniform(materials.edges, "uOpacity", opacity);
setUniform(materials.edges, "uIntensity", 1.3 * glow);
// Lifted, the rim takes the colour of the frame type.
setUniformMix(materials.edges, "uCyan", theme.edge, rim, look);
if (materials.typed) setMaterialOpacity(materials.typed, opacity * look);
};
useAnimatedNode(
@@ -136,13 +145,14 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
motion,
apply,
);
useLivingEdges([materials.edges]);
const restY = frame.center[1] - hostCenter[1];
const rise = Math.max(0, target.position[1] - restY);
const [fx, fy, fz] = frame.size;
return (
<group ref={group}>
<group ref={group} visible={target.look === "glass" || target.look === "type"}>
<mesh geometry={bars.glass} material={materials.bars} raycast={noRaycast} renderOrder={1} dispose={null} />
{comb ? (
<mesh geometry={comb.glass} material={materials.comb} raycast={noRaycast} renderOrder={1} dispose={null} />
@@ -154,6 +164,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
{/* The hit box reaches down to the rest place, so a lifted frame does not slip from under the pointer. */}
<mesh
position={[0, -rise / 2, 0]}
scale={[fx, fy + rise, fz]}
raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut}
@@ -161,7 +172,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
onClick={handlers.onClick}
userData={{ hit: `${frame.boxId}#${frame.slot}` }}
>
<boxGeometry args={[fx, fy + rise, fz]} />
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial visible={false} />
</mesh>
{label ? <HoloLabel position={[0, fy / 2 + 0.12, 0]} text={label.text} tone="frame" /> : null}
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { FRAME_TYPES } from "@/lib/hive/types";
import type { FrameType } from "@/lib/hive/types";
import { createFrameTextureCache, framePatternSpec } from "./frame-textures";
import { createFrameTextureCache, framePatternSpec, frameRimColor } from "./frame-textures";
/** A canvas whose 2D context records every fill colour and shape drawn. */
function fakeCanvas() {
@@ -107,3 +107,12 @@ describe("createFrameTextureCache", () => {
expect(cache.get("BROOD")).toBeNull();
});
});
describe("frameRimColor", () => {
it("gives every frame type its own rim colour for the lifted look", () => {
const rims = FRAME_TYPES.filter((type) => type !== "NONE").map(frameRimColor);
expect(new Set(rims).size).toBe(rims.length);
expect(frameRimColor("BROOD")).toBe(framePatternSpec("BROOD")!.rim);
expect(frameRimColor("NONE")).toBeNull();
});
});
@@ -137,6 +137,14 @@ export function framePatternSpec(type: FrameType): FramePatternSpec | null {
return SPECS[type] ?? null;
}
/** Rim colours of the frames without a comb texture. */
const OPEN_RIMS: Partial<Record<FrameType, string>> = { DRONE_FRAME: "#ffc46b", DIVIDER: "#bfe4ff" };
/** Edge colour of a lifted or selected frame, matching its type; null for an empty slot. */
export function frameRimColor(type: FrameType): string | null {
return SPECS[type]?.rim ?? OPEN_RIMS[type] ?? null;
}
/** Small deterministic PRNG (mulberry32). */
function random(seed: number): () => number {
let state = seed >>> 0;
@@ -1,15 +1,17 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { Canvas } from "@react-three/fiber";
import type { HiveLayout } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import { useHiveView } from "../use-hive-view";
import { useHiveViewStore } from "../use-hive-view";
import { CAMERA_FOV_DEG } from "./camera-fit";
import type { OverlayInsets } from "./camera-fit";
import { HiveSceneContent } from "./hive-scene-content";
import { HiveStage, detectStageTier } from "./hive-stage";
import type { QualityVerdict } from "./quality";
import { SceneKitProvider } from "./scene-kit";
import { shouldIdle } from "./use-hive-motion";
@@ -24,6 +26,8 @@ export interface HiveCanvasProps {
onReady: () => void;
/** The WebGL context was lost; the browser may or may not restore it. */
onContextLost: () => void;
/** Card pixels covered by the frosted overlays (title/focus bar, buttons). */
insets?: OverlayInsets;
}
/** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */
@@ -61,12 +65,28 @@ export default function HiveCanvas({
onSelectFrame,
onReady,
onContextLost,
insets,
}: HiveCanvasProps) {
const motion = useEffectiveMotionLevel();
const view = useHiveView();
// Only acts on the view (empty click); the scene content subscribes itself.
const view = useHiveViewStore();
const root = useRef<HTMLDivElement>(null);
const detachContextLost = useRef<(() => void) | null>(null);
// Detach on unmount, so R3F's own forceContextLoss after unmount does not count as a loss.
useEffect(() => () => detachContextLost.current?.(), []);
const { visible, tabVisible } = useOnScreen(root);
const [tier] = useState(detectStageTier);
// Starts from the device class, drops to low when it renders below 45 fps for 2 s;
// depth of field only after it measured 55 fps or more (a 60 Hz screen).
const [tier, setTier] = useState(detectStageTier);
const [fast, setFast] = useState(false);
const onQuality = useCallback((verdict: QualityVerdict) => {
if (verdict === "slow") {
setTier("low");
setFast(false);
} else {
setFast(true);
}
}, []);
const idle = shouldIdle({ motion, visible, tabVisible });
return (
@@ -86,17 +106,20 @@ export default function HiveCanvas({
view.setHoveredFrame(null);
}}
onCreated={({ gl, invalidate }) => {
gl.domElement.addEventListener("webglcontextlost", (event) => {
const canvas = gl.domElement;
const onLost = (event: Event) => {
// Without preventDefault the browser never restores the context.
event.preventDefault();
onContextLost();
});
};
canvas.addEventListener("webglcontextlost", onLost);
detachContextLost.current = () => canvas.removeEventListener("webglcontextlost", onLost);
invalidate();
onReady();
}}
>
<SceneKitProvider motion={motion}>
<HiveStage tier={tier} />
<SceneKitProvider motion={motion} tier={tier}>
<HiveStage tier={tier} depthOfField={fast && motion === "full"} onQuality={onQuality} />
<HiveSceneContent
layout={layout}
state={state}
@@ -105,6 +128,8 @@ export default function HiveCanvas({
onSelectPart={onSelectPart}
onSelectFrame={onSelectFrame}
idle={idle}
onScreen={visible && tabVisible}
insets={insets}
/>
</SceneKitProvider>
</Canvas>
@@ -83,6 +83,13 @@ const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactTh
const hits = (renderer: Rendered["renderer"]): string[] =>
renderer.scene.findAll((node) => typeof node.props.userData?.hit === "string").map((node) => node.props.userData.hit);
/** Frame plates stay mounted (no mount work on the focus flight); only the open box shows them. */
const shownFrames = (renderer: Rendered["renderer"]): string[] =>
renderer.scene
.findAll((node) => typeof node.props.userData?.hit === "string" && node.props.userData.hit.includes("#"))
.filter((node) => node.parent?.instance.visible)
.map((node) => node.props.userData.hit);
async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mouse") {
const target = hit(renderer, id);
await renderer.fireEvent(target, "onPointerDown", { pointerType });
@@ -92,18 +99,29 @@ async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mo
describe("HiveSceneContent", () => {
it("renders one hit box per part and insert, and frames only as hints at rest", async () => {
const { renderer } = await renderScene();
expect(hits(renderer).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]);
expect(hits(renderer).filter((id) => !id.includes("#")).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]);
expect(shownFrames(renderer)).toEqual([]);
});
it("shows the frames of an open box as plates, without the empty slots under the pocket", async () => {
const { renderer, store } = await renderScene();
await act(async () => store.openBox("b1"));
const frameHits = hits(renderer).filter((id) => id.startsWith("b1#"));
const frameHits = shownFrames(renderer);
expect(frameHits).toHaveLength(8);
expect(frameHits.every((id) => id.startsWith("b1#"))).toBe(true);
expect(frameHits).not.toContain("b1#1");
expect(frameHits).not.toContain("b1#2");
});
it("keeps the merged frame-hint geometry across hover changes (no rebuild per render)", async () => {
const { renderer, store } = await renderScene();
const hint = () => renderer.scene.find((node) => node.props.name === "frame-hints:b1").instance;
const before = (hint() as unknown as { geometry: unknown }).geometry;
await act(async () => store.setHovered("b1"));
await act(async () => store.setHovered("fe"));
expect((hint() as unknown as { geometry: unknown }).geometry).toBe(before);
});
it("reports pointer over a part as hover to the shared store and clears it on leave", async () => {
const { renderer, store } = await renderScene();
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "mouse" });
@@ -2,10 +2,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useThree } from "@react-three/fiber";
import type { HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { focusedIdOf, useHiveView } from "../use-hive-view";
import type { HoveredFrame } from "../use-hive-view";
import type { OverlayInsets } from "./camera-fit";
import { CameraRig } from "./camera-rig";
import { FramePlate } from "./frame-plate";
import { ModelRig } from "./model-rig";
@@ -37,9 +38,16 @@ export interface HiveSceneContentProps {
onSelectFrame: (boxId: string, slot: number) => void;
/** Idle motion may run (see shouldIdle). */
idle: boolean;
/** Card and tab visible (the quality probe runs at any motion level). */
onScreen?: boolean;
/** Card pixels covered by the frosted overlays; the camera fits the model between them. */
insets?: OverlayInsets;
}
const NO_INSETS: OverlayInsets = { top: 0, bottom: 0 };
const partKey = (id: string): string => `part:${id}`;
const NO_FRAMES: FrameBox[] = [];
const frameTargetKey = (boxId: string, slot: number): string => `frame:${frameKey(boxId, slot)}`;
function parseKey(key: string): { kind: "part"; id: string } | { kind: "frame"; boxId: string; slot: number } | null {
@@ -72,6 +80,8 @@ export function HiveSceneContent({
onSelectPart,
onSelectFrame,
idle,
onScreen = false,
insets = NO_INSETS,
}: HiveSceneContentProps) {
const view = useHiveView();
const focusedId = focusedIdOf(view);
@@ -174,16 +184,22 @@ export function HiveSceneContent({
}, []);
const getPivot = useCallback(() => pivot.current, []);
const insertsOf = (host: PartBox) => layout.inserts.filter((insert) => insert.stackIndex === host.stackIndex);
// One stable array per box, so the merged hint geometry is built once per layout, not per render.
const framesByBox = useMemo(() => {
const grouped = new Map<string, FrameBox[]>();
for (const frame of layout.frames) grouped.set(frame.boxId, [...(grouped.get(frame.boxId) ?? []), frame]);
return grouped;
}, [layout]);
return (
<>
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} />
<ModelRig getPivot={getPivot} idle={idle}>
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} insets={insets} />
<ModelRig getPivot={getPivot} idle={idle} onScreen={onScreen}>
{layout.parts.map((placed) => {
const part = model.get(placed.id);
const target = targets.nodes.get(placed.id);
if (!part || !target) return null;
const frames = layout.frames.filter((frame) => frame.boxId === placed.id);
const frames = framesByBox.get(placed.id) ?? NO_FRAMES;
return (
<StackPart
key={placed.id}
@@ -209,22 +225,22 @@ export function HiveSceneContent({
/>
);
})}
{target.content
? frames.map((frame) => {
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
if (!frameTarget || frameTarget.look === "hidden") return null;
return (
<FramePlate
key={frame.slot}
frame={frame}
hostCenter={placed.center}
target={frameTarget}
handlers={handlersFor(frameTargetKey(frame.boxId, frame.slot))}
label={frameLabel(labels, frame.boxId, frame.slot)}
/>
);
})
: null}
{/* Frame plates stay mounted (hidden while their box is closed), so opening a box
only animates and does not create materials during the flight. */}
{frames.map((frame) => {
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
if (!frameTarget || frameTarget.look === "hidden") return null;
return (
<FramePlate
key={frame.slot}
frame={frame}
hostCenter={placed.center}
target={frameTarget}
handlers={handlersFor(frameTargetKey(frame.boxId, frame.slot))}
label={frameLabel(labels, frame.boxId, frame.slot)}
/>
);
})}
</StackPart>
);
})}
@@ -1,12 +1,14 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { MeshReflectorMaterial } from "@react-three/drei";
import { Bloom, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing";
import { Bloom, DepthOfField, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing";
import { ToneMappingMode } from "postprocessing";
import type { DepthOfFieldEffect } from "postprocessing";
import {
AdditiveBlending,
DoubleSide,
BufferGeometry,
CanvasTexture,
Float32BufferAttribute,
@@ -15,8 +17,10 @@ import {
ShaderMaterial,
} from "three";
import type { Points } from "three";
import { createQualityMonitor } from "./quality";
import type { QualityVerdict, StageTier } from "./quality";
import { useSceneKit } from "./scene-kit";
import { setSceneBackground, setUniform } from "./scene-mutations";
import { markTimerFrame, setEffectBokeh, setEffectTarget, setSceneBackground, setUniform } from "./scene-mutations";
import type { HoloTheme } from "./theme";
// The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a
@@ -29,7 +33,7 @@ const OVERLAY_LAYER = 1;
const FLOOR_SIZE = 240;
const PARTICLE_COUNT = 42;
export type StageTier = "high" | "low";
export type { StageTier } from "./quality";
/** High: reflective floor and MSAA (desktop-class). Low: fake floor, no MSAA (phones, ≤ 4 cores). */
export function detectStageTier(): StageTier {
@@ -342,9 +346,43 @@ function Lights() {
);
}
function Effects({ tier }: { tier: StageTier }) {
/** Watches frame times while the scene animates and reports slow or fast (adaptive tier, depth of field). */
function QualityWatcher({ onVerdict }: { onVerdict: (verdict: QualityVerdict) => void }) {
const { signals } = useSceneKit();
const [monitor] = useState(() => createQualityMonitor());
useFrame((_, delta) => {
if (signals.timerFrame) {
// Frames paced by the idle timer say nothing about speed; start the window over.
markTimerFrame(signals, false);
monitor.push(Number.POSITIVE_INFINITY);
return;
}
const verdict = monitor.push(delta);
if (verdict) onVerdict(verdict);
});
return null;
}
/** Bokeh of the depth of field in focus mode; 0 in the rest view. */
const FOCUS_BOKEH = 2.4;
function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolean }) {
const { signals } = useSceneKit();
const dof = useRef<DepthOfFieldEffect>(null);
// Mounted for good once allowed (no composer rebuild and shader compile on the focus click);
// the focus point switches the blur on and off.
useFrame(() => {
if (!dof.current) return;
if (signals.focusPoint) setEffectTarget(dof.current, signals.focusPoint);
setEffectBokeh(dof.current, signals.focusPoint ? FOCUS_BOKEH : 0);
});
return (
<EffectComposer multisampling={tier === "high" ? 4 : 0}>
{depthOfField ? (
<DepthOfField ref={dof} target={[0, 0, 0]} worldFocusRange={4} bokehScale={0} resolutionScale={0.5} />
) : (
<></>
)}
<Bloom
mipmapBlur
luminanceThreshold={0.7}
@@ -359,14 +397,81 @@ function Effects({ tier }: { tier: StageTier }) {
);
}
export function HiveStage({ tier }: { tier: StageTier }) {
const HAZE_VERTEX = /* glsl */ `
varying float vHeight;
varying float vFacing;
void main() {
vHeight = position.y;
vec4 view = modelViewMatrix * vec4(position, 1.0);
vec3 normalView = normalize(normalMatrix * normal);
vFacing = abs(dot(normalView, normalize(-view.xyz)));
gl_Position = projectionMatrix * view;
}
`;
const HAZE_FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uHeight;
varying float vHeight;
varying float vFacing;
void main() {
float along = clamp(vHeight / uHeight + 0.5, 0.0, 1.0);
// Brightest in the middle of the cone, fading toward the floor and the apex.
float fade = smoothstep(0.0, 0.35, along) * (1.0 - smoothstep(0.75, 1.0, along));
gl_FragColor = vec4(uColor * pow(vFacing, 2.0) * fade * 0.07, 1.0);
}
`;
const HAZE_HEIGHT = 18;
/** A faint cone of cool light falling onto the hive from above and behind. */
function Haze() {
const { theme } = useSceneKit();
const material = useMemo(
() =>
new ShaderMaterial({
uniforms: { uColor: { value: theme.rim.clone() }, uHeight: { value: HAZE_HEIGHT } },
vertexShader: HAZE_VERTEX,
fragmentShader: HAZE_FRAGMENT,
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
side: DoubleSide,
}),
[theme],
);
useEffect(() => () => material.dispose(), [material]);
return (
<mesh
position={[-1.5, HAZE_HEIGHT / 2 - 1, -3]}
rotation={[0.18, 0, 0.12]}
material={material}
renderOrder={-1}
layers={OVERLAY_LAYER}
raycast={() => {}}
>
<coneGeometry args={[6.5, HAZE_HEIGHT, 48, 1, true]} />
</mesh>
);
}
export interface HiveStageProps {
tier: StageTier;
/** Depth of field (used in focus mode): only when the device measured fast (≥ 55 fps). */
depthOfField: boolean;
onQuality: (verdict: QualityVerdict) => void;
}
export function HiveStage({ tier, depthOfField, onQuality }: HiveStageProps) {
return (
<>
<Backdrop />
<Lights />
<Floor tier={tier} />
<Particles />
<Effects tier={tier} />
<Haze />
{tier === "high" ? <Particles /> : null}
<QualityWatcher onVerdict={onQuality} />
<Effects tier={tier} depthOfField={depthOfField && tier === "high"} />
</>
);
}
@@ -1,17 +1,22 @@
import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three";
import type { CubeTexture } from "three";
import type { HoloTheme } from "./theme";
// Hand-written shaders instead of transmission: a fake studio environment
// (white soft box above, blue rim light behind, faint cool bounce below) with
// fresnel gives the glass look at a fraction of the cost (spec 9.9).
// Hand-written shaders instead of transmission. The glass samples a small
// procedural studio cube map (scene-env.ts) for reflections, a dispersive
// refraction of it for body, blurred mips for frosted inner faces and its hot
// spots for glints; without the cube map it falls back to an analytic studio.
// One draw call per part either way (spec 9.9).
const GLASS_VERTEX = /* glsl */ `
varying vec3 vNormalW;
varying vec3 vViewDir;
varying vec3 vLocal;
void main() {
vec4 world = modelMatrix * vec4(position, 1.0);
vNormalW = normalize(mat3(modelMatrix) * normal);
vViewDir = cameraPosition - world.xyz;
vLocal = position;
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
@@ -20,31 +25,71 @@ const GLASS_FRAGMENT = /* glsl */ `
uniform vec3 uTint;
uniform vec3 uRim;
uniform vec3 uBounce;
uniform vec3 uSweepColor;
uniform float uOpacity;
uniform float uFrost;
uniform float uGlow;
uniform samplerCube uEnv;
uniform float uEnvStrength;
uniform float uDispersion;
uniform float uSweep;
uniform float uHeight;
varying vec3 vNormalW;
varying vec3 vViewDir;
void main() {
vec3 n = normalize(vNormalW);
vec3 v = normalize(vViewDir);
if (!gl_FrontFacing) n = -n;
float ndv = clamp(abs(dot(n, v)), 0.0, 1.0);
float fresnel = pow(1.0 - ndv, 3.0);
vec3 r = reflect(-v, n);
varying vec3 vLocal;
vec3 analyticStudio(vec3 r) {
float sky = smoothstep(-0.1, 0.9, r.y);
float ground = smoothstep(0.05, -0.7, r.y);
float softbox = pow(max(dot(r, normalize(vec3(-0.35, 0.8, -0.5))), 0.0), 28.0);
float rimBack = pow(max(dot(r, normalize(vec3(0.25, 0.2, -1.0))), 0.0), 5.0);
vec3 env = uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3;
vec3 color = uTint * (0.16 + uFrost * 0.3) + env * (0.25 + 0.75 * fresnel);
color += uBounce * 0.08 * smoothstep(0.5, -0.9, n.y);
float alpha = 0.085 + uFrost * 0.16 + fresnel * 0.55 + softbox * 0.45;
if (!gl_FrontFacing) {
// Faintly frosted inner faces.
return uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3;
}
void main() {
vec3 n = normalize(vNormalW);
vec3 v = normalize(vViewDir);
bool back = !gl_FrontFacing;
if (back) n = -n;
float ndv = clamp(abs(dot(n, v)), 0.0, 1.0);
float fresnel = pow(1.0 - ndv, 3.0);
vec3 r = reflect(-v, n);
// Reflection: sharp on the outside, blurred (frosted) on the inner faces.
vec3 cubeReflection = textureCube(uEnv, r, back ? 4.0 : 0.0).rgb;
vec3 reflection = mix(analyticStudio(r), cubeReflection, uEnvStrength);
// Body: the studio seen through the glass, slightly split per channel.
vec3 refraction = vec3(
textureCube(uEnv, refract(-v, n, 1.0 / (1.5 - uDispersion)), 2.0).r,
textureCube(uEnv, refract(-v, n, 1.0 / 1.5), 2.0).g,
textureCube(uEnv, refract(-v, n, 1.0 / (1.5 + uDispersion)), 2.0).b
) * uEnvStrength;
// Glints where the chamfers catch the hot spots.
float glint = smoothstep(1.6, 4.0, dot(cubeReflection, vec3(0.333))) * uEnvStrength;
vec3 color = uTint * (0.14 + uFrost * 0.3)
+ reflection * (0.22 + 0.78 * fresnel)
+ mix(refraction, vec3(dot(refraction, vec3(0.333))), 0.6) * 0.09
+ vec3(1.0) * glint * 0.9;
float alpha = 0.08 + uFrost * 0.16 + fresnel * 0.55 + glint * 0.5
+ dot(refraction, vec3(0.03));
if (back) {
alpha *= 0.6;
color = mix(color, uTint * 0.45, 0.35);
}
// Hover: a holographic shimmer sweeps across once and a thin scan line climbs the part.
if (uSweep > 0.0) {
float height = max(uHeight, 1e-3);
float y = clamp(vLocal.y / height + 0.5, 0.0, 1.0);
float diagonal = y * 0.75 + vLocal.x * 0.08;
float progress = uSweep * 2.2 - 0.6;
float band = exp(-pow((diagonal - progress) * 7.0, 2.0)) * (1.0 - smoothstep(0.8, 1.0, uSweep));
float scan = (1.0 - smoothstep(0.0, 0.012, abs(y - uSweep))) * step(uSweep, 0.999);
color += uSweepColor * (band * 0.55 + scan * 1.4);
alpha += band * 0.18 + scan * 0.45;
}
color *= 0.78 + 0.22 * uGlow;
gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0) * uOpacity);
}
@@ -53,6 +98,10 @@ void main() {
export interface GlassOptions {
/** 0 = clear, 1 = milky (insulation, divider board). */
frost?: number;
/** Studio cube map; without it the analytic studio is used. */
env?: CubeTexture | null;
/** Colour split of the refraction; 0 on the low tier. */
dispersion?: number;
}
export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}): ShaderMaterial {
@@ -61,9 +110,15 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
uTint: { value: theme.tint.clone() },
uRim: { value: theme.rim.clone() },
uBounce: { value: theme.bounce.clone() },
uSweepColor: { value: theme.edgeStrong.clone() },
uOpacity: { value: 1 },
uFrost: { value: options.frost ?? 0 },
uGlow: { value: 1 },
uEnv: { value: options.env ?? null },
uEnvStrength: { value: options.env ? 1 : 0 },
uDispersion: { value: options.dispersion ?? 0.03 },
uSweep: { value: 0 },
uHeight: { value: 1 },
},
vertexShader: GLASS_VERTEX,
fragmentShader: GLASS_FRAGMENT,
@@ -77,12 +132,23 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
const EDGE_VERTEX = /* glsl */ `
attribute float aHot;
attribute float aStrength;
attribute float aAlong;
attribute float aPhase;
varying float vHot;
varying float vStrength;
varying float vAlong;
varying float vPhase;
varying float vFacing;
void main() {
vHot = aHot;
vStrength = aStrength;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vAlong = aAlong;
vPhase = aPhase;
vec4 view = modelViewMatrix * vec4(position, 1.0);
// Tube normal in view space: faces toward the camera form the white-hot core.
vec3 normalView = normalize(normalMatrix * normal);
vFacing = abs(dot(normalView, normalize(-view.xyz)));
gl_Position = projectionMatrix * view;
}
`;
@@ -91,17 +157,26 @@ uniform vec3 uCyan;
uniform vec3 uHot;
uniform float uIntensity;
uniform float uOpacity;
uniform float uTime;
uniform float uPulse;
varying float vHot;
varying float vStrength;
varying float vAlong;
varying float vPhase;
varying float vFacing;
void main() {
float hot = vHot * vHot;
vec3 color = mix(uCyan, uHot, hot);
float energy = uIntensity * vStrength * (1.0 + hot * 1.6);
float core = pow(vFacing, 4.0);
vec3 color = mix(uCyan, uHot, clamp(max(hot, core * 0.75), 0.0, 1.0));
// Slow light pulses travelling along the edges (motion full only).
float phase = fract(vAlong * 0.22 - uTime * 0.12 + vPhase);
float pulse = smoothstep(0.0, 0.035, phase) * (1.0 - smoothstep(0.035, 0.14, phase)) * uPulse;
float energy = uIntensity * vStrength * (0.55 + 0.45 * core + hot * 1.6 + pulse * 1.3);
gl_FragColor = vec4(color * energy * uOpacity, 1.0);
}
`;
/** Glowing edges: additive, white-hot at the corners, electric cyan along the edge. */
/** Glowing edges: additive, white-hot core and corners, electric cyan rim, slow pulses at `full`. */
export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
@@ -109,6 +184,8 @@ export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
uHot: { value: theme.hot.clone() },
uIntensity: { value: 1.6 },
uOpacity: { value: 1 },
uTime: { value: 0 },
uPulse: { value: 0 },
},
vertexShader: EDGE_VERTEX,
fragmentShader: EDGE_FRAGMENT,
@@ -118,6 +195,49 @@ export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
});
}
const CAUSTIC_VERTEX = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const CAUSTIC_FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uTime;
uniform float uStrength;
uniform vec2 uScale;
varying vec2 vUv;
void main() {
vec2 p = vUv * uScale;
float v = sin(p.x + uTime) + sin((p.y + uTime) * 0.5) + sin((p.x + p.y + uTime) * 0.5);
vec2 q = p + vec2(sin(uTime / 3.0), cos(uTime / 2.0)) * 2.0;
v += sin(sqrt(q.x * q.x + q.y * q.y + 1.0) + uTime);
float caustic = pow(1.0 - abs(sin(v * 1.7)), 6.0);
float edge = smoothstep(0.0, 0.08, vUv.x) * smoothstep(0.0, 0.08, 1.0 - vUv.x)
* smoothstep(0.0, 0.08, vUv.y) * smoothstep(0.0, 0.08, 1.0 - vUv.y);
gl_FragColor = vec4(uColor * caustic * edge * uStrength, 1.0);
}
`;
/** Soft golden caustics on the floor under a liquid; additive, animated only at `full`. */
export function createCausticMaterial(color: Color, scale: [number, number]): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
uColor: { value: color.clone() },
uTime: { value: 0 },
uStrength: { value: 0.9 },
uScale: { value: scale },
},
vertexShader: CAUSTIC_VERTEX,
fragmentShader: CAUSTIC_FRAGMENT,
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
});
}
const GRID_VERTEX = /* glsl */ `
varying vec3 vLocal;
void main() {
@@ -5,7 +5,10 @@ import type { MutableRefObject, ReactNode } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { Group } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { createRigPointer } from "./rig-pointer";
import type { RigInput } from "./rig-pointer";
import { useSceneKit } from "./scene-kit";
import { capturePointer, markTimerFrame, releasePointer } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion";
@@ -15,28 +18,11 @@ import { stepToward } from "./use-hive-motion";
// springs back. Vertical swipes scroll the page (touch-action: pan-y).
// - at `full` only: idle float ±4 mm over 6 s and a pointer tilt ≤ 6°.
export const MAX_TURN = (60 * Math.PI) / 180;
export const MAX_TILT = (6 * Math.PI) / 180;
export { MAX_TILT, MAX_TURN, SPRING_BACK_MS, clampTurn } from "./rig-pointer";
export const IDLE_FLOAT = 0.04;
export const IDLE_PERIOD_SECONDS = 6;
export const SPRING_BACK_MS = 4000;
/** Idle motion asks for frames at about 30 fps, which is plenty for ±4 mm. */
export const IDLE_FRAME_MS = 33;
/** Pixels of horizontal travel before a press counts as a drag. */
const DRAG_START_PX = 8;
/** A drag across the full card width turns the model by this much. */
const TURN_PER_WIDTH = Math.PI;
export function clampTurn(angle: number): number {
return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle));
}
interface RigInput {
turn: number;
tiltX: number;
tiltY: number;
dragging: boolean;
}
function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
const gl = useThree((state) => state.gl);
@@ -46,64 +32,31 @@ function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
useEffect(() => {
const element = gl.domElement as HTMLElement | undefined;
if (!element || typeof element.addEventListener !== "function") return;
let press: { x: number; y: number; turn: number; id: number } | null = null;
let springBack: ReturnType<typeof setTimeout> | null = null;
const onDown = (event: PointerEvent) => {
press = { x: event.clientX, y: event.clientY, turn: input.current.turn, id: event.pointerId };
if (springBack) clearTimeout(springBack);
};
const onMove = (event: PointerEvent) => {
const rect = element.getBoundingClientRect();
if (press && press.id === event.pointerId) {
const dx = event.clientX - press.x;
const dy = event.clientY - press.y;
if (!input.current.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
input.current.dragging = true;
}
if (input.current.dragging) {
input.current.turn = clampTurn(press.turn + (dx / Math.max(rect.width, 1)) * TURN_PER_WIDTH);
invalidate();
}
return;
}
if (enabledTilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) {
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
input.current.tiltY = nx * MAX_TILT;
input.current.tiltX = ny * MAX_TILT * 0.5;
invalidate();
}
};
const onUp = () => {
if (input.current.dragging) {
springBack = setTimeout(() => {
input.current.turn = 0;
invalidate();
}, SPRING_BACK_MS);
}
press = null;
// Let the click that follows a drag see `dragging`, then clear it.
setTimeout(() => {
input.current.dragging = false;
}, 0);
};
const onLeave = () => {
input.current.tiltX = 0;
input.current.tiltY = 0;
invalidate();
};
const pointer = createRigPointer(input.current, {
tilt: enabledTilt,
rect: () => element.getBoundingClientRect(),
// Captured while dragging, so a release outside the canvas still ends the drag.
capture: (id) => capturePointer(element, id),
release: (id) => releasePointer(element, id),
changed: invalidate,
});
const onDown = (event: PointerEvent) => pointer.down(event);
const onMove = (event: PointerEvent) => pointer.move(event);
const onUp = (event: PointerEvent) => pointer.up(event);
const onLeave = () => pointer.leave();
element.addEventListener("pointerdown", onDown);
element.addEventListener("pointermove", onMove);
element.addEventListener("pointerup", onUp);
element.addEventListener("pointercancel", onUp);
element.addEventListener("lostpointercapture", onUp);
element.addEventListener("pointerleave", onLeave);
return () => {
if (springBack) clearTimeout(springBack);
pointer.dispose();
element.removeEventListener("pointerdown", onDown);
element.removeEventListener("pointermove", onMove);
element.removeEventListener("pointerup", onUp);
element.removeEventListener("pointercancel", onUp);
element.removeEventListener("lostpointercapture", onUp);
element.removeEventListener("pointerleave", onLeave);
};
}, [gl, invalidate, enabledTilt]);
@@ -111,21 +64,37 @@ function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
return input;
}
/** Asks for a frame about every 33 ms while the idle motion runs (demand frame loop). */
function useIdleTicker(active: boolean): void {
/** Seconds of continuous idle frames after mount, so the quality monitor can measure the device. */
export const QUALITY_PROBE_SECONDS = 4;
/**
* Asks for frames (demand frame loop): continuously during the quality probe
* while the card is on screen (any motion level, so a weak device also drops
* from the high tier), then about every 33 ms while the idle motion runs
* (marked as timer frames).
*/
function useIdleTicker(active: boolean, probing: boolean): void {
const invalidate = useThree((state) => state.invalidate);
const { signals } = useSceneKit();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const probe = useRef(0);
useEffect(() => {
if (active) invalidate();
if (active || probing) invalidate();
return () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
};
}, [active, invalidate]);
useFrame(() => {
}, [active, probing, invalidate]);
useFrame((_, delta) => {
if (probing && probe.current < QUALITY_PROBE_SECONDS) {
probe.current += Math.min(delta, MAX_FRAME_SECONDS);
invalidate();
return;
}
if (!active || timer.current !== null) return;
timer.current = setTimeout(() => {
timer.current = null;
markTimerFrame(signals, true);
invalidate();
}, IDLE_FRAME_MS);
});
@@ -136,10 +105,12 @@ export interface ModelRigProps {
getPivot: () => Vec3;
/** Idle motion may run (motion `full`, card visible, tab visible). */
idle: boolean;
/** Card and tab visible: the quality probe may run (any motion level). */
onScreen?: boolean;
children: ReactNode;
}
export function ModelRig({ getPivot, idle, children }: ModelRigProps) {
export function ModelRig({ getPivot, idle, onScreen = false, children }: ModelRigProps) {
const { motion } = useSceneKit();
const invalidate = useThree((state) => state.invalidate);
const full = motion === "full";
@@ -147,7 +118,7 @@ export function ModelRig({ getPivot, idle, children }: ModelRigProps) {
const outer = useRef<Group>(null);
const inner = useRef<Group>(null);
const current = useRef({ turn: 0, tiltX: 0, tiltY: 0, float: 0, time: 0 });
useIdleTicker(idle && full);
useIdleTicker(idle && full, onScreen || (idle && full));
useFrame((_, delta) => {
if (!outer.current || !inner.current) return;
@@ -169,4 +169,16 @@ describe("geometry builders", () => {
expect(min).toBe(0);
geometry.dispose();
});
it("gives the edge tubes unit normals, a distance along the edge and a phase per edge", () => {
const geometry = buildEdgeGeometry([{ a: [0, 0, 0], b: [2, 0, 0], hotA: 1, hotB: 1, strength: 1 }]);
const normal = geometry.getAttribute("normal");
const along = geometry.getAttribute("aAlong");
expect(Math.hypot(normal.getX(0), normal.getY(0), normal.getZ(0))).toBeCloseTo(1, 6);
expect(normal.getX(0)).toBeCloseTo(0, 6);
expect(along.getX(0)).toBe(0);
expect(along.getX(along.count - 1)).toBeCloseTo(2, 6);
expect(geometry.getAttribute("aPhase").getX(0)).toBeGreaterThanOrEqual(0);
geometry.dispose();
});
});
@@ -517,14 +517,21 @@ function perpendicular(direction: Vec3): [Vec3, Vec3] {
return [u, normalize(cross(u, direction))];
}
/** Thin square tubes along every edge, with `aHot` (corner glow) and `aStrength` attributes. */
/**
* Thin square tubes along every edge: `normal` (round tube normals for the
* white-hot core), `aHot` (corner glow), `aStrength`, `aAlong` (distance along
* the edge) and `aPhase` (per edge) for the travelling pulses.
*/
export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_WIDTH): BufferGeometry {
const positions: number[] = [];
const normals: number[] = [];
const hot: number[] = [];
const strength: number[] = [];
const along: number[] = [];
const phase: number[] = [];
const indices: number[] = [];
const half = width / 2;
for (const edge of segments) {
for (const [segmentIndex, edge] of segments.entries()) {
const delta: Vec3 = [edge.b[0] - edge.a[0], edge.b[1] - edge.a[1], edge.b[2] - edge.a[2]];
const length = Math.hypot(delta[0], delta[1], delta[2]);
if (length < 1e-6) continue;
@@ -538,6 +545,8 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
[1, edge.hotB],
];
const base = positions.length / 3;
// Deterministic per-edge phase, so the travelling pulses do not march in step.
const edgePhase = (Math.sin(segmentIndex * 12.9898) * 43758.5453) % 1;
for (const [t, ringHot] of rings) {
for (const [su, sw] of [
[1, 1],
@@ -545,11 +554,21 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
[-1, -1],
[1, -1],
]) {
const offset: Vec3 = [
(u[0] * su + w[0] * sw) * half,
(u[1] * su + w[1] * sw) * half,
(u[2] * su + w[2] * sw) * half,
];
const offsetLength = Math.hypot(offset[0], offset[1], offset[2]) || 1;
for (let axis = 0; axis < 3; axis += 1) {
positions.push(edge.a[axis] + delta[axis] * t + (u[axis] * su + w[axis] * sw) * half);
positions.push(edge.a[axis] + delta[axis] * t + offset[axis]);
// Smooth tube normal: from the edge's axis out through the corner.
normals.push(offset[axis] / offsetLength);
}
hot.push(ringHot);
strength.push(edge.strength);
along.push(t * length);
phase.push(Math.abs(edgePhase));
}
}
for (let band = 0; band < 3; band += 1) {
@@ -564,8 +583,11 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
geometry.setAttribute("aHot", new Float32BufferAttribute(hot, 1));
geometry.setAttribute("aStrength", new Float32BufferAttribute(strength, 1));
geometry.setAttribute("aAlong", new Float32BufferAttribute(along, 1));
geometry.setAttribute("aPhase", new Float32BufferAttribute(phase, 1));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
return geometry;
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { createQualityMonitor, springStep } from "./quality";
describe("createQualityMonitor", () => {
const feed = (monitor: ReturnType<typeof createQualityMonitor>, fps: number, seconds: number) => {
const verdicts = [];
for (let time = 0; time < seconds; time += 1 / fps) verdicts.push(monitor.push(1 / fps));
return verdicts.filter((verdict) => verdict !== null);
};
it("reports slow after two seconds of rendering below 45 fps", () => {
const monitor = createQualityMonitor();
expect(feed(monitor, 30, 1.9)).toEqual([]);
expect(feed(monitor, 30, 0.3)).toEqual(["slow"]);
});
it("reports fast after two seconds at 60 fps or more", () => {
expect(feed(createQualityMonitor(), 120, 2.1)).toEqual(["fast"]);
});
it("counts a 60 Hz screen (about 59.9 fps) as fast", () => {
expect(feed(createQualityMonitor(), 59.9, 2.2)).toEqual(["fast"]);
});
it("stays silent between the two thresholds", () => {
expect(feed(createQualityMonitor(), 50, 4.5)).toEqual([]);
});
it("ignores pauses of the demand frame loop instead of counting them as slow frames", () => {
const monitor = createQualityMonitor();
feed(monitor, 120, 1);
expect(monitor.push(3)).toBeNull();
// The window restarts after the pause: one more second is not enough.
expect(feed(monitor, 120, 1)).toEqual([]);
expect(feed(monitor, 120, 1.1)).toEqual(["fast"]);
});
});
describe("springStep", () => {
it("overshoots the target slightly and settles", () => {
let state = { value: 0, velocity: 0 };
let peak = 0;
for (let frame = 0; frame < 120; frame += 1) {
state = springStep(state.value, state.velocity, 1, 1 / 60);
peak = Math.max(peak, state.value);
}
expect(peak).toBeGreaterThan(1.005);
expect(peak).toBeLessThan(1.08);
expect(state).toEqual({ value: 1, velocity: 0 });
});
it("is most of the way there after about 350 ms", () => {
let state = { value: 0, velocity: 0 };
for (let frame = 0; frame < 21; frame += 1) state = springStep(state.value, state.velocity, 1, 1 / 60);
expect(state.value).toBeGreaterThan(0.9);
});
it("is frame-rate independent enough for a long frame", () => {
const state = springStep(0, 0, 1, 1 / 15);
expect(state.value).toBeGreaterThan(0);
expect(state.value).toBeLessThan(1.1);
});
});
@@ -0,0 +1,78 @@
// Adaptive quality and the flight spring, as pure functions.
/** High: reflective floor, MSAA, particles, dispersion, depth of field when fast. Low: none of these. */
export type StageTier = "high" | "low";
/** Below this the scene drops to the low tier (no reflector, MSAA, depth of field, particles). */
export const SLOW_FPS = 45;
/** At or above this depth of field may run in focus mode (55, so a 60 Hz screen measuring ~59.9 counts). */
export const FAST_FPS = 55;
/** Seconds of continuous rendering per verdict. */
export const QUALITY_WINDOW_SECONDS = 2;
/** A longer gap is a pause of the demand frame loop, not a slow frame. */
const PAUSE_SECONDS = 0.25;
export type QualityVerdict = "slow" | "fast";
export interface QualityMonitor {
/** One rendered frame with its delta; returns a verdict when a window completes. */
push(deltaSeconds: number): QualityVerdict | null;
}
/**
* Watches frame times while the scene actually renders (demand loop: flights,
* idle motion). Equivalent to drei's PerformanceMonitor, without counting the
* pauses between demand frames as slow frames.
*/
export function createQualityMonitor(
slowFps: number = SLOW_FPS,
fastFps: number = FAST_FPS,
windowSeconds: number = QUALITY_WINDOW_SECONDS,
): QualityMonitor {
let time = 0;
let frames = 0;
return {
push(delta) {
if (!(delta > 0) || delta > PAUSE_SECONDS) {
time = 0;
frames = 0;
return null;
}
time += delta;
frames += 1;
if (time < windowSeconds) return null;
const fps = frames / time;
time = 0;
frames = 0;
if (fps < slowFps) return "slow";
if (fps >= fastFps) return "fast";
return null;
},
};
}
/** Spring for flights at motion `full`: a touch of overshoot (damping ratio ≈ 0.72), settled in ~0.5 s. */
export const SPRING_STIFFNESS = 210;
export const SPRING_DAMPING = 21;
const SPRING_SUBSTEP = 1 / 120;
const SPRING_EPSILON = 1e-3;
export interface SpringState {
value: number;
velocity: number;
}
/** One frame of a damped spring toward `target` (semi-implicit Euler with small substeps). */
export function springStep(value: number, velocity: number, target: number, deltaSeconds: number): SpringState {
let x = value;
let v = velocity;
let left = Math.max(0, deltaSeconds);
while (left > 0) {
const dt = Math.min(SPRING_SUBSTEP, left);
v += (SPRING_STIFFNESS * (target - x) - SPRING_DAMPING * v) * dt;
x += v * dt;
left -= dt;
}
if (Math.abs(target - x) < SPRING_EPSILON && Math.abs(v) < SPRING_EPSILON * 10) return { value: target, velocity: 0 };
return { value: x, velocity: v };
}
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_TURN, SPRING_BACK_MS, createRigPointer } from "./rig-pointer";
import type { PointerSample, RigInput } from "./rig-pointer";
function setup(tilt = true) {
const input: RigInput = { turn: 0, tiltX: 0, tiltY: 0, dragging: false };
const host = {
tilt,
rect: () => ({ left: 0, top: 0, width: 400, height: 300 }),
capture: vi.fn(),
release: vi.fn(),
changed: vi.fn(),
};
const pointer = createRigPointer(input, host);
return { input, host, pointer };
}
const sample = (clientX: number, extra: Partial<PointerSample> = {}): PointerSample => ({
clientX,
clientY: 100,
pointerId: 1,
pointerType: "mouse",
buttons: 1,
...extra,
});
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("createRigPointer", () => {
it("turns on a horizontal drag and captures the pointer, so the release is seen outside the canvas", () => {
const { input, host, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(104));
expect(input.dragging).toBe(false);
pointer.move(sample(160));
expect(input.dragging).toBe(true);
expect(host.capture).toHaveBeenCalledWith(1);
expect(input.turn).toBeGreaterThan(0);
});
it("clamps the turn to ±60°", () => {
const { input, pointer } = setup();
pointer.down(sample(0));
pointer.move(sample(2000));
expect(input.turn).toBeCloseTo(MAX_TURN, 9);
});
it("ends a mouse drag whose button was released outside (buttons = 0) and springs back after 4 s", () => {
const { input, host, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
expect(input.dragging).toBe(true);
// The pointer comes back over the canvas with no button pressed.
pointer.move(sample(260, { buttons: 0 }));
vi.advanceTimersByTime(1);
expect(input.dragging).toBe(false);
expect(host.release).toHaveBeenCalledWith(1);
const turned = input.turn;
expect(turned).toBeGreaterThan(0);
vi.advanceTimersByTime(SPRING_BACK_MS);
expect(input.turn).toBe(0);
});
it("springs back 4 s after a normal release, and a new press cancels it", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
vi.advanceTimersByTime(SPRING_BACK_MS - 100);
pointer.down(sample(200));
vi.advanceTimersByTime(500);
expect(input.turn).toBeGreaterThan(0);
});
it("keeps dragging true until the click after the drag has run", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
expect(input.dragging).toBe(true);
vi.advanceTimersByTime(0);
expect(input.dragging).toBe(false);
});
it("tilts toward a hovering mouse only when tilt is enabled, and levels on leave", () => {
const tilted = setup(true);
tilted.pointer.move(sample(400, { buttons: 0 }));
expect(tilted.input.tiltY).toBeGreaterThan(0);
tilted.pointer.leave();
expect(tilted.input.tiltY).toBe(0);
const still = setup(false);
still.pointer.move(sample(400, { buttons: 0 }));
expect(still.input.tiltY).toBe(0);
});
it("clears its timers on dispose", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
pointer.dispose();
vi.advanceTimersByTime(SPRING_BACK_MS);
expect(input.turn).toBeGreaterThan(0);
});
});
@@ -0,0 +1,118 @@
// Pointer handling of the model rig (spec 9.4), without DOM or three.js so it
// can be tested: a horizontal drag turns the model ±60° (pointer captured, so
// a release outside the canvas still ends it), 4 s after the drag it springs
// back; a hovering mouse tilts it slightly (motion `full`).
export const MAX_TURN = (60 * Math.PI) / 180;
export const MAX_TILT = (6 * Math.PI) / 180;
export const SPRING_BACK_MS = 4000;
/** Pixels of horizontal travel before a press counts as a drag. */
const DRAG_START_PX = 8;
/** A drag across the full card width turns the model by this much. */
const TURN_PER_WIDTH = Math.PI;
export interface RigInput {
turn: number;
tiltX: number;
tiltY: number;
dragging: boolean;
}
export interface PointerSample {
clientX: number;
clientY: number;
pointerId: number;
pointerType: string;
/** Pressed buttons; 0 = none (a mouse released outside the canvas). */
buttons: number;
}
export interface RigPointerHost {
/** Pointer tilt allowed (motion `full`). */
tilt: boolean;
rect(): { left: number; top: number; width: number; height: number };
capture(pointerId: number): void;
release(pointerId: number): void;
/** Something changed: request a frame. */
changed(): void;
}
export interface RigPointer {
down(event: PointerSample): void;
move(event: PointerSample): void;
up(event?: Pick<PointerSample, "pointerId">): void;
leave(): void;
dispose(): void;
}
export function clampTurn(angle: number): number {
return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle));
}
export function createRigPointer(input: RigInput, host: RigPointerHost): RigPointer {
let press: { x: number; y: number; turn: number; id: number; captured: boolean } | null = null;
let springBack: ReturnType<typeof setTimeout> | null = null;
let clearDrag: ReturnType<typeof setTimeout> | null = null;
const pointer: RigPointer = {
down(event) {
press = { x: event.clientX, y: event.clientY, turn: input.turn, id: event.pointerId, captured: false };
if (springBack) clearTimeout(springBack);
springBack = null;
},
move(event) {
if (press && press.id === event.pointerId) {
// The mouse button went up outside the canvas: end the drag now.
if (event.pointerType === "mouse" && event.buttons === 0) {
pointer.up(event);
return;
}
const dx = event.clientX - press.x;
const dy = event.clientY - press.y;
if (!input.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
input.dragging = true;
press.captured = true;
host.capture(press.id);
}
if (input.dragging) {
input.turn = clampTurn(press.turn + (dx / Math.max(host.rect().width, 1)) * TURN_PER_WIDTH);
host.changed();
}
return;
}
const rect = host.rect();
if (host.tilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) {
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
input.tiltY = nx * MAX_TILT;
input.tiltX = ny * MAX_TILT * 0.5;
host.changed();
}
},
up() {
if (!press) return;
if (press.captured) host.release(press.id);
if (input.dragging) {
springBack = setTimeout(() => {
input.turn = 0;
host.changed();
}, SPRING_BACK_MS);
}
press = null;
// Let the click that follows a drag see `dragging`, then clear it.
clearDrag = setTimeout(() => {
input.dragging = false;
}, 0);
},
leave() {
input.tiltX = 0;
input.tiltY = 0;
host.changed();
},
dispose() {
if (springBack) clearTimeout(springBack);
if (clearDrag) clearTimeout(clearDrag);
},
};
return pointer;
}
@@ -0,0 +1,82 @@
import {
BackSide,
BoxGeometry,
Color,
CubeCamera,
HalfFloatType,
LinearMipmapLinearFilter,
Mesh,
MeshBasicMaterial,
PlaneGeometry,
Scene,
WebGLCubeRenderTarget,
} from "three";
import type { CubeTexture, WebGLRenderer } from "three";
// A small procedural studio, rendered once into a cube map: dark navy room,
// a white soft box above, electric-blue rim strips behind, a cyan side strip
// and a few hot spots for glints on the chamfers. Cool light only (owner
// 2026-10-16); no HDR file is downloaded.
const ENV_SIZE = 128;
interface Lightformer {
size: [number, number];
position: [number, number, number];
color: string;
intensity: number;
}
const LIGHTFORMERS: Lightformer[] = [
{ size: [9, 6], position: [-1, 9.5, -2], color: "#ffffff", intensity: 2.6 },
{ size: [1.2, 12], position: [-3.5, 1, -9.5], color: "#5a9cff", intensity: 3 },
{ size: [1.2, 12], position: [3.5, 1, -9.5], color: "#5a9cff", intensity: 3 },
{ size: [0.7, 10], position: [9.5, 1, 1], color: "#5fd2ff", intensity: 2.4 },
{ size: [12, 3], position: [0, 3, 9.5], color: "#1b3f7a", intensity: 0.8 },
{ size: [14, 14], position: [0, -9.5, 0], color: "#0b1d3d", intensity: 1 },
{ size: [0.8, 0.8], position: [-6, 8, -6], color: "#ffffff", intensity: 9 },
{ size: [0.6, 0.6], position: [6, 7, 4], color: "#dff4ff", intensity: 7 },
];
export interface StudioEnvironment {
texture: CubeTexture;
dispose(): void;
}
/** Renders the studio once; null when the renderer cannot (test renderer, lost context). */
export function createStudioEnvironment(gl: WebGLRenderer): StudioEnvironment | null {
const scene = new Scene();
const room = new Mesh(new BoxGeometry(20, 20, 20), new MeshBasicMaterial({ color: "#050b19", side: BackSide }));
scene.add(room);
for (const light of LIGHTFORMERS) {
const material = new MeshBasicMaterial({
color: new Color(light.color).multiplyScalar(light.intensity),
toneMapped: false,
});
const panel = new Mesh(new PlaneGeometry(light.size[0], light.size[1]), material);
panel.position.set(...light.position);
// lookAt points the plane's +z (its front face) at the centre of the room.
panel.lookAt(0, 0, 0);
scene.add(panel);
}
const target = new WebGLCubeRenderTarget(ENV_SIZE, {
type: HalfFloatType,
generateMipmaps: true,
minFilter: LinearMipmapLinearFilter,
});
const camera = new CubeCamera(0.1, 40, target);
try {
camera.update(gl, scene);
} catch {
target.dispose();
return null;
} finally {
scene.traverse((object) => {
if (object instanceof Mesh) {
object.geometry.dispose();
(object.material as MeshBasicMaterial).dispose();
}
});
}
return { texture: target.texture, dispose: () => target.dispose() };
}
@@ -1,33 +1,71 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useThree } from "@react-three/fiber";
import { Mesh } from "three";
import type { CubeTexture } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { createFrameTextureCache } from "./frame-textures";
import type { FrameTextureCache } from "./frame-textures";
import { createGeometryCache } from "./part-geometry";
import type { GeometryCache } from "./part-geometry";
import type { StageTier } from "./quality";
import { createStudioEnvironment } from "./scene-env";
import { readHoloTheme } from "./theme";
import type { HoloTheme } from "./theme";
// Shared per canvas: theme (tokens read once), geometries per part kind and
// size, comb textures per frame type, the motion level. Everything is
// disposed when the canvas unmounts (spec 9.9: no leak between colonies).
// size, comb textures per frame type, the studio cube map, motion level and
// quality tier. Everything is disposed when the canvas unmounts (spec 9.9:
// no leak between colonies).
/** Mutable per-frame hand-offs between scene parts (never drive rendering on their own). */
export interface SceneSignals {
/** The coming frame was requested by the idle timer, not by an animation (quality monitor skips it). */
timerFrame: boolean;
/** World point the camera frames in focus mode (depth of field focuses there). */
focusPoint: Vec3 | null;
}
export interface SceneKit {
theme: HoloTheme;
geometries: GeometryCache;
textures: FrameTextureCache;
motion: MotionLevel;
tier: StageTier;
/** Studio reflections; null until rendered or where the renderer cannot. */
env: CubeTexture | null;
signals: SceneSignals;
}
const SceneKitContext = createContext<SceneKit | null>(null);
export function SceneKitProvider({ motion, children }: { motion: MotionLevel; children: ReactNode }) {
export interface SceneKitProviderProps {
motion: MotionLevel;
tier?: StageTier;
children: ReactNode;
}
export function SceneKitProvider({ motion, tier = "high", children }: SceneKitProviderProps) {
const gl = useThree((state) => state.gl);
const invalidate = useThree((state) => state.invalidate);
const [theme] = useState(readHoloTheme);
const [geometries] = useState(createGeometryCache);
const [textures] = useState(() => createFrameTextureCache());
const [signals] = useState<SceneSignals>(() => ({ timerFrame: false, focusPoint: null }));
const [env, setEnv] = useState<CubeTexture | null>(null);
useEffect(() => {
const studio = createStudioEnvironment(gl);
// Rendered once after mount; the materials pick it up on the next render.
// eslint-disable-next-line react-hooks/set-state-in-effect -- the cube map needs the live renderer
setEnv(studio?.texture ?? null);
invalidate();
return () => studio?.dispose();
}, [gl, invalidate]);
useEffect(
() => () => {
geometries.dispose();
@@ -35,9 +73,11 @@ export function SceneKitProvider({ motion, children }: { motion: MotionLevel; ch
},
[geometries, textures],
);
return (
<SceneKitContext.Provider value={{ theme, geometries, textures, motion }}>{children}</SceneKitContext.Provider>
const kit = useMemo<SceneKit>(
() => ({ theme, geometries, textures, motion, tier, env, signals }),
[theme, geometries, textures, motion, tier, env, signals],
);
return <SceneKitContext.Provider value={kit}>{children}</SceneKitContext.Provider>;
}
export function useSceneKit(): SceneKit {
@@ -1,6 +1,7 @@
import type { Camera, Color, Material, Mesh, Scene, ShaderMaterial, Texture, WebGLRenderer } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { cameraPosition } from "./camera-fit";
import type { SceneSignals } from "./scene-kit";
// Imperative three.js updates used from frame callbacks and effects. They live
// outside the components on purpose: scene objects are mutable by design,
@@ -17,6 +18,12 @@ export function setUniformColor(material: ShaderMaterial, name: string, color: C
if (uniform) (uniform.value as Color).copy(color);
}
/** Sets a colour uniform to the blend of `from` and `to` at `t` (0…1). */
export function setUniformMix(material: ShaderMaterial, name: string, from: Color, to: Color, t: number): void {
const uniform = material.uniforms[name];
if (uniform) (uniform.value as Color).copy(from).lerp(to, Math.min(1, Math.max(0, t)));
}
export function setMaterialOpacity(material: Material, opacity: number): void {
material.opacity = opacity;
material.visible = opacity > 0.01;
@@ -29,6 +36,22 @@ export function placeCamera(camera: Camera, center: Vec3, distance: number): voi
camera.lookAt(center[0], center[1], center[2]);
}
/** Shifts the projection vertically by `offsetY` pixels (0 clears it). */
export function offsetView(camera: Camera, width: number, height: number, offsetY: number): void {
if (!("setViewOffset" in camera) || !(width > 0) || !(height > 0)) return;
const perspective = camera as Camera & {
setViewOffset(fw: number, fh: number, x: number, y: number, w: number, h: number): void;
clearViewOffset(): void;
view: { offsetY: number; enabled: boolean } | null;
};
if (Math.abs(offsetY) < 0.5) {
if (perspective.view?.enabled) perspective.clearViewOffset();
return;
}
if (perspective.view?.enabled && Math.abs(perspective.view.offsetY - offsetY) < 0.5) return;
perspective.setViewOffset(width, height, 0, offsetY, width, height);
}
export function setSceneBackground(scene: Scene, background: Texture | Color | null): void {
scene.background = background;
}
@@ -52,3 +75,37 @@ export function placeFill(
mesh.position.set(center[0], center[1] - size[1] / 2 + height / 2, center[2]);
return height;
}
/** Points an effect with a focus target (depth of field) at a world point. */
export function setEffectTarget(effect: { target: { set(x: number, y: number, z: number): unknown } | null }, point: Vec3): void {
effect.target?.set(point[0], point[1], point[2]);
}
export function setEffectBokeh(effect: { bokehScale: number }, scale: number): void {
if (effect.bokehScale !== scale) effect.bokehScale = scale;
}
export function markTimerFrame(signals: SceneSignals, value: boolean): void {
signals.timerFrame = value;
}
export function setFocusPoint(signals: SceneSignals, point: Vec3 | null): void {
signals.focusPoint = point;
}
/** Pointer capture on the canvas (missing in some test DOMs, refused for unknown ids). */
export function capturePointer(element: HTMLElement, pointerId: number): void {
try {
element.setPointerCapture?.(pointerId);
} catch {
// The pointer is gone already; buttons === 0 on the next move ends the drag instead.
}
}
export function releasePointer(element: HTMLElement, pointerId: number): void {
try {
if (element.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
} catch {
// Already released.
}
}
@@ -17,7 +17,9 @@ import { setUniform, setUniformColor } from "./scene-mutations";
import type { SceneLabel, ScenePart } from "./scene-model";
import { useAnimatedNode } from "./use-animated-node";
import type { AnimatedValues } from "./use-animated-node";
import { GLOW } from "./use-hive-motion";
import type { NodeTarget } from "./use-hive-motion";
import { useHoverSweep, useLivingEdges } from "./use-scene-effects";
// One part of the stack or one insert: glass body, glowing edges, screens,
// an invisible hit box for pointer events, its content in focus mode and its
@@ -48,14 +50,17 @@ export interface StackPartProps {
}
export function StackPart({ part, target, focused, handlers, labels, hintFrames, children }: StackPartProps) {
const { theme, geometries, motion } = useSceneKit();
const { theme, geometries, motion, env, tier } = useSceneKit();
const group = useRef<Group>(null);
const { kind, box, state, levels } = part;
const shape = geometries.shape(shapeKey(kind, box.size, state, levels.length), () =>
partShapeSpec(kind, box.size, state, levels.length),
);
const glass = useMemo(() => createGlassMaterial(theme, { frost: shape.frost }), [theme, shape.frost]);
const glass = useMemo(
() => createGlassMaterial(theme, { frost: shape.frost, env, dispersion: tier === "high" ? 0.03 : 0 }),
[theme, shape.frost, env, tier],
);
const edges = useMemo(() => createEdgeMaterial(theme), [theme]);
const grids = useMemo(
() => shape.grids.map((grid) => createGridMaterial(theme, { cell: grid.panel.cell, bars: grid.panel.bars })),
@@ -80,6 +85,8 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
grids.forEach((material) => setUniform(material, "uOpacity", opacity));
};
useAnimatedNode(group, { ...target, look: 0 }, motion, apply);
useLivingEdges([edges]);
useHoverSweep(glass, target.glow >= GLOW.hover, box.size[1]);
const showHints = hintFrames && hintFrames.length > 0 && !target.content;
const sy = box.size[1];
@@ -102,6 +109,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
{/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */}
<mesh
position={[0, -target.lift / 2, 0]}
scale={[box.size[0], box.size[1] + target.lift, box.size[2]]}
raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut}
@@ -109,7 +117,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
onClick={handlers.onClick}
userData={{ hit: part.id }}
>
<boxGeometry args={[box.size[0], box.size[1] + target.lift, box.size[2]]} />
<boxGeometry args={[1, 1, 1]} />
{/* Focused: only back faces take hits, so inserts and frames in front stay reachable. */}
<meshBasicMaterial visible={false} side={focused ? BackSide : FrontSide} />
</mesh>
@@ -6,10 +6,11 @@ import { useFrame, useThree } from "@react-three/fiber";
import type { Object3D } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { springStep } from "./quality";
import { SUBTLE_FADE_SECONDS, fadeToward, stepToward } from "./use-hive-motion";
// Moves a node toward its target (spec 9.8): `full` damps position, scale and
// fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms;
// Moves a node toward its target (spec 9.8): `full` flies position and scale
// on a spring with a slight overshoot and eases the fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms;
// `off` sets everything at once. Frames are only requested while moving.
/** After a pause the demand loop reports a long delta; never step more than this. */
@@ -30,8 +31,16 @@ export interface AnimatedValues {
look: number;
}
function stepFade(current: number, target: number, dt: number, motion: MotionLevel): number {
if (motion === "full") return stepToward(current, target, dt, true);
/** Opacity fades a little slower than the flight at `full`, so the ghost hive eases in. */
const OPACITY_RATE = 7;
function easeToward(current: number, target: number, dt: number, rate: number): number {
const next = current + (target - current) * (1 - Math.exp(-rate * dt));
return Math.abs(target - next) < 1e-3 ? target : next;
}
function stepFade(current: number, target: number, dt: number, motion: MotionLevel, rate?: number): number {
if (motion === "full") return rate ? easeToward(current, target, dt, rate) : stepToward(current, target, dt, true);
if (motion === "subtle") return fadeToward(current, target, dt, SUBTLE_FADE_SECONDS);
return target;
}
@@ -43,7 +52,13 @@ export function useAnimatedNode(
apply: (values: AnimatedValues) => void,
): void {
const invalidate = useThree((state) => state.invalidate);
const current = useRef<{ position: Vec3; scale: number; values: AnimatedValues } | null>(null);
const current = useRef<{
position: Vec3;
velocity: Vec3;
scale: number;
scaleVelocity: number;
values: AnimatedValues;
} | null>(null);
const latestApply = useRef(apply);
useEffect(() => {
latestApply.current = apply;
@@ -61,19 +76,34 @@ export function useAnimatedNode(
const goal: AnimatedValues = { opacity: target.opacity, glow: target.glow, look: target.look ?? 0 };
if (!current.current) {
// First frame: appear in place, no fly-in from the origin.
current.current = { position: [...target.position], scale: target.scale, values: goal };
current.current = {
position: [...target.position],
velocity: [0, 0, 0],
scale: target.scale,
scaleVelocity: 0,
values: goal,
};
} else {
const dt = Math.min(delta, MAX_FRAME_SECONDS);
const damped = motion === "full";
const state = current.current;
state.position = [
stepToward(state.position[0], target.position[0], dt, damped),
stepToward(state.position[1], target.position[1], dt, damped),
stepToward(state.position[2], target.position[2], dt, damped),
];
state.scale = stepToward(state.scale, target.scale, dt, damped);
if (motion === "full") {
// Flights on a spring with a touch of overshoot.
for (let axis = 0; axis < 3; axis += 1) {
const next = springStep(state.position[axis], state.velocity[axis], target.position[axis], dt);
state.position[axis] = next.value;
state.velocity[axis] = next.velocity;
}
const nextScale = springStep(state.scale, state.scaleVelocity, target.scale, dt);
state.scale = nextScale.value;
state.scaleVelocity = nextScale.velocity;
} else {
state.position = [...target.position];
state.velocity = [0, 0, 0];
state.scale = target.scale;
state.scaleVelocity = 0;
}
state.values = {
opacity: stepFade(state.values.opacity, goal.opacity, dt, motion),
opacity: stepFade(state.values.opacity, goal.opacity, dt, motion, OPACITY_RATE),
glow: stepFade(state.values.glow, goal.glow, dt, motion),
look: stepFade(state.values.look, goal.look, dt, motion),
};
@@ -0,0 +1,48 @@
"use client";
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { ShaderMaterial } from "three";
import { useSceneKit } from "./scene-kit";
import { setUniform } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
// Small time-driven effects. All of them only move at motion `full`; the
// pulses ride on frames the idle motion already requests, the hover sweep
// asks for its own frames for about one second.
/** Duration of one hover sweep (shimmer and scan line), seconds. */
export const SWEEP_SECONDS = 1.1;
/** Travelling light pulses along the edges. */
export function useLivingEdges(materials: ShaderMaterial[]): void {
const { motion } = useSceneKit();
useFrame((state) => {
const pulse = motion === "full" ? 1 : 0;
for (const material of materials) {
setUniform(material, "uTime", state.clock.elapsedTime);
setUniform(material, "uPulse", pulse);
}
});
}
/** A holographic shimmer and a thin scan line run once across a part when it becomes hovered. */
export function useHoverSweep(material: ShaderMaterial, active: boolean, height: number): void {
const { motion } = useSceneKit();
const invalidate = useThree((state) => state.invalidate);
const clock = useRef(0);
useFrame((_, delta) => {
if (motion !== "full" || !active) {
if (clock.current !== 0) {
clock.current = 0;
setUniform(material, "uSweep", 0);
}
return;
}
clock.current += Math.min(delta, MAX_FRAME_SECONDS);
const sweep = Math.min(clock.current / SWEEP_SECONDS, 1);
setUniform(material, "uHeight", height);
setUniform(material, "uSweep", sweep >= 1 ? 0 : sweep);
if (sweep < 1) invalidate();
});
}