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:
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;
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -27,4 +27,5 @@ export const PART_KIND_LABELS: Record<HivePartKind, string> = {
|
||||
ROOF: "Dach",
|
||||
FLOOR_INSERT: "Bausperre",
|
||||
EVAPORATOR: "Verdunster",
|
||||
FRAME_FEEDER: "Futtertasche",
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }],
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user