import type { CatalogPartType, FrameType, HiveCatalog, HivePart, HivePartKind, HiveState } from "./types"; // Layout math shared by the 3D scene, the SVG fallback and the tests. // 1 unit = 100 mm; x = length, y = height (stack grows on +y from 0), z = width. // The model is centred at x = z = 0. export type Vec3 = [number, number, number]; /** a − b, component by component. */ export function subVec3(a: Vec3, b: Vec3): Vec3 { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; } export interface PartBox { id: string; partTypeId: string; kind: HivePartKind; center: Vec3; size: Vec3; /** Stack index; an insert carries the index of its host. */ stackIndex: number; } export interface FrameBox { boxId: string; slot: number; type: FrameType; name?: string; center: Vec3; size: Vec3; } export interface HiveLayout { parts: PartBox[]; inserts: PartBox[]; frames: FrameBox[]; bounds: { w: number; h: number; d: number }; } const UNIT_MM = 100; const ROOF_OVERLAP_MM = 35; const BOX_WALL_MM = 20; const FRAME_THICKNESS_MM = 25; const FRAME_HEIGHT_MARGIN_MM = 10; /** 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)]; } function insertBox( insert: HivePart, type: CatalogPartType, host: PartBox, ): PartBox { const size = sizeOf(type); const hostBottom = host.center[1] - host.size[1] / 2; 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" ? hostTop - FLOOR_GRID_DEPTH + size[1] / 2 : hostBottom + size[1] / 2; return { id: insert.id, partTypeId: insert.partTypeId, kind: type.kind, center: [0, centerY, 0], size, stackIndex: host.stackIndex, }; } /** 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); 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): 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): 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: [left + pitch * (index + 0.5), box.center[1], 0], size, })); } /** Pure layout of a hive state in 3D units (1 = 100 mm). Unknown part types are skipped. */ export function hiveLayout(state: HiveState, catalog: HiveCatalog): HiveLayout { const parts: PartBox[] = []; const inserts: PartBox[] = []; const frames: FrameBox[] = []; let top = 0; let maxTop = 0; let width = 0; let depth = 0; state.stack.forEach((part, stackIndex) => { const type = catalog.partTypes.get(part.partTypeId); if (!type) return; const size = sizeOf(type); const bottom = type.kind === "ROOF" ? top - mm(ROOF_OVERLAP_MM) : top; const box: PartBox = { id: part.id, partTypeId: part.partTypeId, kind: type.kind, center: [0, bottom + size[1] / 2, 0], size, stackIndex, }; parts.push(box); top = bottom + size[1]; maxTop = Math.max(maxTop, top); 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) 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, box, grid)); }); return { parts, inserts, frames, bounds: { w: width, h: maxTop, d: depth } }; }