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

+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 } };