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

@@ -26,12 +26,14 @@ export const HIVE_PART_KINDS = [
'ROOF',
'FLOOR_INSERT',
'EVAPORATOR',
'FRAME_FEEDER',
] as const;
export const HIVE_BOX_DEPTHS = ['FULL', 'SHALLOW'] as const;
const MAX_MM = 5000;
const MAX_SCHEMA_FIELDS = 30;
const MAX_FRAME_SLOTS = 20;
const MAX_SLOT_WIDTH = 5;
export class CreateHivePartTypeDto {
@ApiProperty({ enum: HIVE_PART_KINDS })
@@ -80,6 +82,15 @@ export class CreateHivePartTypeDto {
@Max(MAX_FRAME_SLOTS)
frameSlots?: number;
@ApiPropertyOptional({
description: 'Required for kind FRAME_FEEDER, forbidden otherwise (1-5)',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_SLOT_WIDTH)
slotWidth?: number;
@ApiPropertyOptional({ description: 'State fields, spec 4.2' })
@IsOptional()
@IsArray()
@@ -454,5 +454,61 @@ describe('HiveCatalogService', () => {
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('requires slotWidth for a FRAME_FEEDER', async () => {
await expect(
service.createPartType(
SYSTEM_ID,
{
kind: 'FRAME_FEEDER',
name: 'Tasche',
lengthMm: 1,
widthMm: 1,
heightMm: 1,
},
USER,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects slotWidth on a part type that is no FRAME_FEEDER', async () => {
await expect(
service.createPartType(
SYSTEM_ID,
{
kind: 'ROOF',
name: 'Dach',
lengthMm: 1,
widthMm: 1,
heightMm: 1,
slotWidth: 2,
},
USER,
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.updatePartType('pt-1', { slotWidth: 2 }, USER),
).rejects.toBeInstanceOf(BadRequestException);
});
it('creates a FRAME_FEEDER with its slotWidth', async () => {
tx.hivePartType.create.mockResolvedValue({ id: 'new' });
await service.createPartType(
SYSTEM_ID,
{
kind: 'FRAME_FEEDER',
name: 'Tasche',
lengthMm: 477,
widthMm: 70,
heightMm: 220,
slotWidth: 2,
},
USER,
);
const [{ data }] = tx.hivePartType.create.mock.calls[0] as [
{ data: { kind: string; slotWidth: number } },
];
expect(data).toMatchObject({ kind: 'FRAME_FEEDER', slotWidth: 2 });
});
});
});
@@ -92,6 +92,7 @@ export class HiveCatalogService {
widthMm: row.widthMm,
heightMm: row.heightMm,
frameSlots: row.frameSlots,
slotWidth: row.slotWidth,
stateSchema: parseStateSchema(row.stateSchema),
active: row.active,
})),
@@ -191,6 +192,7 @@ export class HiveCatalogService {
if (dto.kind === 'BOX' && dto.frameSlots === undefined) {
throw new BadRequestException('frameSlots is required for a BOX');
}
this.assertSlotWidth(dto.kind, dto.slotWidth);
const stateSchema = dto.stateSchema ?? [];
this.assertSchemaShape(stateSchema);
return this.prisma
@@ -233,6 +235,7 @@ export class HiveCatalogService {
const before = await tx.hivePartType.findUnique({ where: { id } });
if (!before) throw new NotFoundException('Hive part type not found');
const { stateSchema, ...rest } = dto;
this.assertSlotWidth(before.kind, dto.slotWidth ?? before.slotWidth);
if (stateSchema !== undefined) {
this.assertSchemaShape(stateSchema);
this.assertAdditive(before.stateSchema, stateSchema);
@@ -262,6 +265,21 @@ export class HiveCatalogService {
.catch((error: unknown) => conflictOnDuplicate(error, 'Part type'));
}
/** slotWidth belongs to FRAME_FEEDER part types and to no other kind. */
private assertSlotWidth(
kind: string,
slotWidth: number | null | undefined,
): void {
if (kind === 'FRAME_FEEDER' && slotWidth == null) {
throw new BadRequestException('slotWidth is required for a FRAME_FEEDER');
}
if (kind !== 'FRAME_FEEDER' && slotWidth != null) {
throw new BadRequestException(
'slotWidth is only allowed for a FRAME_FEEDER',
);
}
}
private assertSchemaShape(stateSchema: unknown): void {
const problems = stateSchemaProblems(stateSchema);
if (problems.length > 0) {
@@ -115,4 +115,32 @@ describe('deriveHiveFacts', () => {
};
expect(deriveHiveFacts(state, catalog).evaporatorAcidMl).toBe(0);
});
it('reports a frame feeder level with its feed type', () => {
const state = stackOf({
...makeBox(['NONE', 'NONE', ...Array<FrameType>(8).fill('BROOD')]),
inserts: [
{
id: 'ff1',
partTypeId: T('frame-feeder'),
slot: 1,
state: { syrupLiters: 1.5 },
},
],
});
const facts = deriveHiveFacts(state, catalog);
expect(facts.levels).toEqual([
{
partId: 'ff1',
key: 'syrupLiters',
label: 'Flüssigfutter',
unit: 'l',
value: 1.5,
max: 2,
feedType: 'SUGAR_WATER',
},
]);
// The covered NONE slots are not built frames.
expect(facts.builtFrames).toBe(8);
});
});
+6 -1
View File
@@ -13,7 +13,8 @@ export type HivePartKind =
| 'INSULATION'
| 'ROOF'
| 'FLOOR_INSERT'
| 'EVAPORATOR';
| 'EVAPORATOR'
| 'FRAME_FEEDER';
// Mirrors the Prisma enum HiveBoxDepth; used from Task 3 on.
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;
}
+113 -13
View File
@@ -10,10 +10,21 @@ import type { CatalogPartType, HiveCatalog, StateField } from './hive-types';
// keep it: root shape (schemaVersion must be 1 -> 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.
// fields, type, name), inserts (each insert in the 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`); the frontend must do the same.
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'];
function unknownFields(
@@ -168,6 +179,80 @@ function validatePartCommon(
return { part, type };
}
const INSERT_KINDS: Partial<Record<string, string[]>> = {
FLOOR: ['FLOOR_INSERT'],
BOX: ['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 || !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,
@@ -181,24 +266,25 @@ function validateInserts(
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
return;
}
const allowed =
hostType.kind === 'FLOOR'
? 'FLOOR_INSERT'
: hostType.kind === 'BOX'
? 'EVAPORATOR'
: null;
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 && checked.type.kind === 'EVAPORATOR') {
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 ')}`);
}
if (checked.part.inserts !== undefined) {
const nested = checked.part.inserts;
@@ -206,7 +292,17 @@ function validateInserts(
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(
@@ -256,12 +352,16 @@ export function validateHiveState(
if (!checked) return;
if (
checked.type.kind === 'FLOOR_INSERT' ||
checked.type.kind === 'EVAPORATOR'
checked.type.kind === 'EVAPORATOR' ||
checked.type.kind === 'FRAME_FEEDER'
) {
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);