Gemeinsame Helfer statt vier Kopien der Teilsuche, zwei Vektor- Subtraktionen und zwei Zufallsgeneratoren; hasPartSheet an einer Stelle; ungenutzte setOpacity und points-Ref entfernt. Verhalten unverändert (1050 Tests). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
161 lines
5.2 KiB
TypeScript
161 lines
5.2 KiB
TypeScript
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<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: [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 } };
|
||
}
|