feat: holographic 3D hive scene and Futtertasche (Beute L3, first cut)

- 3D dependencies (three, react-three-fiber, drei, postprocessing,
  motion), exact versions, supply-chain checked; scene loaded only on
  demand on the colony page (283 kB gzip), SVG stays as fallback
- glass hologram per owner design 2026-10-16: true Liebig Zander
  shapes (grips, rebate, Wanderboden foot), cool blue edge glow,
  click any part -> it flies to the front while the rest stays as a
  faint ghost; focus shows content (feed levels, Bausperre, pockets);
  frames rise on hover and take their type look (colour + comb pattern)
- full-bleed card with frosted overlays and glass buttons
- Futtertasche (FRAME_FEEDER): new part kind occupying frame slots
  with a fill level, catalog row, validator rules in both ports,
  outline, sheets, follow-ups, settings

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:03 +02:00
1 parent c608f5d405
commit 6e13236d49
100 files changed
+9432 -209

No files matched your search

@@ -24,6 +24,8 @@ export interface HivePartTypeInput {
heightMm: number;
boxDepth?: HiveBoxDepth;
frameSlots?: number;
/** Only FRAME_FEEDER: frame slots the pocket covers (1-5). */
slotWidth?: number;
stateSchema?: StateField[];
}
@@ -8,6 +8,8 @@ import type { StateField } from "./types";
export const MAX_DIMENSION_MM = 5000;
/** Upper bound of `frameSlots` in the backend DTO. */
export const MAX_FRAME_SLOTS = 20;
/** Upper bound of `slotWidth` (frame feeder) in the backend DTO. */
export const MAX_SLOT_WIDTH = 5;
export const MAX_KEY_LENGTH = 40;
export const MAX_FIELD_LABEL_LENGTH = 60;
export const MAX_UNIT_LENGTH = 10;
+1
View File
@@ -20,6 +20,7 @@ export function catalogFromSystem(system: HiveSystemDto): HiveCatalog {
widthMm: row.widthMm,
heightMm: row.heightMm,
frameSlots: row.frameSlots,
slotWidth: row.slotWidth ?? null,
stateSchema: row.stateSchema,
active: row.active,
}));
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { feederRange, freeFeederStarts, pocketRanges, rangeText } from "./feeder-slots";
import { frames, inlineCatalog, part } from "./test-utils";
const catalog = inlineCatalog();
const boxType = catalog.partTypes.get("box")!;
const feederType = catalog.partTypes.get("frame-feeder")!;
describe("feeder slots", () => {
it("reads the covered range from slot and slotWidth", () => {
expect(feederRange(part("p", "frame-feeder", { slot: 3 }), feederType)).toEqual({ from: 3, to: 4 });
expect(feederRange(part("p", "frame-feeder"), feederType)).toBeNull();
expect(feederRange(part("e", "evaporator", { slot: 1 }), catalog.partTypes.get("evaporator"))).toBeNull();
});
it("offers every start in an empty box", () => {
const box = part("b", "box", { frames: frames(10) });
expect(freeFeederStarts(box, boxType, feederType, catalog)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it("leaves out starts that overlap a pocket", () => {
const box = part("b", "box", { frames: frames(10), inserts: [part("p", "frame-feeder", { slot: 4 })] });
expect(pocketRanges(box, catalog)).toEqual([{ from: 4, to: 5 }]);
expect(freeFeederStarts(box, boxType, feederType, catalog)).toEqual([1, 2, 6, 7, 8, 9]);
});
it("offers nothing in a box without slots", () => {
const empty = { ...boxType, frameSlots: 0 };
expect(freeFeederStarts(part("b", "box"), empty, feederType, catalog)).toEqual([]);
});
it("words a range", () => {
expect(rangeText({ from: 1, to: 2 })).toBe("1–2");
expect(rangeText({ from: 3, to: 3 })).toBe("3");
});
});
@@ -0,0 +1,50 @@
import type { CatalogPartType, HiveCatalog, HivePart } from "./types";
// Frame feeder (Futtertasche) positions in a box. A pocket hangs in place of
// `slotWidth` frames from its `slot` on (spec 4.1); the server rule is
// RULE_FEEDER_SLOT.
export interface SlotRange {
from: number;
to: number;
}
/** Slots a frame feeder insert covers; null without a usable slot or width. */
export function feederRange(insert: HivePart, type: CatalogPartType | undefined): SlotRange | null {
if (type?.kind !== "FRAME_FEEDER") return null;
const width = type.slotWidth;
if (typeof insert.slot !== "number" || !width) return null;
return { from: insert.slot, to: insert.slot + width - 1 };
}
/** Ranges of the frame feeders already hanging in a box. */
export function pocketRanges(box: HivePart, catalog: HiveCatalog): SlotRange[] {
return (box.inserts ?? []).flatMap((insert) => {
const range = feederRange(insert, catalog.partTypes.get(insert.partTypeId));
return range ? [range] : [];
});
}
/** First slots where a new frame feeder of `feederType` still fits (inside the box, no overlap). */
export function freeFeederStarts(
box: HivePart,
boxType: CatalogPartType,
feederType: CatalogPartType,
catalog: HiveCatalog,
): number[] {
const width = feederType.slotWidth ?? 0;
const slots = boxType.frameSlots ?? 0;
if (width < 1) return [];
const taken = pocketRanges(box, catalog);
const starts: number[] = [];
for (let from = 1; from + width - 1 <= slots; from++) {
const to = from + width - 1;
if (!taken.some((range) => from <= range.to && to >= range.from)) starts.push(from);
}
return starts;
}
/** „1–2“ for a range, „3“ for a single slot. */
export function rangeText(range: SlotRange): string {
return range.from === range.to ? String(range.from) : `${range.from}–${range.to}`;
}
@@ -114,4 +114,40 @@ describe("hiveFollowUps", () => {
{ kind: "feeding", amount: 1.5, unit: "kg", feedType: "FEED_PASTE" },
]);
});
describe("frame feeder", () => {
function withPocket(syrup?: number): HiveState {
const state = templateState();
const frames = state.stack[1].frames!;
frames[0] = { slot: 1, type: "NONE" };
frames[1] = { slot: 2, type: "NONE" };
state.stack[1].inserts = [
{ ...part("ff", "frame-feeder", { slot: 1 }), state: syrup === undefined ? {} : { syrupLiters: syrup } },
];
return state;
}
it("suggests a feeding when the pocket level rises", () => {
const ops: HiveOp[] = [{ op: "setState", id: "ff", key: "syrupLiters", value: 1.5 }];
expect(run(withPocket(), ops)).toEqual([
{ kind: "feeding", amount: 1.5, unit: "l", feedType: "SUGAR_WATER" },
]);
});
it("suggests nothing when the pocket level falls", () => {
const ops: HiveOp[] = [{ op: "setState", id: "ff", key: "syrupLiters", value: 0.5 }];
expect(run(withPocket(1.5), ops)).toEqual([]);
});
it("suggests the filled amount for a pocket added already filled", () => {
const before = templateState();
const frames = before.stack[1].frames!;
frames[0] = { slot: 1, type: "NONE" };
frames[1] = { slot: 2, type: "NONE" };
const filled = { ...part("ff", "frame-feeder", { slot: 1 }), state: { syrupLiters: 1 } };
expect(run(before, [{ op: "addInsert", hostId: "b", part: filled }])).toEqual([
{ kind: "feeding", amount: 1, unit: "l", feedType: "SUGAR_WATER" },
]);
});
});
});
+5 -2
View File
@@ -10,6 +10,9 @@ export type FollowUp =
const FEED_TYPES: readonly FeedType[] = ["SUGAR_WATER", "FEED_SYRUP", "FEED_PASTE", "OWN_HONEY", "OTHER"];
const ACID_KEY = "acidMl";
/** Part kinds that hold liquid or paste feed (stack feeder and frame feeder pocket). */
const isFeedKind = (kind: string | undefined): boolean => kind === "FEEDER" || kind === "FRAME_FEEDER";
const ROUNDING = 1000;
function findPart(state: HiveState, id: string): HivePart | undefined {
@@ -89,13 +92,13 @@ export function hiveFollowUps(
const was = findPart(before, op.id);
const kind = typeOf(now, catalog)?.kind;
if (now && kind === "EVAPORATOR" && op.key === ACID_KEY) followUps.push(treatmentFor(was, now));
else if (now && kind === "FEEDER") followUps.push(feedingForPart(was, now, op.key, catalog));
else if (now && isFeedKind(kind)) followUps.push(feedingForPart(was, now, op.key, catalog));
} else if (op.op === "addInsert" || op.op === "addPart") {
const now = findPart(after, op.part.id);
const type = typeOf(now, catalog);
if (now && type?.kind === "EVAPORATOR" && once(now.id, ACID_KEY)) {
followUps.push(treatmentFor(findPart(before, now.id), now));
} else if (now && type?.kind === "FEEDER") {
} else if (now && type && isFeedKind(type.kind)) {
for (const field of type.stateSchema) {
if (field.kind === "level" && once(now.id, field.key)) {
followUps.push(feedingForPart(findPart(before, now.id), now, field.key, catalog));
+1
View File
@@ -27,4 +27,5 @@ export const PART_KIND_LABELS: Record<HivePartKind, string> = {
ROOF: "Dach",
FLOOR_INSERT: "Bausperre",
EVAPORATOR: "Verdunster",
FRAME_FEEDER: "Futtertasche",
};
+28 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { hiveLayout } from "./layout";
import { FLOOR_GRID_DEPTH, hiveLayout } from "./layout";
import { inlineCatalog, part, templateState } from "./test-utils";
describe("hiveLayout", () => {
@@ -49,14 +49,16 @@ describe("hiveLayout", () => {
});
});
it("puts the floor insert 5 mm above the floor bottom and the evaporator on the box bottom", () => {
it("lays the floor insert on the floor's grid (40 mm below the floor top) and the evaporator on the box bottom", () => {
const state = templateState();
state.stack[0].inserts = [part("fi", "floor-insert")];
state.stack[1].inserts = [part("ev", "evaporator")];
const layout = hiveLayout(state, catalog);
const insert = layout.inserts.find((i) => i.id === "fi")!;
const evaporator = layout.inserts.find((i) => i.id === "ev")!;
expect(insert.center[1]).toBeCloseTo(0.05, 9);
// Floor top 1.2, grid 40 mm (0.4 units) below it, Bausperre 5 mm thick lying on the grid.
expect(insert.center[1]).toBeCloseTo(1.2 - FLOOR_GRID_DEPTH + 0.025, 9);
expect(FLOOR_GRID_DEPTH).toBeCloseTo(0.4, 9);
expect(insert.kind).toBe("FLOOR_INSERT");
expect(evaporator.center[1]).toBeCloseTo(1.2 + 0.6, 9);
expect(evaporator.stackIndex).toBe(1);
@@ -66,4 +68,27 @@ describe("hiveLayout", () => {
const layout = hiveLayout({ schemaVersion: 1, stack: [] }, catalog);
expect(layout).toEqual({ parts: [], inserts: [], frames: [], bounds: { w: 0, h: 0, d: 0 } });
});
it("lays a frame feeder over its slots with the frame height", () => {
const state = templateState();
const frames = state.stack[1].frames!;
frames[2] = { slot: 3, type: "NONE" };
frames[3] = { slot: 4, type: "NONE" };
state.stack[1].inserts = [part("ff", "frame-feeder", { slot: 3 })];
const layout = hiveLayout(state, catalog);
const pocket = layout.inserts.find((i) => i.id === "ff")!;
const inner = 5.25 - 0.4;
const pitch = inner / 10;
const box = layout.parts[1];
expect(pocket.kind).toBe("FRAME_FEEDER");
expect(pocket.stackIndex).toBe(1);
expect(pocket.size[0]).toBeCloseTo(2 * pitch, 9);
expect(pocket.size[1]).toBeCloseTo(2.2, 9);
expect(pocket.size[2]).toBeCloseTo(4.3 - 0.4, 9);
expect(pocket.center[0]).toBeCloseTo(-inner / 2 + pitch * 3, 9);
expect(pocket.center[1]).toBeCloseTo(box.center[1], 9);
// The pocket sits exactly where frames 3 and 4 would hang.
const covered = layout.frames.filter((f) => f.slot === 3 || f.slot === 4).map((f) => f.center[0]);
expect(pocket.center[0]).toBeCloseTo((covered[0] + covered[1]) / 2, 9);
});
});
+45 -17
View File
@@ -37,10 +37,14 @@ const ROOF_OVERLAP_MM = 35;
const BOX_WALL_MM = 20;
const FRAME_THICKNESS_MM = 25;
const FRAME_HEIGHT_MARGIN_MM = 10;
const FLOOR_INSERT_LIFT_MM = 5;
/** The stainless grid of the floor (Liebig Wanderboden) lies this far below its top edge. */
const FLOOR_GRID_DEPTH_MM = 40;
const mm = (value: number): number => value / UNIT_MM;
/** Depth of the floor's grid below the floor's top edge, in layout units (shared with the 3D floor). */
export const FLOOR_GRID_DEPTH = mm(FLOOR_GRID_DEPTH_MM);
function sizeOf(type: CatalogPartType): Vec3 {
return [mm(type.lengthMm), mm(type.heightMm), mm(type.widthMm)];
}
@@ -52,11 +56,11 @@ function insertBox(
): PartBox {
const size = sizeOf(type);
const hostBottom = host.center[1] - host.size[1] / 2;
// FLOOR_INSERT lies just above the floor's bottom area (its centre y = floor
// bottom + 5 mm, y is always a centre); every other insert
// (the EVAPORATOR) stands on the bottom of the box it sits in.
const hostTop = host.center[1] + host.size[1] / 2;
// FLOOR_INSERT (Bausperre) lies on the floor's grid near its top edge; every
// other insert (the EVAPORATOR) stands on the bottom of the box it sits in.
const centerY =
type.kind === "FLOOR_INSERT" ? hostBottom + mm(FLOOR_INSERT_LIFT_MM) : hostBottom + size[1] / 2;
type.kind === "FLOOR_INSERT" ? hostTop - FLOOR_GRID_DEPTH + size[1] / 2 : hostBottom + size[1] / 2;
return {
id: insert.id,
partTypeId: insert.partTypeId,
@@ -67,22 +71,42 @@ function insertBox(
};
}
function frameBoxes(part: HivePart, type: CatalogPartType, box: PartBox): FrameBox[] {
const frames = part.frames ?? [];
if (frames.length === 0) return [];
/** Frame grid of a box: x of the first slot's left edge, slot pitch, frame size. */
function frameGrid(part: HivePart, type: CatalogPartType, box: PartBox): { left: number; pitch: number; size: Vec3 } | null {
const count = part.frames?.length ?? 0;
if (count === 0) return null;
const inner = box.size[0] - 2 * mm(BOX_WALL_MM);
const pitch = inner / frames.length;
const size: Vec3 = [
mm(FRAME_THICKNESS_MM),
mm(type.heightMm - FRAME_HEIGHT_MARGIN_MM),
box.size[2] - 2 * mm(BOX_WALL_MM),
];
return {
left: -inner / 2,
pitch: inner / count,
size: [mm(FRAME_THICKNESS_MM), mm(type.heightMm - FRAME_HEIGHT_MARGIN_MM), box.size[2] - 2 * mm(BOX_WALL_MM)],
};
}
/** A frame feeder hangs where its slots' frames would: width = slotWidth x pitch, frame height. */
function feederBox(insert: HivePart, type: CatalogPartType, host: PartBox, grid: ReturnType<typeof frameGrid>): PartBox | null {
if (!grid || typeof insert.slot !== "number" || !type.slotWidth) return null;
const width = grid.pitch * type.slotWidth;
return {
id: insert.id,
partTypeId: insert.partTypeId,
kind: type.kind,
center: [grid.left + grid.pitch * (insert.slot - 1) + width / 2, host.center[1], 0],
size: [width, grid.size[1], grid.size[2]],
stackIndex: host.stackIndex,
};
}
function frameBoxes(part: HivePart, box: PartBox, grid: ReturnType<typeof frameGrid>): FrameBox[] {
const frames = part.frames ?? [];
if (!grid) return [];
const { left, pitch, size } = grid;
return frames.map((frame, index) => ({
boxId: part.id,
slot: frame.slot,
type: frame.type,
...(frame.name !== undefined ? { name: frame.name } : {}),
center: [-inner / 2 + pitch * (index + 0.5), box.center[1], 0],
center: [left + pitch * (index + 0.5), box.center[1], 0],
size,
}));
}
@@ -116,11 +140,15 @@ export function hiveLayout(state: HiveState, catalog: HiveCatalog): HiveLayout {
width = Math.max(width, size[0]);
depth = Math.max(depth, size[2]);
const grid = type.kind === "BOX" ? frameGrid(part, type, box) : null;
for (const insert of part.inserts ?? []) {
const insertType = catalog.partTypes.get(insert.partTypeId);
if (insertType) inserts.push(insertBox(insert, insertType, box));
if (!insertType) continue;
const placed =
insertType.kind === "FRAME_FEEDER" ? feederBox(insert, insertType, box, grid) : insertBox(insert, insertType, box);
if (placed) inserts.push(placed);
}
if (type.kind === "BOX") frames.push(...frameBoxes(part, type, box));
if (type.kind === "BOX") frames.push(...frameBoxes(part, box, grid));
});
return { parts, inserts, frames, bounds: { w: width, h: maxTop, d: depth } };
@@ -8,6 +8,7 @@ const TEXTS: Record<string, string> = {
FRAMES_LENGTH: "Dieses Rähmchen gibt es in der Zarge nicht.",
RULE_INSERT_HOST: "Dieses Teil passt nicht an diese Stelle.",
RULE_INSERT_LIMIT: "Davon passt nur eines an diese Stelle.",
RULE_FEEDER_SLOT: "Die Futtertasche passt dort nicht hin.",
RULE_FLOOR_BOTTOM: "Der Boden muss ganz unten liegen.",
RULE_ROOF_TOP: "Das Dach muss ganz oben liegen.",
STATE_OUT_OF_RANGE: "Dieser Wert ist nicht erlaubt.",
@@ -66,6 +66,7 @@ function type(
widthMm,
heightMm,
frameSlots: null,
slotWidth: null,
stateSchema: noState,
active: true,
...extra,
@@ -93,6 +94,12 @@ export function inlineCatalog(): HiveCatalog {
}),
type("insulation", "INSULATION", 465, 430, 20),
type("roof", "ROOF", 525, 430, 60),
type("frame-feeder", "FRAME_FEEDER", 477, 70, 220, {
slotWidth: 2,
stateSchema: [
{ key: "syrupLiters", label: "Flüssigfutter", kind: "level", unit: "l", max: 2, step: 0.25, feedType: "SUGAR_WATER" },
],
}),
type("evaporator", "EVAPORATOR", 200, 150, 120, {
stateSchema: [{ key: "acidMl", label: "Säure", kind: "level", unit: "ml", max: 300, step: 10 }],
}),
+6 -1
View File
@@ -14,7 +14,8 @@ export type HivePartKind =
| "INSULATION"
| "ROOF"
| "FLOOR_INSERT"
| "EVAPORATOR";
| "EVAPORATOR"
| "FRAME_FEEDER";
export type HiveBoxDepth = "FULL" | "SHALLOW";
@@ -61,6 +62,8 @@ export interface HivePart {
state: Record<string, number | string>;
inserts?: HivePart[];
frames?: HiveFrame[];
/** Only on FRAME_FEEDER inserts: first frame slot (1-based) the pocket covers. */
slot?: number;
}
export interface HiveState {
@@ -112,6 +115,8 @@ export interface CatalogPartType {
widthMm: number;
heightMm: number;
frameSlots: number | null;
/** Only FRAME_FEEDER: number of consecutive frame slots the pocket covers. */
slotWidth: number | null;
stateSchema: StateField[];
active: boolean;
}
+87 -13
View File
@@ -13,10 +13,12 @@ import {
// STATE_OUT_OF_RANGE; extra root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per
// stack part: id, unknown fields (UNKNOWN_FIELD), type, label, state, frames
// (per frame: slot, unknown fields, type, name), inserts (each insert in the
// same order), then FLOOR, then ROOF. Text lengths are UTF-16 code units
// (`.length`).
// same order; then per insert RULE_INSERT_HOST, nested inserts and `slot` /
// RULE_FEEDER_SLOT; after all inserts of a box the FRAME_FEEDER overlap check,
// also RULE_FEEDER_SLOT), then FLOOR, then ROOF. Text lengths are UTF-16 code
// units (`.length`).
const ROOT_KEYS = ["schemaVersion", "stack"];
const PART_KEYS = ["id", "partTypeId", "label", "state", "inserts", "frames"];
const PART_KEYS = ["id", "partTypeId", "label", "state", "inserts", "frames", "slot"];
const FRAME_KEYS = ["slot", "type", "name"];
type Obj = Record<string, unknown>;
@@ -144,10 +146,70 @@ function validatePartCommon(
return { part, type };
}
function allowedInsertKind(hostType: CatalogPartType): string | null {
if (hostType.kind === "FLOOR") return "FLOOR_INSERT";
if (hostType.kind === "BOX") return "EVAPORATOR";
return null;
const INSERT_KINDS: Partial<Record<string, string[]>> = {
FLOOR: ["FLOOR_INSERT"],
BOX: ["EVAPORATOR", "FRAME_FEEDER"],
};
const INSERT_ONLY_KINDS: string[] = ["FLOOR_INSERT", "EVAPORATOR", "FRAME_FEEDER"];
interface SlotRange {
from: number;
to: number;
}
/**
* RULE_FEEDER_SLOT checks for one insert: `slot` only on FRAME_FEEDER, an
* integer inside 1..frameSlots for the covered width, and every covered frame
* NONE. Returns the covered range for the overlap check, or null.
*/
function validateFeederSlot(
insert: Obj,
type: CatalogPartType,
host: Obj,
hostType: CatalogPartType,
where: string,
out: string[],
): SlotRange | null {
if (type.kind !== "FRAME_FEEDER") {
if (insert.slot !== undefined) out.push(`RULE_FEEDER_SLOT: ${where} has a slot but is no frame feeder`);
return null;
}
if (hostType.kind !== "BOX") return null;
const slot = insert.slot;
const width = type.slotWidth;
const slots = hostType.frameSlots ?? 0;
if (typeof slot !== "number" || !Number.isInteger(slot) || slot < 1) {
out.push(`RULE_FEEDER_SLOT: ${where} needs an integer slot of 1 or more`);
return null;
}
if (width === null || width === undefined || !Number.isInteger(width) || width < 1) {
out.push(`RULE_FEEDER_SLOT: ${where} part type has no slot width`);
return null;
}
const range = { from: slot, to: slot + width - 1 };
if (range.to > slots) {
out.push(`RULE_FEEDER_SLOT: ${where} covers slots ${range.from}-${range.to} but the box has ${slots}`);
return null;
}
const frames = Array.isArray(host.frames) ? (host.frames as Obj[]) : [];
for (let at = range.from; at <= range.to; at++) {
if (frames[at - 1]?.type !== "NONE") {
out.push(`RULE_FEEDER_SLOT: ${where} covers frame ${at} which is not NONE`);
return null;
}
}
return range;
}
function validateFeederOverlap(ranges: SlotRange[], where: string, out: string[]): void {
const sorted = [...ranges].sort((a, b) => a.from - b.from);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].from <= sorted[i - 1].to) {
out.push(`RULE_FEEDER_SLOT: ${where} has overlapping frame feeders`);
return;
}
}
}
function validateInserts(host: Obj, hostType: CatalogPartType, where: string, ctx: WalkContext): void {
@@ -158,25 +220,34 @@ function validateInserts(host: Obj, hostType: CatalogPartType, where: string, ct
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
return;
}
const allowed = allowedInsertKind(hostType);
const allowed = INSERT_KINDS[hostType.kind] ?? null;
if (inserts.length > 0 && allowed === null) {
out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`);
}
if (allowed !== null && inserts.length > 1) {
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one ${allowed}`);
if (hostType.kind === "FLOOR" && inserts.length > 1) {
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one FLOOR_INSERT`);
}
const ranges: SlotRange[] = [];
let evaporators = 0;
inserts.forEach((insert: unknown, index) => {
const here = `${where}.inserts[${index}]`;
const checked = validatePartCommon(insert, here, ctx);
if (!checked) return;
if (allowed !== null && checked.type.kind !== allowed) {
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed}`);
if (checked.type.kind === "EVAPORATOR") {
evaporators += 1;
if (evaporators > 1) out.push(`RULE_INSERT_LIMIT: ${where} holds at most one EVAPORATOR`);
}
if (allowed !== null && !allowed.includes(checked.type.kind)) {
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed.join(" or ")}`);
}
const nested = checked.part.inserts;
if (nested !== undefined && (!Array.isArray(nested) || nested.length > 0)) {
out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`);
}
const range = validateFeederSlot(checked.part, checked.type, host, hostType, here, out);
if (range) ranges.push(range);
});
validateFeederOverlap(ranges, where, out);
}
function validateStackRules(kinds: (CatalogPartType | null)[], out: string[]): void {
@@ -207,9 +278,12 @@ export function validateHiveState(state: unknown, catalog: HiveCatalog): string[
const checked = validatePartCommon(entry, where, ctx);
types.push(checked?.type ?? null);
if (!checked) return;
if (checked.type.kind === "FLOOR_INSERT" || checked.type.kind === "EVAPORATOR") {
if (INSERT_ONLY_KINDS.includes(checked.type.kind)) {
out.push(`RULE_INSERT_HOST: ${where} must be an insert, not a stack part`);
}
if (checked.part.slot !== undefined) {
out.push(`RULE_FEEDER_SLOT: ${where} has a slot but is no frame feeder`);
}
validateInserts(checked.part, checked.type, where, ctx);
});
validateStackRules(types, out);
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { canUseWebGL, resetWebGLProbe, shouldUse3D } from "./webgl";
const base = { webgl: true, motion: "full", cores: 8, forcedColors: false } as const;
describe("shouldUse3D", () => {
it("is true with WebGL and a normal setup", () => {
expect(shouldUse3D(base)).toBe(true);
});
it("is false without WebGL", () => {
expect(shouldUse3D({ ...base, webgl: false })).toBe(false);
});
it("is false with forced colors", () => {
expect(shouldUse3D({ ...base, forcedColors: true })).toBe(false);
});
it("is false with motion off on a weak device (2 cores or fewer)", () => {
expect(shouldUse3D({ ...base, motion: "off", cores: 2 })).toBe(false);
expect(shouldUse3D({ ...base, motion: "off", cores: 1 })).toBe(false);
});
it("keeps 3D with motion off on a stronger device", () => {
expect(shouldUse3D({ ...base, motion: "off", cores: 4 })).toBe(true);
});
it("keeps 3D on a weak device when motion is not off", () => {
expect(shouldUse3D({ ...base, motion: "subtle", cores: 2 })).toBe(true);
});
});
describe("canUseWebGL", () => {
afterEach(() => {
vi.restoreAllMocks();
resetWebGLProbe();
});
it("is false when no context can be created (jsdom)", () => {
expect(canUseWebGL()).toBe(false);
});
it("is true when a context is available, and probes only once", () => {
const getContext = vi
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue({} as unknown as RenderingContext);
expect(canUseWebGL()).toBe(true);
expect(canUseWebGL()).toBe(true);
expect(getContext).toHaveBeenCalledTimes(1);
});
it("is false when creating the context throws", () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
throw new Error("blocked");
});
expect(canUseWebGL()).toBe(false);
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { MotionLevel } from "@/lib/motion/motion-preference";
/** At or below this many cores, motion `off` keeps the card on the SVG (spec 9.7). */
const WEAK_DEVICE_CORES = 2;
let probed: boolean | undefined;
/** Whether a WebGL context can be created here. Probed once, then cached. */
export function canUseWebGL(): boolean {
if (probed !== undefined) return probed;
if (typeof document === "undefined") return false;
try {
const canvas = document.createElement("canvas");
probed = Boolean(canvas.getContext("webgl2") ?? canvas.getContext("webgl"));
} catch {
probed = false;
}
return probed;
}
/** Test hook: forget the cached probe result. */
export function resetWebGLProbe(): void {
probed = undefined;
}
export interface Use3DInput {
webgl: boolean;
motion: MotionLevel;
cores: number;
forcedColors: boolean;
}
/** The card shows the 3D scene only when all of this holds; otherwise it stays on the SVG. */
export function shouldUse3D({ webgl, motion, cores, forcedColors }: Use3DInput): boolean {
if (!webgl || forcedColors) return false;
return !(motion === "off" && cores <= WEAK_DEVICE_CORES);
}