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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user