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:
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();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user