feat(t5): Beute-Bühne folgt Sonne und Darstellung live (T5a A12)
- useHoloTheme liest die --holo-*-Token nur bei einem Wechsel von Dunkel/Sonne neu (Attribut-Beobachter plus apicore:appearance), nie pro Bild; gleiche Token behalten das Theme, nur Farbmaterialien werden neu gebaut und alte freigegeben. - Sonne nach Beute-Spec 9.3 und T5 17.8: schwarze Bühne ohne Boden, Spiegel, Bloom, Vignette, Dunst und Partikel; weiße Kanten 2 px in Bildschirmbreite (Hover, aktiver Platz, Vorschau 3 px), Rähmchen schwarz-weiß schraffiert, Labels 16 px auf fester Platte, neutrales Licht. - Glas-Knöpfe ohne Blur und Schein in Sonne. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
fce9d10192
commit
7025a6b589
18 files changed
+863
-132
No files matched your search
@@ -1,12 +1,13 @@
|
|||||||
// Frosted glass surfaces over the holo stage (mockups g2/h2). Text is
|
// Frosted glass surfaces over the holo stage (mockups g2/h2). Text is
|
||||||
// --holo-label-fg on --holo-label-bg at 70 % over the midnight stage, well
|
// --holo-label-fg on --holo-label-bg at 70 % over the midnight stage, well
|
||||||
// above 4.5:1. The focus ring comes from the global rule; .holo-stage on the
|
// above 4.5:1. The focus ring comes from the global rule; .holo-stage on the
|
||||||
// stage root draws it in the strong edge colour (spec 17.8).
|
// stage root draws it in the strong edge colour (spec 17.8). In sun the stage
|
||||||
|
// is black: no blur and no glow (T5 spec 17.6), the fill stays.
|
||||||
|
|
||||||
const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md";
|
const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md sun:backdrop-blur-none";
|
||||||
|
|
||||||
export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] ${GLASS_FILL}`;
|
export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] sun:shadow-none ${GLASS_FILL}`;
|
||||||
|
|
||||||
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-2 aria-pressed:border-holo-edge-strong aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] ${GLASS_FILL}`;
|
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-2 aria-pressed:border-holo-edge-strong aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] sun:shadow-none ${GLASS_FILL}`;
|
||||||
|
|
||||||
export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 ${GLASS_SURFACE}`;
|
export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 ${GLASS_SURFACE}`;
|
||||||
@@ -11,12 +11,12 @@ import type { DropSlot } from "../editor/drop-slots";
|
|||||||
import { pickAnchor } from "./drop-anchors";
|
import { pickAnchor } from "./drop-anchors";
|
||||||
import type { SlotAnchor } from "./drop-anchors";
|
import type { SlotAnchor } from "./drop-anchors";
|
||||||
import { clampZoom, pinchZoom, wheelZoom } from "./camera-fit";
|
import { clampZoom, pinchZoom, wheelZoom } from "./camera-fit";
|
||||||
import { createEdgeMaterial, createGlassMaterial } from "./materials";
|
import { createEdgeMaterial, createGlassMaterial, setEdgeIntensity } from "./materials";
|
||||||
import { buildEdgeGeometry, buildGlassGeometry } from "./part-geometry";
|
import { buildEdgeGeometry, buildGlassGeometry } from "./part-geometry";
|
||||||
import type { EdgeSegment } from "./part-geometry";
|
import type { EdgeSegment } from "./part-geometry";
|
||||||
import { noRaycast, useSceneKit } from "./scene-kit";
|
import { noRaycast, useSceneKit } from "./scene-kit";
|
||||||
import { setPinching, setUniform, setUniformColor } from "./scene-mutations";
|
import { setPinching, setUniform, setUniformColor } from "./scene-mutations";
|
||||||
import { useGlassEnvironment } from "./use-scene-effects";
|
import { useEdgeViewport, useGlassEnvironment } from "./use-scene-effects";
|
||||||
|
|
||||||
// Editor drop targets (spec 9.5): every legal slot glows, the slot under the
|
// Editor drop targets (spec 9.5): every legal slot glows, the slot under the
|
||||||
// pointer lights up and a glass preview of the dragged part snaps into it.
|
// pointer lights up and a glass preview of the dragged part snaps into it.
|
||||||
@@ -38,11 +38,14 @@ function outline(size: Vec3, strength: number): EdgeSegment[] {
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tube width of the slot markers (a little wider than the part edges). */
|
||||||
|
const SLOT_EDGE_WIDTH = 0.03;
|
||||||
|
|
||||||
/** The glowing place of one slot: a flat frame for a stack gap, a box outline for an insert. */
|
/** The glowing place of one slot: a flat frame for a stack gap, a box outline for an insert. */
|
||||||
function SlotMarker({ anchor, material }: { anchor: SlotAnchor; material: ShaderMaterial }) {
|
function SlotMarker({ anchor, material }: { anchor: SlotAnchor; material: ShaderMaterial }) {
|
||||||
const flat = anchor.slot.kind === "stack";
|
const flat = anchor.slot.kind === "stack";
|
||||||
const [mx, my, mz] = flat ? [anchor.size[0], 0.001, anchor.size[2]] : anchor.size;
|
const [mx, my, mz] = flat ? [anchor.size[0], 0.001, anchor.size[2]] : anchor.size;
|
||||||
const geometry = useMemo(() => buildEdgeGeometry(outline([mx, my, mz], 1), 0.03), [mx, my, mz]);
|
const geometry = useMemo(() => buildEdgeGeometry(outline([mx, my, mz], 1), SLOT_EDGE_WIDTH), [mx, my, mz]);
|
||||||
useEffect(() => () => geometry.dispose(), [geometry]);
|
useEffect(() => () => geometry.dispose(), [geometry]);
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
@@ -68,6 +71,7 @@ function Preview({ anchor }: { anchor: SlotAnchor }) {
|
|||||||
const glass = useMemo(() => createGlassMaterial(theme, { frost: 0.75 }), [theme]);
|
const glass = useMemo(() => createGlassMaterial(theme, { frost: 0.75 }), [theme]);
|
||||||
useGlassEnvironment(glass, true);
|
useGlassEnvironment(glass, true);
|
||||||
const glow = useMemo(() => createEdgeMaterial(theme), [theme]);
|
const glow = useMemo(() => createEdgeMaterial(theme), [theme]);
|
||||||
|
useEdgeViewport([glow]);
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
geometry.dispose();
|
geometry.dispose();
|
||||||
@@ -81,7 +85,7 @@ function Preview({ anchor }: { anchor: SlotAnchor }) {
|
|||||||
// Brighter than the parts around it, so the snapped place reads at once.
|
// Brighter than the parts around it, so the snapped place reads at once.
|
||||||
setUniform(glass, "uOpacity", 0.9);
|
setUniform(glass, "uOpacity", 0.9);
|
||||||
setUniform(glass, "uGlow", 2.2);
|
setUniform(glass, "uGlow", 2.2);
|
||||||
setUniform(glow, "uIntensity", 3);
|
setEdgeIntensity(glow, 3);
|
||||||
setUniformColor(glow, "uCyan", theme.edgeStrong);
|
setUniformColor(glow, "uCyan", theme.edgeStrong);
|
||||||
}, [glass, glow, theme]);
|
}, [glass, glow, theme]);
|
||||||
return (
|
return (
|
||||||
@@ -118,10 +122,18 @@ export function DropTargets({ anchors, hover, pointer, subscribePointer, onHover
|
|||||||
const hovered = anchors.find((anchor) => sameSlot(anchor.slot, hover)) ?? null;
|
const hovered = anchors.find((anchor) => sameSlot(anchor.slot, hover)) ?? null;
|
||||||
|
|
||||||
// Two shared materials for every marker: idle and the one under the pointer.
|
// Two shared materials for every marker: idle and the one under the pointer.
|
||||||
const materials = useMemo(() => ({ idle: createEdgeMaterial(theme), active: createEdgeMaterial(theme) }), [theme]);
|
// In sun the idle ones are grey and the active one white and wider (no glow to tell them apart).
|
||||||
|
const materials = useMemo(
|
||||||
|
() => ({
|
||||||
|
idle: createEdgeMaterial(theme, { tubeWidth: SLOT_EDGE_WIDTH, sunGrey: true }),
|
||||||
|
active: createEdgeMaterial(theme, { tubeWidth: SLOT_EDGE_WIDTH }),
|
||||||
|
}),
|
||||||
|
[theme],
|
||||||
|
);
|
||||||
|
useEdgeViewport([materials.idle, materials.active]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUniform(materials.idle, "uIntensity", 1.4);
|
setEdgeIntensity(materials.idle, 1.4);
|
||||||
setUniform(materials.active, "uIntensity", 3.2);
|
setEdgeIntensity(materials.active, 3.2);
|
||||||
return () => {
|
return () => {
|
||||||
materials.idle.dispose();
|
materials.idle.dispose();
|
||||||
materials.active.dispose();
|
materials.active.dispose();
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { Color, MeshStandardMaterial } from "three";
|
import { MeshBasicMaterial, MeshStandardMaterial } from "three";
|
||||||
import type { Group, ShaderMaterial } from "three";
|
import type { Group, Material, ShaderMaterial, Texture } from "three";
|
||||||
import { subVec3 } from "@/lib/hive/layout";
|
import { subVec3 } from "@/lib/hive/layout";
|
||||||
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
|
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
|
||||||
import { HoloLabel } from "./hive-labels";
|
import { HoloLabel } from "./hive-labels";
|
||||||
import { createEdgeMaterial, createGlassMaterial } from "./materials";
|
import { createEdgeMaterial, createGlassMaterial, setEdgeIntensity } from "./materials";
|
||||||
import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry";
|
import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry";
|
||||||
import type { PartShapeSpec } from "./part-geometry";
|
import type { PartShapeSpec } from "./part-geometry";
|
||||||
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
|
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
|
||||||
import { setMaterialOpacity, setUniform, setUniformMix } from "./scene-mutations";
|
import { setMaterialOpacity, setUniform, setUniformMix } from "./scene-mutations";
|
||||||
import { useGlassEnvironment, useLivingEdges } from "./use-scene-effects";
|
import { useGlassEnvironment, useLivingEdges } from "./use-scene-effects";
|
||||||
import { frameRimColor } from "./frame-textures";
|
import { frameLookFor } from "./frame-textures";
|
||||||
import type { SceneLabel } from "./scene-model";
|
import type { SceneLabel } from "./scene-model";
|
||||||
import type { NodeHandlers } from "./stack-part";
|
import type { NodeHandlers } from "./stack-part";
|
||||||
import { useAnimatedNode } from "./use-animated-node";
|
import { useAnimatedNode } from "./use-animated-node";
|
||||||
@@ -21,7 +21,8 @@ import type { FrameTarget } from "./use-hive-motion";
|
|||||||
|
|
||||||
// Frames (spec 9.3/9.4). At rest only their top bars are hinted inside the
|
// Frames (spec 9.3/9.4). At rest only their top bars are hinted inside the
|
||||||
// box. In an open box each frame is a clear glass plate; hovered or selected
|
// box. In an open box each frame is a clear glass plate; hovered or selected
|
||||||
// it rises and takes its type look (colour and comb pattern).
|
// it rises and takes its type look (colour and comb pattern; in sun a black
|
||||||
|
// and white hatch per type with a white rim instead).
|
||||||
|
|
||||||
/** Faint top bars of all frames of a box, one merged mesh using the box's materials. */
|
/** Faint top bars of all frames of a box, one merged mesh using the box's materials. */
|
||||||
export function FrameHints({
|
export function FrameHints({
|
||||||
@@ -69,6 +70,31 @@ export interface FramePlateProps {
|
|||||||
|
|
||||||
const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 };
|
const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 };
|
||||||
|
|
||||||
|
/** Above the neutral tone mapping's knee: the white of a sun hatch comes out white, not 88 % grey. */
|
||||||
|
const SUN_HATCH_GAIN = 6;
|
||||||
|
|
||||||
|
/** The comb of a lifted frame: lit comb texture, or in sun the hatch unlit (stays pure black and white). */
|
||||||
|
export function frameTypeMaterial(texture: Texture, hatch: boolean, type: FrameBox["type"]): Material {
|
||||||
|
if (hatch) {
|
||||||
|
const material = new MeshBasicMaterial({ map: texture, transparent: true, opacity: 0 });
|
||||||
|
material.color.setScalar(SUN_HATCH_GAIN);
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
return 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.8,
|
||||||
|
roughness: type === "HONEY" ? 0.2 : 0.45,
|
||||||
|
metalness: 0.05,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) {
|
export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) {
|
||||||
const { theme, geometries, textures, motion } = useSceneKit();
|
const { theme, geometries, textures, motion } = useSceneKit();
|
||||||
const group = useRef<Group>(null);
|
const group = useRef<Group>(null);
|
||||||
@@ -93,32 +119,18 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
|
|||||||
// While mounted the plate holds its shapes, so the cache does not dispose them.
|
// While mounted the plate holds its shapes, so the cache does not dispose them.
|
||||||
useEffect(() => geometries.retain(barsKey, bars), [geometries, barsKey, bars]);
|
useEffect(() => geometries.retain(barsKey, bars), [geometries, barsKey, bars]);
|
||||||
useEffect(() => (combKey && comb ? geometries.retain(combKey, comb) : undefined), [geometries, combKey, comb]);
|
useEffect(() => (combKey && comb ? geometries.retain(combKey, comb) : undefined), [geometries, combKey, comb]);
|
||||||
const texture = textures.get(frame.type);
|
const look = useMemo(() => frameLookFor(frame.type, { sun: theme.sun }), [frame.type, theme.sun]);
|
||||||
const rimHex = frameRimColor(frame.type);
|
const texture = look.hatch ? textures.hatch(look.hatch) : textures.get(frame.type);
|
||||||
const rim = useMemo(() => (rimHex ? new Color(rimHex) : theme.edgeStrong), [rimHex, theme]);
|
const rim = look.color ?? theme.edgeStrong;
|
||||||
|
|
||||||
const materials = useMemo(
|
const materials = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
bars: createGlassMaterial(theme, { frost: 0.28 }),
|
bars: createGlassMaterial(theme, { frost: 0.28 }),
|
||||||
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }),
|
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }),
|
||||||
edges: createEdgeMaterial(theme),
|
edges: createEdgeMaterial(theme),
|
||||||
typed: texture
|
typed: texture ? frameTypeMaterial(texture, look.hatch !== null, frame.type) : null,
|
||||||
? 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.8,
|
|
||||||
roughness: frame.type === "HONEY" ? 0.2 : 0.45,
|
|
||||||
metalness: 0.05,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0,
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
}),
|
}),
|
||||||
[theme, texture, frame.type],
|
[theme, texture, look.hatch, frame.type],
|
||||||
);
|
);
|
||||||
useGlassEnvironment(materials.bars, true);
|
useGlassEnvironment(materials.bars, true);
|
||||||
useGlassEnvironment(materials.comb, false);
|
useGlassEnvironment(materials.comb, false);
|
||||||
@@ -140,7 +152,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
|
|||||||
setUniform(materials.comb, "uOpacity", opacity * (1 - (materials.typed ? look * 0.92 : 0)));
|
setUniform(materials.comb, "uOpacity", opacity * (1 - (materials.typed ? look * 0.92 : 0)));
|
||||||
setUniform(materials.comb, "uGlow", glow);
|
setUniform(materials.comb, "uGlow", glow);
|
||||||
setUniform(materials.edges, "uOpacity", opacity);
|
setUniform(materials.edges, "uOpacity", opacity);
|
||||||
setUniform(materials.edges, "uIntensity", 1.3 * glow);
|
setEdgeIntensity(materials.edges, 1.3 * glow);
|
||||||
// Lifted, the rim takes the colour of the frame type.
|
// Lifted, the rim takes the colour of the frame type.
|
||||||
setUniformMix(materials.edges, "uCyan", theme.edge, rim, look);
|
setUniformMix(materials.edges, "uCyan", theme.edge, rim, look);
|
||||||
if (materials.typed) setMaterialOpacity(materials.typed, opacity * look);
|
if (materials.typed) setMaterialOpacity(materials.typed, opacity * look);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { FRAME_TYPES } from "@/lib/hive/types";
|
import { FRAME_TYPES } from "@/lib/hive/types";
|
||||||
import type { FrameType } from "@/lib/hive/types";
|
import type { FrameType } from "@/lib/hive/types";
|
||||||
import { createFrameTextureCache, framePatternSpec, frameRimColor } from "./frame-textures";
|
import { createFrameTextureCache, frameLookFor, framePatternSpec, frameRimColor } from "./frame-textures";
|
||||||
|
|
||||||
/** A canvas whose 2D context records every fill colour and shape drawn. */
|
/** A canvas whose 2D context records every fill colour and shape drawn. */
|
||||||
function fakeCanvas() {
|
function fakeCanvas() {
|
||||||
@@ -20,13 +20,13 @@ function fakeCanvas() {
|
|||||||
globalAlpha: 1,
|
globalAlpha: 1,
|
||||||
fillRect: vi.fn(() => fills.push(`rect:${String(fillStyle)}`)),
|
fillRect: vi.fn(() => fills.push(`rect:${String(fillStyle)}`)),
|
||||||
beginPath: vi.fn(),
|
beginPath: vi.fn(),
|
||||||
moveTo: vi.fn(),
|
moveTo: vi.fn((x: number, y: number) => fills.push(`move:${x.toFixed(1)},${y.toFixed(1)}`)),
|
||||||
lineTo: vi.fn(),
|
lineTo: vi.fn((x: number, y: number) => fills.push(`line:${x.toFixed(1)},${y.toFixed(1)}`)),
|
||||||
closePath: vi.fn(),
|
closePath: vi.fn(),
|
||||||
arc: vi.fn(),
|
arc: vi.fn(),
|
||||||
ellipse: vi.fn(),
|
ellipse: vi.fn(),
|
||||||
fill: vi.fn(() => fills.push(typeof fillStyle === "string" ? fillStyle : "gradient")),
|
fill: vi.fn(() => fills.push(typeof fillStyle === "string" ? fillStyle : "gradient")),
|
||||||
stroke: vi.fn(),
|
stroke: vi.fn(() => fills.push("stroke")),
|
||||||
createRadialGradient: vi.fn(() => gradient),
|
createRadialGradient: vi.fn(() => gradient),
|
||||||
createLinearGradient: vi.fn(() => gradient),
|
createLinearGradient: vi.fn(() => gradient),
|
||||||
};
|
};
|
||||||
@@ -116,3 +116,63 @@ describe("frameRimColor", () => {
|
|||||||
expect(frameRimColor("NONE")).toBeNull();
|
expect(frameRimColor("NONE")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("frameLookFor", () => {
|
||||||
|
const typed = FRAME_TYPES.filter((type) => type !== "NONE");
|
||||||
|
|
||||||
|
it("tints a lifted frame in its type colour and draws no hatch outside sun", () => {
|
||||||
|
for (const type of typed) {
|
||||||
|
const look = frameLookFor(type, { sun: false });
|
||||||
|
expect(look.hatch).toBeNull();
|
||||||
|
expect(`#${look.color!.getHexString()}`).toBe(frameRimColor(type));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("in sun every frame type has its own hatch and no tint", () => {
|
||||||
|
const hatches = typed.map((type) => frameLookFor(type, { sun: true }));
|
||||||
|
for (const look of hatches) {
|
||||||
|
expect(look.color).toBeNull();
|
||||||
|
expect(look.hatch).not.toBeNull();
|
||||||
|
}
|
||||||
|
expect(new Set(hatches.map((look) => look.hatch)).size).toBe(typed.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty slot has neither tint nor hatch", () => {
|
||||||
|
expect(frameLookFor("NONE", { sun: false })).toEqual({ color: null, hatch: null });
|
||||||
|
expect(frameLookFor("NONE", { sun: true })).toEqual({ color: null, hatch: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createFrameTextureCache hatches", () => {
|
||||||
|
it("draws one cached black-and-white hatch texture per kind, each with its own strokes", () => {
|
||||||
|
const drawn: string[][] = [];
|
||||||
|
const cache = createFrameTextureCache(() => {
|
||||||
|
const fake = fakeCanvas();
|
||||||
|
drawn.push(fake.fills);
|
||||||
|
return fake.canvas;
|
||||||
|
});
|
||||||
|
const kinds = FRAME_TYPES.map((type) => frameLookFor(type, { sun: true }).hatch).filter(
|
||||||
|
(kind): kind is NonNullable<typeof kind> => kind !== null,
|
||||||
|
);
|
||||||
|
const first = cache.hatch(kinds[0]);
|
||||||
|
expect(first).not.toBeNull();
|
||||||
|
expect(cache.hatch(kinds[0])).toBe(first);
|
||||||
|
kinds.slice(1).forEach((kind) => cache.hatch(kind));
|
||||||
|
expect(drawn).toHaveLength(kinds.length);
|
||||||
|
// Black ground, white lines: no colour at all.
|
||||||
|
for (const fills of drawn) {
|
||||||
|
expect(fills[0]).toBe("rect:#000000");
|
||||||
|
const paints = fills.filter((entry) => entry.startsWith("rect:") || entry.startsWith("#"));
|
||||||
|
expect(paints.every((entry) => ["rect:#000000", "rect:#ffffff", "#ffffff"].includes(entry))).toBe(true);
|
||||||
|
}
|
||||||
|
expect(new Set(drawn.map((fills) => fills.join("|"))).size).toBe(kinds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disposes the hatch textures with the comb textures", () => {
|
||||||
|
const cache = createFrameTextureCache(() => fakeCanvas().canvas);
|
||||||
|
const hatch = cache.hatch(frameLookFor("BROOD", { sun: true }).hatch!)!;
|
||||||
|
const dispose = vi.spyOn(hatch, "dispose");
|
||||||
|
cache.dispose();
|
||||||
|
expect(dispose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { CanvasTexture, ClampToEdgeWrapping, SRGBColorSpace } from "three";
|
import { CanvasTexture, ClampToEdgeWrapping, Color, SRGBColorSpace } from "three";
|
||||||
import type { FrameType } from "@/lib/hive/types";
|
import type { FrameType } from "@/lib/hive/types";
|
||||||
|
|
||||||
// Procedural comb textures for the frame look (spec 9.3): colour AND pattern
|
// Procedural comb textures for the frame look (spec 9.3): colour AND pattern
|
||||||
// per frame type, drawn once on a canvas and cached per type. No image assets.
|
// per frame type, drawn once on a canvas and cached per type. No image assets.
|
||||||
|
// In sun the tints give way to a black-and-white hatch per type (spec 9.3,
|
||||||
|
// T5 spec 17.8); the type also stands as a word in the label.
|
||||||
|
|
||||||
const WIDTH = 512;
|
const WIDTH = 512;
|
||||||
const HEIGHT = 288;
|
const HEIGHT = 288;
|
||||||
@@ -145,6 +147,44 @@ export function frameRimColor(type: FrameType): string | null {
|
|||||||
return SPECS[type]?.rim ?? OPEN_RIMS[type] ?? null;
|
return SPECS[type]?.rim ?? OPEN_RIMS[type] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Black-and-white patterns that tell the frame types apart in sun without colour. */
|
||||||
|
export type HatchKind =
|
||||||
|
| "crosshatch"
|
||||||
|
| "rings"
|
||||||
|
| "horizontal"
|
||||||
|
| "dots"
|
||||||
|
| "diagonal"
|
||||||
|
| "grid"
|
||||||
|
| "vertical"
|
||||||
|
| "zigzag"
|
||||||
|
| "checker";
|
||||||
|
|
||||||
|
const HATCHES: Partial<Record<FrameType, HatchKind>> = {
|
||||||
|
BROOD: "crosshatch",
|
||||||
|
DRONE: "rings",
|
||||||
|
HONEY: "horizontal",
|
||||||
|
POLLEN: "dots",
|
||||||
|
FEED: "diagonal",
|
||||||
|
FOUNDATION: "grid",
|
||||||
|
EMPTY_COMB: "vertical",
|
||||||
|
DRONE_FRAME: "zigzag",
|
||||||
|
DIVIDER: "checker",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FrameLook {
|
||||||
|
/** Rim colour of a lifted or selected frame; null in sun and for an empty slot. */
|
||||||
|
color: Color | null;
|
||||||
|
/** Hatch in place of the tint (sun only). */
|
||||||
|
hatch: HatchKind | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a lifted or selected frame shows its type: a tint, or in sun a hatch. */
|
||||||
|
export function frameLookFor(type: FrameType, { sun }: { sun: boolean }): FrameLook {
|
||||||
|
if (sun) return { color: null, hatch: HATCHES[type] ?? null };
|
||||||
|
const rim = frameRimColor(type);
|
||||||
|
return { color: rim ? new Color(rim) : null, hatch: null };
|
||||||
|
}
|
||||||
|
|
||||||
/** Small deterministic PRNG (mulberry32). */
|
/** Small deterministic PRNG (mulberry32). */
|
||||||
function random(seed: number): () => number {
|
function random(seed: number): () => number {
|
||||||
let state = seed >>> 0;
|
let state = seed >>> 0;
|
||||||
@@ -244,41 +284,141 @@ function drawPattern(context: CanvasRenderingContext2D, spec: FramePatternSpec):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hatch spacing and line width in canvas pixels: coarse enough to read on a phone in the sun. */
|
||||||
|
const HATCH_STEP = 24;
|
||||||
|
const HATCH_LINE = 5;
|
||||||
|
const HATCH_INK = "#ffffff";
|
||||||
|
|
||||||
|
function strokeLines(context: CanvasRenderingContext2D, lines: [number, number, number, number][]): void {
|
||||||
|
context.beginPath();
|
||||||
|
for (const [x1, y1, x2, y2] of lines) {
|
||||||
|
context.moveTo(x1, y1);
|
||||||
|
context.lineTo(x2, y2);
|
||||||
|
}
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function steps(from: number, to: number, step: number): number[] {
|
||||||
|
const values: number[] = [];
|
||||||
|
for (let value = from; value < to; value += step) values.push(value);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HATCH_DRAWERS: Record<HatchKind, (context: CanvasRenderingContext2D) => void> = {
|
||||||
|
horizontal: (context) =>
|
||||||
|
strokeLines(context, steps(HATCH_STEP / 2, HEIGHT, HATCH_STEP).map((y) => [0, y, WIDTH, y])),
|
||||||
|
vertical: (context) =>
|
||||||
|
strokeLines(context, steps(HATCH_STEP / 2, WIDTH, HATCH_STEP).map((x) => [x, 0, x, HEIGHT])),
|
||||||
|
grid: (context) => {
|
||||||
|
HATCH_DRAWERS.horizontal(context);
|
||||||
|
HATCH_DRAWERS.vertical(context);
|
||||||
|
},
|
||||||
|
diagonal: (context) =>
|
||||||
|
strokeLines(context, steps(-HEIGHT, WIDTH, HATCH_STEP).map((x) => [x, HEIGHT, x + HEIGHT, 0])),
|
||||||
|
crosshatch: (context) => {
|
||||||
|
HATCH_DRAWERS.diagonal(context);
|
||||||
|
strokeLines(context, steps(-HEIGHT, WIDTH, HATCH_STEP).map((x) => [x, 0, x + HEIGHT, HEIGHT]));
|
||||||
|
},
|
||||||
|
zigzag: (context) => {
|
||||||
|
const half = HATCH_STEP / 2;
|
||||||
|
strokeLines(
|
||||||
|
context,
|
||||||
|
steps(HATCH_STEP, HEIGHT, HATCH_STEP * 1.5).flatMap((y) =>
|
||||||
|
steps(0, WIDTH, HATCH_STEP).flatMap((x): [number, number, number, number][] => [
|
||||||
|
[x, y, x + half, y - half],
|
||||||
|
[x + half, y - half, x + HATCH_STEP, y],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
dots: (context) => {
|
||||||
|
context.fillStyle = HATCH_INK;
|
||||||
|
for (const y of steps(HATCH_STEP / 2, HEIGHT, HATCH_STEP)) {
|
||||||
|
for (const x of steps(HATCH_STEP / 2, WIDTH, HATCH_STEP)) {
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(x, y, HATCH_LINE * 0.9, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rings: (context) => {
|
||||||
|
const step = HATCH_STEP * 1.5;
|
||||||
|
for (const y of steps(step / 2, HEIGHT, step)) {
|
||||||
|
for (const x of steps(step / 2, WIDTH, step)) {
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(x, y, step * 0.32, 0, Math.PI * 2);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
checker: (context) => {
|
||||||
|
const step = HATCH_STEP * 1.5;
|
||||||
|
context.fillStyle = HATCH_INK;
|
||||||
|
steps(0, HEIGHT, step).forEach((y, row) => {
|
||||||
|
for (const x of steps(row % 2 === 0 ? 0 : step, WIDTH, step * 2)) context.fillRect(x, y, step, step);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawHatch(context: CanvasRenderingContext2D, kind: HatchKind): void {
|
||||||
|
context.fillStyle = "#000000";
|
||||||
|
context.fillRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
context.strokeStyle = HATCH_INK;
|
||||||
|
context.lineWidth = HATCH_LINE;
|
||||||
|
HATCH_DRAWERS[kind](context);
|
||||||
|
}
|
||||||
|
|
||||||
export interface FrameTextureCache {
|
export interface FrameTextureCache {
|
||||||
/** Texture of a frame type, created on first use; null for types without a comb or without 2D canvas. */
|
/** Texture of a frame type, created on first use; null for types without a comb or without 2D canvas. */
|
||||||
get(type: FrameType): CanvasTexture | null;
|
get(type: FrameType): CanvasTexture | null;
|
||||||
|
/** Black-and-white hatch of a frame type in sun, created on first use; null without 2D canvas. */
|
||||||
|
hatch(kind: HatchKind): CanvasTexture | null;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultCanvas = (): HTMLCanvasElement => document.createElement("canvas");
|
const defaultCanvas = (): HTMLCanvasElement => document.createElement("canvas");
|
||||||
|
|
||||||
|
function canvasTexture(
|
||||||
|
makeCanvas: () => HTMLCanvasElement,
|
||||||
|
draw: (context: CanvasRenderingContext2D) => void,
|
||||||
|
): CanvasTexture | null {
|
||||||
|
const canvas = makeCanvas();
|
||||||
|
canvas.width = WIDTH;
|
||||||
|
canvas.height = HEIGHT;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return null;
|
||||||
|
draw(context);
|
||||||
|
const texture = new CanvasTexture(canvas);
|
||||||
|
texture.colorSpace = SRGBColorSpace;
|
||||||
|
texture.wrapS = ClampToEdgeWrapping;
|
||||||
|
texture.wrapT = ClampToEdgeWrapping;
|
||||||
|
texture.anisotropy = 4;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
export function createFrameTextureCache(makeCanvas: () => HTMLCanvasElement = defaultCanvas): FrameTextureCache {
|
export function createFrameTextureCache(makeCanvas: () => HTMLCanvasElement = defaultCanvas): FrameTextureCache {
|
||||||
const textures = new Map<FrameType, CanvasTexture | null>();
|
const textures = new Map<FrameType, CanvasTexture | null>();
|
||||||
|
const hatches = new Map<HatchKind, CanvasTexture | null>();
|
||||||
return {
|
return {
|
||||||
get(type) {
|
get(type) {
|
||||||
if (textures.has(type)) return textures.get(type) ?? null;
|
if (textures.has(type)) return textures.get(type) ?? null;
|
||||||
const spec = framePatternSpec(type);
|
const spec = framePatternSpec(type);
|
||||||
if (!spec) return null;
|
if (!spec) return null;
|
||||||
const canvas = makeCanvas();
|
const texture = canvasTexture(makeCanvas, (context) => drawPattern(context, spec));
|
||||||
canvas.width = WIDTH;
|
|
||||||
canvas.height = HEIGHT;
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) {
|
|
||||||
textures.set(type, null);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
drawPattern(context, spec);
|
|
||||||
const texture = new CanvasTexture(canvas);
|
|
||||||
texture.colorSpace = SRGBColorSpace;
|
|
||||||
texture.wrapS = ClampToEdgeWrapping;
|
|
||||||
texture.wrapT = ClampToEdgeWrapping;
|
|
||||||
texture.anisotropy = 4;
|
|
||||||
textures.set(type, texture);
|
textures.set(type, texture);
|
||||||
return texture;
|
return texture;
|
||||||
},
|
},
|
||||||
|
hatch(kind) {
|
||||||
|
if (hatches.has(kind)) return hatches.get(kind) ?? null;
|
||||||
|
const texture = canvasTexture(makeCanvas, (context) => drawHatch(context, kind));
|
||||||
|
hatches.set(kind, texture);
|
||||||
|
return texture;
|
||||||
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
textures.forEach((texture) => texture?.dispose());
|
textures.forEach((texture) => texture?.dispose());
|
||||||
textures.clear();
|
textures.clear();
|
||||||
|
hatches.forEach((texture) => texture?.dispose());
|
||||||
|
hatches.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,10 @@ import type { SceneLabel } from "./scene-model";
|
|||||||
|
|
||||||
// DOM labels in the scene (spec 9.3): sharp, translatable text in a glass
|
// DOM labels in the scene (spec 9.3): sharp, translatable text in a glass
|
||||||
// pill with a thin leader line. At most three at once (sceneLabels caps it).
|
// pill with a thin leader line. At most three at once (sceneLabels caps it).
|
||||||
|
// In sun 16 px on a solid plate, without blur and glow (spec 9.3, T5 17.6).
|
||||||
|
|
||||||
const PILL =
|
export const HOLO_LABEL_PILL =
|
||||||
"whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md";
|
"whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md sun:border-holo-edge sun:bg-holo-label-bg sun:text-base sun:shadow-none sun:backdrop-blur-none";
|
||||||
|
|
||||||
export interface HoloLabelProps {
|
export interface HoloLabelProps {
|
||||||
position: Vec3;
|
position: Vec3;
|
||||||
@@ -22,7 +23,7 @@ export function HoloLabel({ position, text, tone }: HoloLabelProps) {
|
|||||||
<Html position={position} zIndexRange={[30, 10]} style={{ pointerEvents: "none" }}>
|
<Html position={position} zIndexRange={[30, 10]} style={{ pointerEvents: "none" }}>
|
||||||
{tone === "frame" ? (
|
{tone === "frame" ? (
|
||||||
<div className="pointer-events-none flex -translate-x-1/2 -translate-y-full flex-col items-center select-none">
|
<div className="pointer-events-none flex -translate-x-1/2 -translate-y-full flex-col items-center select-none">
|
||||||
<span className={PILL}>{text}</span>
|
<span className={HOLO_LABEL_PILL}>{text}</span>
|
||||||
<span aria-hidden className="h-6 w-px bg-holo-edge/80" />
|
<span aria-hidden className="h-6 w-px bg-holo-edge/80" />
|
||||||
<span aria-hidden className="size-1.5 rounded-full bg-holo-edge-strong shadow-[0_0_8px_var(--holo-edge)]" />
|
<span aria-hidden className="size-1.5 rounded-full bg-holo-edge-strong shadow-[0_0_8px_var(--holo-edge)]" />
|
||||||
</div>
|
</div>
|
||||||
@@ -30,7 +31,7 @@ export function HoloLabel({ position, text, tone }: HoloLabelProps) {
|
|||||||
<div className="pointer-events-none flex -translate-y-1/2 items-center select-none">
|
<div className="pointer-events-none flex -translate-y-1/2 items-center select-none">
|
||||||
<span aria-hidden className="size-1.5 rounded-full bg-holo-edge-strong shadow-[0_0_8px_var(--holo-edge)]" />
|
<span aria-hidden className="size-1.5 rounded-full bg-holo-edge-strong shadow-[0_0_8px_var(--holo-edge)]" />
|
||||||
<span aria-hidden className="h-px w-7 bg-holo-edge/80" />
|
<span aria-hidden className="h-px w-7 bg-holo-edge/80" />
|
||||||
<span className={PILL}>
|
<span className={HOLO_LABEL_PILL}>
|
||||||
{tone === "level" ? <span aria-hidden className="me-2 inline-block size-2 rounded-full bg-amber-400 align-middle" /> : null}
|
{tone === "level" ? <span aria-hidden className="me-2 inline-block size-2 rounded-full bg-amber-400 align-middle" /> : null}
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { partDisplayName } from "@/lib/hive/names";
|
|||||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||||
import type { HiveState } from "@/lib/hive/types";
|
import type { HiveState } from "@/lib/hive/types";
|
||||||
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
||||||
|
import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core";
|
||||||
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
||||||
import type { HiveViewStore } from "../use-hive-view";
|
import type { HiveViewStore } from "../use-hive-view";
|
||||||
import { dropSlots } from "../editor/drop-slots";
|
import { dropSlots } from "../editor/drop-slots";
|
||||||
@@ -105,6 +106,7 @@ async function renderScene(motion: MotionLevel = "full", editor?: SceneEditor):
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await current?.renderer.unmount();
|
await current?.renderer.unmount();
|
||||||
current = null;
|
current = null;
|
||||||
|
document.documentElement.removeAttribute("data-contrast");
|
||||||
});
|
});
|
||||||
|
|
||||||
const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactThreeTestInstance =>
|
const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactThreeTestInstance =>
|
||||||
@@ -277,6 +279,44 @@ describe("HiveSceneContent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Edge materials of the mounted scene (they carry the travelling pulse). */
|
||||||
|
const edgeMaterials = (renderer: Rendered["renderer"]): ShaderMaterial[] =>
|
||||||
|
renderer.scene
|
||||||
|
.findAll((node) => (node.instance as unknown as Mesh).material instanceof ShaderMaterial)
|
||||||
|
.map((node) => (node.instance as unknown as Mesh).material as ShaderMaterial)
|
||||||
|
.filter((material) => "uPulse" in material.uniforms);
|
||||||
|
|
||||||
|
describe("HiveSceneContent and the appearance", () => {
|
||||||
|
it("the scene re-reads the holo tokens when the appearance changes", async () => {
|
||||||
|
const { renderer } = await renderScene();
|
||||||
|
const before = edgeMaterials(renderer);
|
||||||
|
expect(before.length).toBeGreaterThan(0);
|
||||||
|
expect(before.every((material) => material.uniforms.uSun.value === 0)).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.documentElement.setAttribute("data-contrast", "sun");
|
||||||
|
window.dispatchEvent(new Event(APPEARANCE_EVENT));
|
||||||
|
});
|
||||||
|
const after = edgeMaterials(renderer);
|
||||||
|
expect(after).not.toContain(before[0]);
|
||||||
|
expect(after.every((material) => material.uniforms.uSun.value === 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sun draws white edges as a 2 px line", async () => {
|
||||||
|
document.documentElement.setAttribute("data-contrast", "sun");
|
||||||
|
const { renderer } = await renderScene();
|
||||||
|
await renderer.advanceFrames(1, 1 / 60);
|
||||||
|
for (const material of edgeMaterials(renderer)) {
|
||||||
|
expect(material.uniforms.uSun.value).toBe(1);
|
||||||
|
expect(material.uniforms.uCyan.value.getHex()).toBe(0xffffff);
|
||||||
|
expect(material.uniforms.uHot.value.getHex()).toBe(0xffffff);
|
||||||
|
expect(material.uniforms.uLinePixels.value).toBe(2);
|
||||||
|
// The line width is in screen pixels: the shader needs the canvas height.
|
||||||
|
expect(material.uniforms.uViewHeight.value).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("HiveSceneContent in the editor", () => {
|
describe("HiveSceneContent in the editor", () => {
|
||||||
const editorOf = (extra: Partial<SceneEditor> = {}): SceneEditor => ({
|
const editorOf = (extra: Partial<SceneEditor> = {}): SceneEditor => ({
|
||||||
drag: null,
|
drag: null,
|
||||||
@@ -337,6 +377,49 @@ describe("HiveSceneContent in the editor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("HiveSceneContent in sun: state cues without colour", () => {
|
||||||
|
it("a hovered part draws its edges 3 px, the others 2 px", async () => {
|
||||||
|
document.documentElement.setAttribute("data-contrast", "sun");
|
||||||
|
const { renderer, store } = await renderScene("off");
|
||||||
|
await act(async () => store.setHovered("b1"));
|
||||||
|
await renderer.advanceFrames(2, 1 / 60);
|
||||||
|
const pixelsOf = (id: string) =>
|
||||||
|
hit(renderer, id)
|
||||||
|
.parent!.children.map((node) => (node.instance as unknown as Mesh).material)
|
||||||
|
.filter((material): material is ShaderMaterial => material instanceof ShaderMaterial && "uPulse" in material.uniforms)
|
||||||
|
.map((material) => material.uniforms.uLinePixels.value);
|
||||||
|
expect(pixelsOf("b1")).toContain(3);
|
||||||
|
expect(pixelsOf("fe")).toEqual(pixelsOf("fe").map(() => 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idle drop slots are grey 2 px on the tube they are built from; the hovered slot is white 3 px", async () => {
|
||||||
|
document.documentElement.setAttribute("data-contrast", "sun");
|
||||||
|
const state = hiveState();
|
||||||
|
const slots = dropSlots(state, catalog, { partTypeId: "box" });
|
||||||
|
const editor: SceneEditor = {
|
||||||
|
drag: { source: { partTypeId: "box" }, slots, hover: slots[0] },
|
||||||
|
pointer: { current: null },
|
||||||
|
subscribePointer: () => () => {},
|
||||||
|
onHoverSlot: vi.fn(),
|
||||||
|
onPartPress: vi.fn(),
|
||||||
|
zoom: 1,
|
||||||
|
onZoom: vi.fn(),
|
||||||
|
};
|
||||||
|
const { renderer } = await renderScene("off", editor);
|
||||||
|
await renderer.advanceFrames(1, 1 / 60);
|
||||||
|
const markers = renderer.scene
|
||||||
|
.findAll((node) => node.props.name === "drop-slot")
|
||||||
|
.map((node) => (node.instance as unknown as Mesh).material as ShaderMaterial);
|
||||||
|
const idle = markers.filter((material) => material.uniforms.uSunGrey.value === 1);
|
||||||
|
const active = markers.filter((material) => material.uniforms.uSunGrey.value === 0);
|
||||||
|
expect(idle.length).toBe(slots.length - 1);
|
||||||
|
expect(active).toHaveLength(1);
|
||||||
|
expect(active[0].uniforms.uLinePixels.value).toBe(3);
|
||||||
|
expect(idle[0].uniforms.uLinePixels.value).toBe(2);
|
||||||
|
for (const material of markers) expect(material.uniforms.uTubeCorner.value).toBeCloseTo(0.015 * Math.SQRT2, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("HiveSceneContent drop anchors", () => {
|
describe("HiveSceneContent drop anchors", () => {
|
||||||
it("computes the slot anchors and motion targets once per drag, not again on every hover change", async () => {
|
it("computes the slot anchors and motion targets once per drag, not again on every hover change", async () => {
|
||||||
const state = hiveState();
|
const state = hiveState();
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import ReactThreeTestRenderer from "@react-three/test-renderer";
|
import ReactThreeTestRenderer from "@react-three/test-renderer";
|
||||||
import { act, forwardRef, useEffect, useImperativeHandle } from "react";
|
import { act, forwardRef, useEffect, useImperativeHandle } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { WebGLRenderTarget } from "three";
|
import { Color, WebGLRenderTarget } from "three";
|
||||||
|
import type { Light, Scene } from "three";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core";
|
||||||
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
||||||
import type { HiveViewStore } from "../use-hive-view";
|
import type { HiveViewStore } from "../use-hive-view";
|
||||||
import { HiveStage } from "./hive-stage";
|
import { HiveStage } from "./hive-stage";
|
||||||
@@ -28,9 +30,9 @@ vi.mock("@react-three/postprocessing", () => ({
|
|||||||
});
|
});
|
||||||
return <group name="depth-of-field" />;
|
return <group name="depth-of-field" />;
|
||||||
}),
|
}),
|
||||||
Bloom: () => null,
|
Bloom: () => <group name="bloom" />,
|
||||||
ToneMapping: () => null,
|
ToneMapping: () => <group name="tone-mapping" />,
|
||||||
Vignette: () => null,
|
Vignette: () => <group name="vignette" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The test renderer's mock context cannot render into targets: the mirror pass is skipped.
|
// The test renderer's mock context cannot render into targets: the mirror pass is skipped.
|
||||||
@@ -49,6 +51,7 @@ let renderer: Awaited<ReturnType<typeof ReactThreeTestRenderer.create>> | null =
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await renderer?.unmount();
|
await renderer?.unmount();
|
||||||
renderer = null;
|
renderer = null;
|
||||||
|
document.documentElement.removeAttribute("data-contrast");
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,3 +123,60 @@ describe("HiveStage reflective floor", () => {
|
|||||||
expect(dispose.mock.calls.length - afterMount).toBeGreaterThanOrEqual(4);
|
expect(dispose.mock.calls.length - afterMount).toBeGreaterThanOrEqual(4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const named = (name: string) => renderer!.scene.findAll((node) => node.props.name === name).length;
|
||||||
|
const isBlack = (value: unknown) => value instanceof Color && value.getHex() === 0x000000;
|
||||||
|
|
||||||
|
describe("HiveStage in sun", () => {
|
||||||
|
it("sun turns bloom off: no bloom, no vignette, no haze or particles, a plain black stage", async () => {
|
||||||
|
document.documentElement.setAttribute("data-contrast", "sun");
|
||||||
|
renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), "high", false));
|
||||||
|
expect(named("bloom")).toBe(0);
|
||||||
|
expect(named("vignette")).toBe(0);
|
||||||
|
expect(named("tone-mapping")).toBe(1);
|
||||||
|
expect(named("stage-haze")).toBe(0);
|
||||||
|
expect(named("stage-particles")).toBe(0);
|
||||||
|
expect(isBlack((renderer.scene.instance as Scene).background)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sun draws no mirror floor and no blue hex grid, and lights the hive in neutral white", async () => {
|
||||||
|
renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), "high", false));
|
||||||
|
expect(named("stage-floor-mirror")).toBe(1);
|
||||||
|
expect(named("stage-floor-grid")).toBe(1);
|
||||||
|
await renderer.unmount();
|
||||||
|
|
||||||
|
document.documentElement.setAttribute("data-contrast", "sun");
|
||||||
|
for (const tier of ["high", "low"] as const) {
|
||||||
|
renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), tier, false));
|
||||||
|
expect(named("stage-floor-mirror")).toBe(0);
|
||||||
|
expect(named("stage-floor-grid")).toBe(0);
|
||||||
|
const lights = renderer.scene.findAll((node) => (node.instance as unknown as Light).isLight === true);
|
||||||
|
expect(lights.length).toBeGreaterThan(0);
|
||||||
|
for (const light of lights) {
|
||||||
|
const { r, g, b } = (light.instance as unknown as Light).color;
|
||||||
|
expect(Math.max(r, g, b) - Math.min(r, g, b)).toBeLessThan(1e-6);
|
||||||
|
}
|
||||||
|
await renderer.unmount();
|
||||||
|
}
|
||||||
|
renderer = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches bloom off and on live when the appearance changes", async () => {
|
||||||
|
renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), "high", false));
|
||||||
|
expect(named("bloom")).toBe(1);
|
||||||
|
expect(named("stage-haze")).toBe(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.documentElement.setAttribute("data-contrast", "sun");
|
||||||
|
window.dispatchEvent(new Event(APPEARANCE_EVENT));
|
||||||
|
});
|
||||||
|
expect(named("bloom")).toBe(0);
|
||||||
|
expect(isBlack((renderer.scene.instance as Scene).background)).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.documentElement.setAttribute("data-contrast", "normal");
|
||||||
|
window.dispatchEvent(new Event(APPEARANCE_EVENT));
|
||||||
|
});
|
||||||
|
expect(named("bloom")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,7 +29,8 @@ import type { HoloTheme } from "./theme";
|
|||||||
// The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a
|
// The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a
|
||||||
// cool light from behind, glossy dark floor with a faint hexagon grid, a few
|
// cool light from behind, glossy dark floor with a faint hexagon grid, a few
|
||||||
// slow bokeh particles, bloom at half resolution, vignette. Cool light only:
|
// slow bokeh particles, bloom at half resolution, vignette. Cool light only:
|
||||||
// warm colours belong to the content (owner 2026-10-16).
|
// warm colours belong to the content (owner 2026-10-16). In sun the stage is
|
||||||
|
// plain black without bloom, vignette, haze and particles (spec 9.3, T5 17.8).
|
||||||
|
|
||||||
/** Objects on this layer are seen by the camera but not by the floor reflection. */
|
/** Objects on this layer are seen by the camera but not by the floor reflection. */
|
||||||
const OVERLAY_LAYER = 1;
|
const OVERLAY_LAYER = 1;
|
||||||
@@ -94,7 +95,7 @@ function Backdrop() {
|
|||||||
const { theme } = useSceneKit();
|
const { theme } = useSceneKit();
|
||||||
const scene = useThree((state) => state.scene);
|
const scene = useThree((state) => state.scene);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const texture = backdropTexture(theme);
|
const texture = theme.sun ? null : backdropTexture(theme);
|
||||||
setSceneBackground(scene, texture ?? theme.background);
|
setSceneBackground(scene, texture ?? theme.background);
|
||||||
return () => {
|
return () => {
|
||||||
setSceneBackground(scene, null);
|
setSceneBackground(scene, null);
|
||||||
@@ -197,7 +198,7 @@ function ReflectiveFloor() {
|
|||||||
if (surface.current) reflector.render(gl, scene, camera, surface.current);
|
if (surface.current) reflector.render(gl, scene, camera, surface.current);
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<mesh ref={surface} rotation-x={-Math.PI / 2} material={reflector.material} renderOrder={-3}>
|
<mesh ref={surface} name="stage-floor-mirror" rotation-x={-Math.PI / 2} material={reflector.material} renderOrder={-3}>
|
||||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
@@ -229,7 +230,7 @@ function Floor({ tier }: { tier: StageTier }) {
|
|||||||
|
|
||||||
if (tier === "low") {
|
if (tier === "low") {
|
||||||
return (
|
return (
|
||||||
<mesh rotation-x={-Math.PI / 2} material={materials.grid} renderOrder={-2}>
|
<mesh name="stage-floor-grid" rotation-x={-Math.PI / 2} material={materials.grid} renderOrder={-2}>
|
||||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
@@ -239,6 +240,7 @@ function Floor({ tier }: { tier: StageTier }) {
|
|||||||
<ReflectiveFloor />
|
<ReflectiveFloor />
|
||||||
{/* The grid and the distance fade sit on a layer the reflection camera does not see. */}
|
{/* The grid and the distance fade sit on a layer the reflection camera does not see. */}
|
||||||
<mesh
|
<mesh
|
||||||
|
name="stage-floor-grid"
|
||||||
rotation-x={-Math.PI / 2}
|
rotation-x={-Math.PI / 2}
|
||||||
position-y={0.004}
|
position-y={0.004}
|
||||||
material={materials.grid}
|
material={materials.grid}
|
||||||
@@ -340,14 +342,15 @@ function Particles() {
|
|||||||
setUniform(material, "uTime", state.clock.elapsedTime);
|
setUniform(material, "uTime", state.clock.elapsedTime);
|
||||||
setUniform(material, "uPixelRatio", dpr);
|
setUniform(material, "uPixelRatio", dpr);
|
||||||
});
|
});
|
||||||
return <points geometry={geometry} material={material} renderOrder={5} raycast={noRaycast} />;
|
return <points name="stage-particles" geometry={geometry} material={material} renderOrder={5} raycast={noRaycast} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Lights() {
|
function Lights() {
|
||||||
const { theme } = useSceneKit();
|
const { theme } = useSceneKit();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ambientLight intensity={0.55} color="#cfe6ff" />
|
{/* Sun: neutral white light only (theme.rim is grey there). */}
|
||||||
|
<ambientLight intensity={0.55} color={theme.sun ? "#ffffff" : "#cfe6ff"} />
|
||||||
<directionalLight position={[3, 9, 7]} intensity={1.5} color="#ffffff" />
|
<directionalLight position={[3, 9, 7]} intensity={1.5} color="#ffffff" />
|
||||||
<directionalLight position={[-5, 5, -7]} intensity={1.3} color={theme.rim} />
|
<directionalLight position={[-5, 5, -7]} intensity={1.3} color={theme.rim} />
|
||||||
</>
|
</>
|
||||||
@@ -382,7 +385,7 @@ function useFocusMode(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolean }) {
|
function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolean }) {
|
||||||
const { signals } = useSceneKit();
|
const { signals, theme } = useSceneKit();
|
||||||
const dof = useRef<DepthOfFieldEffect>(null);
|
const dof = useRef<DepthOfFieldEffect>(null);
|
||||||
// Depth of field runs its seven passes every frame even with no blur, so it is mounted only in
|
// Depth of field runs its seven passes every frame even with no blur, so it is mounted only in
|
||||||
// focus mode. Entering focus rebuilds the composer once (one shader compile as the flight starts);
|
// focus mode. Entering focus rebuilds the composer once (one shader compile as the flight starts);
|
||||||
@@ -402,16 +405,21 @@ function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolea
|
|||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
)}
|
)}
|
||||||
<Bloom
|
{/* Sun: no glow around the white edges, no darkened corners on the black stage. */}
|
||||||
mipmapBlur
|
{theme.sun ? (
|
||||||
luminanceThreshold={0.7}
|
<></>
|
||||||
luminanceSmoothing={0.28}
|
) : (
|
||||||
intensity={0.85}
|
<Bloom
|
||||||
radius={0.72}
|
mipmapBlur
|
||||||
resolutionScale={0.5}
|
luminanceThreshold={0.7}
|
||||||
/>
|
luminanceSmoothing={0.28}
|
||||||
|
intensity={0.85}
|
||||||
|
radius={0.72}
|
||||||
|
resolutionScale={0.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ToneMapping mode={ToneMappingMode.NEUTRAL} />
|
<ToneMapping mode={ToneMappingMode.NEUTRAL} />
|
||||||
<Vignette offset={0.26} darkness={0.7} />
|
{theme.sun ? <></> : <Vignette offset={0.26} darkness={0.7} />}
|
||||||
</EffectComposer>
|
</EffectComposer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -462,6 +470,7 @@ function Haze() {
|
|||||||
useEffect(() => () => material.dispose(), [material]);
|
useEffect(() => () => material.dispose(), [material]);
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
|
name="stage-haze"
|
||||||
position={[-1.5, HAZE_HEIGHT / 2 - 1, -3]}
|
position={[-1.5, HAZE_HEIGHT / 2 - 1, -3]}
|
||||||
rotation={[0.18, 0, 0.12]}
|
rotation={[0.18, 0, 0.12]}
|
||||||
material={material}
|
material={material}
|
||||||
@@ -482,13 +491,15 @@ export interface HiveStageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HiveStage({ tier, depthOfField, onQuality }: HiveStageProps) {
|
export function HiveStage({ tier, depthOfField, onQuality }: HiveStageProps) {
|
||||||
|
const { theme } = useSceneKit();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Backdrop />
|
<Backdrop />
|
||||||
<Lights />
|
<Lights />
|
||||||
<Floor tier={tier} />
|
{/* Sun: the black stage has no floor (no blue grid, no mirror of the white edges). */}
|
||||||
<Haze />
|
{theme.sun ? null : <Floor tier={tier} />}
|
||||||
{tier === "high" ? <Particles /> : null}
|
{theme.sun ? null : <Haze />}
|
||||||
|
{tier === "high" && !theme.sun ? <Particles /> : null}
|
||||||
<QualityWatcher onVerdict={onQuality} />
|
<QualityWatcher onVerdict={onQuality} />
|
||||||
<Effects tier={tier} depthOfField={depthOfField && tier === "high"} />
|
<Effects tier={tier} depthOfField={depthOfField && tier === "high"} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { CanvasTexture, MeshBasicMaterial, MeshStandardMaterial } from "three";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { frameTypeMaterial } from "./frame-plate";
|
||||||
|
import { SUN_EDGE_PIXELS, SUN_STRONG_EDGE_PIXELS, createEdgeMaterial, setEdgeIntensity, sunEdgePixels } from "./materials";
|
||||||
|
import { EDGE_TUBE_CORNER } from "./part-geometry";
|
||||||
|
import { readHoloTheme } from "./theme";
|
||||||
|
|
||||||
|
afterEach(() => document.documentElement.removeAttribute("data-contrast"));
|
||||||
|
|
||||||
|
const sunTheme = () => readHoloTheme("sun-light");
|
||||||
|
|
||||||
|
describe("createEdgeMaterial", () => {
|
||||||
|
it("finds the axis of the default tube and of a wider one (drop slots)", () => {
|
||||||
|
expect(createEdgeMaterial(sunTheme()).uniforms.uTubeCorner.value).toBeCloseTo(EDGE_TUBE_CORNER, 9);
|
||||||
|
expect(createEdgeMaterial(sunTheme(), { tubeWidth: 0.03 }).uniforms.uTubeCorner.value).toBeCloseTo(
|
||||||
|
0.015 * Math.SQRT2,
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws quiet lines in detail grey in sun when asked (idle drop slots)", () => {
|
||||||
|
expect(createEdgeMaterial(sunTheme()).uniforms.uSunGrey.value).toBe(0);
|
||||||
|
expect(createEdgeMaterial(sunTheme(), { sunGrey: true }).uniforms.uSunGrey.value).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sun edge state", () => {
|
||||||
|
it("draws a strong state (hover, lifted frame, active slot) 3 px wide and the rest 2 px", () => {
|
||||||
|
expect(sunEdgePixels(1.25)).toBe(SUN_EDGE_PIXELS);
|
||||||
|
expect(sunEdgePixels(1.4)).toBe(SUN_EDGE_PIXELS);
|
||||||
|
expect(sunEdgePixels(1.875)).toBe(SUN_EDGE_PIXELS);
|
||||||
|
expect(sunEdgePixels(2.21)).toBe(SUN_STRONG_EDGE_PIXELS);
|
||||||
|
expect(sunEdgePixels(2.875)).toBe(SUN_STRONG_EDGE_PIXELS);
|
||||||
|
expect(SUN_STRONG_EDGE_PIXELS).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setEdgeIntensity sets the glow and the sun line width together", () => {
|
||||||
|
const material = createEdgeMaterial(sunTheme());
|
||||||
|
setEdgeIntensity(material, 3.2);
|
||||||
|
expect(material.uniforms.uIntensity.value).toBe(3.2);
|
||||||
|
expect(material.uniforms.uLinePixels.value).toBe(3);
|
||||||
|
setEdgeIntensity(material, 1.3);
|
||||||
|
expect(material.uniforms.uLinePixels.value).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("frameTypeMaterial", () => {
|
||||||
|
const texture = new CanvasTexture(document.createElement("canvas"));
|
||||||
|
|
||||||
|
it("draws the sun hatch unlit and brighter than 1, so white survives the tone mapping", () => {
|
||||||
|
const material = frameTypeMaterial(texture, true, "BROOD");
|
||||||
|
expect(material).toBeInstanceOf(MeshBasicMaterial);
|
||||||
|
const { r, g, b } = (material as MeshBasicMaterial).color;
|
||||||
|
expect(Math.min(r, g, b)).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(r).toBe(g);
|
||||||
|
expect(g).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the lit comb outside sun", () => {
|
||||||
|
expect(frameTypeMaterial(texture, false, "HONEY")).toBeInstanceOf(MeshStandardMaterial);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three";
|
import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three";
|
||||||
import type { CubeTexture } from "three";
|
import type { CubeTexture } from "three";
|
||||||
|
import { EDGE_TUBE_CORNER, edgeTubeCorner } from "./part-geometry";
|
||||||
import type { HoloTheme } from "./theme";
|
import type { HoloTheme } from "./theme";
|
||||||
|
|
||||||
// Hand-written shaders instead of transmission. The glass samples a small
|
// Hand-written shaders instead of transmission. The glass samples a small
|
||||||
@@ -132,11 +133,42 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Edge width in sun, in CSS pixels (Beute spec 9.3). */
|
||||||
|
export const SUN_EDGE_PIXELS = 2;
|
||||||
|
/** Sun has no glow: a strong state (hover, lifted frame, active drop slot) draws a wider line. */
|
||||||
|
export const SUN_STRONG_EDGE_PIXELS = 3;
|
||||||
|
/**
|
||||||
|
* Edge intensity from which a state counts as strong. Rest and focus stay below
|
||||||
|
* (stack part 1.25 / 1.875, frame 1.3, idle slot 1.4); hover 2.875, lifted
|
||||||
|
* frame 2.21, active slot 3.2 and the drop preview 3 lie above.
|
||||||
|
*/
|
||||||
|
const SUN_STRONG_INTENSITY = 2;
|
||||||
|
|
||||||
|
/** Line width in sun for an edge intensity. */
|
||||||
|
export function sunEdgePixels(intensity: number): number {
|
||||||
|
return intensity >= SUN_STRONG_INTENSITY ? SUN_STRONG_EDGE_PIXELS : SUN_EDGE_PIXELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the glow of an edge material and, for sun, its line width (no allocation, frame-safe). */
|
||||||
|
export function setEdgeIntensity(material: ShaderMaterial, intensity: number): void {
|
||||||
|
const { uIntensity, uLinePixels } = material.uniforms;
|
||||||
|
if (uIntensity) uIntensity.value = intensity;
|
||||||
|
if (uLinePixels) uLinePixels.value = sunEdgePixels(intensity);
|
||||||
|
}
|
||||||
|
/** Above the neutral tone mapping's knee, so a sun edge comes out pure white. */
|
||||||
|
const SUN_EDGE_GAIN = 6;
|
||||||
|
/** Detail lines (rebates, grips, frame hints) stay a quiet grey in sun, so the outline leads. */
|
||||||
|
const SUN_DETAIL_LEVEL = 0.35;
|
||||||
|
|
||||||
const EDGE_VERTEX = /* glsl */ `
|
const EDGE_VERTEX = /* glsl */ `
|
||||||
attribute float aHot;
|
attribute float aHot;
|
||||||
attribute float aStrength;
|
attribute float aStrength;
|
||||||
attribute float aAlong;
|
attribute float aAlong;
|
||||||
attribute float aPhase;
|
attribute float aPhase;
|
||||||
|
uniform float uSun;
|
||||||
|
uniform float uTubeCorner;
|
||||||
|
uniform float uLinePixels;
|
||||||
|
uniform float uViewHeight;
|
||||||
varying float vHot;
|
varying float vHot;
|
||||||
varying float vStrength;
|
varying float vStrength;
|
||||||
varying float vAlong;
|
varying float vAlong;
|
||||||
@@ -148,6 +180,13 @@ void main() {
|
|||||||
vAlong = aAlong;
|
vAlong = aAlong;
|
||||||
vPhase = aPhase;
|
vPhase = aPhase;
|
||||||
vec4 view = modelViewMatrix * vec4(position, 1.0);
|
vec4 view = modelViewMatrix * vec4(position, 1.0);
|
||||||
|
if (uSun > 0.5) {
|
||||||
|
// Sun: a line of uLinePixels on screen at any distance. Each vertex sits uTubeCorner
|
||||||
|
// from the edge's axis along its normal; the tube is rebuilt around that axis in pixels.
|
||||||
|
vec4 axis = modelViewMatrix * vec4(position - normal * uTubeCorner, 1.0);
|
||||||
|
float pixel = 2.0 * max(-axis.z, 1e-3) / (projectionMatrix[1][1] * max(uViewHeight, 1.0));
|
||||||
|
view = vec4(axis.xyz + normalize(view.xyz - axis.xyz) * pixel * uLinePixels * 0.70710678, 1.0);
|
||||||
|
}
|
||||||
// Tube normal in view space: faces toward the camera form the white-hot core.
|
// Tube normal in view space: faces toward the camera form the white-hot core.
|
||||||
vec3 normalView = normalize(normalMatrix * normal);
|
vec3 normalView = normalize(normalMatrix * normal);
|
||||||
vFacing = abs(dot(normalView, normalize(-view.xyz)));
|
vFacing = abs(dot(normalView, normalize(-view.xyz)));
|
||||||
@@ -162,12 +201,21 @@ uniform float uIntensity;
|
|||||||
uniform float uOpacity;
|
uniform float uOpacity;
|
||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
uniform float uPulse;
|
uniform float uPulse;
|
||||||
|
uniform float uSun;
|
||||||
|
uniform float uSunGrey;
|
||||||
varying float vHot;
|
varying float vHot;
|
||||||
varying float vStrength;
|
varying float vStrength;
|
||||||
varying float vAlong;
|
varying float vAlong;
|
||||||
varying float vPhase;
|
varying float vPhase;
|
||||||
varying float vFacing;
|
varying float vFacing;
|
||||||
void main() {
|
void main() {
|
||||||
|
if (uSun > 0.5) {
|
||||||
|
// Sun: flat white outlines (the sun tokens), bright enough to stay white through the tone
|
||||||
|
// mapping, detail lines grey; ghosts fade quadratically so the shadow of the hive stays faint.
|
||||||
|
float level = vStrength >= 0.5 && uSunGrey < 0.5 ? ${SUN_EDGE_GAIN.toFixed(2)} : ${SUN_DETAIL_LEVEL.toFixed(2)};
|
||||||
|
gl_FragColor = vec4(uCyan * level * uOpacity * uOpacity, 1.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
float hot = vHot * vHot;
|
float hot = vHot * vHot;
|
||||||
float core = pow(vFacing, 4.0);
|
float core = pow(vFacing, 4.0);
|
||||||
vec3 color = mix(uCyan, uHot, clamp(max(hot, core * 0.75), 0.0, 1.0));
|
vec3 color = mix(uCyan, uHot, clamp(max(hot, core * 0.75), 0.0, 1.0));
|
||||||
@@ -179,16 +227,32 @@ void main() {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/** Glowing edges: additive, white-hot core and corners, electric cyan rim, slow pulses at `full`. */
|
/**
|
||||||
export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
|
* Glowing edges: additive, white-hot core and corners, electric cyan rim, slow pulses at `full`.
|
||||||
|
* In sun (Beute spec 9.3): flat white lines of `SUN_EDGE_PIXELS` on screen, no glow, no pulse.
|
||||||
|
*/
|
||||||
|
export interface EdgeOptions {
|
||||||
|
/** Width the edge geometry was built with, when not the default (drop slots). */
|
||||||
|
tubeWidth?: number;
|
||||||
|
/** Sun: draw every line in detail grey (idle drop slots), so the active one stands out. */
|
||||||
|
sunGrey?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEdgeMaterial(theme: HoloTheme, options: EdgeOptions = {}): ShaderMaterial {
|
||||||
return new ShaderMaterial({
|
return new ShaderMaterial({
|
||||||
uniforms: {
|
uniforms: {
|
||||||
uCyan: { value: theme.edge.clone() },
|
uCyan: { value: theme.edge.clone() },
|
||||||
uHot: { value: theme.hot.clone() },
|
uHot: { value: theme.sun ? theme.edge.clone() : theme.hot.clone() },
|
||||||
uIntensity: { value: 1.6 },
|
uIntensity: { value: 1.6 },
|
||||||
uOpacity: { value: 1 },
|
uOpacity: { value: 1 },
|
||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
uPulse: { value: 0 },
|
uPulse: { value: 0 },
|
||||||
|
uSun: { value: theme.sun ? 1 : 0 },
|
||||||
|
uSunGrey: { value: options.sunGrey ? 1 : 0 },
|
||||||
|
uTubeCorner: { value: options.tubeWidth === undefined ? EDGE_TUBE_CORNER : edgeTubeCorner(options.tubeWidth) },
|
||||||
|
uLinePixels: { value: SUN_EDGE_PIXELS },
|
||||||
|
// Canvas height in CSS pixels, kept current by useEdgeViewport.
|
||||||
|
uViewHeight: { value: 1 },
|
||||||
},
|
},
|
||||||
vertexShader: EDGE_VERTEX,
|
vertexShader: EDGE_VERTEX,
|
||||||
fragmentShader: EDGE_FRAGMENT,
|
fragmentShader: EDGE_FRAGMENT,
|
||||||
|
|||||||
@@ -503,6 +503,12 @@ export function buildGlassGeometry(slabs: Slab[]): BufferGeometry {
|
|||||||
|
|
||||||
/** About 1.5 px at rest-view distance, thicker when a part is in focus. */
|
/** About 1.5 px at rest-view distance, thicker when a part is in focus. */
|
||||||
const EDGE_WIDTH = 0.024;
|
const EDGE_WIDTH = 0.024;
|
||||||
|
/** Distance from an edge's axis to each vertex of a tube of `width` (the sun shader finds the axis with it). */
|
||||||
|
export function edgeTubeCorner(width: number): number {
|
||||||
|
return (width / 2) * Math.SQRT2;
|
||||||
|
}
|
||||||
|
/** Tube corner of the default edge width. */
|
||||||
|
export const EDGE_TUBE_CORNER = edgeTubeCorner(EDGE_WIDTH);
|
||||||
/** Length of the white-hot zone at each corner. */
|
/** Length of the white-hot zone at each corner. */
|
||||||
const HOT_ZONE = 0.22;
|
const HOT_ZONE = 0.22;
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import { createGeometryCache } from "./part-geometry";
|
|||||||
import type { GeometryCache } from "./part-geometry";
|
import type { GeometryCache } from "./part-geometry";
|
||||||
import type { StageTier } from "./quality";
|
import type { StageTier } from "./quality";
|
||||||
import { createStudioEnvironment } from "./scene-env";
|
import { createStudioEnvironment } from "./scene-env";
|
||||||
import { readHoloTheme } from "./theme";
|
import { useHoloTheme } from "./theme";
|
||||||
import type { HoloTheme } from "./theme";
|
import type { HoloTheme } from "./theme";
|
||||||
|
|
||||||
// Shared per canvas: theme (tokens read once), geometries per part kind and
|
// Shared per canvas: theme (tokens re-read on each appearance change, T5 spec
|
||||||
// size, comb textures per frame type, the studio cube map, motion level and
|
// 17.8), geometries per part kind and size, comb textures per frame type, the
|
||||||
// quality tier. Everything is disposed when the canvas unmounts (spec 9.9:
|
// studio cube map, motion level and quality tier. Everything is disposed when
|
||||||
// no leak between colonies).
|
// the canvas unmounts (spec 9.9: no leak between colonies).
|
||||||
|
|
||||||
/** Mutable per-frame hand-offs between scene parts (never drive rendering on their own). */
|
/** Mutable per-frame hand-offs between scene parts (never drive rendering on their own). */
|
||||||
export interface SceneSignals {
|
export interface SceneSignals {
|
||||||
@@ -55,7 +55,8 @@ export interface SceneKitProviderProps {
|
|||||||
export function SceneKitProvider({ motion, tier = "high", children }: SceneKitProviderProps) {
|
export function SceneKitProvider({ motion, tier = "high", children }: SceneKitProviderProps) {
|
||||||
const gl = useThree((state) => state.gl);
|
const gl = useThree((state) => state.gl);
|
||||||
const invalidate = useThree((state) => state.invalidate);
|
const invalidate = useThree((state) => state.invalidate);
|
||||||
const [theme] = useState(readHoloTheme);
|
// A new theme rebuilds the materials that use it (a rare event, not per frame).
|
||||||
|
const theme = useHoloTheme(invalidate);
|
||||||
const [geometries] = useState(createGeometryCache);
|
const [geometries] = useState(createGeometryCache);
|
||||||
const [textures] = useState(() => createFrameTextureCache());
|
const [textures] = useState(() => createFrameTextureCache());
|
||||||
const [signals] = useState<SceneSignals>(() => ({
|
const [signals] = useState<SceneSignals>(() => ({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { FrameBox, Vec3 } from "@/lib/hive/layout";
|
|||||||
import { FillVolume } from "./fill-volume";
|
import { FillVolume } from "./fill-volume";
|
||||||
import { FrameHints } from "./frame-plate";
|
import { FrameHints } from "./frame-plate";
|
||||||
import { HoloLabel } from "./hive-labels";
|
import { HoloLabel } from "./hive-labels";
|
||||||
import { createEdgeMaterial, createGlassMaterial, createGridMaterial } from "./materials";
|
import { createEdgeMaterial, createGlassMaterial, createGridMaterial, setEdgeIntensity } from "./materials";
|
||||||
import { evaporatorBottle, feederChambers, partShapeSpec, pocketInner, shapeKey } from "./part-geometry";
|
import { evaporatorBottle, feederChambers, partShapeSpec, pocketInner, shapeKey } from "./part-geometry";
|
||||||
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
|
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
|
||||||
import { setUniform, setUniformColor } from "./scene-mutations";
|
import { setUniform, setUniformColor } from "./scene-mutations";
|
||||||
@@ -81,7 +81,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
|
|||||||
setUniform(glass, "uGlow", glow);
|
setUniform(glass, "uGlow", glow);
|
||||||
// Ghost edges keep a little more presence than the faces, so the outline of the hive reads.
|
// Ghost edges keep a little more presence than the faces, so the outline of the hive reads.
|
||||||
setUniform(edges, "uOpacity", Math.min(1, opacity * 1.6));
|
setUniform(edges, "uOpacity", Math.min(1, opacity * 1.6));
|
||||||
setUniform(edges, "uIntensity", EDGE_INTENSITY * glow);
|
setEdgeIntensity(edges, EDGE_INTENSITY * glow);
|
||||||
setUniformColor(edges, "uCyan", glow > 1.2 ? theme.edgeStrong : theme.edge);
|
setUniformColor(edges, "uCyan", glow > 1.2 ? theme.edgeStrong : theme.edge);
|
||||||
grids.forEach((material) => setUniform(material, "uOpacity", opacity));
|
grids.forEach((material) => setUniform(material, "uOpacity", opacity));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core";
|
||||||
|
import { readHoloTheme, useHoloTheme } from "./theme";
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
function setAppearance({ dark, sun }: { dark: boolean; sun: boolean }) {
|
||||||
|
root.classList.toggle("dark", dark);
|
||||||
|
root.setAttribute("data-contrast", sun ? "sun" : "normal");
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
root.classList.remove("dark");
|
||||||
|
root.removeAttribute("data-contrast");
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readHoloTheme", () => {
|
||||||
|
it("reads the tokens on every call, not once", () => {
|
||||||
|
const style = vi.spyOn(window, "getComputedStyle");
|
||||||
|
readHoloTheme();
|
||||||
|
const first = style.mock.calls.length;
|
||||||
|
expect(first).toBeGreaterThan(0);
|
||||||
|
readHoloTheme();
|
||||||
|
expect(style.mock.calls.length).toBe(first * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("knows sun from the document and falls back to a black stage with white edges there", () => {
|
||||||
|
setAppearance({ dark: false, sun: true });
|
||||||
|
const theme = readHoloTheme();
|
||||||
|
expect(theme.sun).toBe(true);
|
||||||
|
// jsdom has no 2D canvas: the sun fallbacks stand in for the sun tokens.
|
||||||
|
expect(theme.background.getHex()).toBe(0x000000);
|
||||||
|
expect(theme.edge.getHex()).toBe(0xffffff);
|
||||||
|
expect(theme.edgeStrong.getHex()).toBe(0xffffff);
|
||||||
|
|
||||||
|
setAppearance({ dark: false, sun: false });
|
||||||
|
const normal = readHoloTheme();
|
||||||
|
expect(normal.sun).toBe(false);
|
||||||
|
expect(normal.edge.getHex()).not.toBe(0xffffff);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readHoloTheme in sun", () => {
|
||||||
|
it("has no chromatic stage colour: stage, edges, glass, lights and floor are neutral", () => {
|
||||||
|
const theme = readHoloTheme("sun-dark");
|
||||||
|
for (const key of ["background", "edge", "edgeStrong", "hot", "tint", "rim", "bounce", "grid"] as const) {
|
||||||
|
const { r, g, b } = theme[key];
|
||||||
|
expect(Math.max(r, g, b) - Math.min(r, g, b), key).toBeLessThan(1e-6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useHoloTheme", () => {
|
||||||
|
it("re-reads the tokens when the appearance event fires after a change", async () => {
|
||||||
|
const { result } = renderHook(() => useHoloTheme());
|
||||||
|
const before = result.current;
|
||||||
|
expect(before.sun).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setAppearance({ dark: false, sun: true });
|
||||||
|
window.dispatchEvent(new Event(APPEARANCE_EVENT));
|
||||||
|
});
|
||||||
|
expect(result.current).not.toBe(before);
|
||||||
|
expect(result.current.sun).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows a change of the document made elsewhere (other tab, device setting)", async () => {
|
||||||
|
const { result } = renderHook(() => useHoloTheme());
|
||||||
|
await act(async () => setAppearance({ dark: true, sun: true }));
|
||||||
|
expect(result.current.sun).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the same theme between light and dark, whose holo tokens are the same (no material rebuild)", async () => {
|
||||||
|
const { result } = renderHook(() => useHoloTheme());
|
||||||
|
const before = result.current;
|
||||||
|
await act(async () => {
|
||||||
|
setAppearance({ dark: true, sun: false });
|
||||||
|
window.dispatchEvent(new Event(APPEARANCE_EVENT));
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the same theme while nothing changes (no material rebuild)", async () => {
|
||||||
|
const { result, rerender } = renderHook(() => useHoloTheme());
|
||||||
|
const before = result.current;
|
||||||
|
rerender();
|
||||||
|
await act(async () => window.dispatchEvent(new Event(APPEARANCE_EVENT)));
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { Color } from "three";
|
import { Color } from "three";
|
||||||
|
import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core";
|
||||||
|
|
||||||
// Scene colours. The holo tokens (spec 9.3) are read once from :root; the
|
// Scene colours. The holo tokens (spec 9.3) are read from :root whenever the
|
||||||
// material colours (honey, wax, white-hot corners) are fixed design values.
|
// appearance changes (T5 spec 17.8: sun swaps them live); the material colours
|
||||||
// Lighting stays cool blue/cyan and white; warm colours only appear as
|
// (honey, wax, white-hot corners) are fixed design values. Lighting stays cool
|
||||||
// content (feed in focus, lifted frame looks).
|
// blue/cyan and white; warm colours only appear as content (feed in focus,
|
||||||
|
// lifted frame looks).
|
||||||
|
|
||||||
export interface HoloTheme {
|
export interface HoloTheme {
|
||||||
|
/** Sun is on: black stage, white 2 px edges, no bloom, hatches instead of tints. */
|
||||||
|
sun: boolean;
|
||||||
background: Color;
|
background: Color;
|
||||||
/** Electric cyan along the edges. */
|
/** Electric cyan along the edges (white in sun). */
|
||||||
edge: Color;
|
edge: Color;
|
||||||
/** Brighter edge of a hovered or focused part. */
|
/** Brighter edge of a hovered or focused part. */
|
||||||
edgeStrong: Color;
|
edgeStrong: Color;
|
||||||
@@ -27,46 +32,132 @@ export interface HoloTheme {
|
|||||||
grid: Color;
|
grid: Color;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACKS = {
|
/** What the document shows; the tokens differ per set (the holo ones only between sun and not). */
|
||||||
"--holo-bg": "#0b1224",
|
export type HoloAppearance = "light" | "dark" | "sun-light" | "sun-dark";
|
||||||
"--holo-edge": "#33c2f0",
|
|
||||||
"--holo-edge-strong": "#a9dcfd",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type TokenName = keyof typeof FALLBACKS;
|
type TokenName = "--holo-bg" | "--holo-edge" | "--holo-edge-strong";
|
||||||
|
|
||||||
/**
|
/** Used where a token cannot be resolved (no 2D canvas, token missing); the sun set mirrors globals.css. */
|
||||||
* Resolves a CSS colour token (the holo tokens are oklch) to a hex string
|
const FALLBACKS: Record<"normal" | "sun", Record<TokenName, string>> = {
|
||||||
* three.js understands, by letting a 2D canvas convert it. Falls back when
|
normal: { "--holo-bg": "#0b1224", "--holo-edge": "#33c2f0", "--holo-edge-strong": "#a9dcfd" },
|
||||||
* there is no 2D context (jsdom) or the token is missing.
|
sun: { "--holo-bg": "#000000", "--holo-edge": "#ffffff", "--holo-edge-strong": "#ffffff" },
|
||||||
*/
|
};
|
||||||
export function resolveCssColor(property: TokenName): string {
|
|
||||||
const fallback = FALLBACKS[property];
|
const SERVER_APPEARANCE: HoloAppearance = "light";
|
||||||
if (typeof document === "undefined") return fallback;
|
|
||||||
const value = getComputedStyle(document.documentElement).getPropertyValue(property).trim();
|
/** The appearance the document shows right now (set by the inline script and useAppearance). */
|
||||||
if (!value) return fallback;
|
export function currentHoloAppearance(): HoloAppearance {
|
||||||
const context = document.createElement("canvas").getContext("2d");
|
if (typeof document === "undefined") return SERVER_APPEARANCE;
|
||||||
if (!context) return fallback;
|
const root = document.documentElement;
|
||||||
context.fillStyle = value;
|
const scheme = root.classList.contains("dark") ? "dark" : "light";
|
||||||
context.fillRect(0, 0, 1, 1);
|
return root.getAttribute("data-contrast") === "sun" ? `sun-${scheme}` : scheme;
|
||||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
|
||||||
return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads the tokens; call once per canvas (useState initialiser). */
|
const toHex = (channel: number) => channel.toString(16).padStart(2, "0");
|
||||||
export function readHoloTheme(): HoloTheme {
|
|
||||||
|
/**
|
||||||
|
* Resolves CSS colour tokens (the holo tokens are oklch) to hex strings three.js
|
||||||
|
* understands, by letting one 2D canvas convert them. Falls back per token when
|
||||||
|
* there is no 2D context (jsdom) or the token is missing.
|
||||||
|
*/
|
||||||
|
function resolveCssColors(names: readonly TokenName[], fallbacks: Record<TokenName, string>): Record<TokenName, string> {
|
||||||
|
const resolved = { ...fallbacks };
|
||||||
|
if (typeof document === "undefined") return resolved;
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const values = names
|
||||||
|
.map((name) => [name, style.getPropertyValue(name).trim()] as const)
|
||||||
|
.filter(([, value]) => value !== "");
|
||||||
|
if (values.length === 0) return resolved;
|
||||||
|
const context = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!context) return resolved;
|
||||||
|
for (const [name, value] of values) {
|
||||||
|
context.clearRect(0, 0, 1, 1);
|
||||||
|
context.fillStyle = value;
|
||||||
|
context.fillRect(0, 0, 1, 1);
|
||||||
|
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
||||||
|
resolved[name] = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKENS: readonly TokenName[] = ["--holo-bg", "--holo-edge", "--holo-edge-strong"];
|
||||||
|
|
||||||
|
type StageLight = Pick<HoloTheme, "hot" | "tint" | "rim" | "bounce" | "grid">;
|
||||||
|
|
||||||
|
/** Cool blue light of the holo stage. */
|
||||||
|
const HOLO_LIGHT = { hot: "#f2fbff", tint: "#7fb8e8", rim: "#4aa8ff", bounce: "#9fd8ff", grid: "#2a6fd6" } as const;
|
||||||
|
|
||||||
|
/** Sun: white on black, no hue anywhere on the stage (glass, lights, floor). */
|
||||||
|
const SUN_LIGHT = { hot: "#ffffff", tint: "#8c8c8c", rim: "#d9d9d9", bounce: "#6e6e6e", grid: "#000000" } as const;
|
||||||
|
|
||||||
|
/** Fresh colours per theme, so no theme shares a mutable Color with another. */
|
||||||
|
function stageLight(sun: boolean): StageLight {
|
||||||
|
const hex = sun ? SUN_LIGHT : HOLO_LIGHT;
|
||||||
return {
|
return {
|
||||||
background: new Color(resolveCssColor("--holo-bg")),
|
hot: new Color(hex.hot),
|
||||||
edge: new Color(resolveCssColor("--holo-edge")),
|
tint: new Color(hex.tint),
|
||||||
edgeStrong: new Color(resolveCssColor("--holo-edge-strong")),
|
rim: new Color(hex.rim),
|
||||||
hot: new Color("#f2fbff"),
|
bounce: new Color(hex.bounce),
|
||||||
tint: new Color("#7fb8e8"),
|
grid: new Color(hex.grid),
|
||||||
rim: new Color("#4aa8ff"),
|
};
|
||||||
bounce: new Color("#9fd8ff"),
|
}
|
||||||
|
|
||||||
|
/** Reads the tokens anew on every call; the scene calls it once per appearance (useHoloTheme). */
|
||||||
|
export function readHoloTheme(appearance: HoloAppearance = currentHoloAppearance()): HoloTheme {
|
||||||
|
const sun = appearance.startsWith("sun");
|
||||||
|
const tokens = resolveCssColors(TOKENS, FALLBACKS[sun ? "sun" : "normal"]);
|
||||||
|
return {
|
||||||
|
sun,
|
||||||
|
background: new Color(tokens["--holo-bg"]),
|
||||||
|
edge: new Color(tokens["--holo-edge"]),
|
||||||
|
edgeStrong: new Color(tokens["--holo-edge-strong"]),
|
||||||
|
...stageLight(sun),
|
||||||
honey: new Color("#ffb21a"),
|
honey: new Color("#ffb21a"),
|
||||||
honeyDeep: new Color("#8a3a00"),
|
honeyDeep: new Color("#8a3a00"),
|
||||||
fondant: new Color("#f1d79a"),
|
fondant: new Color("#f1d79a"),
|
||||||
acid: new Color("#c8f4ff"),
|
acid: new Color("#c8f4ff"),
|
||||||
grid: new Color("#2a6fd6"),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes of scheme or contrast: the appearance event (this tab) and the
|
||||||
|
* document attributes themselves (inline script, another tab, the device's
|
||||||
|
* setting, which useAppearance applies without the event).
|
||||||
|
*/
|
||||||
|
function subscribeAppearance(onChange: () => void): () => void {
|
||||||
|
window.addEventListener(APPEARANCE_EVENT, onChange);
|
||||||
|
const observer = typeof MutationObserver === "undefined" ? null : new MutationObserver(onChange);
|
||||||
|
observer?.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-contrast"] });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(APPEARANCE_EVENT, onChange);
|
||||||
|
observer?.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverAppearance = (): HoloAppearance => SERVER_APPEARANCE;
|
||||||
|
|
||||||
|
const COLOR_KEYS = ["background", "edge", "edgeStrong", "hot", "tint", "rim", "bounce", "grid"] as const;
|
||||||
|
|
||||||
|
function sameHoloTheme(a: HoloTheme, b: HoloTheme): boolean {
|
||||||
|
return a.sun === b.sun && COLOR_KEYS.every((key) => a[key].equals(b[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The holo theme of the current appearance. A new theme (and with it new
|
||||||
|
* materials) only when scheme or contrast change; never per frame.
|
||||||
|
*/
|
||||||
|
export function useHoloTheme(onChange?: () => void): HoloTheme {
|
||||||
|
const appearance = useSyncExternalStore(subscribeAppearance, currentHoloAppearance, serverAppearance);
|
||||||
|
// Light and dark share the holo tokens: a switch between them keeps the theme (no rebuild).
|
||||||
|
const [current, setCurrent] = useState(() => ({ appearance, theme: readHoloTheme(appearance) }));
|
||||||
|
if (current.appearance !== appearance) {
|
||||||
|
const next = readHoloTheme(appearance);
|
||||||
|
setCurrent({ appearance, theme: sameHoloTheme(next, current.theme) ? current.theme : next });
|
||||||
|
}
|
||||||
|
const { theme } = current;
|
||||||
|
// Frames are drawn on demand: ask for one with the new colours.
|
||||||
|
useEffect(() => {
|
||||||
|
onChange?.();
|
||||||
|
}, [theme, onChange]);
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ export const SWEEP_SECONDS = 1.1;
|
|||||||
* material instead of rebuilding and recompiling them.
|
* material instead of rebuilding and recompiling them.
|
||||||
*/
|
*/
|
||||||
export function useGlassEnvironment(material: ShaderMaterial, dispersive: boolean): void {
|
export function useGlassEnvironment(material: ShaderMaterial, dispersive: boolean): void {
|
||||||
const { env, tier } = useSceneKit();
|
const { env: studio, tier, theme } = useSceneKit();
|
||||||
|
// Sun: the blue studio stays out of the glass; it reflects the neutral analytic studio instead.
|
||||||
|
const env = theme.sun ? null : studio;
|
||||||
const invalidate = useThree((state) => state.invalidate);
|
const invalidate = useThree((state) => state.invalidate);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setGlassEnvironment(material, env, dispersive && tier === "high" ? GLASS_DISPERSION : 0);
|
setGlassEnvironment(material, env, dispersive && tier === "high" ? GLASS_DISPERSION : 0);
|
||||||
@@ -29,7 +31,7 @@ export function useGlassEnvironment(material: ShaderMaterial, dispersive: boolea
|
|||||||
}, [material, env, tier, dispersive, invalidate]);
|
}, [material, env, tier, dispersive, invalidate]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Travelling light pulses along the edges. */
|
/** Travelling light pulses along the edges (and the canvas height the sun line width needs). */
|
||||||
export function useLivingEdges(materials: ShaderMaterial[]): void {
|
export function useLivingEdges(materials: ShaderMaterial[]): void {
|
||||||
const { motion } = useSceneKit();
|
const { motion } = useSceneKit();
|
||||||
useFrame((state) => {
|
useFrame((state) => {
|
||||||
@@ -37,10 +39,18 @@ export function useLivingEdges(materials: ShaderMaterial[]): void {
|
|||||||
for (const material of materials) {
|
for (const material of materials) {
|
||||||
setUniform(material, "uTime", state.clock.elapsedTime);
|
setUniform(material, "uTime", state.clock.elapsedTime);
|
||||||
setUniform(material, "uPulse", pulse);
|
setUniform(material, "uPulse", pulse);
|
||||||
|
setUniform(material, "uViewHeight", state.size.height);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Edges without pulses (editor drop targets): only the canvas height for the sun line width. */
|
||||||
|
export function useEdgeViewport(materials: ShaderMaterial[]): void {
|
||||||
|
useFrame((state) => {
|
||||||
|
for (const material of materials) setUniform(material, "uViewHeight", state.size.height);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** A holographic shimmer and a thin scan line run once across a part when it becomes hovered. */
|
/** 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 {
|
export function useHoverSweep(material: ShaderMaterial, active: boolean, height: number): void {
|
||||||
const { motion } = useSceneKit();
|
const { motion } = useSceneKit();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { GLASS_BUTTON, GLASS_PILL, GLASS_SURFACE } from "./hive-glass";
|
||||||
|
import { HOLO_LABEL_PILL } from "./hive-scene/hive-labels";
|
||||||
|
|
||||||
const SRC = join(__dirname, "..", "..");
|
const SRC = join(__dirname, "..", "..");
|
||||||
const read = (relative: string) => readFileSync(join(SRC, relative), "utf8");
|
const read = (relative: string) => readFileSync(join(SRC, relative), "utf8");
|
||||||
@@ -26,3 +28,26 @@ describe("hive stage roots carry .holo-stage", () => {
|
|||||||
expect(read("components/hive/editor/editor-stack-list.tsx")).not.toMatch(/outline-holo-edge-strong/);
|
expect(read("components/hive/editor/editor-stack-list.tsx")).not.toMatch(/outline-holo-edge-strong/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Beute spec 9.3 / T5 spec 17.6: in sun the labels grow to 16 px and nothing on the stage blurs. */
|
||||||
|
describe("the stage in sun", () => {
|
||||||
|
const classes = (value: string) => value.split(/\s+/);
|
||||||
|
|
||||||
|
it("labels are 16 px and without backdrop blur in sun", () => {
|
||||||
|
const label = classes(HOLO_LABEL_PILL);
|
||||||
|
expect(label).toContain("sun:text-base");
|
||||||
|
expect(label).toContain("sun:backdrop-blur-none");
|
||||||
|
// A solid plate instead of the see-through glass, without glow.
|
||||||
|
expect(label).toContain("sun:bg-holo-label-bg");
|
||||||
|
expect(label).toContain("sun:shadow-none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["GLASS_SURFACE", GLASS_SURFACE],
|
||||||
|
["GLASS_BUTTON", GLASS_BUTTON],
|
||||||
|
["GLASS_PILL", GLASS_PILL],
|
||||||
|
])("%s drops blur and glow in sun", (_name, value) => {
|
||||||
|
expect(classes(value)).toContain("sun:backdrop-blur-none");
|
||||||
|
expect(classes(value)).toContain("sun:shadow-none");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user