diff --git a/ApiCore-Backend/prisma/migrations/20261018100000_hive_frame_feeder/migration.sql b/ApiCore-Backend/prisma/migrations/20261018100000_hive_frame_feeder/migration.sql new file mode 100644 index 0000000..5c26903 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261018100000_hive_frame_feeder/migration.sql @@ -0,0 +1,10 @@ +-- Beute in 3D: Futtertasche (FRAME_FEEDER, spec 2026-10-15 sections 4.1, 4.3, +-- 5.1). A frame feeder hangs in a box in place of frames and covers +-- `slotWidth` frame slots. The enum value cannot be used in the migration that +-- adds it, so the catalog row comes in 20261018100100. + +-- AlterEnum +ALTER TYPE "HivePartKind" ADD VALUE 'FRAME_FEEDER'; + +-- AlterTable +ALTER TABLE "HivePartType" ADD COLUMN "slotWidth" INTEGER; diff --git a/ApiCore-Backend/prisma/migrations/20261018100100_hive_catalog_frame_feeder/migration.sql b/ApiCore-Backend/prisma/migrations/20261018100100_hive_catalog_frame_feeder/migration.sql new file mode 100644 index 0000000..a7e24bc --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261018100100_hive_catalog_frame_feeder/migration.sql @@ -0,0 +1,20 @@ +-- Beute in 3D: start catalog row for the Futtertasche (owner wish 2026-10-16, +-- spec sections 4.3 and 4.1). Fixed id like the other start data; dimensions and +-- the 2 l capacity are assumptions the owner confirms (catalog data, editable). + +INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "slotWidth", "stateSchema", "active", "updatedAt") +VALUES ( + 'hive-part-zl10-frame-feeder', + 'hive-system-zander-liebig-10', + 'FRAME_FEEDER', + 'Holz-Futtertasche Zander, doppelte Wabenbreite', + 477, + 70, + 220, + NULL, + NULL, + 2, + '[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":2,"step":0.25,"feedType":"SUGAR_WATER"}]'::jsonb, + true, + CURRENT_TIMESTAMP +); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 575614b..f20fefa 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -2217,6 +2217,7 @@ model HivePartType { heightMm Int boxDepth HiveBoxDepth? frameSlots Int? // nur BOX + slotWidth Int? // nur FRAME_FEEDER: belegte Rähmchen-Plätze stateSchema Json // Abschnitt 4.2 active Boolean @default(true) createdAt DateTime @default(now()) @@ -2269,6 +2270,7 @@ enum HivePartKind { ROOF FLOOR_INSERT EVAPORATOR + FRAME_FEEDER } enum HiveBoxDepth { diff --git a/ApiCore-Backend/src/hive/dto/create-hive-part-type.dto.ts b/ApiCore-Backend/src/hive/dto/create-hive-part-type.dto.ts index 40b9dc6..3424457 100644 --- a/ApiCore-Backend/src/hive/dto/create-hive-part-type.dto.ts +++ b/ApiCore-Backend/src/hive/dto/create-hive-part-type.dto.ts @@ -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() diff --git a/ApiCore-Backend/src/hive/hive-catalog.service.spec.ts b/ApiCore-Backend/src/hive/hive-catalog.service.spec.ts index 4c5c5af..2e2aba6 100644 --- a/ApiCore-Backend/src/hive/hive-catalog.service.spec.ts +++ b/ApiCore-Backend/src/hive/hive-catalog.service.spec.ts @@ -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 }); + }); }); }); diff --git a/ApiCore-Backend/src/hive/hive-catalog.service.ts b/ApiCore-Backend/src/hive/hive-catalog.service.ts index a147e17..af0a912 100644 --- a/ApiCore-Backend/src/hive/hive-catalog.service.ts +++ b/ApiCore-Backend/src/hive/hive-catalog.service.ts @@ -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) { diff --git a/ApiCore-Backend/src/hive/hive-facts.spec.ts b/ApiCore-Backend/src/hive/hive-facts.spec.ts index a6e1e5e..a652fe8 100644 --- a/ApiCore-Backend/src/hive/hive-facts.spec.ts +++ b/ApiCore-Backend/src/hive/hive-facts.spec.ts @@ -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(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); + }); }); diff --git a/ApiCore-Backend/src/hive/hive-types.ts b/ApiCore-Backend/src/hive/hive-types.ts index f396b7a..28849be 100644 --- a/ApiCore-Backend/src/hive/hive-types.ts +++ b/ApiCore-Backend/src/hive/hive-types.ts @@ -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; 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; } diff --git a/ApiCore-Backend/src/hive/hive-validate.ts b/ApiCore-Backend/src/hive/hive-validate.ts index f437c45..39cd5a0 100644 --- a/ApiCore-Backend/src/hive/hive-validate.ts +++ b/ApiCore-Backend/src/hive/hive-validate.ts @@ -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> = { + 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); diff --git a/ApiCore-Backend/test/backup.e2e-spec.ts b/ApiCore-Backend/test/backup.e2e-spec.ts index 1a90ec0..58c41a8 100644 --- a/ApiCore-Backend/test/backup.e2e-spec.ts +++ b/ApiCore-Backend/test/backup.e2e-spec.ts @@ -689,6 +689,12 @@ describe('Backup round trip (e2e)', () => { expect(exportedHive.hiveSystem.some((row) => row.id === systemId)).toBe( true, ); + // The frame feeder's slotWidth travels with the part type rows. + expect( + exportedHive.hivePartType.find( + (row) => row.id === 'hive-part-zl10-frame-feeder', + )?.slotWidth, + ).toBe(2); // The colony is lost; the catalog rows stay, as a fresh install has // them from the migration. diff --git a/ApiCore-Backend/test/fixtures/hive-ops/100-frame-feeder-without-slot-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/100-frame-feeder-without-slot-fails.json new file mode 100644 index 0000000..8bfef6c --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/100-frame-feeder-without-slot-fails.json @@ -0,0 +1,81 @@ +{ + "name": "frame feeder without slot fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {} + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FEEDER_SLOT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/101-frame-feeder-slot-zero-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/101-frame-feeder-slot-zero-fails.json new file mode 100644 index 0000000..6059d8d --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/101-frame-feeder-slot-zero-fails.json @@ -0,0 +1,82 @@ +{ + "name": "frame feeder slot 0 fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 0 + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FEEDER_SLOT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/102-frame-feeder-with-a-fractional-slot-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/102-frame-feeder-with-a-fractional-slot-fails.json new file mode 100644 index 0000000..2fbc795 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/102-frame-feeder-with-a-fractional-slot-fails.json @@ -0,0 +1,82 @@ +{ + "name": "frame feeder with a fractional slot fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "NONE" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1.5 + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FEEDER_SLOT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/103-setframes-covering-a-feeder-slot-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/103-setframes-covering-a-feeder-slot-fails.json new file mode 100644 index 0000000..ac17d44 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/103-setframes-covering-a-feeder-slot-fails.json @@ -0,0 +1,126 @@ +{ + "name": "setFrames putting a type on a covered slot fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrames", + "boxId": "b1", + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FEEDER_SLOT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/104-setstate-on-a-frame-feeder-sets-the-level.json b/ApiCore-Backend/test/fixtures/hive-ops/104-setstate-on-a-frame-feeder-sets-the-level.json new file mode 100644 index 0000000..cbc9b93 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/104-setstate-on-a-frame-feeder-sets-the-level.json @@ -0,0 +1,156 @@ +{ + "name": "setState on a frame feeder sets the level", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "p1", + "key": "syrupLiters", + "value": 2 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": { + "syrupLiters": 2 + }, + "slot": 1 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/105-setstate-on-a-frame-feeder-above-max-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/105-setstate-on-a-frame-feeder-above-max-fails.json new file mode 100644 index 0000000..350cf84 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/105-setstate-on-a-frame-feeder-above-max-fails.json @@ -0,0 +1,86 @@ +{ + "name": "setState on a frame feeder above max fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "p1", + "key": "syrupLiters", + "value": 2.25 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_OUT_OF_RANGE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/106-evaporator-and-frame-feeder-share-a-box.json b/ApiCore-Backend/test/fixtures/hive-ops/106-evaporator-and-frame-feeder-share-a-box.json new file mode 100644 index 0000000..eaf1f5a --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/106-evaporator-and-frame-feeder-share-a-box.json @@ -0,0 +1,162 @@ +{ + "name": "evaporator and frame feeder share a box", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "e1", + "partTypeId": "hive-part-zl10-evaporator", + "state": {} + } + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + }, + { + "id": "e1", + "partTypeId": "hive-part-zl10-evaporator", + "state": {} + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/107-slot-on-a-non-feeder-insert-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/107-slot-on-a-non-feeder-insert-fails.json new file mode 100644 index 0000000..e1a3b0c --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/107-slot-on-a-non-feeder-insert-fails.json @@ -0,0 +1,82 @@ +{ + "name": "slot on a non-feeder insert fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "e1", + "partTypeId": "hive-part-zl10-evaporator", + "state": {}, + "slot": 1 + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FEEDER_SLOT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/108-frame-feeder-in-a-box-without-slots-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/108-frame-feeder-in-a-box-without-slots-fails.json new file mode 100644 index 0000000..be0e24e --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/108-frame-feeder-in-a-box-without-slots-fails.json @@ -0,0 +1,40 @@ +{ + "name": "frame feeder in a box without slots fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b0", + "partTypeId": "hive-part-zl10-box-empty", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b0", + "part": { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FEEDER_SLOT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/109-two-evaporators-still-fail-next-to-a-frame-feeder.json b/ApiCore-Backend/test/fixtures/hive-ops/109-two-evaporators-still-fail-next-to-a-frame-feeder.json new file mode 100644 index 0000000..7b93a21 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/109-two-evaporators-still-fail-next-to-a-frame-feeder.json @@ -0,0 +1,94 @@ +{ + "name": "two evaporators still fail next to a frame feeder", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + }, + { + "id": "e1", + "partTypeId": "hive-part-zl10-evaporator", + "state": {} + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "e2", + "partTypeId": "hive-part-zl10-evaporator", + "state": {} + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_LIMIT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/90-frame-feeder-at-slot-1-over-two-none-frames-is-ok.json b/ApiCore-Backend/test/fixtures/hive-ops/90-frame-feeder-at-slot-1-over-two-none-frames-is-ok.json new file mode 100644 index 0000000..ba363a6 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/90-frame-feeder-at-slot-1-over-two-none-frames-is-ok.json @@ -0,0 +1,150 @@ +{ + "name": "frame feeder at slot 1 over two NONE frames is ok", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/91-setframes-then-addinsert-places-a-frame-feeder.json b/ApiCore-Backend/test/fixtures/hive-ops/91-setframes-then-addinsert-places-a-frame-feeder.json new file mode 100644 index 0000000..2646340 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/91-setframes-then-addinsert-places-a-frame-feeder.json @@ -0,0 +1,200 @@ +{ + "name": "setFrames then addInsert places a frame feeder", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrames", + "boxId": "b1", + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "NONE" + }, + { + "slot": 5, + "type": "NONE" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": { + "syrupLiters": 1.5 + }, + "slot": 4 + } + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "NONE" + }, + { + "slot": 5, + "type": "NONE" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": { + "syrupLiters": 1.5 + }, + "slot": 4 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/92-frame-feeder-over-frames-that-are-not-none-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/92-frame-feeder-over-frames-that-are-not-none-fails.json new file mode 100644 index 0000000..baa48bb --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/92-frame-feeder-over-frames-that-are-not-none-fails.json @@ -0,0 +1,82 @@ +{ + "name": "frame feeder over frames that are not NONE fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FEEDER_SLOT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/93-overlapping-frame-feeders-fail.json b/ApiCore-Backend/test/fixtures/hive-ops/93-overlapping-frame-feeders-fail.json new file mode 100644 index 0000000..b3630f5 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/93-overlapping-frame-feeders-fail.json @@ -0,0 +1,90 @@ +{ + "name": "overlapping frame feeders fail", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "NONE" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "p2", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 2 + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FEEDER_SLOT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/94-frame-feeder-beyond-frameslots-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/94-frame-feeder-beyond-frameslots-fails.json new file mode 100644 index 0000000..8697626 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/94-frame-feeder-beyond-frameslots-fails.json @@ -0,0 +1,82 @@ +{ + "name": "frame feeder beyond frameSlots fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "NONE" + }, + { + "slot": 10, + "type": "NONE" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 10 + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FEEDER_SLOT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/95-setframe-on-a-covered-slot-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/95-setframe-on-a-covered-slot-fails.json new file mode 100644 index 0000000..4f5bea8 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/95-setframe-on-a-covered-slot-fails.json @@ -0,0 +1,86 @@ +{ + "name": "setFrame on a covered slot fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "b1", + "slot": 2, + "type": "BROOD" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FEEDER_SLOT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/96-removing-the-frame-feeder-frees-its-slots.json b/ApiCore-Backend/test/fixtures/hive-ops/96-removing-the-frame-feeder-frees-its-slots.json new file mode 100644 index 0000000..dd650bc --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/96-removing-the-frame-feeder-frees-its-slots.json @@ -0,0 +1,150 @@ +{ + "name": "removing the frame feeder frees its slots", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "removePart", + "id": "p1" + }, + { + "op": "setFrame", + "boxId": "b1", + "slot": 1, + "type": "BROOD" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "BROOD" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/97-two-frame-feeders-without-overlap-are-ok.json b/ApiCore-Backend/test/fixtures/hive-ops/97-two-frame-feeders-without-overlap-are-ok.json new file mode 100644 index 0000000..7070399 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/97-two-frame-feeders-without-overlap-are-ok.json @@ -0,0 +1,164 @@ +{ + "name": "two frame feeders without overlap are ok", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "NONE" + }, + { + "slot": 6, + "type": "NONE" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "p2", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 5 + } + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "NONE" + }, + { + "slot": 2, + "type": "NONE" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "NONE" + }, + { + "slot": 6, + "type": "NONE" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [ + { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + }, + { + "id": "p2", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 5 + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/98-frame-feeder-cannot-sit-in-the-stack.json b/ApiCore-Backend/test/fixtures/hive-ops/98-frame-feeder-cannot-sit-in-the-stack.json new file mode 100644 index 0000000..c836d93 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/98-frame-feeder-cannot-sit-in-the-stack.json @@ -0,0 +1,82 @@ +{ + "name": "frame feeder cannot sit in the stack", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "at": 2, + "part": { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_HOST" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/99-frame-feeder-cannot-go-into-a-floor.json b/ApiCore-Backend/test/fixtures/hive-ops/99-frame-feeder-cannot-go-into-a-floor.json new file mode 100644 index 0000000..68cb5dd --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/99-frame-feeder-cannot-go-into-a-floor.json @@ -0,0 +1,82 @@ +{ + "name": "frame feeder cannot go into a floor", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "f1", + "part": { + "id": "p1", + "partTypeId": "hive-part-zl10-frame-feeder", + "state": {}, + "slot": 1 + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_HOST" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/catalog.json b/ApiCore-Backend/test/fixtures/hive-ops/catalog.json index e15ccfc..8ddc8cb 100644 --- a/ApiCore-Backend/test/fixtures/hive-ops/catalog.json +++ b/ApiCore-Backend/test/fixtures/hive-ops/catalog.json @@ -9,6 +9,7 @@ "widthMm": 430, "heightMm": 120, "frameSlots": null, + "slotWidth": null, "stateSchema": [ { "key": "entrance", @@ -49,6 +50,7 @@ "widthMm": 375, "heightMm": 5, "frameSlots": null, + "slotWidth": null, "stateSchema": [], "active": true }, @@ -60,6 +62,7 @@ "widthMm": 430, "heightMm": 230, "frameSlots": 10, + "slotWidth": null, "stateSchema": [], "active": true }, @@ -71,6 +74,7 @@ "widthMm": 430, "heightMm": 230, "frameSlots": 0, + "slotWidth": null, "stateSchema": [], "active": true }, @@ -82,6 +86,7 @@ "widthMm": 430, "heightMm": 50, "frameSlots": null, + "slotWidth": null, "stateSchema": [ { "key": "bungHoles", @@ -109,6 +114,7 @@ "widthMm": 376, "heightMm": 2, "frameSlots": null, + "slotWidth": null, "stateSchema": [], "active": true }, @@ -120,6 +126,7 @@ "widthMm": 420, "heightMm": 70, "frameSlots": null, + "slotWidth": null, "stateSchema": [ { "key": "syrupLiters", @@ -150,6 +157,7 @@ "widthMm": 378, "heightMm": 1, "frameSlots": null, + "slotWidth": null, "stateSchema": [], "active": true }, @@ -161,6 +169,7 @@ "widthMm": 378, "heightMm": 1, "frameSlots": null, + "slotWidth": null, "stateSchema": [], "active": true }, @@ -172,6 +181,7 @@ "widthMm": 378, "heightMm": 3, "frameSlots": null, + "slotWidth": null, "stateSchema": [], "active": true }, @@ -183,6 +193,7 @@ "widthMm": 430, "heightMm": 35, "frameSlots": null, + "slotWidth": null, "stateSchema": [ { "key": "feedHole", @@ -208,6 +219,7 @@ "widthMm": 365, "heightMm": 20, "frameSlots": null, + "slotWidth": null, "stateSchema": [], "active": true }, @@ -219,6 +231,7 @@ "widthMm": 430, "heightMm": 60, "frameSlots": null, + "slotWidth": null, "stateSchema": [], "active": true }, @@ -230,6 +243,7 @@ "widthMm": 140, "heightMm": 120, "frameSlots": null, + "slotWidth": null, "stateSchema": [ { "key": "acidMl", @@ -241,6 +255,28 @@ } ], "active": true + }, + { + "id": "hive-part-zl10-frame-feeder", + "kind": "FRAME_FEEDER", + "name": "Holz-Futtertasche Zander, doppelte Wabenbreite", + "lengthMm": 477, + "widthMm": 70, + "heightMm": 220, + "frameSlots": null, + "slotWidth": 2, + "stateSchema": [ + { + "key": "syrupLiters", + "label": "Flüssigfutter", + "kind": "level", + "unit": "l", + "max": 2, + "step": 0.25, + "feedType": "SUGAR_WATER" + } + ], + "active": true } ] } diff --git a/ApiCore-Backend/test/hive-catalog-seed.e2e-spec.ts b/ApiCore-Backend/test/hive-catalog-seed.e2e-spec.ts index 40d98ee..fdcc4b0 100644 --- a/ApiCore-Backend/test/hive-catalog-seed.e2e-spec.ts +++ b/ApiCore-Backend/test/hive-catalog-seed.e2e-spec.ts @@ -20,6 +20,7 @@ const PART_SLUGS = [ 'insulation', 'roof', 'evaporator', + 'frame-feeder', ] as const; interface StateField { @@ -66,7 +67,7 @@ describe('Hive catalog seed (e2e)', () => { }); }); - it('seeds 14 active part types for the system under fixed ids', async () => { + it('seeds 15 active part types for the system under fixed ids', async () => { const types = await prisma.hivePartType.findMany({ where: { systemId: SYSTEM_ID, active: true }, orderBy: { id: 'asc' }, @@ -103,6 +104,31 @@ describe('Hive catalog seed (e2e)', () => { ]); }); + it('seeds the frame feeder over two frame slots with a 2 l syrup level', async () => { + const pocket = await partType('frame-feeder'); + const fields = pocket.stateSchema as unknown as StateField[]; + + expect(pocket).toMatchObject({ + kind: 'FRAME_FEEDER', + slotWidth: 2, + frameSlots: null, + lengthMm: 477, + widthMm: 70, + heightMm: 220, + }); + expect(fields).toEqual([ + { + key: 'syrupLiters', + label: 'Flüssigfutter', + kind: 'level', + unit: 'l', + max: 2, + step: 0.25, + feedType: 'SUGAR_WATER', + }, + ]); + }); + it('gives the other stateful parts their choice and level fields', async () => { const keysOf = async (slug: string) => ((await partType(slug)).stateSchema as unknown as StateField[]).map( diff --git a/ApiCore-Backend/test/hive-catalog.e2e-spec.ts b/ApiCore-Backend/test/hive-catalog.e2e-spec.ts index 100c13d..3305cc2 100644 --- a/ApiCore-Backend/test/hive-catalog.e2e-spec.ts +++ b/ApiCore-Backend/test/hive-catalog.e2e-spec.ts @@ -20,6 +20,7 @@ interface FixturePartType { widthMm: number; heightMm: number; frameSlots: number | null; + slotWidth: number | null; stateSchema: unknown[]; active: boolean; } @@ -156,6 +157,7 @@ describe('Hive catalog (e2e)', () => { widthMm: row?.widthMm, heightMm: row?.heightMm, frameSlots: row?.frameSlots, + slotWidth: row?.slotWidth, stateSchema: row?.stateSchema, active: row?.active, }).toEqual(expected); diff --git a/ApiCore-Frontend/package-lock.json b/ApiCore-Frontend/package-lock.json index 7d39243..b6df750 100644 --- a/ApiCore-Frontend/package-lock.json +++ b/ApiCore-Frontend/package-lock.json @@ -9,23 +9,30 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "^1.7.0", + "@react-three/drei": "10.7.9", + "@react-three/fiber": "9.8.1", + "@react-three/postprocessing": "3.1.3", "@tanstack/react-query": "^5.101.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "idb": "^8.0.3", "leaflet": "^1.9.4", "lucide-react": "^1.33.0", + "motion": "13.4.6", "next": "16.3.1", "next-themes": "^0.4.6", + "postprocessing": "6.39.5", "react": "19.2.8", "react-dom": "19.2.8", "react-leaflet": "^5.0.0", "shadcn": "^4.18.0", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", + "three": "0.186.1", "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@react-three/test-renderer": "9.1.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", @@ -34,6 +41,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/three": "0.186.0", "@vitejs/plugin-react": "^4.7.0", "eslint": "^9", "eslint-config-next": "16.3.1", @@ -776,6 +784,12 @@ "node": ">=20.19.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -2357,6 +2371,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -2419,6 +2439,18 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -2706,6 +2738,128 @@ "react-dom": "^19.0.0" } }, + "node_modules/@react-three/drei": { + "version": "10.7.9", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.9.tgz", + "integrity": "sha512-wP4Y6o58KFi+hT1zCfjJarRtPCT6WW/lo53WMiA/aCORuUvBzW916XsCtadnIB1KB+3IQNcDU5JeVh/gYb8MYw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.8.1", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.8.1.tgz", + "integrity": "sha512-GbNP4r6F/gpclFKkLGHzXcuwFWAzLyKsmrQGcDuwK3wQnwLH6F1TfGtEYjHy8OQoGsajwT19B9jtVLLjjfH09w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.1.1", + "react-use-measure": "^2.1.7", + "scheduler": "^0.28.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.4", + "react-dom": ">=19 <19.4", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/@react-three/postprocessing": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.1.3.tgz", + "integrity": "sha512-LhCTSC+nli1agIIIgrhJalvJcdSy7/DOXG5EDGxKVlVsrqUxMPYlMnRN/QqgSAjGPKmnnuiEmpVQtOkTKwiidw==", + "license": "MIT", + "dependencies": { + "maath": "^0.10.8", + "n8ao": "^2.0.0" + }, + "peerDependencies": { + "@react-three/fiber": ">=9.7.0", + "postprocessing": "^6.36.0", + "react": "^19.0.0", + "three": ">= 0.156.0" + } + }, + "node_modules/@react-three/test-renderer": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@react-three/test-renderer/-/test-renderer-9.1.1.tgz", + "integrity": "sha512-4DmLn0tg+AP8aU0Mb5vekjHqFrwRJ3z11HfQGOJIDj6DxZ/5BjRHRsDxY0Y+g4l8+WHysQ6PeuSdzTDDYoGuXg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@react-three/fiber": ">=9.0.0", + "react": "^19.0.0", + "three": ">=0.156" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3703,6 +3857,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -3785,6 +3945,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3833,11 +3999,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3853,12 +4024,47 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz", + "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.3", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", @@ -4508,6 +4714,24 @@ "win32" ] }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -5035,6 +5259,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.16", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz", @@ -5051,7 +5295,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -5150,6 +5393,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -5231,6 +5498,19 @@ "node": ">=6" } }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001809", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", @@ -5544,6 +5824,24 @@ } } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5595,7 +5893,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -5853,6 +6150,15 @@ "node": ">=6" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5920,6 +6226,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/draco3d": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.8.tgz", + "integrity": "sha512-rmmKiIgnv4RKB1hSze/9yRql3+5cxkxn49FUMWfhsJ5ob5tSMyfe0K/aRZsh6HoN/XveZXMjouO/gGK+JgqYPw==", + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6936,6 +7248,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -7060,6 +7378,29 @@ "node": ">= 0.6" } }, + "node_modules/framer-motion": { + "version": "13.5.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.5.1.tgz", + "integrity": "sha512-HeWOaeHD3kyTSIEMT6+nw7fBn4saIfxxabZyAITbycAl4xr1kzGJKIvGt/OWghSlvIA9qTnZ1zTGelHAe6S8DA==", + "license": "MIT", + "dependencies": { + "motion-dom": "13.5.1", + "motion-utils": "13.5.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -7317,6 +7658,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7444,6 +7791,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hls.js": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz", + "integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==", + "license": "Apache-2.0" + }, "node_modules/hono": { "version": "4.13.3", "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", @@ -7517,6 +7870,26 @@ "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", "license": "ISC" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7526,6 +7899,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8208,6 +8587,18 @@ "node": ">= 0.4" } }, + "node_modules/its-fine": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.1.1.tgz", + "integrity": "sha512-C5ZoVkeqSt0Aje0tzESO+iT/EQpNFNL39GkP7mCACtoZUFD6GcyerFCr6kp0ZtiII4W9HoYkY48Soy5xE/uHKw==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -8460,6 +8851,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -8844,6 +9244,16 @@ "lz-string": "bin/bin.js" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -8910,6 +9320,21 @@ "node": ">= 8" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -9001,12 +9426,59 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion": { + "version": "13.4.6", + "resolved": "https://registry.npmjs.org/motion/-/motion-13.4.6.tgz", + "integrity": "sha512-zDXi6lIdyhda6wZlTsv9QgT2ed6UExAfRegtl/akQmZb5iaeLlArXr9Gmo/hx3iTx2y4R7tmdb9k9koFQmV+NQ==", + "license": "MIT", + "dependencies": { + "framer-motion": "^13.4.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "13.5.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.5.1.tgz", + "integrity": "sha512-/7CVCD20Mz1KobktorpdgvowV0V41J7UxqE+qWS3GkSJqrRr5ft+2RloOYtEKv+wvBp3JaSFBclhxt4ja8ovHg==", + "license": "MIT", + "dependencies": { + "motion-utils": "13.5.0" + } + }, + "node_modules/motion-utils": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.5.0.tgz", + "integrity": "sha512-Hqe/sqjIKliPZg8sVjWAkdySDPJNIeKH+euVkDOG8ri3DUUU7ZjndmHsGqEPz2iHpiyVaV5I22iguCMHU2he+Q==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/n8ao": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-2.0.1.tgz", + "integrity": "sha512-MoP1QKUdj4/3cKEnhFl917t0eQwWGER1l43ckz5mNOPyYWGRDGQbVoTOQz/VBvy01in6QVNG+AL6Xxx5AjZBYA==", + "license": "ISC", + "peerDependencies": { + "postprocessing": ">=6.30.0", + "three": ">=0.137" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -9837,6 +10309,21 @@ "node": ">=4" } }, + "node_modules/postprocessing": { + "version": "6.39.5", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.5.tgz", + "integrity": "sha512-YmH+t74J2X1lwYjKzK/tgs9C9kqWWPB9JwZiDF5mEf4BdCEAmEFbO8lXBA7NM0/j4g+x6FgD/Tt5/kT7ogbEDA==", + "license": "Zlib", + "peerDependencies": { + "three": ">= 0.168.0 < 0.187.0" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/powershell-utils": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", @@ -9912,6 +10399,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/promise-worker-transferable/node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -10085,6 +10588,21 @@ "node": ">=0.10.0" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/recast": { "version": "0.23.21", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", @@ -10895,6 +11413,32 @@ "dev": true, "license": "MIT" }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -11238,6 +11782,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -11302,6 +11855,44 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.186.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", + "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -11450,6 +12041,36 @@ "node": ">=20" } }, + "node_modules/troika-three-text": { + "version": "0.52.5", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.5.tgz", + "integrity": "sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.5", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.5", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.5.tgz", + "integrity": "sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -11505,6 +12126,43 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -11832,6 +12490,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -12072,6 +12739,17 @@ "node": ">=18" } }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -12365,6 +13043,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/ApiCore-Frontend/package.json b/ApiCore-Frontend/package.json index 76cb609..b2a1e9e 100644 --- a/ApiCore-Frontend/package.json +++ b/ApiCore-Frontend/package.json @@ -13,23 +13,30 @@ }, "dependencies": { "@base-ui/react": "^1.7.0", + "@react-three/drei": "10.7.9", + "@react-three/fiber": "9.8.1", + "@react-three/postprocessing": "3.1.3", "@tanstack/react-query": "^5.101.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "idb": "^8.0.3", "leaflet": "^1.9.4", "lucide-react": "^1.33.0", + "motion": "13.4.6", "next": "16.3.1", "next-themes": "^0.4.6", + "postprocessing": "6.39.5", "react": "19.2.8", "react-dom": "19.2.8", "react-leaflet": "^5.0.0", "shadcn": "^4.18.0", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", + "three": "0.186.1", "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@react-three/test-renderer": "9.1.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", @@ -38,6 +45,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/three": "0.186.0", "@vitejs/plugin-react": "^4.7.0", "eslint": "^9", "eslint-config-next": "16.3.1", diff --git a/ApiCore-Frontend/src/app/(app)/settings/hive-systems/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/hive-systems/page.spec.tsx index 0dede54..e053a22 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/hive-systems/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/hive-systems/page.spec.tsx @@ -34,6 +34,7 @@ const feeder: HivePartTypeDto = { widthMm: 480, heightMm: 200, frameSlots: null, + slotWidth: null, boxDepth: null, stateSchema: [ { key: "syrup", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 }, diff --git a/ApiCore-Frontend/src/app/(app)/settings/hive-systems/part-type-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/hive-systems/part-type-dialog.spec.tsx index 95016a9..baaeeb5 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/hive-systems/part-type-dialog.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/hive-systems/part-type-dialog.spec.tsx @@ -22,6 +22,7 @@ const feeder: HivePartTypeDto = { widthMm: 480, heightMm: 200, frameSlots: null, + slotWidth: null, boxDepth: null, stateSchema: [ { @@ -62,6 +63,19 @@ async function pickBox() { await userEvent.click(await screen.findByRole("option", { name: "Zarge" })); } +async function pickFrameFeeder() { + await userEvent.click(screen.getByRole("combobox", { name: /Art des Teils/ })); + await userEvent.click(await screen.findByRole("option", { name: "Futtertasche" })); +} + +const pocket: HivePartTypeDto = { + ...feeder, + id: "pt2", + kind: "FRAME_FEEDER", + name: "Futtertasche", + slotWidth: 2, +}; + describe("PartTypeDialog", () => { beforeEach(() => { vi.mocked(createHivePartType).mockReset(); @@ -247,4 +261,60 @@ describe("PartTypeDialog", () => { expect(await screen.findByRole("alert")).toHaveTextContent("stateSchema[1].max must be positive"); expect(onOpenChange).not.toHaveBeenCalledWith(false); }); + + it("creates a frame feeder with the number of frame slots it covers", async () => { + renderDialog(); + await typeInto(/^Name/, "Futtertasche"); + await pickFrameFeeder(); + await typeInto(/^Länge/, "477"); + await typeInto(/^Breite/, "70"); + await typeInto(/^Höhe/, "220"); + await typeInto(/^Belegte Rähmchenplätze/, "2"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + await waitFor(() => + expect(createHivePartType).toHaveBeenCalledWith("s1", { + kind: "FRAME_FEEDER", + name: "Futtertasche", + lengthMm: 477, + widthMm: 70, + heightMm: 220, + slotWidth: 2, + }), + ); + }); + + it("requires the covered slots of a frame feeder and shows none for other kinds", async () => { + renderDialog(); + expect(screen.queryByLabelText(/^Belegte Rähmchenplätze/)).toBeNull(); + await typeInto(/^Name/, "Futtertasche"); + await pickFrameFeeder(); + await typeInto(/^Länge/, "477"); + await typeInto(/^Breite/, "70"); + await typeInto(/^Höhe/, "220"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + expect(await screen.findByRole("alert")).toHaveTextContent(/belegten Rähmchenplätze/); + expect(createHivePartType).not.toHaveBeenCalled(); + }); + + it("caps the covered slots at the server limit of 5", async () => { + renderDialog(); + await typeInto(/^Name/, "Futtertasche"); + await pickFrameFeeder(); + await typeInto(/^Länge/, "477"); + await typeInto(/^Breite/, "70"); + await typeInto(/^Höhe/, "220"); + await typeInto(/^Belegte Rähmchenplätze/, "9"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + await waitFor(() => + expect(createHivePartType).toHaveBeenCalledWith("s1", expect.objectContaining({ slotWidth: 5 })), + ); + }); + + it("edit: sends only a changed slot width", async () => { + renderDialog(pocket); + expect(screen.getByLabelText(/^Belegte Rähmchenplätze/)).toHaveValue("2"); + await typeInto(/^Belegte Rähmchenplätze/, "3"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt2", { slotWidth: 3 })); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/settings/hive-systems/part-type-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/hive-systems/part-type-dialog.tsx index 2b409a4..c344b79 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/hive-systems/part-type-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/hive-systems/part-type-dialog.tsx @@ -25,6 +25,7 @@ import { import { MAX_DIMENSION_MM, MAX_FRAME_SLOTS, + MAX_SLOT_WIDTH, draftProblem, emptyDraft, fieldFromDraft, @@ -59,6 +60,7 @@ interface FormValues { heightMm: string; boxDepth: HiveBoxDepth; frameSlots: string; + slotWidth: string; } function initialValues(partType: HivePartTypeDto | undefined): FormValues { @@ -71,6 +73,7 @@ function initialValues(partType: HivePartTypeDto | undefined): FormValues { heightMm: partType ? String(partType.heightMm) : "", boxDepth: partType?.boxDepth ?? "FULL", frameSlots: partType?.frameSlots == null ? "" : String(partType.frameSlots), + slotWidth: partType?.slotWidth == null ? "" : String(partType.slotWidth), }; } @@ -88,6 +91,9 @@ function valuesProblem(values: FormValues): string | null { if (values.kind === "BOX" && values.frameSlots === "") { return "Eine Zarge braucht die Zahl der Rähmchenplätze."; } + if (values.kind === "FRAME_FEEDER" && values.slotWidth === "") { + return "Eine Futtertasche braucht die Zahl der belegten Rähmchenplätze."; + } return null; } @@ -109,6 +115,9 @@ function patchFrom( if (values.boxDepth !== partType.boxDepth) patch.boxDepth = values.boxDepth; if (Number(values.frameSlots) !== partType.frameSlots) patch.frameSlots = Number(values.frameSlots); } + if (partType.kind === "FRAME_FEEDER" && Number(values.slotWidth) !== partType.slotWidth) { + patch.slotWidth = Number(values.slotWidth); + } if (newField) patch.stateSchema = [...partType.stateSchema, fieldFromDraft(newField)]; return patch; } @@ -124,6 +133,7 @@ function inputFrom(values: FormValues, newField: FieldDraft | null): HivePartTyp widthMm: Number(values.widthMm), heightMm: Number(values.heightMm), ...(kind === "BOX" ? { boxDepth: values.boxDepth, frameSlots: Number(values.frameSlots) } : {}), + ...(kind === "FRAME_FEEDER" ? { slotWidth: Number(values.slotWidth) } : {}), ...(newField ? { stateSchema: [fieldFromDraft(newField)] } : {}), }; } @@ -269,6 +279,23 @@ function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) { ) : null} + {values.kind === "FRAME_FEEDER" ? ( + + {({ id, describedBy }) => ( + setValue("slotWidth", slotWidth)} + min={1} + max={MAX_SLOT_WIDTH} + unitLabel="Plätze" + decrementLabel="Belegte Rähmchenplätze verringern" + incrementLabel="Belegte Rähmchenplätze erhöhen" + /> + )} + + ) : null}

@@ -280,7 +307,7 @@ function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) { draft={newField} onChange={setNewField} onDiscard={() => setNewField(null)} - allowFeedType={values.kind === "FEEDER"} + allowFeedType={values.kind === "FEEDER" || values.kind === "FRAME_FEEDER"} /> ) : ( - ) : null} - -
-
- +
+ +
+ {focusedId ? ( + editPart(focusedId)} + onBack={leaveFocus} + /> + ) : ( +
+ Beute + +
+ )} + {/* One column for the outline panel and the button bar: the panel always ends above the + buttons, however they wrap. DOM order keeps the buttons before the tree (tab order). */} +
+
+ {past ? ( +
+ { + setPast(null); + historyButtonRef.current?.focus(); + }} + /> +
+ ) : null} + {focusedId ? ( + // In focus mode the top belongs to the focus bar; the facts stay readable here. + // Phones keep the room for the part; the focus chips carry its state there. +
+ +
+ ) : null} +
+ {showRebuild && !offline ? ( + + + Umbauen + + ) : null} + {showRebuild && offline ? ( + + ) : null} + + +
+ {showRebuild && offline ? ( +

+ {OFFLINE_REBUILD_HINT} +

+ ) : null} +
+
+
+
+ +
+
- -
- {showRebuild && !offline ? ( - - Umbauen - - ) : null} - {showRebuild && offline ? ( - - ) : null} - - -
- {showRebuild && offline ? ( -

- {OFFLINE_REBUILD_HINT} -

- ) : null} diff --git a/ApiCore-Frontend/src/components/hive/hive-facts-line.tsx b/ApiCore-Frontend/src/components/hive/hive-facts-line.tsx index ed5455e..964c94c 100644 --- a/ApiCore-Frontend/src/components/hive/hive-facts-line.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-facts-line.tsx @@ -5,15 +5,17 @@ export interface HiveFactsLineProps { facts: HiveFacts; /** The latest change; omitted while a past state is shown. */ lastChange?: { occurredOn: string; source: HiveChangeSource } | null; + /** "stage": light text for the frosted overlay on the holo stage. */ + tone?: "card" | "stage"; } /** „2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin“ and „Stand: 12.10. aus Durchsicht“ (spec 9.4). */ -export function HiveFactsLine({ facts, lastChange }: HiveFactsLineProps) { +export function HiveFactsLine({ facts, lastChange, tone = "card" }: HiveFactsLineProps) { return (

{hiveFactsText(facts)}

{lastChange ? ( -

+

Stand: {shortGermanDay(lastChange.occurredOn)} aus {sourceLabel(lastChange.source)}

) : null} diff --git a/ApiCore-Frontend/src/components/hive/hive-focus-bar.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-focus-bar.spec.tsx new file mode 100644 index 0000000..f7e0a5f --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-focus-bar.spec.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { frames, inlineCatalog, part } from "@/lib/hive/test-utils"; +import type { HiveState } from "@/lib/hive/types"; +import { HiveFocusBar, focusChips } from "./hive-focus-bar"; + +const catalog = inlineCatalog(); +const state: HiveState = { + schemaVersion: 1, + stack: [ + part("f", "floor", { inserts: [part("fi", "floor-insert")] }), + part("b1", "box", { frames: frames(10).map((frame) => (frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame)) }), + part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }), + part("lid", "inner-lid", { state: { feedHole: "CLOSED" } }), + ], +}; + +function renderBar(extra: Partial[0]> = {}) { + const props = { + focusedId: "fe", + name: "Futterzarge", + chips: focusChips(state, catalog, "fe"), + canEdit: true, + onEdit: vi.fn(), + onBack: vi.fn(), + ...extra, + }; + render(); + return props; +} + +describe("focusChips", () => { + it("lists levels with their maximum and choices with their German text", () => { + expect(focusChips(state, catalog, "fe").map((chip) => chip.text)).toEqual([ + "Flüssigfutter 2,5 l von 3 l", + "Futterteig 1 kg von 2,5 kg", + ]); + expect(focusChips(state, catalog, "lid").map((chip) => chip.text)).toEqual(["Futterloch: zu"]); + }); + + it("counts the frames of a box and says whether the floor holds a Bausperre", () => { + expect(focusChips(state, catalog, "b1")[0].text).toBe("8 Rähmchen"); + expect(focusChips(state, catalog, "f").at(-1)!.text).toBe("Bausperre drin"); + }); + + it("finds inserts and returns nothing for unknown ids", () => { + expect(focusChips(state, catalog, "fi")).toEqual([]); + expect(focusChips(state, catalog, "nope")).toEqual([]); + }); +}); + +describe("HiveFocusBar", () => { + it("renders nothing outside focus mode", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows „Zurück“, the name, the state chips and „Ändern“", () => { + const props = renderBar(); + expect(screen.getByText("Futterzarge")).toBeInTheDocument(); + expect(screen.getByText("Flüssigfutter 2,5 l von 3 l")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Zurück" })); + expect(props.onBack).toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Ändern" })); + expect(props.onEdit).toHaveBeenCalled(); + }); + + it("hides „Ändern“ for readers", () => { + renderBar({ canEdit: false }); + expect(screen.queryByRole("button", { name: "Ändern" })).toBeNull(); + }); + + it("leaves focus mode on Esc", () => { + const props = renderBar(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(props.onBack).toHaveBeenCalledTimes(1); + }); + + it("leaves Esc to an open sheet", () => { + const props = renderBar(); + const dialog = document.createElement("div"); + dialog.setAttribute("role", "dialog"); + const input = document.createElement("input"); + dialog.appendChild(input); + document.body.appendChild(dialog); + fireEvent.keyDown(input, { key: "Escape" }); + expect(props.onBack).not.toHaveBeenCalled(); + dialog.remove(); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-focus-bar.tsx b/ApiCore-Frontend/src/components/hive/hive-focus-bar.tsx new file mode 100644 index 0000000..19d9e71 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-focus-bar.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect } from "react"; +import { ArrowLeft, SlidersHorizontal } from "lucide-react"; +import type { HiveCatalog, HivePart, HiveState } from "@/lib/hive/types"; +import { GLASS_PILL, GLASS_SURFACE } from "./hive-glass"; +import { formatAmount } from "./hive-scene/scene-model"; + +// DOM overlay of focus mode (spec 9.4): glass „Zurück“ pill, the part's name +// and its state as small glass chips, „Ändern“ for parts with a sheet. +// Esc leaves focus mode unless a dialog (sheet) has the keyboard. + +export interface FocusChip { + key: string; + text: string; +} + +function findPart(state: HiveState, id: string): HivePart | undefined { + for (const part of state.stack) { + if (part.id === id) return part; + const insert = part.inserts?.find((candidate) => candidate.id === id); + if (insert) return insert; + } + return undefined; +} + +/** Chips for the focused part: frame count of a box, every state field with its value. */ +export function focusChips(state: HiveState, catalog: HiveCatalog, id: string): FocusChip[] { + const part = findPart(state, id); + const type = part ? catalog.partTypes.get(part.partTypeId) : undefined; + if (!part || !type) return []; + const chips: FocusChip[] = []; + if (type.kind === "BOX") { + const frames = (part.frames ?? []).filter((frame) => frame.type !== "NONE").length; + chips.push({ key: "frames", text: frames === 1 ? "1 Rähmchen" : `${frames} Rähmchen` }); + } + for (const field of type.stateSchema) { + const raw = part.state[field.key]; + if (field.kind === "level") { + const value = typeof raw === "number" ? raw : 0; + chips.push({ key: field.key, text: `${field.label} ${formatAmount(value, field.unit)} von ${formatAmount(field.max, field.unit)}` }); + } else { + const index = Math.max(0, field.options.indexOf(String(raw))); + chips.push({ key: field.key, text: `${field.label}: ${field.optionLabels[index] ?? field.options[index]}` }); + } + } + const inserts = (part.inserts ?? []).map((insert) => catalog.partTypes.get(insert.partTypeId)?.kind); + if (type.kind === "FLOOR") chips.push({ key: "insert", text: inserts.includes("FLOOR_INSERT") ? "Bausperre drin" : "ohne Bausperre" }); + return chips; +} + +export interface HiveFocusBarProps { + /** Part in focus mode; nothing is rendered without one. */ + focusedId: string | null; + name: string; + chips: FocusChip[]; + /** Shows „Ändern“ (the part has a sheet and the user may edit). */ + canEdit: boolean; + onEdit: () => void; + onBack: () => void; +} + +export function HiveFocusBar({ focusedId, name, chips, canEdit, onEdit, onBack }: HiveFocusBarProps) { + useEffect(() => { + if (!focusedId) return; + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented) return; + // A sheet handles its own Esc. + if (event.target instanceof Element && event.target.closest("[role='dialog'], [role='alertdialog']")) return; + onBack(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [focusedId, onBack]); + + if (!focusedId) return null; + + // Fades in (150 ms); only at motion `full` it also slides down (motion-full variant, spec 9.8). + return ( +
+
+ +

{name}

+
+ {chips.length > 0 || canEdit ? ( +
    + {chips.map((chip) => ( +
  • + {chip.text} +
  • + ))} + {canEdit ? ( +
  • + +
  • + ) : null} +
+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-glass.ts b/ApiCore-Frontend/src/components/hive/hive-glass.ts new file mode 100644 index 0000000..484cb10 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-glass.ts @@ -0,0 +1,11 @@ +// Frosted glass surfaces over the holo stage (mockups g2/h2). Text is +// --holo-label-fg on --holo-label-bg at 70 % over the midnight stage, well +// above 4.5:1; focus rings use the strong edge colour. + +const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md"; + +export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] ${GLASS_FILL}`; + +export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-holo-edge/70 aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] ${GLASS_FILL}`; + +export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong ${GLASS_SURFACE}`; diff --git a/ApiCore-Frontend/src/components/hive/hive-outline.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-outline.spec.tsx index 4ccdb52..6968d48 100644 --- a/ApiCore-Frontend/src/components/hive/hive-outline.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-outline.spec.tsx @@ -319,4 +319,87 @@ describe("HiveOutline", () => { expect(document.body).toHaveFocus(); }); }); + + describe("frame feeder", () => { + const pocketState: HiveState = { + schemaVersion: 1, + stack: [ + part("f", "floor"), + part("b1", "box", { + frames: Array.from({ length: 6 }, (_, index) => ({ + slot: index + 1, + type: index === 2 || index === 3 ? ("NONE" as const) : ("BROOD" as const), + })), + inserts: [part("ff", "frame-feeder", { slot: 3, state: { syrupLiters: 1.5 } })], + }), + part("r", "roof"), + ], + }; + + function open(props: Partial> = {}) { + const result = setup({ state: pocketState, ...props }); + act(() => result.store.openBox("b1")); + return result; + } + + it("lists the pocket at its slot position and hides the covered frames", () => { + open(); + const items = names(); + const start = items.findIndex((t) => t?.startsWith("R1")); + expect(items.slice(start, start + 5)).toEqual([ + "R1 · Brutwabe", + "R2 · Brutwabe", + "F3–4 · Futtertasche · 1,5 l", + "R5 · Brutwabe", + "R6 · Brutwabe", + ]); + expect(items.some((t) => t?.startsWith("R3") || t?.startsWith("R4"))).toBe(false); + }); + + it("nests the pocket one level below its box and edits it on Enter", async () => { + const { user, onEditPart } = open(); + const pocket = screen.getByRole("treeitem", { name: /^F3–4 · Futtertasche/ }); + expect(pocket).toHaveAttribute("aria-level", "2"); + await user.click(pocket); + expect(onEditPart).toHaveBeenCalledWith("ff"); + }); + + it("offers Futtertasche einsetzen while a position is free and passes the intent", async () => { + const { user, onEditPart } = open(); + await user.click(screen.getByRole("treeitem", { name: /Futtertasche einsetzen/ })); + expect(onEditPart).toHaveBeenCalledWith("b1", "frameFeeder"); + }); + + it("hides the add action without canEdit or when no position is left", () => { + const { unmount } = open({ canEdit: false }); + expect(screen.queryByRole("treeitem", { name: /Futtertasche einsetzen/ })).toBeNull(); + unmount(); + const full: HiveState = { + ...pocketState, + stack: pocketState.stack.map((p) => + p.id === "b1" + ? { + ...p, + frames: Array.from({ length: 10 }, (_, index) => ({ slot: index + 1, type: "NONE" as const })), + inserts: [1, 3, 5, 7, 9].map((slot) => part(`ff${slot}`, "frame-feeder", { slot })), + } + : p, + ), + }; + open({ state: full }); + expect(screen.queryByRole("treeitem", { name: /Futtertasche einsetzen/ })).toBeNull(); + }); + + it("focusPart on the pocket expands the box and focuses the pocket", () => { + const ref = createRef(); + const store = createHiveViewStore(); + render( + + {}} onEditFrame={() => {}} /> + , + ); + act(() => ref.current?.focusPart("ff")); + expect(screen.getByRole("treeitem", { name: /^F3–4 · Futtertasche/ })).toHaveFocus(); + }); + }); }); diff --git a/ApiCore-Frontend/src/components/hive/hive-outline.tsx b/ApiCore-Frontend/src/components/hive/hive-outline.tsx index fa6820e..feb5da3 100644 --- a/ApiCore-Frontend/src/components/hive/hive-outline.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-outline.tsx @@ -3,6 +3,8 @@ import { useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import type { FocusEvent, KeyboardEvent, Ref } from "react"; import { FRAME_TYPE_LABELS } from "@/lib/hive/labels"; +import { feederRange, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots"; +import type { SlotRange } from "@/lib/hive/feeder-slots"; import { partDisplayName } from "@/lib/hive/names"; import type { CatalogPartType, HiveCatalog, HivePart, HiveState, StateField } from "@/lib/hive/types"; import { useHiveView } from "./use-hive-view"; @@ -26,6 +28,8 @@ interface OutlineNode { isBox: boolean; isFloor: boolean; hasState: boolean; + /** Which add action an `action` node stands for. */ + action?: "evaporator" | "frameFeeder"; setSize: number; position: number; } @@ -59,6 +63,58 @@ function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: Hive } const ADD_EVAPORATOR_LABEL = "Verdunster einsetzen …"; +const ADD_FRAME_FEEDER_LABEL = "Futtertasche einsetzen …"; + +type Child = + | { type: "insert"; part: HivePart; range: SlotRange | null } + | { type: "frame"; frame: NonNullable[number] }; + +/** + * Children of a box in outline order: other inserts (evaporator), then the + * frames and frame feeders by slot. Frames a pocket covers are not listed. + */ +function childrenOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): Child[] { + const inserts = visibleInserts(part, catalog).map((insert) => ({ + part: insert, + range: feederRange(insert, catalog.partTypes.get(insert.partTypeId)), + })); + const others: Child[] = inserts.filter((i) => !i.range).map((i) => ({ type: "insert", ...i })); + if (type?.kind !== "BOX") return inserts.map((i) => ({ type: "insert", ...i })); + const pockets = inserts.filter((i) => i.range); + const bySlot: Child[] = []; + for (const frame of part.frames ?? []) { + const pocket = pockets.find((p) => p.range && frame.slot >= p.range.from && frame.slot <= p.range.to); + if (!pocket) bySlot.push({ type: "frame", frame }); + else if (pocket.range?.from === frame.slot) bySlot.push({ type: "insert", ...pocket }); + } + // A pocket whose slot lies outside the frames still shows up, after the frames. + for (const pocket of pockets) { + if (!bySlot.some((child) => child.type === "insert" && child.part.id === pocket.part.id)) { + bySlot.push({ type: "insert", ...pocket }); + } + } + return [...others, ...bySlot]; +} + +function firstActiveType(catalog: HiveCatalog, kind: CatalogPartType["kind"]): CatalogPartType | undefined { + return [...catalog.partTypes.values()].find((t) => t.kind === kind && t.active); +} + +function addActions(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog, canEdit: boolean) { + if (type?.kind !== "BOX" || !canEdit) return []; + const actions: { action: "evaporator" | "frameFeeder"; label: string }[] = []; + const hasEvaporator = (part.inserts ?? []).some( + (insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR", + ); + if (!hasEvaporator && firstActiveType(catalog, "EVAPORATOR")) { + actions.push({ action: "evaporator", label: ADD_EVAPORATOR_LABEL }); + } + const feederType = firstActiveType(catalog, "FRAME_FEEDER"); + if (feederType && freeFeederStarts(part, type, feederType, catalog).length > 0) { + actions.push({ action: "frameFeeder", label: ADD_FRAME_FEEDER_LABEL }); + } + return actions; +} function buildNodes( state: HiveState, @@ -66,18 +122,22 @@ function buildNodes( expanded: ReadonlySet, canEdit: boolean, ): OutlineNode[] { - const canAddEvaporator = [...catalog.partTypes.values()].some((t) => t.kind === "EVAPORATOR" && t.active); const nodes: OutlineNode[] = []; const topDown = [...state.stack].reverse(); - const addPart = (part: HivePart, level: number, parentKey: string | null, position: number, setSize: number) => { + const addPart = ( + part: HivePart, + level: number, + parentKey: string | null, + position: number, + setSize: number, + range: SlotRange | null = null, + ) => { const type = catalog.partTypes.get(part.partTypeId); - const inserts = visibleInserts(part, catalog); - const frames = type?.kind === "BOX" ? (part.frames ?? []) : []; - const isBox = type?.kind === "BOX"; - const hasEvaporator = inserts.some((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR"); - const hasAddAction = isBox && canEdit && canAddEvaporator && !hasEvaporator; - const childCount = inserts.length + frames.length + (hasAddAction ? 1 : 0); + const children = childrenOf(part, type, catalog); + const actions = addActions(part, type, catalog, canEdit); + const childCount = children.length + actions.length; + const displayName = partDisplayName(state, catalog, part.id); nodes.push({ key: part.id, kind: "part", @@ -85,23 +145,28 @@ function buildNodes( level, parentKey, childCount, - name: partDisplayName(state, catalog, part.id), + name: range ? `F${rangeText(range)} · ${displayName}` : displayName, hint: hintOf(part, type, catalog), - isBox, + isBox: type?.kind === "BOX", isFloor: type?.kind === "FLOOR", hasState: (type?.stateSchema.length ?? 0) > 0, setSize, position, }); if (!expanded.has(part.id)) return; - inserts.forEach((insert, index) => addPart(insert, level + 1, part.id, index + 1, childCount)); - frames.forEach((frame, index) => { - const label = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · "); + children.forEach((child, index) => { + if (child.type === "insert") { + addPart(child.part, level + 1, part.id, index + 1, childCount, child.range); + return; + } + const label = [`R${child.frame.slot}`, FRAME_TYPE_LABELS[child.frame.type], child.frame.name] + .filter(Boolean) + .join(" · "); nodes.push({ - key: `${part.id}#${frame.slot}`, + key: `${part.id}#${child.frame.slot}`, kind: "frame", id: part.id, - slot: frame.slot, + slot: child.frame.slot, level: level + 1, parentKey: part.id, childCount: 0, @@ -111,26 +176,27 @@ function buildNodes( isFloor: false, hasState: false, setSize: childCount, - position: inserts.length + index + 1, + position: index + 1, }); }); - if (hasAddAction) { + actions.forEach((action, index) => { nodes.push({ - key: `${part.id}#add-evaporator`, + key: `${part.id}#add-${action.action}`, kind: "action", id: part.id, + action: action.action, level: level + 1, parentKey: part.id, childCount: 0, - name: ADD_EVAPORATOR_LABEL, + name: action.label, hint: null, isBox: false, isFloor: false, hasState: false, setSize: childCount, - position: childCount, + position: children.length + index + 1, }); - } + }); }; topDown.forEach((part, index) => addPart(part, 1, null, index + 1, topDown.length)); @@ -167,7 +233,8 @@ export interface HiveOutlineProps { canEdit: boolean; /** Text for the polite live region ("Rähmchen 4 in Zarge 1 ist jetzt Brutwabe."). */ announcement: string; - onEditPart: (id: string) => void; + /** `intent` is set by the box's „Futtertasche einsetzen“ action. */ + onEditPart: (id: string, intent?: "frameFeeder") => void; onEditFrame: (boxId: string, slot: number) => void; } @@ -282,7 +349,9 @@ export function HiveOutline({ ref, state, catalog, canEdit, announcement, onEdit const activateNode = (node: OutlineNode): void => { if (node.kind === "action") { - if (canEdit) onEditPart(node.id); + if (!canEdit) return; + if (node.action === "frameFeeder") onEditPart(node.id, "frameFeeder"); + else onEditPart(node.id); return; } if (node.kind === "frame") { diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/camera-fit.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/camera-fit.spec.ts new file mode 100644 index 0000000..7c215db --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/camera-fit.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { cameraBasis, cameraPosition, fitDistance, focusFrame, overlayMargin } from "./camera-fit"; + +describe("cameraBasis", () => { + it("looks from 20° above and 30° to the side, with orthonormal axes", () => { + const { back, right, up } = cameraBasis(); + expect(Math.asin(back[1]) * (180 / Math.PI)).toBeCloseTo(20, 6); + expect(Math.atan2(back[0], back[2]) * (180 / Math.PI)).toBeCloseTo(30, 6); + const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + expect(dot(back, right)).toBeCloseTo(0, 9); + expect(dot(back, up)).toBeCloseTo(0, 9); + expect(dot(right, up)).toBeCloseTo(0, 9); + expect(up[1]).toBeGreaterThan(0); + }); +}); + +describe("fitDistance", () => { + const hive: [number, number, number] = [5.25, 7, 4.3]; + + it("moves the camera further away for a taller model", () => { + expect(fitDistance([5.25, 10, 4.3], 1.6)).toBeGreaterThan(fitDistance(hive, 1.6)); + }); + + it("moves further away in a narrow card than in a wide one", () => { + expect(fitDistance(hive, 0.6)).toBeGreaterThan(fitDistance(hive, 2)); + }); + + it("grows with the margin and survives a zero aspect", () => { + expect(fitDistance(hive, 1.5, 1.4)).toBeGreaterThan(fitDistance(hive, 1.5, 1.2)); + expect(Number.isFinite(fitDistance(hive, 0))).toBe(true); + }); + + it("keeps a minimum distance for a flat part", () => { + expect(fitDistance([0, 0, 0], 1)).toBeGreaterThanOrEqual(1); + }); +}); + +describe("overlayMargin", () => { + it("leaves more room on a short card than on a tall one, capped at 2", () => { + expect(overlayMargin(1000, 150)).toBeCloseTo(1000 / 850, 9); + expect(overlayMargin(460, 150)).toBeCloseTo(460 / 310, 9); + expect(overlayMargin(200, 150)).toBe(2); + expect(overlayMargin(0)).toBe(1); + }); +}); + +describe("focusFrame", () => { + it("pulls the view toward the ghost hive and keeps the focused part inside the box", () => { + const frame = focusFrame([-2, 1, 5], [6, 3, 5], [0, 4, 0], [0.5, 0.5, 0.5]); + expect(frame.center).toEqual([-1, 2.5, 2.5]); + expect(frame.size).toEqual([8, 6, 10]); + }); + + it("is the focused part itself without pull", () => { + expect(focusFrame([1, 2, 3], [4, 5, 6], [0, 0, 0], [0, 0, 0])).toEqual({ center: [1, 2, 3], size: [4, 5, 6] }); + }); +}); + +describe("cameraPosition", () => { + it("places the camera along the viewing direction from the centre", () => { + const [x, y, z] = cameraPosition([1, 2, 3], 10); + const { back } = cameraBasis(); + expect(x).toBeCloseTo(1 + back[0] * 10, 9); + expect(y).toBeCloseTo(2 + back[1] * 10, 9); + expect(z).toBeCloseTo(3 + back[2] * 10, 9); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/camera-fit.ts b/ApiCore-Frontend/src/components/hive/hive-scene/camera-fit.ts new file mode 100644 index 0000000..2b594dc --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/camera-fit.ts @@ -0,0 +1,114 @@ +import type { Vec3 } from "@/lib/hive/layout"; + +// Camera placement for the card (spec 9.2): fixed viewing direction slightly +// from above and in front, distance chosen so the target box fits the card. + +export const CAMERA_ELEVATION_DEG = 20; +export const CAMERA_AZIMUTH_DEG = 30; +export const CAMERA_FOV_DEG = 30; +/** Space around the model, as a factor on its projected size. */ +export const FIT_MARGIN = 1.05; +/** Focus mode: the frame is pulled toward the ghost hive (focusFrame), so less extra room. */ +export const FOCUS_FIT_MARGIN = 0.95; +/** Card pixels taken by the frosted overlays (title or focus bar on top, buttons below). */ +export const OVERLAY_RESERVE_PX = 150; +/** Share of the way from the focused part toward the ghost hive the view centre moves (x, y, z). */ +export const FOCUS_PULL: Vec3 = [0.3, 0.42, 0.3]; + +const toRad = (deg: number): number => (deg * Math.PI) / 180; + +export interface CameraBasis { + /** Unit vector from the target to the camera. */ + back: Vec3; + right: Vec3; + up: Vec3; +} + +export function cameraBasis( + elevationDeg: number = CAMERA_ELEVATION_DEG, + azimuthDeg: number = CAMERA_AZIMUTH_DEG, +): CameraBasis { + const elevation = toRad(elevationDeg); + const azimuth = toRad(azimuthDeg); + const back: Vec3 = [ + Math.sin(azimuth) * Math.cos(elevation), + Math.sin(elevation), + Math.cos(azimuth) * Math.cos(elevation), + ]; + const right: Vec3 = [Math.cos(azimuth), 0, -Math.sin(azimuth)]; + // up = back x right + const up: Vec3 = [ + back[1] * right[2] - back[2] * right[1], + back[2] * right[0] - back[0] * right[2], + back[0] * right[1] - back[1] * right[0], + ]; + return { back, right, up }; +} + +/** + * Extra margin so the model fits the part of the card the overlays leave free: + * about 1.2 on a tall desktop card, about 1.5 on a phone. Never more than 2. + */ +export function overlayMargin(heightPx: number, reservePx: number = OVERLAY_RESERVE_PX): number { + if (!(heightPx > 0)) return 1; + return heightPx / Math.max(heightPx - reservePx, heightPx / 2); +} + +/** Half extent of an axis-aligned box of `size` projected on a unit axis. */ +function halfExtent(size: Vec3, axis: Vec3): number { + return (Math.abs(axis[0]) * size[0] + Math.abs(axis[1]) * size[1] + Math.abs(axis[2]) * size[2]) / 2; +} + +/** Distance from the box centre at which the box fills the view with `margin` around it. */ +export function fitDistance( + size: Vec3, + aspect: number, + margin: number = FIT_MARGIN, + fovDeg: number = CAMERA_FOV_DEG, + basis: CameraBasis = cameraBasis(), +): number { + const tanHalf = Math.tan(toRad(fovDeg) / 2); + const safeAspect = aspect > 0 && Number.isFinite(aspect) ? aspect : 1; + const halfWidth = halfExtent(size, basis.right); + const halfHeight = halfExtent(size, basis.up); + const halfDepth = halfExtent(size, basis.back); + const distance = Math.max(halfHeight / tanHalf, halfWidth / (tanHalf * safeAspect)) * margin; + // The near face of the box sits half its depth closer to the camera. + return Math.max(distance + halfDepth, 1); +} + +/** + * Box the camera fits in focus mode: the focused part plus part of the way + * toward the ghost hive, so the ghost peeks in behind it (mockup h2) and a + * tall card is not left empty above a flat part. + */ +export function focusFrame( + focusCenter: Vec3, + focusSize: Vec3, + hiveCenter: Vec3, + pull: Vec3 = FOCUS_PULL, +): { center: Vec3; size: Vec3 } { + const shift: Vec3 = [ + (hiveCenter[0] - focusCenter[0]) * pull[0], + (hiveCenter[1] - focusCenter[1]) * pull[1], + (hiveCenter[2] - focusCenter[2]) * pull[2], + ]; + return { + center: [focusCenter[0] + shift[0], focusCenter[1] + shift[1], focusCenter[2] + shift[2]], + // The focused part stays fully inside the box around the shifted centre. + size: [ + focusSize[0] + 2 * Math.abs(shift[0]), + focusSize[1] + 2 * Math.abs(shift[1]), + focusSize[2] + 2 * Math.abs(shift[2]), + ], + }; +} + +/** Camera position looking at `center` along the fixed viewing direction. */ +export function cameraPosition(center: Vec3, distance: number, basis: CameraBasis = cameraBasis()): Vec3 { + return [ + center[0] + basis.back[0] * distance, + center[1] + basis.back[1] * distance, + center[2] + basis.back[2] * distance, + ]; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx new file mode 100644 index 0000000..50c8f0d --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import type { HiveLayout, Vec3 } from "@/lib/hive/layout"; +import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, overlayMargin } from "./camera-fit"; +import { useSceneKit } from "./scene-kit"; +import { placeCamera } from "./scene-mutations"; +import { MAX_FRAME_SECONDS } from "./use-animated-node"; +import { restBounds, stepToward } from "./use-hive-motion"; +import type { FocusInfo } from "./use-hive-motion"; + +// Auto-fit (spec 9.2): the camera looks from 20° above and 30° to the side +// and moves so the hive (or the focused part) fills the card. It refits on +// layout, card size and focus changes; damped at `full`, at once otherwise. + +export interface CameraRigProps { + layout: HiveLayout; + focus: FocusInfo | null; + /** Receives the current look-at point every frame; the model rig turns around it. */ + onPivot: (center: Vec3) => void; +} + +export function CameraRig({ layout, focus, onPivot }: CameraRigProps) { + const { motion } = useSceneKit(); + const camera = useThree((state) => state.camera); + const width = useThree((state) => state.size.width); + const height = useThree((state) => state.size.height); + const invalidate = useThree((state) => state.invalidate); + + const goal = useMemo(() => { + const aspect = height > 0 ? width / height : 1; + const room = overlayMargin(height); + const bounds = restBounds(layout); + if (focus) { + const frame = focusFrame(focus.center, focus.size, bounds.center); + return { center: frame.center, distance: fitDistance(frame.size, aspect, FOCUS_FIT_MARGIN * room) }; + } + return { center: bounds.center, distance: fitDistance(bounds.size, aspect, FIT_MARGIN * room) }; + }, [focus, layout, width, height]); + + const current = useRef<{ center: Vec3; distance: number } | null>(null); + useEffect(() => { + invalidate(); + }, [goal, invalidate]); + + useFrame((_, delta) => { + const damped = motion === "full"; + const dt = Math.min(delta, MAX_FRAME_SECONDS); + if (!current.current) { + current.current = { center: [...goal.center], distance: goal.distance }; + } else { + const state = current.current; + state.center = [ + stepToward(state.center[0], goal.center[0], dt, damped), + stepToward(state.center[1], goal.center[1], dt, damped), + stepToward(state.center[2], goal.center[2], dt, damped), + ]; + state.distance = stepToward(state.distance, goal.distance, dt, damped); + } + const { center, distance } = current.current; + onPivot(center); + placeCamera(camera, center, distance); + const settled = + distance === goal.distance && center.every((value, axis) => value === goal.center[axis]); + if (!settled) invalidate(); + }); + + return null; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/fill-volume.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/fill-volume.tsx new file mode 100644 index 0000000..7564c4d --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/fill-volume.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import { BoxGeometry, CylinderGeometry, MeshStandardMaterial } from "three"; +import type { Mesh } from "three"; +import type { Vec3 } from "@/lib/hive/layout"; +import { createLiquidMaterial } from "./materials"; +import { MAX_FRAME_SECONDS } from "./use-animated-node"; +import { stepToward } from "./use-hive-motion"; +import { noRaycast, useSceneKit } from "./scene-kit"; +import { placeFill, setUniform } from "./scene-mutations"; + +// Levels (spec 9.3): a glowing, half-transparent volume whose surface moves +// gently at motion `full`. Height = value / max of the space it fills. + +/** Surface wave height in layout units (2 mm). */ +const WOBBLE = 0.02; + +export type FillKind = "honey" | "acid" | "fondant"; + +export interface FillVolumeProps { + /** Centre and size of the full space (local to the part). */ + center: Vec3; + size: Vec3; + /** 0…1 */ + ratio: number; + kind: FillKind; + shape?: "box" | "cylinder"; +} + +export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVolumeProps) { + const { theme, motion } = useSceneKit(); + const invalidate = useThree((state) => state.invalidate); + const mesh = useRef(null); + const shown = useRef(ratio); + + const geometry = useMemo( + () => (shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12)), + [shape], + ); + const material = useMemo(() => { + if (kind === "fondant") { + return new MeshStandardMaterial({ + color: theme.fondant, + roughness: 0.55, + emissive: theme.fondant, + emissiveIntensity: 0.18, + transparent: true, + opacity: 0.94, + }); + } + return kind === "acid" + ? createLiquidMaterial({ color: theme.acid, deep: theme.rim, glow: 1.05, alpha: 0.55 }) + : createLiquidMaterial({ color: theme.honey, deep: theme.honeyDeep, glow: 1.2, alpha: 0.78 }); + }, [kind, theme]); + useEffect( + () => () => { + geometry.dispose(); + material.dispose(); + }, + [geometry, material], + ); + useEffect(() => { + invalidate(); + }, [ratio, invalidate]); + + useFrame((state, delta) => { + const node = mesh.current; + if (!node) return; + const dt = Math.min(delta, MAX_FRAME_SECONDS); + shown.current = stepToward(shown.current, ratio, dt, motion === "full"); + const height = placeFill(node, center, size, shown.current, shape === "cylinder"); + if ("uniforms" in material) { + setUniform(material, "uTime", state.clock.elapsedTime); + // The wave lives in unit space; divide by the height so it stays 2 mm. + setUniform(material, "uWobble", motion === "full" && kind !== "fondant" ? WOBBLE / height : 0); + } + if (shown.current !== ratio) invalidate(); + }); + + return ; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx new file mode 100644 index 0000000..39196ed --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { MeshStandardMaterial } from "three"; +import type { Group, ShaderMaterial } from "three"; +import type { FrameBox, Vec3 } from "@/lib/hive/layout"; +import { HoloLabel } from "./hive-labels"; +import { createEdgeMaterial, createGlassMaterial } from "./materials"; +import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry"; +import type { PartShapeSpec } from "./part-geometry"; +import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit"; +import { setMaterialOpacity, setUniform } from "./scene-mutations"; +import type { SceneLabel } from "./scene-model"; +import type { NodeHandlers } from "./stack-part"; +import { useAnimatedNode } from "./use-animated-node"; +import type { AnimatedValues } from "./use-animated-node"; +import type { FrameTarget } from "./use-hive-motion"; + +// Frames (spec 9.3/9.4). At rest only their top bars are hinted inside the +// box. In an open box each frame is a clear glass plate; hovered or selected +// it rises and takes its type look (colour and comb pattern). + +const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + +/** Faint top bars of all frames of a box, one merged mesh using the box's materials. */ +export function FrameHints({ + frames, + hostCenter, + glass, + edges, +}: { + frames: FrameBox[]; + hostCenter: Vec3; + glass: ShaderMaterial; + edges: ShaderMaterial; +}) { + const geometries = useMemo(() => { + const hints = frames + .filter((frame) => frame.type !== "NONE") + .map((frame) => frameHintSpec(frame.size, sub(frame.center, hostCenter))); + return { + glass: buildGlassGeometry(hints.map((hint) => hint.slab)), + edges: buildEdgeGeometry(hints.flatMap((hint) => hint.edges)), + }; + }, [frames, hostCenter]); + useEffect( + () => () => { + geometries.glass.dispose(); + geometries.edges.dispose(); + }, + [geometries], + ); + return ( + <> + + + + ); +} + +export interface FramePlateProps { + frame: FrameBox; + hostCenter: Vec3; + target: FrameTarget; + handlers: NodeHandlers; + label?: SceneLabel; +} + +const COMB_FROST: Partial> = { DIVIDER: 0.75, FOUNDATION: 0.25 }; + +export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) { + const { theme, geometries, textures, motion } = useSceneKit(); + const group = useRef(null); + const spec = useMemo(() => frameShapeSpec(frame.size, frame.type), [frame.size, frame.type]); + const sizeKey = frame.size.map((value) => value.toFixed(4)).join("x"); + const bars = geometries.shape(`frame-bars|${sizeKey}`, (): PartShapeSpec => ({ + slabs: spec.bars, + edges: spec.edges, + grids: [], + frost: 0, + })); + const comb = spec.comb + ? geometries.shape(`frame-comb|${sizeKey}|${spec.comb.size[0].toFixed(3)}`, (): PartShapeSpec => ({ + slabs: spec.comb ? [spec.comb] : [], + edges: [], + grids: [], + frost: 0, + })) + : null; + const texture = textures.get(frame.type); + + const materials = useMemo( + () => ({ + bars: createGlassMaterial(theme, { frost: 0.28 }), + comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }), + edges: createEdgeMaterial(theme), + typed: texture + ? new MeshStandardMaterial({ + map: texture, + emissiveMap: texture, + emissive: "#ffffff", + emissiveIntensity: 0.95, + roughness: frame.type === "HONEY" ? 0.2 : 0.45, + metalness: 0.05, + transparent: true, + opacity: 0, + }) + : null, + }), + [theme, texture, frame.type], + ); + useEffect( + () => () => { + materials.bars.dispose(); + materials.comb.dispose(); + materials.edges.dispose(); + materials.typed?.dispose(); + }, + [materials], + ); + + const typeLook = target.look === "type"; + const apply = ({ opacity, glow, look }: AnimatedValues) => { + setUniform(materials.bars, "uOpacity", opacity); + setUniform(materials.bars, "uGlow", glow); + // The glass comb gives way to the textured comb as the look fades in. + setUniform(materials.comb, "uOpacity", opacity * (1 - (materials.typed ? look * 0.92 : 0))); + setUniform(materials.comb, "uGlow", glow); + setUniform(materials.edges, "uOpacity", opacity); + setUniform(materials.edges, "uIntensity", 1.3 * glow); + if (materials.typed) setMaterialOpacity(materials.typed, opacity * look); + }; + useAnimatedNode( + group, + { position: target.position, scale: 1, opacity: target.opacity, glow: typeLook ? 1.7 : 1, look: typeLook ? 1 : 0 }, + motion, + apply, + ); + + const restY = frame.center[1] - hostCenter[1]; + const rise = Math.max(0, target.position[1] - restY); + const [fx, fy, fz] = frame.size; + + return ( + + + {comb ? ( + + ) : null} + {comb && materials.typed ? ( + + ) : null} + + {/* The hit box reaches down to the rest place, so a lifted frame does not slip from under the pointer. */} + + + + + {label ? : null} + + ); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.spec.ts new file mode 100644 index 0000000..0a0afc0 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { FRAME_TYPES } from "@/lib/hive/types"; +import type { FrameType } from "@/lib/hive/types"; +import { createFrameTextureCache, framePatternSpec } from "./frame-textures"; + +/** A canvas whose 2D context records every fill colour and shape drawn. */ +function fakeCanvas() { + const fills: string[] = []; + let fillStyle: unknown = ""; + const gradient = { addColorStop: vi.fn() }; + const context = { + get fillStyle() { + return fillStyle; + }, + set fillStyle(value: unknown) { + fillStyle = value; + }, + strokeStyle: "", + lineWidth: 1, + globalAlpha: 1, + fillRect: vi.fn(() => fills.push(`rect:${String(fillStyle)}`)), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + closePath: vi.fn(), + arc: vi.fn(), + ellipse: vi.fn(), + fill: vi.fn(() => fills.push(typeof fillStyle === "string" ? fillStyle : "gradient")), + stroke: vi.fn(), + createRadialGradient: vi.fn(() => gradient), + createLinearGradient: vi.fn(() => gradient), + }; + const canvas = { width: 0, height: 0, getContext: vi.fn(() => context) }; + return { canvas: canvas as unknown as HTMLCanvasElement, fills }; +} + +describe("framePatternSpec", () => { + it("has no pattern for the open frame, the divider and an empty slot", () => { + expect(framePatternSpec("DRONE_FRAME")).toBeNull(); + expect(framePatternSpec("DIVIDER")).toBeNull(); + expect(framePatternSpec("NONE")).toBeNull(); + }); + + it("gives every comb type its own pattern", () => { + const combs = FRAME_TYPES.filter((type) => framePatternSpec(type) !== null); + expect(combs).toEqual(["BROOD", "DRONE", "HONEY", "POLLEN", "FEED", "FOUNDATION", "EMPTY_COMB"]); + const serialised = new Set(combs.map((type) => JSON.stringify(framePatternSpec(type)))); + expect(serialised.size).toBe(combs.length); + }); + + it("draws drone cells larger and domed, honey glossy, pollen speckled, foundation without fills", () => { + expect(framePatternSpec("DRONE")!.cellRadius).toBeGreaterThan(framePatternSpec("BROOD")!.cellRadius); + expect(framePatternSpec("DRONE")!.dome).toBe(true); + expect(framePatternSpec("HONEY")!.gloss).toBeGreaterThan(framePatternSpec("BROOD")!.gloss); + expect(framePatternSpec("POLLEN")!.speckle).toBe(true); + expect(framePatternSpec("FOUNDATION")!.fills).toHaveLength(0); + expect(framePatternSpec("EMPTY_COMB")!.open).toBe(1); + }); +}); + +describe("createFrameTextureCache", () => { + it("returns one cached texture per type and none for types without a comb", () => { + const made: ReturnType[] = []; + const cache = createFrameTextureCache(() => { + const fake = fakeCanvas(); + made.push(fake); + return fake.canvas; + }); + const brood = cache.get("BROOD"); + expect(brood).not.toBeNull(); + expect(cache.get("BROOD")).toBe(brood); + expect(cache.get("NONE")).toBeNull(); + expect(cache.get("DIVIDER")).toBeNull(); + expect(made).toHaveLength(1); + }); + + it("draws different pixels for different types", () => { + const drawn = new Map(); + let current: FrameType = "BROOD"; + const cache = createFrameTextureCache(() => { + const fake = fakeCanvas(); + drawn.set(current, fake.fills); + return fake.canvas; + }); + for (const type of ["BROOD", "HONEY", "POLLEN", "FOUNDATION"] as FrameType[]) { + current = type; + cache.get(type); + } + const signatures = new Set([...drawn.values()].map((fills) => fills.join("|"))); + expect(signatures.size).toBe(4); + expect(drawn.get("BROOD")!.length).toBeGreaterThan(50); + }); + + it("disposes every texture it made", () => { + const cache = createFrameTextureCache(() => fakeCanvas().canvas); + const texture = cache.get("HONEY")!; + const dispose = vi.spyOn(texture, "dispose"); + cache.dispose(); + expect(dispose).toHaveBeenCalled(); + expect(cache.get("HONEY")).not.toBe(texture); + }); + + it("returns null when the canvas has no 2D context", () => { + const cache = createFrameTextureCache( + () => ({ width: 0, height: 0, getContext: () => null }) as unknown as HTMLCanvasElement, + ); + expect(cache.get("BROOD")).toBeNull(); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.ts b/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.ts new file mode 100644 index 0000000..852dc9c --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/frame-textures.ts @@ -0,0 +1,276 @@ +import { CanvasTexture, ClampToEdgeWrapping, SRGBColorSpace } from "three"; +import type { FrameType } from "@/lib/hive/types"; + +// Procedural comb textures for the frame look (spec 9.3): colour AND pattern +// per frame type, drawn once on a canvas and cached per type. No image assets. + +const WIDTH = 512; +const HEIGHT = 288; + +interface WeightedColor { + color: string; + weight: number; +} + +export interface FramePatternSpec { + /** Wax / comb background. */ + base: string; + /** Cell wall colour. */ + rim: string; + /** Hexagon radius in canvas pixels. */ + cellRadius: number; + /** Colours of filled cells, picked by weight. */ + fills: WeightedColor[]; + /** Share of cells drawn as open (empty, dark centre). */ + open: number; + /** Capped cells bulge (drone brood). */ + dome: boolean; + /** Strength of the glossy highlight on capped cells (0…1). */ + gloss: number; + /** Small coloured grains inside cells (pollen). */ + speckle: boolean; + /** Seed of the pseudo-random pattern, so every type looks the same each time. */ + seed: number; +} + +const SPECS: Partial> = { + BROOD: { + base: "#b8741f", + rim: "#ffd27a", + cellRadius: 13, + fills: [ + { color: "#eaa443", weight: 6 }, + { color: "#dc952f", weight: 3 }, + { color: "#f4b65a", weight: 2 }, + ], + open: 0.03, + dome: false, + gloss: 0.18, + speckle: false, + seed: 11, + }, + DRONE: { + base: "#8a4e14", + rim: "#e8a24c", + cellRadius: 17, + fills: [ + { color: "#c8822e", weight: 5 }, + { color: "#b67224", weight: 3 }, + ], + open: 0.06, + dome: true, + gloss: 0.3, + speckle: false, + seed: 23, + }, + HONEY: { + base: "#b9800f", + rim: "#ffe08a", + cellRadius: 13, + fills: [ + { color: "#f6c64a", weight: 6 }, + { color: "#ffd766", weight: 3 }, + { color: "#e9b032", weight: 2 }, + ], + open: 0.04, + dome: false, + gloss: 0.75, + speckle: false, + seed: 37, + }, + POLLEN: { + base: "#8a4612", + rim: "#f0a050", + cellRadius: 13, + fills: [ + { color: "#f08a24", weight: 4 }, + { color: "#f4c430", weight: 3 }, + { color: "#d0461c", weight: 2 }, + { color: "#9a6a2a", weight: 1 }, + ], + open: 0.2, + dome: false, + gloss: 0.08, + speckle: true, + seed: 41, + }, + FEED: { + base: "#b89a52", + rim: "#fff2c2", + cellRadius: 13, + fills: [ + { color: "#f3dc94", weight: 5 }, + { color: "#ead08a", weight: 3 }, + ], + open: 0.08, + dome: false, + gloss: 0.45, + speckle: false, + seed: 53, + }, + FOUNDATION: { + base: "#e9d8a8", + rim: "#fff8de", + cellRadius: 13, + fills: [], + open: 0, + dome: false, + gloss: 0, + speckle: false, + seed: 61, + }, + EMPTY_COMB: { + base: "#4a3418", + rim: "#a8834c", + cellRadius: 13, + fills: [], + open: 1, + dome: false, + gloss: 0, + speckle: false, + seed: 71, + }, +}; + +/** Pattern of a frame type, or null for types without a comb (open frame, divider, empty slot). */ +export function framePatternSpec(type: FrameType): FramePatternSpec | null { + return SPECS[type] ?? null; +} + +/** Small deterministic PRNG (mulberry32). */ +function random(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function pick(fills: WeightedColor[], value: number): string { + const total = fills.reduce((sum, entry) => sum + entry.weight, 0); + let left = value * total; + for (const entry of fills) { + left -= entry.weight; + if (left <= 0) return entry.color; + } + return fills[fills.length - 1].color; +} + +function hexPath(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void { + context.beginPath(); + for (let corner = 0; corner < 6; corner += 1) { + const angle = (Math.PI / 3) * corner + Math.PI / 6; + const px = x + radius * Math.cos(angle); + const py = y + radius * Math.sin(angle); + if (corner === 0) context.moveTo(px, py); + else context.lineTo(px, py); + } + context.closePath(); +} + +function drawCell( + context: CanvasRenderingContext2D, + spec: FramePatternSpec, + x: number, + y: number, + next: () => number, +): void { + const inner = spec.cellRadius * 0.82; + const isOpen = spec.fills.length === 0 ? spec.open > 0 : next() < spec.open; + if (isOpen) { + const hollow = context.createRadialGradient(x, y, 0, x, y, inner); + hollow.addColorStop(0, "rgba(74, 38, 8, 0.95)"); + hollow.addColorStop(1, "rgba(150, 92, 30, 0.6)"); + context.fillStyle = hollow; + hexPath(context, x, y, inner); + context.fill(); + return; + } + if (spec.fills.length === 0) return; + const color = pick(spec.fills, next()); + if (spec.dome) { + const dome = context.createRadialGradient(x - inner * 0.3, y - inner * 0.3, inner * 0.1, x, y, inner); + dome.addColorStop(0, "#f0b868"); + dome.addColorStop(1, color); + context.fillStyle = dome; + } else { + context.fillStyle = color; + } + hexPath(context, x, y, inner); + context.fill(); + if (spec.speckle) { + context.fillStyle = pick(spec.fills, next()); + context.beginPath(); + context.arc(x + (next() - 0.5) * inner, y + (next() - 0.5) * inner, 1 + next() * 1.6, 0, Math.PI * 2); + context.fill(); + } + if (spec.gloss > 0) { + context.globalAlpha = spec.gloss; + context.fillStyle = "#fff6dc"; + context.beginPath(); + context.ellipse(x - inner * 0.28, y - inner * 0.32, inner * 0.34, inner * 0.18, -0.5, 0, Math.PI * 2); + context.fill(); + context.globalAlpha = 1; + } +} + +function drawPattern(context: CanvasRenderingContext2D, spec: FramePatternSpec): void { + const next = random(spec.seed); + context.fillStyle = spec.base; + context.fillRect(0, 0, WIDTH, HEIGHT); + const radius = spec.cellRadius; + const stepX = Math.sqrt(3) * radius; + const stepY = 1.5 * radius; + context.strokeStyle = spec.rim; + context.lineWidth = Math.max(1.2, radius * 0.16); + for (let row = 0, y = 0; y < HEIGHT + radius; row += 1, y += stepY) { + const offset = row % 2 === 0 ? 0 : stepX / 2; + for (let x = offset; x < WIDTH + radius; x += stepX) { + drawCell(context, spec, x, y, next); + hexPath(context, x, y, radius); + context.stroke(); + } + } +} + +export interface FrameTextureCache { + /** Texture of a frame type, created on first use; null for types without a comb or without 2D canvas. */ + get(type: FrameType): CanvasTexture | null; + dispose(): void; +} + +const defaultCanvas = (): HTMLCanvasElement => document.createElement("canvas"); + +export function createFrameTextureCache(makeCanvas: () => HTMLCanvasElement = defaultCanvas): FrameTextureCache { + const textures = new Map(); + return { + get(type) { + if (textures.has(type)) return textures.get(type) ?? null; + const spec = framePatternSpec(type); + if (!spec) return null; + const canvas = makeCanvas(); + canvas.width = WIDTH; + canvas.height = HEIGHT; + const context = canvas.getContext("2d"); + if (!context) { + textures.set(type, null); + return null; + } + drawPattern(context, spec); + const texture = new CanvasTexture(canvas); + texture.colorSpace = SRGBColorSpace; + texture.wrapS = ClampToEdgeWrapping; + texture.wrapT = ClampToEdgeWrapping; + texture.anisotropy = 4; + textures.set(type, texture); + return texture; + }, + dispose() { + textures.forEach((texture) => texture?.dispose()); + textures.clear(); + }, + }; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-canvas.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-canvas.tsx new file mode 100644 index 0000000..efee12e --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-canvas.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { RefObject } from "react"; +import { Canvas } from "@react-three/fiber"; +import type { HiveLayout } from "@/lib/hive/layout"; +import type { HiveCatalog, HiveState } from "@/lib/hive/types"; +import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference"; +import { useHiveView } from "../use-hive-view"; +import { CAMERA_FOV_DEG } from "./camera-fit"; +import { HiveSceneContent } from "./hive-scene-content"; +import { HiveStage, detectStageTier } from "./hive-stage"; +import { SceneKitProvider } from "./scene-kit"; +import { shouldIdle } from "./use-hive-motion"; + +export interface HiveCanvasProps { + layout: HiveLayout; + state: HiveState; + catalog: HiveCatalog; + partName: (id: string) => string; + onSelectPart: (id: string) => void; + onSelectFrame: (boxId: string, slot: number) => void; + /** The first frame is on screen: the SVG may cross-fade out. */ + onReady: () => void; + /** The WebGL context was lost; the browser may or may not restore it. */ + onContextLost: () => void; +} + +/** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */ +function useOnScreen(target: RefObject): { visible: boolean; tabVisible: boolean } { + const [visible, setVisible] = useState(true); + const [tabVisible, setTabVisible] = useState(() => typeof document === "undefined" || !document.hidden); + useEffect(() => { + const element = target.current; + if (!element || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver((entries) => { + setVisible(entries.some((entry) => entry.isIntersecting)); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [target]); + useEffect(() => { + const onChange = () => setTabVisible(!document.hidden); + document.addEventListener("visibilitychange", onChange); + return () => document.removeEventListener("visibilitychange", onChange); + }, []); + return { visible, tabVisible }; +} + +/** + * The 3D hive (spec 9.3/9.4): holo stage plus the interactive model. Loaded + * only through next/dynamic; the canvas is aria-hidden, the outline is the + * accessible path. Renders on demand: frames only while something moves. + */ +export default function HiveCanvas({ + layout, + state, + catalog, + partName, + onSelectPart, + onSelectFrame, + onReady, + onContextLost, +}: HiveCanvasProps) { + const motion = useEffectiveMotionLevel(); + const view = useHiveView(); + const root = useRef(null); + const { visible, tabVisible } = useOnScreen(root); + const [tier] = useState(detectStageTier); + const idle = shouldIdle({ motion, visible, tabVisible }); + + return ( +
+ { + // A click or tap into empty space leaves focus mode and clears the hover. + view.openBox(null); + view.setHovered(null); + view.setHoveredFrame(null); + }} + onCreated={({ gl, invalidate }) => { + gl.domElement.addEventListener("webglcontextlost", (event) => { + // Without preventDefault the browser never restores the context. + event.preventDefault(); + onContextLost(); + }); + invalidate(); + onReady(); + }} + > + + + + + +
+ ); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx new file mode 100644 index 0000000..c43e918 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { Html } from "@react-three/drei"; +import type { Vec3 } from "@/lib/hive/layout"; +import type { SceneLabel } from "./scene-model"; + +// DOM labels in the scene (spec 9.3): sharp, translatable text in a glass +// pill with a thin leader line. At most three at once (sceneLabels caps it). + +const PILL = + "whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md"; + +export interface HoloLabelProps { + position: Vec3; + text: string; + tone: SceneLabel["tone"]; +} + +/** A label anchored at `position` (local to its parent): beside a part, above a frame. */ +export function HoloLabel({ position, text, tone }: HoloLabelProps) { + return ( + + {tone === "frame" ? ( +
+ {text} + + +
+ ) : ( +
+ + + + {tone === "level" ? : null} + {text} + +
+ )} + + ); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx new file mode 100644 index 0000000..5c5661e --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx @@ -0,0 +1,242 @@ +import ReactThreeTestRenderer from "@react-three/test-renderer"; +import type { ReactThreeTest } from "@react-three/test-renderer"; +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Vec3 } from "@/lib/hive/layout"; +import { hiveLayout } from "@/lib/hive/layout"; +import { partDisplayName } from "@/lib/hive/names"; +import { frames, inlineCatalog, part } from "@/lib/hive/test-utils"; +import type { HiveState } from "@/lib/hive/types"; +import type { MotionLevel } from "@/lib/motion/motion-preference"; +import { createHiveViewStore, HiveViewProvider } from "../use-hive-view"; +import type { HiveViewStore } from "../use-hive-view"; +import { HiveSceneContent } from "./hive-scene-content"; +import { IDLE_FLOAT, ModelRig } from "./model-rig"; +import { SceneKitProvider } from "./scene-kit"; + +// The scene content runs on the r3f test renderer (no WebGL). Labels are DOM +// portals (drei Html), so they are replaced by a marker here. +vi.mock("./hive-labels", () => ({ + HoloLabel: ({ text }: { text: string }) => , +})); + +const catalog = inlineCatalog(); + +function hiveState(): HiveState { + return { + schemaVersion: 1, + stack: [ + part("f", "floor", { inserts: [part("fi", "floor-insert")] }), + part("b1", "box", { + frames: frames(10).map((frame) => + frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame.slot === 4 ? { ...frame, type: "BROOD" as const } : frame, + ), + inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } })], + }), + part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }), + part("r", "roof"), + ], + }; +} + +interface Rendered { + renderer: Awaited>; + store: HiveViewStore; + onSelectPart: ReturnType; + onSelectFrame: ReturnType; +} + +let current: Rendered | null = null; + +async function renderScene(motion: MotionLevel = "full"): Promise { + const state = hiveState(); + const layout = hiveLayout(state, catalog); + const store = createHiveViewStore(); + const onSelectPart = vi.fn(); + const onSelectFrame = vi.fn(); + const renderer = await ReactThreeTestRenderer.create( + + + partDisplayName(state, catalog, id)} + onSelectPart={onSelectPart} + onSelectFrame={onSelectFrame} + idle={false} + /> + + , + ); + current = { renderer, store, onSelectPart, onSelectFrame }; + return current; +} + +afterEach(async () => { + await current?.renderer.unmount(); + current = null; +}); + +const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactThreeTestInstance => + renderer.scene.find((node) => node.props.userData?.hit === id); +const hits = (renderer: Rendered["renderer"]): string[] => + renderer.scene.findAll((node) => typeof node.props.userData?.hit === "string").map((node) => node.props.userData.hit); + +async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mouse") { + const target = hit(renderer, id); + await renderer.fireEvent(target, "onPointerDown", { pointerType }); + await renderer.fireEvent(target, "onClick", { delta: 0 }); +} + +describe("HiveSceneContent", () => { + it("renders one hit box per part and insert, and frames only as hints at rest", async () => { + const { renderer } = await renderScene(); + expect(hits(renderer).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]); + }); + + it("shows the frames of an open box as plates, without the empty slots under the pocket", async () => { + const { renderer, store } = await renderScene(); + await act(async () => store.openBox("b1")); + const frameHits = hits(renderer).filter((id) => id.startsWith("b1#")); + expect(frameHits).toHaveLength(8); + expect(frameHits).not.toContain("b1#1"); + expect(frameHits).not.toContain("b1#2"); + }); + + it("reports pointer over a part as hover to the shared store and clears it on leave", async () => { + const { renderer, store } = await renderScene(); + await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "mouse" }); + expect(store.getState().hoveredId).toBe("b1"); + await renderer.fireEvent(hit(renderer, "b1"), "onPointerOut", { pointerType: "mouse" }); + expect(store.getState().hoveredId).toBeNull(); + }); + + it("opens a box on click and focuses any other part", async () => { + const { renderer, store } = await renderScene(); + await tap(renderer, "b1"); + expect(store.getState().openBoxId).toBe("b1"); + await act(async () => store.openBox(null)); + await tap(renderer, "fe"); + expect(store.getState()).toMatchObject({ focusedPartId: "fe", openBoxId: null }); + }); + + it("opens the part sheet when the focused part's content is clicked", async () => { + const { renderer, store, onSelectPart } = await renderScene(); + await act(async () => store.focusPart("fe")); + await tap(renderer, "fe"); + expect(onSelectPart).toHaveBeenCalledWith("fe"); + }); + + it("does not react to clicks on ghost parts in focus mode", async () => { + const { renderer, store } = await renderScene(); + await act(async () => store.focusPart("fe")); + // Ghosts do not raycast; the scene also ignores them if an event arrives. + expect(hit(renderer, "b1").props.raycast).not.toBe(hit(renderer, "fe").props.raycast); + }); + + it("hovers and selects frames of the open box", async () => { + const { renderer, store, onSelectFrame } = await renderScene(); + await act(async () => store.openBox("b1")); + await renderer.fireEvent(hit(renderer, "b1#4"), "onPointerOver", { pointerType: "mouse" }); + expect(store.getState().hoveredFrame).toEqual({ boxId: "b1", slot: 4 }); + await tap(renderer, "b1#4"); + expect(onSelectFrame).toHaveBeenCalledWith("b1", 4); + }); + + it("focuses the frame feeder of an open box on click", async () => { + const { renderer, store } = await renderScene(); + await act(async () => store.openBox("b1")); + await tap(renderer, "ff"); + expect(store.getState().focusedPartId).toBe("ff"); + }); + + it("on touch hovers with the first tap and selects with the second", async () => { + const { renderer, store } = await renderScene(); + await tap(renderer, "b1", "touch"); + expect(store.getState()).toMatchObject({ hoveredId: "b1", openBoxId: null }); + await tap(renderer, "b1", "touch"); + expect(store.getState().openBoxId).toBe("b1"); + }); + + it("ignores touch pointer-over (taps drive the hover)", async () => { + const { renderer, store } = await renderScene(); + await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "touch" }); + expect(store.getState().hoveredId).toBeNull(); + }); + + it("ignores a click that ended a drag", async () => { + const { renderer, store } = await renderScene(); + await renderer.fireEvent(hit(renderer, "b1"), "onPointerDown", { pointerType: "mouse" }); + await renderer.fireEvent(hit(renderer, "b1"), "onClick", { delta: 40 }); + expect(store.getState().openBoxId).toBeNull(); + }); + + it("labels the hovered part", async () => { + const { renderer, store } = await renderScene(); + await act(async () => store.setHovered("b1")); + expect(renderer.scene.findAll((node) => node.props.name === "label:Zarge · 8 Rähmchen")).toHaveLength(1); + }); + + it("lifts the hovered part toward its target over a few frames at full", async () => { + const { renderer, store } = await renderScene("full"); + await renderer.advanceFrames(1, 1 / 60); + const group = hit(renderer, "b1").parent!; + const before = group.instance.position.y; + await act(async () => store.setHovered("b1")); + await renderer.advanceFrames(1, 1 / 60); + const midway = group.instance.position.y; + await renderer.advanceFrames(30, 1 / 60); + const settled = group.instance.position.y; + expect(midway).toBeGreaterThan(before); + expect(midway).toBeLessThan(settled); + expect(settled - before).toBeCloseTo(0.08, 3); + }); + + it("jumps to the target at once below full", async () => { + const { renderer, store } = await renderScene("subtle"); + await renderer.advanceFrames(1, 1 / 60); + const group = hit(renderer, "b1").parent!; + const before = group.instance.position.y; + await act(async () => store.setHovered("b1")); + await renderer.advanceFrames(1, 1 / 60); + expect(group.instance.position.y - before).toBeCloseTo(0.08, 6); + }); +}); + +describe("ModelRig", () => { + async function renderRig(motion: MotionLevel, idle: boolean) { + const pivot: Vec3 = [0, 1, 0]; + const renderer = await ReactThreeTestRenderer.create( + + pivot} idle={idle}> + + + , + ); + const outer = renderer.scene.children[0]; + return { renderer, outer }; + } + + it("floats the model at full while idle", async () => { + const { renderer, outer } = await renderRig("full", true); + const heights = new Set(); + for (let step = 0; step < 6; step += 1) { + await renderer.advanceFrames(1, 0.25); + heights.add(Number(outer.instance.position.y.toFixed(4))); + } + expect(heights.size).toBeGreaterThan(1); + for (const height of heights) expect(Math.abs(height - 1)).toBeLessThanOrEqual(IDLE_FLOAT + 1e-9); + await renderer.unmount(); + }); + + it("keeps the model still at subtle, even when idle is allowed", async () => { + const { renderer, outer } = await renderRig("subtle", true); + for (let step = 0; step < 6; step += 1) { + await renderer.advanceFrames(1, 0.25); + expect(outer.instance.position.y).toBe(1); + expect(outer.instance.rotation.y).toBe(0); + } + await renderer.unmount(); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.tsx new file mode 100644 index 0000000..5b50849 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useThree } from "@react-three/fiber"; +import type { HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout"; +import type { HiveCatalog, HiveState } from "@/lib/hive/types"; +import { focusedIdOf, useHiveView } from "../use-hive-view"; +import type { HoveredFrame } from "../use-hive-view"; +import { CameraRig } from "./camera-rig"; +import { FramePlate } from "./frame-plate"; +import { ModelRig } from "./model-rig"; +import { buildSceneModel, sceneLabels } from "./scene-model"; +import type { SceneLabel } from "./scene-model"; +import { setCanvasCursor } from "./scene-mutations"; +import { StackPart } from "./stack-part"; +import type { NodeHandlers } from "./stack-part"; +import { frameKey, hiveMotionTargets, restBounds } from "./use-hive-motion"; +import type { HiveMotionTargets } from "./use-hive-motion"; +import { useTouchSelect } from "./use-touch-select"; + +// Everything inside the canvas that knows about the hive: parts, inserts, +// frames, labels, camera and turning; it must sit inside a SceneKitProvider. +// Pointer events go to the shared view store (spec 9.1), so the outline, the +// SVG and the scene stay in step. + +/** A press that moved further than this is a drag, not a click. */ +export const CLICK_SLOP_PX = 8; + +export interface HiveSceneContentProps { + layout: HiveLayout; + state: HiveState; + catalog: HiveCatalog; + partName: (id: string) => string; + /** Content of a focused part was clicked (opens its sheet). */ + onSelectPart: (id: string) => void; + /** A frame of the open box was clicked (opens the frame sheet). */ + onSelectFrame: (boxId: string, slot: number) => void; + /** Idle motion may run (see shouldIdle). */ + idle: boolean; +} + +const partKey = (id: string): string => `part:${id}`; +const frameTargetKey = (boxId: string, slot: number): string => `frame:${frameKey(boxId, slot)}`; + +function parseKey(key: string): { kind: "part"; id: string } | { kind: "frame"; boxId: string; slot: number } | null { + if (key.startsWith("part:")) return { kind: "part", id: key.slice(5) }; + if (key.startsWith("frame:")) { + const rest = key.slice(6); + const split = rest.lastIndexOf("#"); + const slot = Number(rest.slice(split + 1)); + if (split < 0 || !Number.isInteger(slot)) return null; + return { kind: "frame", boxId: rest.slice(0, split), slot }; + } + return null; +} + +function labelsFor(labels: SceneLabel[], id: string): SceneLabel[] { + return labels.filter((label) => label.anchor.kind === "node" && label.anchor.id === id); +} + +function frameLabel(labels: SceneLabel[], boxId: string, slot: number): SceneLabel | undefined { + return labels.find( + (label) => label.anchor.kind === "frame" && label.anchor.boxId === boxId && label.anchor.slot === slot, + ); +} + +export function HiveSceneContent({ + layout, + state, + catalog, + partName, + onSelectPart, + onSelectFrame, + idle, +}: HiveSceneContentProps) { + const view = useHiveView(); + const focusedId = focusedIdOf(view); + const gl = useThree((root) => root.gl); + + // The frame whose sheet was opened stays lifted until the focus changes. + const [selectedFrame, setSelectedFrame] = useState(null); + const [seenFocus, setSeenFocus] = useState(focusedId); + if (seenFocus !== focusedId) { + setSeenFocus(focusedId); + setSelectedFrame(null); + } + + const model = useMemo(() => buildSceneModel(layout, state, catalog, partName), [layout, state, catalog, partName]); + const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]); + const targets: HiveMotionTargets = useMemo(() => hiveMotionTargets(layout, motionView), [layout, motionView]); + const labels = useMemo(() => sceneLabels(model, layout, motionView), [model, layout, motionView]); + + const setCursor = (pointer: boolean) => setCanvasCursor(gl, pointer); + + const selectPart = (id: string) => { + const part = model.get(id); + if (!part) return; + if (focusedId === id) { + // A click on the focused part is a click on its content: open its sheet. + if (part.kind !== "BOX") onSelectPart(id); + return; + } + view.setHovered(null); + if (part.kind === "BOX") view.openBox(id); + else view.focusPart(id); + }; + + const selectFrame = (boxId: string, slot: number) => { + setSelectedFrame({ boxId, slot }); + onSelectFrame(boxId, slot); + }; + + const hoverKey = (key: string | null) => { + const parsed = key === null ? null : parseKey(key); + if (!parsed) { + view.setHovered(null); + view.setHoveredFrame(null); + } else if (parsed.kind === "part") { + view.setHovered(parsed.id); + } else { + view.setHoveredFrame({ boxId: parsed.boxId, slot: parsed.slot }); + } + }; + + const touch = useTouchSelect({ + onHover: hoverKey, + onSelect: (key) => { + const parsed = parseKey(key); + if (parsed?.kind === "part") selectPart(parsed.id); + else if (parsed?.kind === "frame") selectFrame(parsed.boxId, parsed.slot); + }, + }); + + // A tap into empty space (hover cleared) or a new focus starts the two-tap cycle over. + const nothingHovered = view.hoveredId === null && view.hoveredFrame === null; + useEffect(() => { + if (nothingHovered) touch.reset(); + }, [nothingHovered, focusedId, touch]); + + const pointerType = useRef("mouse"); + const handlersFor = (key: string): NodeHandlers => ({ + onPointerOver(event) { + event.stopPropagation(); + if (event.pointerType === "touch") return; + hoverKey(key); + setCursor(true); + }, + onPointerOut(event) { + if (event.pointerType === "touch") return; + const parsed = parseKey(key); + if (parsed?.kind === "part" && view.hoveredId === parsed.id) view.setHovered(null); + if ( + parsed?.kind === "frame" && + view.hoveredFrame?.boxId === parsed.boxId && + view.hoveredFrame.slot === parsed.slot + ) { + view.setHoveredFrame(null); + } + setCursor(false); + }, + onPointerDown(event) { + pointerType.current = event.pointerType ?? "mouse"; + }, + onClick(event) { + event.stopPropagation(); + if ((event.delta ?? 0) > CLICK_SLOP_PX) return; + touch.tap(key, pointerType.current); + }, + }); + + const pivot = useRef(restBounds(layout).center); + const setPivot = useCallback((center: Vec3) => { + pivot.current = center; + }, []); + const getPivot = useCallback(() => pivot.current, []); + const insertsOf = (host: PartBox) => layout.inserts.filter((insert) => insert.stackIndex === host.stackIndex); + + return ( + <> + + + {layout.parts.map((placed) => { + const part = model.get(placed.id); + const target = targets.nodes.get(placed.id); + if (!part || !target) return null; + const frames = layout.frames.filter((frame) => frame.boxId === placed.id); + return ( + + {insertsOf(placed).map((insert) => { + const insertPart = model.get(insert.id); + const insertTarget = targets.nodes.get(insert.id); + if (!insertPart || !insertTarget) return null; + return ( + + ); + })} + {target.content + ? frames.map((frame) => { + const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot)); + if (!frameTarget || frameTarget.look === "hidden") return null; + return ( + + ); + }) + : null} + + ); + })} + + + ); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx new file mode 100644 index 0000000..c2d91d0 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import { MeshReflectorMaterial } from "@react-three/drei"; +import { Bloom, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing"; +import { ToneMappingMode } from "postprocessing"; +import { + AdditiveBlending, + BufferGeometry, + CanvasTexture, + Float32BufferAttribute, + NormalBlending, + SRGBColorSpace, + ShaderMaterial, +} from "three"; +import type { Points } from "three"; +import { useSceneKit } from "./scene-kit"; +import { setSceneBackground, setUniform } from "./scene-mutations"; +import type { HoloTheme } from "./theme"; + +// The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a +// cool light from behind, glossy dark floor with a faint hexagon grid, a few +// slow bokeh particles, bloom at half resolution, vignette. Cool light only: +// warm colours belong to the content (owner 2026-10-16). + +/** Objects on this layer are seen by the camera but not by the floor reflection. */ +const OVERLAY_LAYER = 1; +const FLOOR_SIZE = 240; +const PARTICLE_COUNT = 42; + +export type StageTier = "high" | "low"; + +/** High: reflective floor and MSAA (desktop-class). Low: fake floor, no MSAA (phones, ≤ 4 cores). */ +export function detectStageTier(): StageTier { + if (typeof window === "undefined") return "low"; + const cores = navigator.hardwareConcurrency ?? 4; + const coarse = window.matchMedia?.("(pointer: coarse)").matches ?? false; + return cores >= 6 && !coarse ? "high" : "low"; +} + +function backdropTexture(theme: HoloTheme): CanvasTexture | null { + const canvas = document.createElement("canvas"); + canvas.width = 512; + canvas.height = 512; + const context = canvas.getContext("2d"); + if (!context) return null; + const base = `#${theme.background.getHexString()}`; + const vertical = context.createLinearGradient(0, 0, 0, 512); + vertical.addColorStop(0, "#081633"); + vertical.addColorStop(0.55, base); + vertical.addColorStop(1, "#040814"); + context.fillStyle = vertical; + context.fillRect(0, 0, 512, 512); + // Cool rim light glowing behind the hive. + const glow = context.createRadialGradient(290, 170, 10, 290, 170, 300); + glow.addColorStop(0, "rgba(70, 150, 255, 0.55)"); + glow.addColorStop(0.45, "rgba(40, 100, 220, 0.22)"); + glow.addColorStop(1, "rgba(10, 30, 80, 0)"); + context.fillStyle = glow; + context.fillRect(0, 0, 512, 512); + // Soft light shafts from the upper left. + context.globalCompositeOperation = "lighter"; + for (const [x, width, alpha] of [ + [120, 60, 0.05], + [210, 34, 0.04], + [300, 80, 0.035], + ] as const) { + const shaft = context.createLinearGradient(x, 0, x + width, 0); + shaft.addColorStop(0, "rgba(120, 180, 255, 0)"); + shaft.addColorStop(0.5, `rgba(140, 200, 255, ${alpha})`); + shaft.addColorStop(1, "rgba(120, 180, 255, 0)"); + context.save(); + context.translate(x, 0); + context.rotate(-0.38); + context.translate(-x, 0); + context.fillStyle = shaft; + context.fillRect(x, -80, width, 560); + context.restore(); + } + const texture = new CanvasTexture(canvas); + texture.colorSpace = SRGBColorSpace; + return texture; +} + +function Backdrop() { + const { theme } = useSceneKit(); + const scene = useThree((state) => state.scene); + useEffect(() => { + const texture = backdropTexture(theme); + setSceneBackground(scene, texture ?? theme.background); + return () => { + setSceneBackground(scene, null); + texture?.dispose(); + }; + }, [scene, theme]); + return null; +} + +const FLOOR_VERTEX = /* glsl */ ` +varying vec2 vXZ; +void main() { + vec4 world = modelMatrix * vec4(position, 1.0); + vXZ = world.xz; + gl_Position = projectionMatrix * viewMatrix * world; +} +`; + +const HEX_FRAGMENT = /* glsl */ ` +uniform vec3 uGrid; +uniform vec3 uPool; +uniform vec3 uFade; +uniform float uHex; +uniform float uBase; +varying vec2 vXZ; +float hexDistance(vec2 p) { + p = abs(p); + return max(dot(p, normalize(vec2(1.0, 1.7320508))), p.x); +} +void main() { + vec2 uv = vXZ / uHex; + vec2 r = vec2(1.0, 1.7320508); + vec2 h = r * 0.5; + vec2 a = mod(uv, r) - h; + vec2 b = mod(uv - h, r) - h; + vec2 g = dot(a, a) < dot(b, b) ? a : b; + float edge = 0.5 - hexDistance(g); + float width = fwidth(edge) * 1.4; + float line = 1.0 - smoothstep(0.0, width + 0.006, edge); + float distance = length(vXZ); + float fade = 1.0 - smoothstep(3.0, 17.0, distance); + float pool = exp(-distance * distance / 14.0); + vec3 color = uGrid * line * fade * 0.32 + uPool * pool * 0.18; + // uBase > 0: opaque fake floor (low tier) that also fades into the backdrop. + vec3 floorColor = mix(uFade, uFade * 1.6 + uPool * 0.06, pool) + color; + float far = smoothstep(12.0, 45.0, distance); + vec3 outColor = mix(color, mix(floorColor, uFade, far), uBase); + gl_FragColor = vec4(outColor, 1.0); +} +`; + +function hexMaterial(theme: HoloTheme, opaque: boolean): ShaderMaterial { + return new ShaderMaterial({ + uniforms: { + uGrid: { value: theme.grid.clone() }, + uPool: { value: theme.rim.clone() }, + uFade: { value: theme.background.clone().multiplyScalar(0.75) }, + uHex: { value: 2.4 }, + uBase: { value: opaque ? 1 : 0 }, + }, + vertexShader: FLOOR_VERTEX, + fragmentShader: HEX_FRAGMENT, + transparent: !opaque, + depthWrite: opaque, + blending: opaque ? NormalBlending : AdditiveBlending, + }); +} + +const FADE_FRAGMENT = /* glsl */ ` +uniform vec3 uFade; +varying vec2 vXZ; +void main() { + float far = smoothstep(12.0, 45.0, length(vXZ)); + gl_FragColor = vec4(uFade, far); +} +`; + +function Floor({ tier }: { tier: StageTier }) { + const { theme } = useSceneKit(); + const camera = useThree((state) => state.camera); + const materials = useMemo( + () => ({ + grid: hexMaterial(theme, tier === "low"), + fade: new ShaderMaterial({ + uniforms: { uFade: { value: theme.background.clone().multiplyScalar(0.75) } }, + vertexShader: FLOOR_VERTEX, + fragmentShader: FADE_FRAGMENT, + transparent: true, + depthWrite: false, + }), + }), + [theme, tier], + ); + useEffect(() => { + camera.layers.enable(OVERLAY_LAYER); + return () => { + materials.grid.dispose(); + materials.fade.dispose(); + }; + }, [camera, materials]); + + if (tier === "low") { + return ( + + + + ); + } + return ( + <> + + + + + {/* The grid and the distance fade sit on a layer the reflection camera does not see. */} + + + + + + + + ); +} + +const PARTICLE_VERTEX = /* glsl */ ` +attribute float aSize; +attribute float aPhase; +uniform float uTime; +uniform float uPixelRatio; +varying float vSoft; +varying float vPhase; +void main() { + vec3 p = position; + p.y += sin(uTime * 0.11 + aPhase) * 0.35; + p.x += cos(uTime * 0.07 + aPhase * 1.7) * 0.25; + vec4 view = modelViewMatrix * vec4(p, 1.0); + gl_PointSize = aSize * uPixelRatio * (9.0 / max(-view.z, 0.5)); + vSoft = clamp(aSize / 18.0, 0.0, 1.0); + vPhase = aPhase; + gl_Position = projectionMatrix * view; +} +`; + +const PARTICLE_FRAGMENT = /* glsl */ ` +uniform vec3 uCool; +uniform vec3 uWhite; +uniform float uTime; +varying float vSoft; +varying float vPhase; +void main() { + float d = length(gl_PointCoord - 0.5) * 2.0; + // Small motes are crisp, large ones are soft bokeh discs with a brighter rim. + float disc = 1.0 - smoothstep(0.55 - vSoft * 0.35, 1.0, d); + float rim = smoothstep(0.6, 0.9, d) * (1.0 - smoothstep(0.9, 1.0, d)) * vSoft; + float twinkle = 0.75 + 0.25 * sin(uTime * 0.6 + vPhase * 3.0); + vec3 color = mix(uWhite, uCool, vSoft); + float alpha = (disc * (0.9 - vSoft * 0.6) + rim * 0.35) * twinkle; + gl_FragColor = vec4(color * alpha, 1.0); +} +`; + +/** Deterministic placement, so the stage looks the same on every visit (Park-Miller). */ +function seededRandom(start: number): () => number { + let seed = start; + return () => { + seed = (seed * 16807) % 2147483647; + return seed / 2147483647; + }; +} + +function Particles() { + const { theme } = useSceneKit(); + const dpr = useThree((state) => state.viewport.dpr); + const points = useRef(null); + const { geometry, material } = useMemo(() => { + const next = seededRandom(7); + const positions: number[] = []; + const sizes: number[] = []; + const phases: number[] = []; + for (let index = 0; index < PARTICLE_COUNT; index += 1) { + positions.push((next() - 0.5) * 22, 0.4 + next() * 9, (next() - 0.7) * 18); + sizes.push(next() < 0.7 ? 2 + next() * 5 : 10 + next() * 14); + phases.push(next() * Math.PI * 2); + } + const built = new BufferGeometry(); + built.setAttribute("position", new Float32BufferAttribute(positions, 3)); + built.setAttribute("aSize", new Float32BufferAttribute(sizes, 1)); + built.setAttribute("aPhase", new Float32BufferAttribute(phases, 1)); + const shader = new ShaderMaterial({ + uniforms: { + uTime: { value: 0 }, + uPixelRatio: { value: 1 }, + uCool: { value: theme.edge.clone() }, + uWhite: { value: theme.hot.clone() }, + }, + vertexShader: PARTICLE_VERTEX, + fragmentShader: PARTICLE_FRAGMENT, + transparent: true, + depthWrite: false, + blending: AdditiveBlending, + }); + return { geometry: built, material: shader }; + }, [theme]); + useEffect( + () => () => { + geometry.dispose(); + material.dispose(); + }, + [geometry, material], + ); + useFrame((state) => { + setUniform(material, "uTime", state.clock.elapsedTime); + setUniform(material, "uPixelRatio", dpr); + }); + return {}} />; +} + +function Lights() { + const { theme } = useSceneKit(); + return ( + <> + + + + + ); +} + +function Effects({ tier }: { tier: StageTier }) { + return ( + + + + + + ); +} + +export function HiveStage({ tier }: { tier: StageTier }) { + return ( + <> + + + + + + + ); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts b/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts new file mode 100644 index 0000000..bfdab3f --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts @@ -0,0 +1,250 @@ +import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three"; +import type { HoloTheme } from "./theme"; + +// Hand-written shaders instead of transmission: a fake studio environment +// (white soft box above, blue rim light behind, faint cool bounce below) with +// fresnel gives the glass look at a fraction of the cost (spec 9.9). + +const GLASS_VERTEX = /* glsl */ ` +varying vec3 vNormalW; +varying vec3 vViewDir; +void main() { + vec4 world = modelMatrix * vec4(position, 1.0); + vNormalW = normalize(mat3(modelMatrix) * normal); + vViewDir = cameraPosition - world.xyz; + gl_Position = projectionMatrix * viewMatrix * world; +} +`; + +const GLASS_FRAGMENT = /* glsl */ ` +uniform vec3 uTint; +uniform vec3 uRim; +uniform vec3 uBounce; +uniform float uOpacity; +uniform float uFrost; +uniform float uGlow; +varying vec3 vNormalW; +varying vec3 vViewDir; +void main() { + vec3 n = normalize(vNormalW); + vec3 v = normalize(vViewDir); + if (!gl_FrontFacing) n = -n; + float ndv = clamp(abs(dot(n, v)), 0.0, 1.0); + float fresnel = pow(1.0 - ndv, 3.0); + vec3 r = reflect(-v, n); + float sky = smoothstep(-0.1, 0.9, r.y); + float ground = smoothstep(0.05, -0.7, r.y); + float softbox = pow(max(dot(r, normalize(vec3(-0.35, 0.8, -0.5))), 0.0), 28.0); + float rimBack = pow(max(dot(r, normalize(vec3(0.25, 0.2, -1.0))), 0.0), 5.0); + vec3 env = uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3; + vec3 color = uTint * (0.16 + uFrost * 0.3) + env * (0.25 + 0.75 * fresnel); + color += uBounce * 0.08 * smoothstep(0.5, -0.9, n.y); + float alpha = 0.085 + uFrost * 0.16 + fresnel * 0.55 + softbox * 0.45; + if (!gl_FrontFacing) { + // Faintly frosted inner faces. + alpha *= 0.6; + color = mix(color, uTint * 0.45, 0.35); + } + color *= 0.78 + 0.22 * uGlow; + gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0) * uOpacity); +} +`; + +export interface GlassOptions { + /** 0 = clear, 1 = milky (insulation, divider board). */ + frost?: number; +} + +export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}): ShaderMaterial { + return new ShaderMaterial({ + uniforms: { + uTint: { value: theme.tint.clone() }, + uRim: { value: theme.rim.clone() }, + uBounce: { value: theme.bounce.clone() }, + uOpacity: { value: 1 }, + uFrost: { value: options.frost ?? 0 }, + uGlow: { value: 1 }, + }, + vertexShader: GLASS_VERTEX, + fragmentShader: GLASS_FRAGMENT, + transparent: true, + depthWrite: false, + side: DoubleSide, + blending: NormalBlending, + }); +} + +const EDGE_VERTEX = /* glsl */ ` +attribute float aHot; +attribute float aStrength; +varying float vHot; +varying float vStrength; +void main() { + vHot = aHot; + vStrength = aStrength; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +const EDGE_FRAGMENT = /* glsl */ ` +uniform vec3 uCyan; +uniform vec3 uHot; +uniform float uIntensity; +uniform float uOpacity; +varying float vHot; +varying float vStrength; +void main() { + float hot = vHot * vHot; + vec3 color = mix(uCyan, uHot, hot); + float energy = uIntensity * vStrength * (1.0 + hot * 1.6); + gl_FragColor = vec4(color * energy * uOpacity, 1.0); +} +`; + +/** Glowing edges: additive, white-hot at the corners, electric cyan along the edge. */ +export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial { + return new ShaderMaterial({ + uniforms: { + uCyan: { value: theme.edge.clone() }, + uHot: { value: theme.hot.clone() }, + uIntensity: { value: 1.6 }, + uOpacity: { value: 1 }, + }, + vertexShader: EDGE_VERTEX, + fragmentShader: EDGE_FRAGMENT, + transparent: true, + depthWrite: false, + blending: AdditiveBlending, + }); +} + +const GRID_VERTEX = /* glsl */ ` +varying vec3 vLocal; +void main() { + vLocal = position; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +const GRID_FRAGMENT = /* glsl */ ` +uniform vec3 uColor; +uniform float uCell; +uniform float uBars; +uniform float uOpacity; +varying vec3 vLocal; +float gridLine(float coord) { + float scaled = coord / uCell; + float distance = abs(fract(scaled - 0.5) - 0.5) / max(fwidth(scaled), 1e-4); + return 1.0 - min(distance / 1.2, 1.0); +} +void main() { + float along = gridLine(vLocal.z); + float across = uBars > 0.5 ? 0.0 : gridLine(vLocal.x); + float line = max(along, across); + gl_FragColor = vec4(uColor * (0.25 + line * 1.1) * uOpacity, 1.0); +} +`; + +export interface GridOptions { + /** Line spacing in layout units. */ + cell: number; + /** Only parallel bars (queen excluder) instead of a mesh. */ + bars?: boolean; +} + +/** Screens, Bausperre and excluder: a fine glowing mesh, additive. */ +export function createGridMaterial(theme: HoloTheme, options: GridOptions): ShaderMaterial { + return new ShaderMaterial({ + uniforms: { + uColor: { value: theme.edge.clone().multiplyScalar(0.4) }, + uCell: { value: options.cell }, + uBars: { value: options.bars ? 1 : 0 }, + uOpacity: { value: 1 }, + }, + vertexShader: GRID_VERTEX, + fragmentShader: GRID_FRAGMENT, + transparent: true, + depthWrite: false, + side: DoubleSide, + blending: AdditiveBlending, + }); +} + +const LIQUID_VERTEX = /* glsl */ ` +uniform float uTime; +uniform float uWobble; +varying vec3 vNormalW; +varying vec3 vViewDir; +varying float vTop; +varying float vDepth; +void main() { + vec3 p = position; + vTop = step(0.49, p.y); + vDepth = 0.5 - p.y; + if (p.y > 0.49) { + p.y += uWobble * (0.6 * sin(p.x * 9.0 + uTime * 1.7) + 0.4 * sin(p.z * 7.0 - uTime * 1.3)); + } + vec4 world = modelMatrix * vec4(p, 1.0); + vNormalW = normalize(mat3(modelMatrix) * normal); + vViewDir = cameraPosition - world.xyz; + gl_Position = projectionMatrix * viewMatrix * world; +} +`; + +const LIQUID_FRAGMENT = /* glsl */ ` +uniform vec3 uColor; +uniform vec3 uDeep; +uniform float uGlow; +uniform float uOpacity; +uniform float uAlpha; +varying vec3 vNormalW; +varying vec3 vViewDir; +varying float vTop; +varying float vDepth; +void main() { + vec3 n = normalize(vNormalW); + vec3 v = normalize(vViewDir); + float fresnel = pow(1.0 - clamp(abs(dot(n, v)), 0.0, 1.0), 2.0); + // Deep amber below, bright gold toward the surface and the rims. + float lightness = clamp(0.25 + 0.55 * (1.0 - vDepth) + 0.4 * fresnel, 0.0, 1.0); + vec3 color = mix(uDeep, uColor, lightness); + color += uColor * vTop * 0.45; + float sparkle = pow(max(dot(reflect(-v, n), normalize(vec3(-0.3, 0.9, 0.2))), 0.0), 40.0); + color += vec3(1.0, 0.95, 0.8) * sparkle * vTop * 1.4; + float alpha = uAlpha * (0.62 + 0.38 * max(fresnel, vTop)); + gl_FragColor = vec4(color * uGlow, alpha * uOpacity); +} +`; + +export interface LiquidOptions { + color: Color; + deep: Color; + /** Brightness; above 1 the liquid blooms softly. */ + glow: number; + alpha: number; +} + +/** Glowing liquid with a gently moving surface (wobble set per frame at motion `full`). */ +export function createLiquidMaterial(options: LiquidOptions): ShaderMaterial { + return new ShaderMaterial({ + uniforms: { + uTime: { value: 0 }, + uWobble: { value: 0 }, + uColor: { value: options.color.clone() }, + uDeep: { value: options.deep.clone() }, + uGlow: { value: options.glow }, + uOpacity: { value: 1 }, + uAlpha: { value: options.alpha }, + }, + vertexShader: LIQUID_VERTEX, + fragmentShader: LIQUID_FRAGMENT, + transparent: true, + depthWrite: false, + }); +} + +/** Sets the per-node uniforms shared by every material here. */ +export function setOpacity(material: ShaderMaterial, opacity: number): void { + const uniform = material.uniforms.uOpacity; + if (uniform) uniform.value = opacity; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/model-rig.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/model-rig.tsx new file mode 100644 index 0000000..b0d3f2e --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/model-rig.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { MutableRefObject, ReactNode } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import type { Group } from "three"; +import type { Vec3 } from "@/lib/hive/layout"; +import { useSceneKit } from "./scene-kit"; +import { MAX_FRAME_SECONDS } from "./use-animated-node"; +import { stepToward } from "./use-hive-motion"; + +// Turning and resting motion of the whole model (spec 9.4): +// - horizontal drag (mouse, pen, one finger) turns it ±60° around the +// vertical axis through the camera target; 4 s after the last drag it +// springs back. Vertical swipes scroll the page (touch-action: pan-y). +// - at `full` only: idle float ±4 mm over 6 s and a pointer tilt ≤ 6°. + +export const MAX_TURN = (60 * Math.PI) / 180; +export const MAX_TILT = (6 * Math.PI) / 180; +export const IDLE_FLOAT = 0.04; +export const IDLE_PERIOD_SECONDS = 6; +export const SPRING_BACK_MS = 4000; +/** Idle motion asks for frames at about 30 fps, which is plenty for ±4 mm. */ +export const IDLE_FRAME_MS = 33; +/** Pixels of horizontal travel before a press counts as a drag. */ +const DRAG_START_PX = 8; +/** A drag across the full card width turns the model by this much. */ +const TURN_PER_WIDTH = Math.PI; + +export function clampTurn(angle: number): number { + return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle)); +} + +interface RigInput { + turn: number; + tiltX: number; + tiltY: number; + dragging: boolean; +} + +function useRigInput(enabledTilt: boolean): MutableRefObject { + const gl = useThree((state) => state.gl); + const invalidate = useThree((state) => state.invalidate); + const input = useRef({ turn: 0, tiltX: 0, tiltY: 0, dragging: false }); + + useEffect(() => { + const element = gl.domElement as HTMLElement | undefined; + if (!element || typeof element.addEventListener !== "function") return; + let press: { x: number; y: number; turn: number; id: number } | null = null; + let springBack: ReturnType | null = null; + + const onDown = (event: PointerEvent) => { + press = { x: event.clientX, y: event.clientY, turn: input.current.turn, id: event.pointerId }; + if (springBack) clearTimeout(springBack); + }; + const onMove = (event: PointerEvent) => { + const rect = element.getBoundingClientRect(); + if (press && press.id === event.pointerId) { + const dx = event.clientX - press.x; + const dy = event.clientY - press.y; + if (!input.current.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) { + input.current.dragging = true; + } + if (input.current.dragging) { + input.current.turn = clampTurn(press.turn + (dx / Math.max(rect.width, 1)) * TURN_PER_WIDTH); + invalidate(); + } + return; + } + if (enabledTilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) { + const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1; + const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1; + input.current.tiltY = nx * MAX_TILT; + input.current.tiltX = ny * MAX_TILT * 0.5; + invalidate(); + } + }; + const onUp = () => { + if (input.current.dragging) { + springBack = setTimeout(() => { + input.current.turn = 0; + invalidate(); + }, SPRING_BACK_MS); + } + press = null; + // Let the click that follows a drag see `dragging`, then clear it. + setTimeout(() => { + input.current.dragging = false; + }, 0); + }; + const onLeave = () => { + input.current.tiltX = 0; + input.current.tiltY = 0; + invalidate(); + }; + element.addEventListener("pointerdown", onDown); + element.addEventListener("pointermove", onMove); + element.addEventListener("pointerup", onUp); + element.addEventListener("pointercancel", onUp); + element.addEventListener("pointerleave", onLeave); + return () => { + if (springBack) clearTimeout(springBack); + element.removeEventListener("pointerdown", onDown); + element.removeEventListener("pointermove", onMove); + element.removeEventListener("pointerup", onUp); + element.removeEventListener("pointercancel", onUp); + element.removeEventListener("pointerleave", onLeave); + }; + }, [gl, invalidate, enabledTilt]); + + return input; +} + +/** Asks for a frame about every 33 ms while the idle motion runs (demand frame loop). */ +function useIdleTicker(active: boolean): void { + const invalidate = useThree((state) => state.invalidate); + const timer = useRef | null>(null); + useEffect(() => { + if (active) invalidate(); + return () => { + if (timer.current) clearTimeout(timer.current); + timer.current = null; + }; + }, [active, invalidate]); + useFrame(() => { + if (!active || timer.current !== null) return; + timer.current = setTimeout(() => { + timer.current = null; + invalidate(); + }, IDLE_FRAME_MS); + }); +} + +export interface ModelRigProps { + /** Current camera target; the model turns around it. */ + getPivot: () => Vec3; + /** Idle motion may run (motion `full`, card visible, tab visible). */ + idle: boolean; + children: ReactNode; +} + +export function ModelRig({ getPivot, idle, children }: ModelRigProps) { + const { motion } = useSceneKit(); + const invalidate = useThree((state) => state.invalidate); + const full = motion === "full"; + const input = useRigInput(full); + const outer = useRef(null); + const inner = useRef(null); + const current = useRef({ turn: 0, tiltX: 0, tiltY: 0, float: 0, time: 0 }); + useIdleTicker(idle && full); + + useFrame((_, delta) => { + if (!outer.current || !inner.current) return; + const dt = Math.min(delta, MAX_FRAME_SECONDS); + const now = current.current; + // Idle time only advances while idle frames run, so the float resumes smoothly. + now.time += dt; + const goal = input.current; + // A drag follows the finger directly at every level; only the way back is animated. + now.turn = goal.dragging ? goal.turn : stepToward(now.turn, goal.turn, dt, full); + now.tiltX = stepToward(now.tiltX, full ? goal.tiltX : 0, dt, full); + now.tiltY = stepToward(now.tiltY, full ? goal.tiltY : 0, dt, full); + const floating = idle && full; + now.float = floating + ? IDLE_FLOAT * Math.sin((now.time / IDLE_PERIOD_SECONDS) * Math.PI * 2) + : stepToward(now.float, 0, dt, full); + const [px, py, pz] = getPivot(); + outer.current.position.set(px, py + now.float, pz); + outer.current.rotation.set(now.tiltX, now.turn + now.tiltY, 0); + inner.current.position.set(-px, -py, -pz); + const moving = + now.turn !== goal.turn || + now.tiltX !== (full ? goal.tiltX : 0) || + now.tiltY !== (full ? goal.tiltY : 0) || + (!floating && now.float !== 0); + if (moving) invalidate(); + }); + + return ( + + {children} + + ); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.spec.ts new file mode 100644 index 0000000..e390107 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { FLOOR_GRID_DEPTH } from "@/lib/hive/layout"; +import type { Vec3 } from "@/lib/hive/layout"; +import type { HivePartKind } from "@/lib/hive/types"; +import { + buildEdgeGeometry, + buildGlassGeometry, + feederChambers, + frameShapeSpec, + partShapeSpec, + shapeKey, +} from "./part-geometry"; +import type { PartShapeSpec } from "./part-geometry"; + +const BOX: Vec3 = [5.25, 2.3, 4.3]; +const FLOOR: Vec3 = [5.25, 1.2, 4.3]; + +/** Outer extent of every slab, to check nothing sticks out of the footprint. */ +function extent(spec: PartShapeSpec): { min: Vec3; max: Vec3 } { + const min: Vec3 = [Infinity, Infinity, Infinity]; + const max: Vec3 = [-Infinity, -Infinity, -Infinity]; + for (const slab of spec.slabs) { + for (let axis = 0; axis < 3; axis += 1) { + min[axis] = Math.min(min[axis], slab.at[axis] - slab.size[axis] / 2); + max[axis] = Math.max(max[axis], slab.at[axis] + slab.size[axis] / 2); + } + } + return { min, max }; +} + +describe("partShapeSpec", () => { + it("builds a box as four glass walls with hand grips on both short sides", () => { + const spec = partShapeSpec("BOX", BOX); + expect(spec.slabs).toHaveLength(4); + const gripEdges = spec.edges.filter((edge) => edge.tag === "grip"); + expect(gripEdges.length).toBeGreaterThan(8); + // Short sides are the ±x faces (430 mm wide). + expect(gripEdges.some((edge) => edge.a[0] > BOX[0] / 2 - 0.1)).toBe(true); + expect(gripEdges.some((edge) => edge.a[0] < -BOX[0] / 2 + 0.1)).toBe(true); + expect(gripEdges.every((edge) => Math.abs(edge.a[2]) < BOX[2] / 2)).toBe(true); + }); + + it("marks the inner rebate (Falz) where the frame lugs rest", () => { + const spec = partShapeSpec("BOX", BOX); + expect(spec.edges.filter((edge) => edge.tag === "rebate").length).toBeGreaterThanOrEqual(2); + }); + + it("keeps the Liebig floor inside its footprint: no landing board, no legs", () => { + const { min, max } = extent(partShapeSpec("FLOOR", FLOOR, { varroaTray: "IN" })); + expect(min[0]).toBeGreaterThanOrEqual(-FLOOR[0] / 2 - 1e-9); + expect(max[0]).toBeLessThanOrEqual(FLOOR[0] / 2 + 1e-9); + expect(min[1]).toBeGreaterThanOrEqual(-FLOOR[1] / 2 - 1e-9); + expect(max[2]).toBeLessThanOrEqual(FLOOR[2] / 2 + 0.05); + }); + + it("gives the floor a top grid, an entrance slider at the front and an inspection flap at the back", () => { + const spec = partShapeSpec("FLOOR", FLOOR, {}); + expect(spec.grids).toHaveLength(1); + expect(spec.grids[0].at[1]).toBeGreaterThan(0); + expect(spec.edges.some((edge) => edge.tag === "entrance" && edge.a[2] > FLOOR[2] / 2 - 0.1)).toBe(true); + expect(spec.edges.some((edge) => edge.tag === "flap" && edge.a[2] < -FLOOR[2] / 2 + 0.1)).toBe(true); + }); + + it("puts the grid 40 mm below the floor top, where the layout lays the Bausperre", () => { + expect(partShapeSpec("FLOOR", FLOOR, {}).grids[0].at[1]).toBeCloseTo(FLOOR[1] / 2 - FLOOR_GRID_DEPTH, 9); + }); + + it("slides the varroa board out to the back when it is out", () => { + const trayIn = partShapeSpec("FLOOR", FLOOR, { varroaTray: "IN" }).slabs.find((slab) => slab.tag === "tray")!; + const trayOut = partShapeSpec("FLOOR", FLOOR, { varroaTray: "OUT" }).slabs.find((slab) => slab.tag === "tray")!; + expect(trayOut.at[2]).toBeLessThan(trayIn.at[2] - 1); + expect(trayIn.at[1]).toBeLessThan(partShapeSpec("FLOOR", FLOOR, {}).grids[0].at[1]); + }); + + it("narrows the entrance slider opening with the entrance state", () => { + const opening = (entrance: string) => + partShapeSpec("FLOOR", FLOOR, { entrance }).edges.filter((edge) => edge.tag === "opening"); + const width = (edges: ReturnType) => + Math.max(...edges.map((edge) => edge.a[0])) - Math.min(...edges.map((edge) => edge.a[0])); + expect(width(opening("NARROW"))).toBeLessThan(width(opening("OPEN"))); + expect(opening("CLOSED")).toHaveLength(0); + }); + + it("gives the roof a slight overhang and the lid a feed hole", () => { + const roof = extent(partShapeSpec("ROOF", [5.25, 0.6, 4.3])); + expect(roof.max[0]).toBeGreaterThan(5.25 / 2); + expect(roof.max[0]).toBeLessThan(5.25 / 2 + 0.3); + expect(partShapeSpec("INNER_LID", [5.25, 0.35, 4.3]).edges.some((edge) => edge.tag === "hole")).toBe(true); + }); + + it("returns a shape for every kind", () => { + const kinds: HivePartKind[] = [ + "FLOOR", + "BOX", + "DIVIDER_BOARD", + "QUEEN_EXCLUDER", + "FEEDER", + "COVER_SHEET", + "PROPOLIS_SCREEN", + "INNER_LID", + "INSULATION", + "ROOF", + "FLOOR_INSERT", + "EVAPORATOR", + "FRAME_FEEDER", + ]; + for (const kind of kinds) { + const spec = partShapeSpec(kind, [2, 0.5, 2]); + expect(spec.slabs.length + spec.grids.length).toBeGreaterThan(0); + expect(spec.edges.length).toBeGreaterThan(0); + } + }); + + it("keys the shape cache by kind, size and the state that changes the shape", () => { + expect(shapeKey("FLOOR", FLOOR, { varroaTray: "OUT" })).not.toBe(shapeKey("FLOOR", FLOOR, { varroaTray: "IN" })); + expect(shapeKey("BOX", BOX, { anything: 1 })).toBe(shapeKey("BOX", BOX, {})); + }); +}); + +describe("feederChambers", () => { + it("splits the feeder into one chamber per level field", () => { + const chambers = feederChambers([5.2, 0.7, 4.2], 2); + expect(chambers).toHaveLength(2); + expect(chambers[0].center[0]).toBeLessThan(chambers[1].center[0]); + expect(chambers[0].size[1]).toBeGreaterThan(0.3); + }); +}); + +describe("frameShapeSpec", () => { + const size: Vec3 = [0.25, 2.2, 3.9]; + + it("has a long top bar with lugs that reach into the rebate", () => { + const spec = frameShapeSpec(size, "BROOD"); + const top = spec.bars.find((bar) => bar.tag === "topBar")!; + expect(top.size[2]).toBeGreaterThan(size[2]); + }); + + it("has a comb for built frames, a thin one for foundation and none for the open drone frame", () => { + expect(frameShapeSpec(size, "HONEY").comb!.size[0]).toBeGreaterThan(frameShapeSpec(size, "FOUNDATION").comb!.size[0]); + expect(frameShapeSpec(size, "DRONE_FRAME").comb).toBeNull(); + expect(frameShapeSpec(size, "DIVIDER").comb).not.toBeNull(); + }); +}); + +describe("geometry builders", () => { + it("merges the glass slabs into one geometry", () => { + const geometry = buildGlassGeometry(partShapeSpec("BOX", BOX).slabs); + expect(geometry.getAttribute("position").count).toBeGreaterThan(100); + geometry.dispose(); + }); + + it("returns an empty geometry for no slabs (a box with only empty slots)", () => { + const geometry = buildGlassGeometry([]); + expect(geometry.getAttribute("position")).toBeUndefined(); + geometry.dispose(); + }); + + it("builds edge tubes with a hot attribute that is 1 at the corners", () => { + const geometry = buildEdgeGeometry(partShapeSpec("BOX", BOX).edges); + const hot = geometry.getAttribute("aHot"); + expect(hot).toBeDefined(); + let max = 0; + let min = 1; + for (let index = 0; index < hot.count; index += 1) { + max = Math.max(max, hot.getX(index)); + min = Math.min(min, hot.getX(index)); + } + expect(max).toBe(1); + expect(min).toBe(0); + geometry.dispose(); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts b/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts new file mode 100644 index 0000000..3056042 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts @@ -0,0 +1,625 @@ +import { BufferGeometry, CylinderGeometry, Float32BufferAttribute, PlaneGeometry } from "three"; +import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js"; +import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js"; +import { FLOOR_GRID_DEPTH } from "@/lib/hive/layout"; +import type { Vec3 } from "@/lib/hive/layout"; +import type { FrameType, HivePart, HivePartKind } from "@/lib/hive/types"; + +// Shapes of the parts in local coordinates (centre at 0, layout units, +// x = length 525 mm, y = height, z = width 430 mm; front = +z, short sides ±x). +// The specs are pure data so the owner's details (Liebig Zander proportions, +// hand grips, rebate, Wanderboden without landing board) are testable. + +export interface Slab { + size: Vec3; + at: Vec3; + radius: number; + shape?: "box" | "cylinder"; + tag?: string; +} + +export interface EdgeSegment { + a: Vec3; + b: Vec3; + /** 1 = white-hot end (corner), 0 = plain cyan. */ + hotA: number; + hotB: number; + /** Brightness factor, 1 for outer edges, lower for inner light lines. */ + strength: number; + tag?: string; +} + +export interface GridPanel { + /** Horizontal panel: size along x and z. */ + size: [number, number]; + at: Vec3; + cell: number; + bars: boolean; +} + +export interface PartShapeSpec { + slabs: Slab[]; + edges: EdgeSegment[]; + grids: GridPanel[]; + frost: number; +} + +type PartState = HivePart["state"]; + +const WALL = 0.2; +const EDGE_RADIUS = 0.022; + +function segment(a: Vec3, b: Vec3, strength: number, hot = 0, tag?: string): EdgeSegment { + return { a, b, hotA: hot, hotB: hot, strength, ...(tag ? { tag } : {}) }; +} + +/** The 12 edges of a box, white-hot at its corners. */ +function boxEdges(size: Vec3, at: Vec3 = [0, 0, 0], strength = 1, hot = 1, tag?: string): EdgeSegment[] { + const [hx, hy, hz] = [size[0] / 2, size[1] / 2, size[2] / 2]; + const corner = (sx: number, sy: number, sz: number): Vec3 => [at[0] + sx * hx, at[1] + sy * hy, at[2] + sz * hz]; + const edges: EdgeSegment[] = []; + for (const sy of [-1, 1]) { + for (const sz of [-1, 1]) edges.push(segment(corner(-1, sy, sz), corner(1, sy, sz), strength, hot, tag)); + for (const sx of [-1, 1]) edges.push(segment(corner(sx, sy, -1), corner(sx, sy, 1), strength, hot, tag)); + } + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) edges.push(segment(corner(sx, -1, sz), corner(sx, 1, sz), strength, hot, tag)); + } + return edges; +} + +/** Horizontal rectangle at height y. */ +function rectXZ(halfX: number, halfZ: number, y: number, strength: number, hot = 0, tag?: string): EdgeSegment[] { + const p = (sx: number, sz: number): Vec3 => [sx * halfX, y, sz * halfZ]; + return [ + segment(p(-1, -1), p(1, -1), strength, hot, tag), + segment(p(1, -1), p(1, 1), strength, hot, tag), + segment(p(1, 1), p(-1, 1), strength, hot, tag), + segment(p(-1, 1), p(-1, -1), strength, hot, tag), + ]; +} + +type Axis = 0 | 1 | 2; + +/** Closed loop of points in the plane spanned by axes u and v around `center`. */ +function loop(points: [number, number][], center: Vec3, u: Axis, w: Axis, strength: number, tag?: string): EdgeSegment[] { + const toVec = ([pu, pw]: [number, number]): Vec3 => { + const out: Vec3 = [center[0], center[1], center[2]]; + out[u] += pu; + out[w] += pw; + return out; + }; + return points.map((point, index) => segment(toVec(point), toVec(points[(index + 1) % points.length]), strength, 0, tag)); +} + +function roundedRectPoints(width: number, height: number, radius: number, steps = 5): [number, number][] { + const r = Math.min(radius, width / 2, height / 2); + const hw = width / 2 - r; + const hh = height / 2 - r; + const corners: [number, number, number][] = [ + [hw, hh, 0], + [-hw, hh, Math.PI / 2], + [-hw, -hh, Math.PI], + [hw, -hh, (3 * Math.PI) / 2], + ]; + const points: [number, number][] = []; + for (const [cx, cy, start] of corners) { + for (let step = 0; step <= steps; step += 1) { + const angle = start + (step / steps) * (Math.PI / 2); + points.push([cx + r * Math.cos(angle), cy + r * Math.sin(angle)]); + } + } + return points; +} + +function circlePoints(radius: number, steps = 32): [number, number][] { + return Array.from({ length: steps }, (_, index) => { + const angle = (index / steps) * Math.PI * 2; + return [radius * Math.cos(angle), radius * Math.sin(angle)] as [number, number]; + }); +} + +/** Four glass walls of a body with an open top and bottom. */ +function walls(size: Vec3, wall = WALL, radius = EDGE_RADIUS): Slab[] { + const [sx, sy, sz] = size; + return [ + { size: [sx, sy, wall], at: [0, 0, sz / 2 - wall / 2], radius }, + { size: [sx, sy, wall], at: [0, 0, -sz / 2 + wall / 2], radius }, + { size: [wall, sy, sz - 2 * wall], at: [sx / 2 - wall / 2, 0, 0], radius }, + { size: [wall, sy, sz - 2 * wall], at: [-sx / 2 + wall / 2, 0, 0], radius }, + ]; +} + +/** Thin inner light lines of a hollow body: top and bottom inner rims and inner corners. */ +function innerLines(size: Vec3, wall = WALL): EdgeSegment[] { + const [sx, sy, sz] = size; + const ix = sx / 2 - wall; + const iz = sz / 2 - wall; + const lines = [...rectXZ(ix, iz, sy / 2, 0.4, 0.4), ...rectXZ(ix, iz, -sy / 2, 0.18)]; + for (const cx of [-1, 1]) { + for (const cz of [-1, 1]) lines.push(segment([cx * ix, -sy / 2, cz * iz], [cx * ix, sy / 2, cz * iz], 0.12)); + } + return lines; +} + +function boxShape(size: Vec3): PartShapeSpec { + const [sx, sy, sz] = size; + const edges = [...boxEdges(size), ...innerLines(size)]; + // Rebate (Falz) on the inner faces of the long walls, where the frame lugs rest. + const ix = sx / 2 - WALL; + for (const side of [-1, 1]) { + edges.push(segment([-ix, sy / 2 - 0.12, side * (sz / 2 - WALL)], [ix, sy / 2 - 0.12, side * (sz / 2 - WALL)], 0.35, 0, "rebate")); + edges.push(segment([-ix, sy / 2, side * (sz / 2 - WALL * 0.45)], [ix, sy / 2, side * (sz / 2 - WALL * 0.45)], 0.25, 0, "rebate")); + } + // Recessed hand grips (Griffmulden) on both short sides (±x faces). + const gripY = sy * 0.14; + for (const side of [-1, 1]) { + edges.push(...loop(roundedRectPoints(1.4, 0.34, 0.16), [side * (sx / 2 + 0.004), gripY, 0], 2, 1, 0.95, "grip")); + edges.push(...loop(roundedRectPoints(1.24, 0.2, 0.09), [side * (sx / 2 - 0.07), gripY, 0], 2, 1, 0.3, "grip")); + } + return { slabs: walls(size), edges, grids: [], frost: 0 }; +} + +const ENTRANCE_OPENING: Record = { OPEN: 1.6, NARROW: 0.55, CLOSED: 0 }; + +/** Liebig Wanderboden: flat box, stainless grid on top, varroa board from the back, flap, entrance slider. */ +function floorShape(size: Vec3, state: PartState): PartShapeSpec { + const [sx, sy, sz] = size; + const slotHeight = Math.min(0.2, sy * 0.25); + const innerX = sx - 2 * WALL; + const innerZ = sz - 2 * WALL; + const bottom = -sy / 2; + const slabs: Slab[] = [ + { size: [WALL, sy, sz], at: [sx / 2 - WALL / 2, 0, 0], radius: EDGE_RADIUS }, + { size: [WALL, sy, sz], at: [-sx / 2 + WALL / 2, 0, 0], radius: EDGE_RADIUS }, + { size: [innerX, sy, WALL], at: [0, 0, -sz / 2 + WALL / 2], radius: EDGE_RADIUS }, + // Front wall above the entrance slot. + { size: [innerX, sy - slotHeight, WALL], at: [0, bottom + slotHeight + (sy - slotHeight) / 2, sz / 2 - WALL / 2], radius: EDGE_RADIUS }, + { size: [innerX, 0.05, innerZ], at: [0, bottom + 0.025, 0], radius: 0.01 }, + // Plastic entrance slider in front of the slot (flush with the front face). + { size: [innerX * 0.86, slotHeight * 0.9, 0.03], at: [0, bottom + slotHeight * 0.5, sz / 2 - 0.015], radius: 0.01, tag: "slider" }, + ]; + // Sticky / varroa board beneath the grid, slid in from the back. + const trayOut = state.varroaTray === "OUT"; + const trayDepth = innerZ - 0.06; + const trayZ = trayOut ? -sz * 0.42 : 0; + slabs.push({ size: [innerX - 0.08, 0.035, trayDepth], at: [0, bottom + 0.16, trayZ], radius: 0.008, tag: "tray" }); + slabs.push({ size: [innerX - 0.08, 0.1, 0.035], at: [0, bottom + 0.2, trayZ - trayDepth / 2], radius: 0.01, tag: "tray" }); + + const gridY = sy / 2 - FLOOR_GRID_DEPTH; + const edges = [ + ...boxEdges(size), + ...rectXZ(innerX / 2, innerZ / 2, gridY, 0.6, 0.4), + ...rectXZ(innerX / 2, innerZ / 2, sy / 2, 0.4), + ]; + // Entrance slot across the front, the slider and its opening. + edges.push(...loop(roundedRectPoints(innerX, slotHeight, 0.02, 1), [0, bottom + slotHeight / 2, sz / 2 + 0.004], 0, 1, 0.75, "entrance")); + const opening = ENTRANCE_OPENING[String(state.entrance ?? "OPEN")] ?? ENTRANCE_OPENING.OPEN; + if (opening > 0) { + edges.push(...loop(roundedRectPoints(opening, slotHeight * 0.45, 0.04), [0, bottom + slotHeight * 0.45, sz / 2 + 0.006], 0, 1, 1, "opening")); + } + // Inspection flap on the back wall, between the board slot and the grid, hinged at its top. + const flapHeight = Math.max(0.1, gridY - (bottom + 0.3)); + const flapCenter = bottom + 0.27 + flapHeight / 2; + edges.push(...loop(roundedRectPoints(innerX * 0.7, flapHeight, 0.04, 2), [0, flapCenter, -sz / 2 - 0.004], 0, 1, 0.55, "flap")); + edges.push(segment([-innerX * 0.33, flapCenter + flapHeight / 2, -sz / 2 - 0.006], [innerX * 0.33, flapCenter + flapHeight / 2, -sz / 2 - 0.006], 0.35, 0, "flap")); + // Board edges. + edges.push(...boxEdges([innerX - 0.08, 0.035, trayDepth], [0, bottom + 0.16, trayZ], 0.35, 0)); + return { + slabs, + edges, + grids: [{ size: [innerX, innerZ], at: [0, gridY, 0], cell: 0.07, bars: false }], + frost: 0, + }; +} + +/** Chambers of a feeder, one per level field, left to right (local coordinates). */ +export function feederChambers(size: Vec3, count: number): { center: Vec3; size: Vec3 }[] { + const wall = 0.15; + const chambers = Math.max(1, count); + const innerX = size[0] - 2 * wall; + const innerZ = size[2] - 2 * wall; + const divider = 0.06; + const width = (innerX - (chambers - 1) * divider) / chambers; + const height = size[1] - 0.06; + return Array.from({ length: chambers }, (_, index) => ({ + center: [-innerX / 2 + width / 2 + index * (width + divider), -size[1] / 2 + 0.04 + height / 2, 0] as Vec3, + size: [width, height, innerZ] as Vec3, + })); +} + +function feederShape(size: Vec3, state: PartState, levelCount: number): PartShapeSpec { + void state; + const [sx, sy, sz] = size; + const wall = 0.15; + const slabs = walls(size, wall); + slabs.push({ size: [sx - 2 * wall, 0.04, sz - 2 * wall], at: [0, -sy / 2 + 0.02, 0], radius: 0.01 }); + const chambers = feederChambers(size, levelCount); + const edges = [...boxEdges(size), ...innerLines(size, wall)]; + for (let index = 1; index < chambers.length; index += 1) { + const x = (chambers[index - 1].center[0] + chambers[index].center[0]) / 2; + slabs.push({ size: [0.06, sy - 0.05, sz - 2 * wall], at: [x, 0.0, 0], radius: 0.01 }); + edges.push(segment([x, sy / 2 - 0.03, -sz / 2 + wall], [x, sy / 2 - 0.03, sz / 2 - wall], 0.6, 0.6)); + } + return { slabs, edges, grids: [], frost: 0 }; +} + +function lidShape(size: Vec3, state: PartState): PartShapeSpec { + const [, sy] = size; + const slabs: Slab[] = [{ size, at: [0, 0, 0], radius: 0.03 }]; + const edges = [...boxEdges(size)]; + edges.push(...loop(circlePoints(0.34), [0, sy / 2 + 0.004, 0], 0, 2, 0.95, "hole")); + edges.push(...loop(circlePoints(0.27), [0, sy / 2 - 0.08, 0], 0, 2, 0.4, "hole")); + if (state.feedHole === "CLOSED") { + slabs.push({ size: [0.84, 0.03, 0.84], at: [0, sy / 2 + 0.015, 0], radius: 0.42, shape: "cylinder", tag: "cap" }); + } + return { slabs, edges, grids: [], frost: 0.15 }; +} + +/** Flat stainless roof with a slight overhang over the part below. */ +function roofShape(size: Vec3): PartShapeSpec { + const [sx, sy, sz] = size; + const overhang = 0.13; + const outer: Vec3 = [sx + 2 * overhang, 0.08, sz + 2 * overhang]; + const skirt = 0.03; + const skirtHeight = sy - outer[1]; + const skirtY = -sy / 2 + skirtHeight / 2; + const slabs: Slab[] = [ + { size: outer, at: [0, sy / 2 - outer[1] / 2, 0], radius: 0.025 }, + { size: [outer[0], skirtHeight, skirt], at: [0, skirtY, outer[2] / 2 - skirt / 2], radius: 0.01 }, + { size: [outer[0], skirtHeight, skirt], at: [0, skirtY, -outer[2] / 2 + skirt / 2], radius: 0.01 }, + { size: [skirt, skirtHeight, outer[2] - 2 * skirt], at: [outer[0] / 2 - skirt / 2, skirtY, 0], radius: 0.01 }, + { size: [skirt, skirtHeight, outer[2] - 2 * skirt], at: [-outer[0] / 2 + skirt / 2, skirtY, 0], radius: 0.01 }, + ]; + const edges = [ + ...boxEdges(outer, [0, sy / 2 - outer[1] / 2, 0], 1.1, 1), + ...rectXZ(outer[0] / 2, outer[2] / 2, -sy / 2, 0.7, 0.6), + ]; + return { slabs, edges, grids: [], frost: 0.05 }; +} + +function sheetShape(size: Vec3): PartShapeSpec { + const thin: Vec3 = [size[0], Math.max(size[1], 0.025), size[2]]; + return { slabs: [{ size: thin, at: [0, 0, 0], radius: 0.006 }], edges: boxEdges(thin, [0, 0, 0], 0.6, 0.5), grids: [], frost: 0.1 }; +} + +/** A flat screen: thin rim bars around a glowing mesh. */ +function screenShape(size: Vec3, cell: number, bars: boolean): PartShapeSpec { + const thick: Vec3 = [size[0], Math.max(size[1], 0.04), size[2]]; + const rim = 0.12; + const [sx, sy, sz] = thick; + const slabs: Slab[] = [ + { size: [sx, sy, rim], at: [0, 0, sz / 2 - rim / 2], radius: 0.01 }, + { size: [sx, sy, rim], at: [0, 0, -sz / 2 + rim / 2], radius: 0.01 }, + { size: [rim, sy, sz - 2 * rim], at: [sx / 2 - rim / 2, 0, 0], radius: 0.01 }, + { size: [rim, sy, sz - 2 * rim], at: [-sx / 2 + rim / 2, 0, 0], radius: 0.01 }, + ]; + return { + slabs, + edges: [...boxEdges(thick, [0, 0, 0], 0.85, 0.8), ...rectXZ(sx / 2 - rim, sz / 2 - rim, 0, 0.4)], + grids: [{ size: [sx - 2 * rim, sz - 2 * rim], at: [0, 0, 0], cell, bars }], + frost: 0, + }; +} + +function dividerShape(size: Vec3, state: PartState): PartShapeSpec { + const [sx, , sz] = size; + const slabs = walls(size); + slabs.push({ size: [sx - 2 * WALL, 0.06, sz - 2 * WALL], at: [0, 0, 0], radius: 0.01 }); + const edges = [...boxEdges(size), ...innerLines(size)]; + for (const x of [-1.1, 1.1]) { + edges.push(...loop(circlePoints(0.22), [x, 0.035, 0], 0, 2, 0.85, "hole")); + if (state.bungHoles === "CLOSED") { + slabs.push({ size: [0.5, 0.04, 0.5], at: [x, 0.05, 0], radius: 0.25, shape: "cylinder", tag: "cap" }); + } + } + return { slabs, edges, grids: [], frost: 0.1 }; +} + +/** Nassenheider evaporator: tray with wick plate and an upright bottle. */ +export function evaporatorBottle(size: Vec3): { center: Vec3; radius: number; height: number } { + const radius = Math.min(size[0], size[2]) * 0.24; + const height = size[1] - 0.32; + return { center: [size[0] * 0.18, -size[1] / 2 + 0.2 + height / 2, 0], radius, height }; +} + +function evaporatorShape(size: Vec3): PartShapeSpec { + const [sx, sy, sz] = size; + const bottle = evaporatorBottle(size); + const trayHeight = 0.16; + const slabs: Slab[] = [ + { size: [sx, trayHeight, sz], at: [0, -sy / 2 + trayHeight / 2, 0], radius: 0.03 }, + { size: [sx - 0.14, 0.03, sz - 0.14], at: [0, -sy / 2 + trayHeight + 0.015, 0], radius: 0.01 }, + { + size: [bottle.radius * 2, bottle.height, bottle.radius * 2], + at: bottle.center, + radius: bottle.radius, + shape: "cylinder", + }, + { + size: [bottle.radius * 0.9, 0.1, bottle.radius * 0.9], + at: [bottle.center[0], bottle.center[1] + bottle.height / 2 + 0.05, 0], + radius: bottle.radius * 0.45, + shape: "cylinder", + }, + ]; + const top = bottle.center[1] + bottle.height / 2; + const base = bottle.center[1] - bottle.height / 2; + const edges = [ + ...boxEdges([sx, trayHeight, sz], [0, -sy / 2 + trayHeight / 2, 0], 0.9, 1), + ...loop(circlePoints(bottle.radius), [bottle.center[0], top, 0], 0, 2, 0.9), + ...loop(circlePoints(bottle.radius), [bottle.center[0], base, 0], 0, 2, 0.6), + ]; + return { slabs, edges, grids: [], frost: 0 }; +} + +/** Futtertasche: a narrow glass pocket hanging in the box with a top bar and lugs. */ +function pocketShape(size: Vec3): PartShapeSpec { + const [sx, sy, sz] = size; + const wall = 0.045; + const slabs = walls(size, wall, 0.012); + slabs.push({ size: [sx - 2 * wall, 0.04, sz - 2 * wall], at: [0, -sy / 2 + 0.02, 0], radius: 0.01 }); + const topBar: Vec3 = [sx + 0.02, 0.08, sz + 0.24]; + slabs.push({ size: topBar, at: [0, sy / 2 - 0.04, 0], radius: 0.015 }); + return { + slabs, + edges: [...boxEdges(size, [0, 0, 0], 0.9, 1), ...boxEdges(topBar, [0, sy / 2 - 0.04, 0], 0.7, 0.6)], + grids: [], + frost: 0, + }; +} + +/** Inner liquid space of a frame feeder (local coordinates). */ +export function pocketInner(size: Vec3): { center: Vec3; size: Vec3 } { + const wall = 0.045; + const height = size[1] - 0.2; + return { center: [0, -size[1] / 2 + 0.04 + height / 2, 0], size: [size[0] - 2 * wall, height, size[2] - 2 * wall] }; +} + +export function partShapeSpec(kind: HivePartKind, size: Vec3, state: PartState = {}, levelCount = 0): PartShapeSpec { + switch (kind) { + case "BOX": + return boxShape(size); + case "FLOOR": + return floorShape(size, state); + case "FEEDER": + return feederShape(size, state, levelCount); + case "INNER_LID": + return lidShape(size, state); + case "ROOF": + return roofShape(size); + case "COVER_SHEET": + return sheetShape(size); + case "PROPOLIS_SCREEN": + return screenShape(size, 0.06, false); + case "QUEEN_EXCLUDER": + return screenShape(size, 0.045, true); + case "FLOOR_INSERT": + return screenShape(size, 0.25, false); + case "INSULATION": + return { slabs: [{ size, at: [0, 0, 0], radius: 0.04 }], edges: boxEdges(size, [0, 0, 0], 0.6, 0.5), grids: [], frost: 0.9 }; + case "DIVIDER_BOARD": + return dividerShape(size, state); + case "EVAPORATOR": + return evaporatorShape(size); + case "FRAME_FEEDER": + return pocketShape(size); + default: + return { slabs: [{ size, at: [0, 0, 0], radius: EDGE_RADIUS }], edges: boxEdges(size), grids: [], frost: 0 }; + } +} + +/** State keys that change a part's shape (everything else is content). */ +const SHAPE_STATE_KEYS: Partial> = { + FLOOR: ["varroaTray", "entrance"], + INNER_LID: ["feedHole"], + DIVIDER_BOARD: ["bungHoles"], +}; + +export function shapeKey(kind: HivePartKind, size: Vec3, state: PartState = {}, levelCount = 0): string { + const keys = SHAPE_STATE_KEYS[kind] ?? []; + const stateKey = keys.map((key) => `${key}=${String(state[key] ?? "")}`).join(","); + return `${kind}|${size.map((value) => value.toFixed(4)).join("x")}|${stateKey}|${kind === "FEEDER" ? levelCount : 0}`; +} + +// ---- Frames ---- + +export interface FrameShapeSpec { + bars: Slab[]; + comb: Slab | null; + edges: EdgeSegment[]; +} + +const COMB_THICKNESS: Partial> = { FOUNDATION: 0.03, DIVIDER: 0.12 }; + +/** Frame in local coordinates: long top bar with lugs, side bars, bottom bar and the comb. */ +export function frameShapeSpec(size: Vec3, type: FrameType): FrameShapeSpec { + const [fx, fy, fz] = size; + const topBar: Slab = { size: [fx, 0.1, fz + 0.26], at: [0, fy / 2 - 0.05, 0], radius: 0.015, tag: "topBar" }; + const sideHeight = fy - 0.1; + const bars: Slab[] = [ + topBar, + { size: [fx * 0.85, sideHeight, 0.09], at: [0, -0.05, fz / 2 - 0.045], radius: 0.012 }, + { size: [fx * 0.85, sideHeight, 0.09], at: [0, -0.05, -fz / 2 + 0.045], radius: 0.012 }, + { size: [fx * 0.6, 0.06, fz - 0.18], at: [0, -fy / 2 + 0.03, 0], radius: 0.01 }, + ]; + const comb: Slab | null = + type === "DRONE_FRAME" || type === "NONE" + ? null + : { + size: [COMB_THICKNESS[type] ?? 0.17, fy - 0.2, fz - 0.18], + at: [0, -0.04, 0], + radius: 0.01, + tag: "comb", + }; + const edges = [ + ...boxEdges(topBar.size, topBar.at, 0.75, 0.7), + ...loop( + [ + [fz / 2, fy / 2 - 0.1], + [-fz / 2, fy / 2 - 0.1], + [-fz / 2, -fy / 2], + [fz / 2, -fy / 2], + ], + [0, 0, 0], + 2, + 1, + 0.4, + ), + ]; + return { bars, comb, edges }; +} + +/** Rest-view hint of a frame: only its top bar with lugs, as a faint glass outline. */ +export function frameHintSpec(size: Vec3, at: Vec3): { slab: Slab; edges: EdgeSegment[] } { + const slab: Slab = { size: [size[0] * 0.8, 0.08, size[2] + 0.26], at: [at[0], at[1] + size[1] / 2 - 0.04, at[2]], radius: 0.012 }; + return { slab, edges: boxEdges(slab.size, slab.at, 0.32, 0.2) }; +} + +// ---- three.js builders ---- + +function slabGeometry(slab: Slab): BufferGeometry { + const [w, h, d] = slab.size; + const geometry = + slab.shape === "cylinder" + ? new CylinderGeometry(w / 2, w / 2, h, 40, 1) + : new RoundedBoxGeometry(w, h, d, 2, Math.max(0, Math.min(slab.radius, Math.min(w, h, d) / 2 - 1e-4))); + geometry.translate(slab.at[0], slab.at[1], slab.at[2]); + return geometry; +} + +/** All slabs of a shape as one geometry (one draw call per part). */ +export function buildGlassGeometry(slabs: Slab[]): BufferGeometry { + if (slabs.length === 0) return new BufferGeometry(); + const parts = slabs.map((slab) => { + const geometry = slabGeometry(slab); + // mergeGeometries needs the same attributes and index state everywhere. + return geometry.index ? geometry.toNonIndexed() : geometry; + }); + const merged = mergeGeometries(parts, false) ?? new BufferGeometry(); + parts.forEach((part) => part.dispose()); + return merged; +} + +/** About 1.5 px at rest-view distance, thicker when a part is in focus. */ +const EDGE_WIDTH = 0.024; +/** Length of the white-hot zone at each corner. */ +const HOT_ZONE = 0.22; + +function perpendicular(direction: Vec3): [Vec3, Vec3] { + const up: Vec3 = Math.abs(direction[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]; + const cross = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; + const normalize = (a: Vec3): Vec3 => { + const length = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / length, a[1] / length, a[2] / length]; + }; + const u = normalize(cross(direction, up)); + return [u, normalize(cross(u, direction))]; +} + +/** Thin square tubes along every edge, with `aHot` (corner glow) and `aStrength` attributes. */ +export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_WIDTH): BufferGeometry { + const positions: number[] = []; + const hot: number[] = []; + const strength: number[] = []; + const indices: number[] = []; + const half = width / 2; + for (const edge of segments) { + const delta: Vec3 = [edge.b[0] - edge.a[0], edge.b[1] - edge.a[1], edge.b[2] - edge.a[2]]; + const length = Math.hypot(delta[0], delta[1], delta[2]); + if (length < 1e-6) continue; + const direction: Vec3 = [delta[0] / length, delta[1] / length, delta[2] / length]; + const [u, w] = perpendicular(direction); + const zone = Math.min(0.35, HOT_ZONE / length); + const rings: [number, number][] = [ + [0, edge.hotA], + [zone, 0], + [1 - zone, 0], + [1, edge.hotB], + ]; + const base = positions.length / 3; + for (const [t, ringHot] of rings) { + for (const [su, sw] of [ + [1, 1], + [-1, 1], + [-1, -1], + [1, -1], + ]) { + for (let axis = 0; axis < 3; axis += 1) { + positions.push(edge.a[axis] + delta[axis] * t + (u[axis] * su + w[axis] * sw) * half); + } + hot.push(ringHot); + strength.push(edge.strength); + } + } + for (let band = 0; band < 3; band += 1) { + for (let side = 0; side < 4; side += 1) { + const a = base + band * 4 + side; + const b = base + band * 4 + ((side + 1) % 4); + const c = base + (band + 1) * 4 + ((side + 1) % 4); + const d = base + (band + 1) * 4 + side; + indices.push(a, b, c, a, c, d); + } + } + } + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); + geometry.setAttribute("aHot", new Float32BufferAttribute(hot, 1)); + geometry.setAttribute("aStrength", new Float32BufferAttribute(strength, 1)); + geometry.setIndex(indices); + geometry.computeBoundingSphere(); + return geometry; +} + +/** One horizontal grid panel as a plane in the xz plane. */ +export function buildGridGeometry(panel: GridPanel): BufferGeometry { + const geometry = new PlaneGeometry(panel.size[0], panel.size[1]); + geometry.rotateX(-Math.PI / 2); + geometry.translate(panel.at[0], panel.at[1], panel.at[2]); + return geometry; +} + +export interface BuiltShape { + glass: BufferGeometry; + edges: BufferGeometry; + grids: { geometry: BufferGeometry; panel: GridPanel }[]; + frost: number; +} + +export function buildShape(spec: PartShapeSpec): BuiltShape { + return { + glass: buildGlassGeometry(spec.slabs), + edges: buildEdgeGeometry(spec.edges), + grids: spec.grids.map((panel) => ({ geometry: buildGridGeometry(panel), panel })), + frost: spec.frost, + }; +} + +export function disposeShape(shape: BuiltShape): void { + shape.glass.dispose(); + shape.edges.dispose(); + shape.grids.forEach((grid) => grid.geometry.dispose()); +} + +/** Geometries shared by every node of the same kind and size; disposed with the scene. */ +export interface GeometryCache { + shape(key: string, build: () => PartShapeSpec): BuiltShape; + dispose(): void; +} + +export function createGeometryCache(): GeometryCache { + const shapes = new Map(); + return { + shape(key, build) { + const known = shapes.get(key); + if (known) return known; + const built = buildShape(build()); + shapes.set(key, built); + return built; + }, + dispose() { + shapes.forEach(disposeShape); + shapes.clear(); + }, + }; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx new file mode 100644 index 0000000..c1691fe --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import { Mesh } from "three"; +import type { MotionLevel } from "@/lib/motion/motion-preference"; +import { createFrameTextureCache } from "./frame-textures"; +import type { FrameTextureCache } from "./frame-textures"; +import { createGeometryCache } from "./part-geometry"; +import type { GeometryCache } from "./part-geometry"; +import { readHoloTheme } from "./theme"; +import type { HoloTheme } from "./theme"; + +// Shared per canvas: theme (tokens read once), geometries per part kind and +// size, comb textures per frame type, the motion level. Everything is +// disposed when the canvas unmounts (spec 9.9: no leak between colonies). + +export interface SceneKit { + theme: HoloTheme; + geometries: GeometryCache; + textures: FrameTextureCache; + motion: MotionLevel; +} + +const SceneKitContext = createContext(null); + +export function SceneKitProvider({ motion, children }: { motion: MotionLevel; children: ReactNode }) { + const [theme] = useState(readHoloTheme); + const [geometries] = useState(createGeometryCache); + const [textures] = useState(() => createFrameTextureCache()); + useEffect( + () => () => { + geometries.dispose(); + textures.dispose(); + }, + [geometries, textures], + ); + return ( + {children} + ); +} + +export function useSceneKit(): SceneKit { + const kit = useContext(SceneKitContext); + if (!kit) throw new Error("useSceneKit must be used inside a SceneKitProvider"); + return kit; +} + +/** Raycast that never hits: for visible meshes, so only the hit boxes take pointer events. */ +export const noRaycast = (): void => {}; +/** The default mesh raycast, for hit boxes that take pointer events. */ +export const meshRaycast = Mesh.prototype.raycast; diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/scene-model.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/scene-model.spec.ts new file mode 100644 index 0000000..f80bfdd --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/scene-model.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { hiveLayout } from "@/lib/hive/layout"; +import { partDisplayName } from "@/lib/hive/names"; +import { frames, inlineCatalog, part } from "@/lib/hive/test-utils"; +import type { HiveState } from "@/lib/hive/types"; +import type { MotionView } from "./use-hive-motion"; +import { MAX_LABELS, buildSceneModel, formatAmount, levelText, sceneLabels } from "./scene-model"; + +const catalog = inlineCatalog(); + +function state(): HiveState { + const boxFrames = frames(10).map((frame) => + frame.slot <= 2 + ? { ...frame, type: "NONE" as const } + : frame.slot === 4 + ? { ...frame, type: "BROOD" as const, name: "Königin" } + : frame, + ); + return { + schemaVersion: 1, + stack: [ + part("f", "floor", { inserts: [part("fi", "floor-insert")] }), + part("b1", "box", { + frames: boxFrames, + inserts: [ + part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } }), + part("ev", "evaporator", { state: { acidMl: 250 } }), + ], + }), + part("b2", "box", { frames: frames(10) }), + part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }), + part("lid", "inner-lid", { state: { feedHole: "CLOSED" } }), + part("r", "roof"), + ], + }; +} + +const hiveState = state(); +const layout = hiveLayout(hiveState, catalog); +const name = (id: string) => partDisplayName(hiveState, catalog, id); +const model = buildSceneModel(layout, hiveState, catalog, name); +const rest: MotionView = { hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null }; + +describe("formatAmount", () => { + it("uses the German decimal comma and drops trailing zeros", () => { + expect(formatAmount(2.5, "l")).toBe("2,5 l"); + expect(formatAmount(3, "l")).toBe("3 l"); + expect(formatAmount(0.25, "l")).toBe("0,25 l"); + }); +}); + +describe("buildSceneModel", () => { + it("knows every part and insert with its kind, name and host", () => { + expect(model.get("b2")).toMatchObject({ kind: "BOX", name: "Zarge 2", hostId: null }); + expect(model.get("ff")).toMatchObject({ kind: "FRAME_FEEDER", hostId: "b1" }); + expect(model.get("fi")).toMatchObject({ kind: "FLOOR_INSERT", hostId: "f" }); + }); + + it("reads level fields with value, max and fill ratio, solid for feed paste", () => { + const feeder = model.get("fe")!; + expect(feeder.levels).toEqual([ + expect.objectContaining({ key: "syrupLiters", value: 2.5, max: 3, ratio: 2.5 / 3, solid: false }), + expect.objectContaining({ key: "fondantKg", value: 1, max: 2.5, ratio: 0.4, solid: true }), + ]); + expect(levelText(feeder.levels[0])).toBe("Flüssigfutter 2,5 l von 3 l"); + expect(levelText(feeder.levels[1])).toBe("Futterteig 1 kg von 2,5 kg"); + }); + + it("treats a missing level as 0 and clamps the ratio", () => { + const empty = buildSceneModel( + layout, + { ...hiveState, stack: hiveState.stack.map((p) => (p.id === "fe" ? { ...p, state: { syrupLiters: 9 } } : p)) }, + catalog, + name, + ).get("fe")!; + expect(empty.levels[0].ratio).toBe(1); + expect(empty.levels[1]).toMatchObject({ value: 0, ratio: 0 }); + }); + + it("reads choice fields with their German text, the first option when missing", () => { + expect(model.get("lid")!.choices).toEqual([ + { key: "feedHole", label: "Futterloch", value: "CLOSED", text: "zu" }, + ]); + }); + + it("counts the frames of a box that are not empty", () => { + expect(model.get("b1")!.frameCount).toBe(8); + expect(model.get("b2")!.frameCount).toBe(10); + }); +}); + +describe("sceneLabels", () => { + it("shows nothing in the rest view without hover", () => { + expect(sceneLabels(model, layout, rest)).toEqual([]); + }); + + it("names the hovered part, a box with its frame count", () => { + expect(sceneLabels(model, layout, { ...rest, hoveredId: "b1" })).toEqual([ + { key: "part:b1", anchor: { kind: "node", id: "b1" }, text: "Zarge 1 · 8 Rähmchen", tone: "part" }, + ]); + expect(sceneLabels(model, layout, { ...rest, hoveredId: "r" })[0].text).toBe("Dach"); + }); + + it("labels the hovered frame „R4 · Brutwabe · Name“ in an open box", () => { + const labels = sceneLabels(model, layout, { ...rest, openBoxId: "b1", hoveredFrame: { boxId: "b1", slot: 4 } }); + expect(labels[0]).toEqual({ + key: "frame:b1#4", + anchor: { kind: "frame", boxId: "b1", slot: 4 }, + text: "R4 · Brutwabe · Königin", + tone: "frame", + }); + }); + + it("keeps the label of a selected frame", () => { + const labels = sceneLabels(model, layout, { ...rest, openBoxId: "b2", selectedFrame: { boxId: "b2", slot: 7 } }); + expect(labels[0].text).toBe("R7 · Mittelwand"); + }); + + it("shows the levels of a focused feeder", () => { + const labels = sceneLabels(model, layout, { ...rest, focusedPartId: "fe" }); + expect(labels.map((label) => label.text)).toEqual(["Flüssigfutter 2,5 l von 3 l", "Futterteig 1 kg von 2,5 kg"]); + expect(labels[0]).toMatchObject({ anchor: { kind: "node", id: "fe" }, tone: "level" }); + }); + + it("shows the levels of the inserts of an open box and never more than three labels", () => { + const labels = sceneLabels(model, layout, { + ...rest, + openBoxId: "b1", + hoveredId: "ev", + hoveredFrame: { boxId: "b1", slot: 4 }, + }); + expect(labels.length).toBeLessThanOrEqual(MAX_LABELS); + expect(MAX_LABELS).toBe(3); + expect(labels[0].tone).toBe("frame"); + expect(labels.some((label) => label.text === "Flüssigfutter 1,5 l von 2 l")).toBe(true); + }); + + it("does not label ghost parts hovered from the outline in focus mode", () => { + expect(sceneLabels(model, layout, { ...rest, focusedPartId: "lid", hoveredId: "b2" })).toEqual([]); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/scene-model.ts b/ApiCore-Frontend/src/components/hive/hive-scene/scene-model.ts new file mode 100644 index 0000000..c92d89a --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/scene-model.ts @@ -0,0 +1,190 @@ +import { FRAME_TYPE_LABELS } from "@/lib/hive/labels"; +import type { HiveLayout, PartBox } from "@/lib/hive/layout"; +import type { CatalogPartType, HiveCatalog, HivePart, HivePartKind, HiveState } from "@/lib/hive/types"; +import { focusedIdOf } from "../use-hive-view"; +import type { MotionView } from "./use-hive-motion"; + +// What the scene shows about each part besides its shape: name, levels, +// choices and frame count. Pure, so the labels can be tested without WebGL. + +/** Spec 9.3: never more than three labels at once. */ +export const MAX_LABELS = 3; + +export interface SceneLevel { + key: string; + label: string; + unit: string; + value: number; + max: number; + /** value / max, clamped to 0…1. */ + ratio: number; + /** Feed paste is a block, everything else a liquid. */ + solid: boolean; +} + +export interface SceneChoice { + key: string; + label: string; + value: string; + text: string; +} + +export interface ScenePart { + id: string; + kind: HivePartKind; + type: CatalogPartType; + box: PartBox; + hostId: string | null; + name: string; + levels: SceneLevel[]; + choices: SceneChoice[]; + /** Frames that are not empty slots (boxes only). */ + frameCount: number; + state: HivePart["state"]; +} + +export type SceneModel = Map; + +const amountFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }); + +export function formatAmount(value: number, unit: string): string { + return `${amountFormat.format(value)} ${unit}`; +} + +export function levelText(level: SceneLevel): string { + return `${level.label} ${formatAmount(level.value, level.unit)} von ${formatAmount(level.max, level.unit)}`; +} + +function readLevels(part: HivePart, type: CatalogPartType): SceneLevel[] { + return type.stateSchema.flatMap((field) => { + if (field.kind !== "level") return []; + const raw = part.state[field.key]; + const value = typeof raw === "number" && Number.isFinite(raw) ? raw : 0; + const ratio = field.max > 0 ? Math.min(1, Math.max(0, value / field.max)) : 0; + return [ + { + key: field.key, + label: field.label, + unit: field.unit, + value, + max: field.max, + ratio, + solid: field.feedType === "FEED_PASTE" || field.unit === "kg", + }, + ]; + }); +} + +function readChoices(part: HivePart, type: CatalogPartType): SceneChoice[] { + return type.stateSchema.flatMap((field) => { + if (field.kind !== "choice" || field.options.length === 0) return []; + const raw = part.state[field.key]; + const index = Math.max(0, field.options.indexOf(String(raw))); + return [ + { + key: field.key, + label: field.label, + value: field.options[index], + text: field.optionLabels[index] ?? field.options[index], + }, + ]; + }); +} + +export function buildSceneModel( + layout: HiveLayout, + state: HiveState, + catalog: HiveCatalog, + partName: (id: string) => string, +): SceneModel { + const boxes = new Map(); + for (const placed of [...layout.parts, ...layout.inserts]) boxes.set(placed.id, placed); + const model: SceneModel = new Map(); + const add = (part: HivePart, hostId: string | null): void => { + const type = catalog.partTypes.get(part.partTypeId); + const box = boxes.get(part.id); + if (!type || !box) return; + model.set(part.id, { + id: part.id, + kind: type.kind, + type, + box, + hostId, + name: partName(part.id), + levels: readLevels(part, type), + choices: readChoices(part, type), + frameCount: (part.frames ?? []).filter((frame) => frame.type !== "NONE").length, + state: part.state, + }); + }; + for (const part of state.stack) { + add(part, null); + for (const insert of part.inserts ?? []) add(insert, part.id); + } + return model; +} + +export type LabelAnchor = { kind: "node"; id: string } | { kind: "frame"; boxId: string; slot: number }; + +export interface SceneLabel { + key: string; + anchor: LabelAnchor; + text: string; + tone: "part" | "frame" | "level"; +} + +function frameLabel(layout: HiveLayout, view: MotionView): SceneLabel | null { + if (!view.openBoxId) return null; + const target = view.hoveredFrame ?? view.selectedFrame; + if (!target || target.boxId !== view.openBoxId) return null; + const frame = layout.frames.find((candidate) => candidate.boxId === target.boxId && candidate.slot === target.slot); + if (!frame || frame.type === "NONE") return null; + const parts = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type]]; + if (frame.name) parts.push(frame.name); + return { + key: `frame:${frame.boxId}#${frame.slot}`, + anchor: { kind: "frame", boxId: frame.boxId, slot: frame.slot }, + text: parts.join(" · "), + tone: "frame", + }; +} + +function hoverLabel(model: SceneModel, view: MotionView): SceneLabel | null { + const id = view.hoveredId; + if (!id) return null; + const part = model.get(id); + if (!part) return null; + const focused = focusedIdOf(view); + // In focus mode only the open box's inserts can be hovered; ghosts stay unlabelled. + if (focused !== null && (view.openBoxId === null || part.hostId !== view.openBoxId)) return null; + if (focused === null && part.hostId !== null) return null; + const text = part.kind === "BOX" && part.frameCount > 0 ? `${part.name} · ${part.frameCount} Rähmchen` : part.name; + return { key: `part:${id}`, anchor: { kind: "node", id }, text, tone: "part" }; +} + +function levelLabels(model: SceneModel, view: MotionView): SceneLabel[] { + const focused = focusedIdOf(view); + if (!focused) return []; + const inserts = view.openBoxId + ? [...model.values()].filter((part) => part.hostId === view.openBoxId).map((part) => part.id) + : []; + const shown = [focused, ...inserts]; + return shown.flatMap((id) => { + const part = model.get(id); + if (!part) return []; + return part.levels.map((level) => ({ + key: `level:${id}:${level.key}`, + anchor: { kind: "node", id } as LabelAnchor, + text: levelText(level), + tone: "level" as const, + })); + }); +} + +/** Labels to show for the view state: frame first, then hover, then levels; at most three. */ +export function sceneLabels(model: SceneModel, layout: HiveLayout, view: MotionView): SceneLabel[] { + const labels = [frameLabel(layout, view), hoverLabel(model, view), ...levelLabels(model, view)].filter( + (label): label is SceneLabel => label !== null, + ); + return labels.slice(0, MAX_LABELS); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts b/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts new file mode 100644 index 0000000..72b231e --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts @@ -0,0 +1,54 @@ +import type { Camera, Color, Material, Mesh, Scene, ShaderMaterial, Texture, WebGLRenderer } from "three"; +import type { Vec3 } from "@/lib/hive/layout"; +import { cameraPosition } from "./camera-fit"; + +// Imperative three.js updates used from frame callbacks and effects. They live +// outside the components on purpose: scene objects are mutable by design, +// while React values (props, memoised objects) must not be mutated in render +// code (react-hooks/immutability). + +export function setUniform(material: ShaderMaterial, name: string, value: number): void { + const uniform = material.uniforms[name]; + if (uniform) uniform.value = value; +} + +export function setUniformColor(material: ShaderMaterial, name: string, color: Color): void { + const uniform = material.uniforms[name]; + if (uniform) (uniform.value as Color).copy(color); +} + +export function setMaterialOpacity(material: Material, opacity: number): void { + material.opacity = opacity; + material.visible = opacity > 0.01; +} + +/** Puts the camera on the fixed viewing direction at `distance` from `center`. */ +export function placeCamera(camera: Camera, center: Vec3, distance: number): void { + const [x, y, z] = cameraPosition(center, distance); + camera.position.set(x, y, z); + camera.lookAt(center[0], center[1], center[2]); +} + +export function setSceneBackground(scene: Scene, background: Texture | Color | null): void { + scene.background = background; +} + +export function setCanvasCursor(gl: WebGLRenderer, pointer: boolean): void { + const style = (gl.domElement as HTMLElement | undefined)?.style; + if (style) style.cursor = pointer ? "pointer" : ""; +} + +/** Scales a unit fill mesh to `ratio` of the space (centre, size) and sets its surface wave. */ +export function placeFill( + mesh: Mesh, + center: Vec3, + size: Vec3, + ratio: number, + cylinder: boolean, +): number { + const height = Math.max(size[1] * ratio, 1e-4); + mesh.visible = ratio > 0.002; + mesh.scale.set(size[0], height, cylinder ? size[0] : size[2]); + mesh.position.set(center[0], center[1] - size[1] / 2 + height / 2, center[2]); + return height; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx new file mode 100644 index 0000000..6aabbf7 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import type { ReactNode } from "react"; +import { useThree } from "@react-three/fiber"; +import type { ThreeEvent } from "@react-three/fiber"; +import { BackSide, FrontSide } from "three"; +import type { Group } from "three"; +import type { FrameBox, Vec3 } from "@/lib/hive/layout"; +import { FillVolume } from "./fill-volume"; +import { FrameHints } from "./frame-plate"; +import { HoloLabel } from "./hive-labels"; +import { createEdgeMaterial, createGlassMaterial, createGridMaterial } from "./materials"; +import { evaporatorBottle, feederChambers, partShapeSpec, pocketInner, shapeKey } from "./part-geometry"; +import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit"; +import { setUniform, setUniformColor } from "./scene-mutations"; +import type { SceneLabel, ScenePart } from "./scene-model"; +import { useAnimatedNode } from "./use-animated-node"; +import type { AnimatedValues } from "./use-animated-node"; +import type { NodeTarget } from "./use-hive-motion"; + +// One part of the stack or one insert: glass body, glowing edges, screens, +// an invisible hit box for pointer events, its content in focus mode and its +// label. Inserts and frames are children, so they fly with their host. + +export interface NodeHandlers { + onPointerOver(event: ThreeEvent): void; + onPointerOut(event: ThreeEvent): void; + onPointerDown(event: ThreeEvent): void; + onClick(event: ThreeEvent): void; +} + +/** Edge brightness at glow 1 (rest); bloom picks it up above the threshold. */ +const EDGE_INTENSITY = 1.25; + +export interface StackPartProps { + part: ScenePart; + /** Position relative to the parent: world for stack parts, the host centre for inserts. */ + target: NodeTarget; + /** This part is the one in focus mode. */ + focused: boolean; + handlers: NodeHandlers; + /** Labels anchored at this part (hover name, levels). */ + labels: SceneLabel[]; + /** Frames of a box, drawn as faint top-bar hints at rest. */ + hintFrames?: FrameBox[]; + children?: ReactNode; +} + +export function StackPart({ part, target, focused, handlers, labels, hintFrames, children }: StackPartProps) { + const { theme, geometries, motion } = useSceneKit(); + const group = useRef(null); + const { kind, box, state, levels } = part; + const shape = geometries.shape(shapeKey(kind, box.size, state, levels.length), () => + partShapeSpec(kind, box.size, state, levels.length), + ); + + const glass = useMemo(() => createGlassMaterial(theme, { frost: shape.frost }), [theme, shape.frost]); + const edges = useMemo(() => createEdgeMaterial(theme), [theme]); + const grids = useMemo( + () => shape.grids.map((grid) => createGridMaterial(theme, { cell: grid.panel.cell, bars: grid.panel.bars })), + [theme, shape.grids], + ); + useEffect( + () => () => { + glass.dispose(); + edges.dispose(); + grids.forEach((material) => material.dispose()); + }, + [glass, edges, grids], + ); + + const apply = ({ opacity, glow }: AnimatedValues) => { + setUniform(glass, "uOpacity", opacity); + setUniform(glass, "uGlow", glow); + // Ghost edges keep a little more presence than the faces, so the outline of the hive reads. + setUniform(edges, "uOpacity", Math.min(1, opacity * 1.6)); + setUniform(edges, "uIntensity", EDGE_INTENSITY * glow); + setUniformColor(edges, "uCyan", glow > 1.2 ? theme.edgeStrong : theme.edge); + grids.forEach((material) => setUniform(material, "uOpacity", opacity)); + }; + useAnimatedNode(group, { ...target, look: 0 }, motion, apply); + + const showHints = hintFrames && hintFrames.length > 0 && !target.content; + const sy = box.size[1]; + + return ( + + + {shape.grids.map((grid, index) => ( + + ))} + + {showHints ? : null} + {/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */} + + + {/* Focused: only back faces take hits, so inserts and frames in front stay reachable. */} + + + {target.content ? : null} + {labels + .filter((label) => label.tone !== "level") + .map((label) => ( + + ))} + {children} + + ); +} + +/** Below this canvas width level labels would run off the card; the focus chips carry the same text. */ +const LEVEL_LABEL_MIN_WIDTH = 520; + +function levelLabel(labels: SceneLabel[], partId: string, key: string): SceneLabel | undefined { + return labels.find((label) => label.key === `level:${partId}:${key}`); +} + +/** What a focused part shows inside (spec 9.3): feed, acid, pocket liquid. */ +function PartContent({ part, labels: allLabels }: { part: ScenePart; labels: SceneLabel[] }) { + const { kind, box, levels } = part; + const wide = useThree((state) => state.size.width >= LEVEL_LABEL_MIN_WIDTH); + const labels = wide ? allLabels : []; + if (kind === "FEEDER") { + const chambers = feederChambers(box.size, levels.length); + return ( + <> + {levels.map((level, index) => { + const chamber = chambers[index]; + const label = levelLabel(labels, part.id, level.key); + const top: Vec3 = [chamber.center[0], chamber.center[1] + chamber.size[1] / 2, chamber.size[2] / 2]; + return ( + + {level.solid ? ( + + ) : ( + + )} + {label ? : null} + + ); + })} + + ); + } + if (kind === "FRAME_FEEDER" && levels[0]) { + const inner = pocketInner(box.size); + const label = levelLabel(labels, part.id, levels[0].key); + return ( + <> + + {label ? : null} + + ); + } + if (kind === "EVAPORATOR" && levels[0]) { + const bottle = evaporatorBottle(box.size); + const inner: Vec3 = [bottle.radius * 1.8, bottle.height - 0.06, bottle.radius * 1.8]; + const label = levelLabel(labels, part.id, levels[0].key); + return ( + <> + + {label ? ( + + ) : null} + + ); + } + return null; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts b/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts new file mode 100644 index 0000000..b5f39c4 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/theme.ts @@ -0,0 +1,72 @@ +import { Color } from "three"; + +// Scene colours. The holo tokens (spec 9.3) are read once from :root; the +// material colours (honey, wax, white-hot corners) are fixed design values. +// Lighting stays cool blue/cyan and white; warm colours only appear as +// content (feed in focus, lifted frame looks). + +export interface HoloTheme { + background: Color; + /** Electric cyan along the edges. */ + edge: Color; + /** Brighter edge of a hovered or focused part. */ + edgeStrong: Color; + /** White-hot corners. */ + hot: Color; + /** Glass body tint. */ + tint: Color; + /** Cool rim light from behind. */ + rim: Color; + /** Faint cool light bouncing up from the floor (owner: no warm light in the rest view). */ + bounce: Color; + honey: Color; + honeyDeep: Color; + fondant: Color; + acid: Color; + /** Hex lines on the floor. */ + grid: Color; +} + +const FALLBACKS = { + "--holo-bg": "#0b1224", + "--holo-edge": "#33c2f0", + "--holo-edge-strong": "#8fe3ff", +} as const; + +type TokenName = keyof typeof FALLBACKS; + +/** + * Resolves a CSS colour token (the holo tokens are oklch) to a hex string + * three.js understands, by letting a 2D canvas convert it. Falls back when + * there is no 2D context (jsdom) or the token is missing. + */ +export function resolveCssColor(property: TokenName): string { + const fallback = FALLBACKS[property]; + if (typeof document === "undefined") return fallback; + const value = getComputedStyle(document.documentElement).getPropertyValue(property).trim(); + if (!value) return fallback; + const context = document.createElement("canvas").getContext("2d"); + if (!context) return fallback; + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + const [r, g, b] = context.getImageData(0, 0, 1, 1).data; + return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +/** Reads the tokens; call once per canvas (useState initialiser). */ +export function readHoloTheme(): HoloTheme { + return { + background: new Color(resolveCssColor("--holo-bg")), + edge: new Color(resolveCssColor("--holo-edge")), + edgeStrong: new Color(resolveCssColor("--holo-edge-strong")), + hot: new Color("#f2fbff"), + tint: new Color("#7fb8e8"), + rim: new Color("#4aa8ff"), + bounce: new Color("#9fd8ff"), + honey: new Color("#ffb21a"), + honeyDeep: new Color("#8a3a00"), + fondant: new Color("#f1d79a"), + acid: new Color("#c8f4ff"), + grid: new Color("#2a6fd6"), + }; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/use-animated-node.ts b/ApiCore-Frontend/src/components/hive/hive-scene/use-animated-node.ts new file mode 100644 index 0000000..20ce1a5 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/use-animated-node.ts @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { RefObject } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import type { Object3D } from "three"; +import type { Vec3 } from "@/lib/hive/layout"; +import type { MotionLevel } from "@/lib/motion/motion-preference"; +import { SUBTLE_FADE_SECONDS, fadeToward, stepToward } from "./use-hive-motion"; + +// Moves a node toward its target (spec 9.8): `full` damps position, scale and +// fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms; +// `off` sets everything at once. Frames are only requested while moving. + +/** After a pause the demand loop reports a long delta; never step more than this. */ +export const MAX_FRAME_SECONDS = 1 / 30; + +export interface AnimatedTarget { + position: Vec3; + scale: number; + opacity: number; + glow: number; + /** 0 = clear glass, 1 = type look (frames only). */ + look?: number; +} + +export interface AnimatedValues { + opacity: number; + glow: number; + look: number; +} + +function stepFade(current: number, target: number, dt: number, motion: MotionLevel): number { + if (motion === "full") return stepToward(current, target, dt, true); + if (motion === "subtle") return fadeToward(current, target, dt, SUBTLE_FADE_SECONDS); + return target; +} + +export function useAnimatedNode( + ref: RefObject, + target: AnimatedTarget, + motion: MotionLevel, + apply: (values: AnimatedValues) => void, +): void { + const invalidate = useThree((state) => state.invalidate); + const current = useRef<{ position: Vec3; scale: number; values: AnimatedValues } | null>(null); + const latestApply = useRef(apply); + useEffect(() => { + latestApply.current = apply; + }); + + // A new target starts the frame loop. + const [px, py, pz] = target.position; + useEffect(() => { + invalidate(); + }, [px, py, pz, target.scale, target.opacity, target.glow, target.look, invalidate]); + + useFrame((_, delta) => { + const node = ref.current; + if (!node) return; + const goal: AnimatedValues = { opacity: target.opacity, glow: target.glow, look: target.look ?? 0 }; + if (!current.current) { + // First frame: appear in place, no fly-in from the origin. + current.current = { position: [...target.position], scale: target.scale, values: goal }; + } else { + const dt = Math.min(delta, MAX_FRAME_SECONDS); + const damped = motion === "full"; + const state = current.current; + state.position = [ + stepToward(state.position[0], target.position[0], dt, damped), + stepToward(state.position[1], target.position[1], dt, damped), + stepToward(state.position[2], target.position[2], dt, damped), + ]; + state.scale = stepToward(state.scale, target.scale, dt, damped); + state.values = { + opacity: stepFade(state.values.opacity, goal.opacity, dt, motion), + glow: stepFade(state.values.glow, goal.glow, dt, motion), + look: stepFade(state.values.look, goal.look, dt, motion), + }; + } + const state = current.current; + node.position.set(state.position[0], state.position[1], state.position[2]); + node.scale.setScalar(state.scale); + latestApply.current(state.values); + const settled = + state.position.every((value, axis) => value === target.position[axis]) && + state.scale === target.scale && + state.values.opacity === goal.opacity && + state.values.glow === goal.glow && + state.values.look === goal.look; + if (!settled) invalidate(); + }); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.spec.ts new file mode 100644 index 0000000..cbe9cc4 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.spec.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "vitest"; +import { hiveLayout } from "@/lib/hive/layout"; +import { frames, inlineCatalog, part } from "@/lib/hive/test-utils"; +import type { HiveState } from "@/lib/hive/types"; +import type { HiveViewState } from "../use-hive-view"; +import { + FOCUS_SCALE, + FRAME_LIFT_RATIO, + GHOST_OPACITY, + HOVER_LIFT, + focusPlacement, + frameKey, + hiveMotionTargets, + REST_GAP, + restBounds, + restCenter, + shouldIdle, + stepToward, +} from "./use-hive-motion"; + +const catalog = inlineCatalog(); + +function sampleState(): HiveState { + const boxFrames = frames(10).map((frame) => + frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame.slot === 4 ? { ...frame, type: "BROOD" as const } : frame, + ); + return { + schemaVersion: 1, + stack: [ + part("f", "floor", { inserts: [part("fi", "floor-insert")] }), + part("b1", "box", { + frames: boxFrames, + inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } })], + }), + part("b2", "box", { frames: frames(10) }), + part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }), + part("r", "roof"), + ], + }; +} + +const layout = hiveLayout(sampleState(), catalog); +const rest: HiveViewState = { hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null }; +const box = (id: string) => layout.parts.find((candidate) => candidate.id === id)!; + +describe("hiveMotionTargets", () => { + it("keeps every part at its layout place, full opacity and interactive in the rest view", () => { + const targets = hiveMotionTargets(layout, rest); + for (const placed of layout.parts) { + const node = targets.nodes.get(placed.id)!; + expect(node.position).toEqual(restCenter(placed)); + expect(node.scale).toBe(1); + expect(node.opacity).toBe(1); + expect(node.interactive).toBe(true); + expect(node.content).toBe(false); + } + expect(targets.focus).toBeNull(); + }); + + it("places inserts relative to their host and keeps them passive in the rest view", () => { + const targets = hiveMotionTargets(layout, rest); + const insert = layout.inserts.find((candidate) => candidate.id === "fi")!; + const node = targets.nodes.get("fi")!; + expect(node.hostId).toBe("f"); + expect(node.position[1]).toBeCloseTo(insert.center[1] - box("f").center[1], 9); + expect(node.interactive).toBe(false); + }); + + it("shows frames only as hints at rest and hides empty slots", () => { + const targets = hiveMotionTargets(layout, rest); + expect(targets.frames.get(frameKey("b1", 4))!.look).toBe("hint"); + expect(targets.frames.get(frameKey("b1", 1))!.look).toBe("hidden"); + expect(targets.frames.get(frameKey("b1", 4))!.interactive).toBe(false); + }); + + it("lifts the hovered part by 8 mm and makes its edges brighter, others stay", () => { + const targets = hiveMotionTargets(layout, { ...rest, hoveredId: "b2" }); + const hovered = targets.nodes.get("b2")!; + expect(hovered.position[1]).toBeCloseTo(restCenter(box("b2"))[1] + HOVER_LIFT, 9); + expect(HOVER_LIFT).toBeCloseTo(0.08, 9); + expect(hovered.glow).toBeGreaterThan(targets.nodes.get("b1")!.glow); + expect(targets.nodes.get("b1")!.position).toEqual(restCenter(box("b1"))); + }); + + it("reports the lift, so the hit box can stay under the pointer", () => { + const targets = hiveMotionTargets(layout, { ...rest, hoveredId: "b2" }); + expect(targets.nodes.get("b2")!.lift).toBeCloseTo(HOVER_LIFT, 9); + expect(targets.nodes.get("b1")!.lift).toBe(0); + }); + + it("opens the rest stack by a small gap per level and reports the bounds with the gaps", () => { + expect(restCenter(box("b2"))[1]).toBeCloseTo(box("b2").center[1] + 2 * REST_GAP, 9); + const bounds = restBounds(layout); + expect(bounds.size[1]).toBeCloseTo(layout.bounds.h + (layout.parts.length - 1) * REST_GAP, 9); + expect(bounds.center[1]).toBeCloseTo(bounds.size[1] / 2, 9); + }); + + it("flies an open box to the focus place, scales it and ghosts every other part at ≈ 0.08", () => { + const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b2" }); + const focused = targets.nodes.get("b2")!; + expect(focused.position).toEqual(focusPlacement(layout.bounds, box("b2").size)); + expect(focused.scale).toBe(FOCUS_SCALE); + expect(focused.opacity).toBe(1); + expect(focused.content).toBe(true); + for (const [id, node] of targets.nodes) { + if (id === "b2") continue; + expect(node.opacity).toBeCloseTo(GHOST_OPACITY, 9); + expect(node.interactive).toBe(false); + } + expect(GHOST_OPACITY).toBeCloseTo(0.08, 9); + expect(targets.focus).toMatchObject({ id: "b2", kind: "BOX" }); + }); + + it("keeps the focus place clear of the ghost hive", () => { + const size = box("b2").size; + const [, , z] = focusPlacement(layout.bounds, size); + expect(z - (size[2] * FOCUS_SCALE) / 2).toBeGreaterThan(layout.bounds.d / 2); + }); + + it("shows the frames of the open box as clear glass plates, ghosts the others", () => { + const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b2" }); + const plate = targets.frames.get(frameKey("b2", 3))!; + expect(plate.look).toBe("glass"); + expect(plate.interactive).toBe(true); + expect(plate.opacity).toBe(1); + const other = targets.frames.get(frameKey("b1", 4))!; + expect(other.look).toBe("hint"); + expect(other.opacity).toBeCloseTo(GHOST_OPACITY, 9); + }); + + it("lifts the hovered frame by 40 % of its height and gives it its type look", () => { + const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b1", hoveredFrame: { boxId: "b1", slot: 4 } }); + const frame = layout.frames.find((candidate) => candidate.boxId === "b1" && candidate.slot === 4)!; + const lifted = targets.frames.get(frameKey("b1", 4))!; + expect(lifted.position[1]).toBeCloseTo(frame.center[1] - box("b1").center[1] + FRAME_LIFT_RATIO * frame.size[1], 9); + expect(lifted.look).toBe("type"); + const neighbour = targets.frames.get(frameKey("b1", 5))!; + expect(neighbour.look).toBe("glass"); + expect(neighbour.position[1]).toBeCloseTo(frame.center[1] - box("b1").center[1], 9); + }); + + it("keeps a selected frame lifted with its type look after the pointer left", () => { + const targets = hiveMotionTargets(layout, { + ...rest, + openBoxId: "b1", + selectedFrame: { boxId: "b1", slot: 5 }, + }); + expect(targets.frames.get(frameKey("b1", 5))!.look).toBe("type"); + expect(targets.frames.get(frameKey("b1", 5))!.position[1]).toBeGreaterThan(0); + }); + + it("makes the inserts of the open box interactive and shows their content", () => { + const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b1" }); + const pocket = targets.nodes.get("ff")!; + expect(pocket.interactive).toBe(true); + expect(pocket.content).toBe(true); + expect(pocket.opacity).toBe(1); + }); + + it("lifts a hovered frame feeder like a frame", () => { + const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b1", hoveredId: "ff" }); + const insert = layout.inserts.find((candidate) => candidate.id === "ff")!; + expect(targets.nodes.get("ff")!.position[1]).toBeCloseTo( + insert.center[1] - box("b1").center[1] + FRAME_LIFT_RATIO * insert.size[1], + 9, + ); + expect(targets.nodes.get("ff")!.lift).toBeCloseTo(FRAME_LIFT_RATIO * insert.size[1], 9); + }); + + it("flies a focused insert out on its own and ghosts its host", () => { + const targets = hiveMotionTargets(layout, { ...rest, focusedPartId: "fi" }); + const insert = layout.inserts.find((candidate) => candidate.id === "fi")!; + const place = focusPlacement(layout.bounds, insert.size); + const node = targets.nodes.get("fi")!; + const host = restCenter(box("f")); + // Inserts are positioned relative to the host, which stays at its rest place. + expect(node.position[0] + host[0]).toBeCloseTo(place[0], 9); + expect(node.position[1] + host[1]).toBeCloseTo(place[1], 9); + expect(node.position[2] + host[2]).toBeCloseTo(place[2], 9); + expect(node.opacity).toBe(1); + expect(node.content).toBe(true); + expect(targets.nodes.get("f")!.opacity).toBeCloseTo(GHOST_OPACITY, 9); + expect(targets.nodes.get("f")!.position).toEqual(host); + }); + + it("focuses a feeder and shows its content", () => { + const targets = hiveMotionTargets(layout, { ...rest, focusedPartId: "fe" }); + const feeder = targets.nodes.get("fe")!; + expect(feeder.content).toBe(true); + expect(feeder.interactive).toBe(true); + expect(targets.focus).toMatchObject({ id: "fe", kind: "FEEDER" }); + expect(targets.nodes.get("b1")!.opacity).toBeCloseTo(GHOST_OPACITY, 9); + }); + + it("ignores a focus id that is not in the layout", () => { + const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "gone" }); + expect(targets.focus).toBeNull(); + expect(targets.nodes.get("b1")!.opacity).toBe(1); + }); +}); + +describe("stepToward", () => { + it("jumps at once when not damped", () => { + expect(stepToward(0, 1, 0.016, false)).toBe(1); + }); + + it("moves part of the way per frame and settles within about 350 ms", () => { + let value = 0; + const first = stepToward(value, 1, 1 / 60, true); + expect(first).toBeGreaterThan(0); + expect(first).toBeLessThan(1); + for (let frame = 0; frame < 21; frame += 1) value = stepToward(value, 1, 1 / 60, true); + expect(value).toBeGreaterThan(0.98); + }); +}); + +describe("shouldIdle", () => { + it("runs only at full, while the card and the tab are visible", () => { + expect(shouldIdle({ motion: "full", visible: true, tabVisible: true })).toBe(true); + expect(shouldIdle({ motion: "subtle", visible: true, tabVisible: true })).toBe(false); + expect(shouldIdle({ motion: "off", visible: true, tabVisible: true })).toBe(false); + expect(shouldIdle({ motion: "full", visible: false, tabVisible: true })).toBe(false); + expect(shouldIdle({ motion: "full", visible: true, tabVisible: false })).toBe(false); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts b/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts new file mode 100644 index 0000000..17bf9ea --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts @@ -0,0 +1,305 @@ +import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout"; +import type { HivePartKind } from "@/lib/hive/types"; +import type { HiveViewState, HoveredFrame } from "../use-hive-view"; +import { focusedIdOf } from "../use-hive-view"; + +// Pure targets for every node of the scene (spec 9.4). The scene animates +// toward them at motion level `full` and sets them directly otherwise. +// Units are layout units (1 = 100 mm, y up). + +/** A hovered part rises 8 mm. */ +export const HOVER_LIFT = 0.08; +/** Every part outside the focus stays as a faint ghost of the whole hive. */ +export const GHOST_OPACITY = 0.08; +/** The focused part flies out and grows by this factor. */ +export const FOCUS_SCALE = 1.25; +/** A hovered or selected frame rises by 40 % of its height. */ +export const FRAME_LIFT_RATIO = 0.4; +/** The rest stack is slightly exploded (mockup g2): this gap per stack level. */ +export const REST_GAP = 0.14; +/** Clear space between the ghost hive and the focused part. */ +const FOCUS_GAP = 0.45; +/** The focused part floats a little above the floor. */ +const FOCUS_FLOAT = 0.18; +/** Share of the hive width the focus place moves to the left (camera at 30° azimuth). */ +const FOCUS_SHIFT = 0.35; + +export const GLOW = { rest: 1, hover: 2.3, focus: 1.5, ghost: 0.45 } as const; + +export type FrameLook = "hidden" | "hint" | "glass" | "type"; + +export interface NodeTarget { + /** Stack parts: world position. Inserts: relative to the host's centre. */ + position: Vec3; + scale: number; + opacity: number; + /** Edge brightness multiplier. */ + glow: number; + /** Takes pointer events (ghosts and parts inside a closed host do not). */ + interactive: boolean; + /** Shows its content (levels, frame looks) - only in focus mode. */ + content: boolean; + hostId: string | null; + /** How far the node is raised above its place (hover); its hit box reaches back down. */ + lift: number; +} + +export interface FrameTarget { + /** Relative to the box centre. */ + position: Vec3; + opacity: number; + look: FrameLook; + interactive: boolean; +} + +export interface FocusInfo { + id: string; + kind: HivePartKind; + /** World centre of the focused part at its focus place. */ + center: Vec3; + /** Scaled size, with head room for a lifted frame. */ + size: Vec3; +} + +export interface HiveMotionTargets { + nodes: Map; + frames: Map; + focus: FocusInfo | null; +} + +export interface MotionView extends HiveViewState { + /** Frame whose sheet was opened from the scene: stays lifted until focus changes. */ + selectedFrame?: HoveredFrame | null; +} + +export const frameKey = (boxId: string, slot: number): string => `${boxId}#${slot}`; + +const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const lift = (value: Vec3, dy: number): Vec3 => [value[0], value[1] + dy, value[2]]; + +/** Rest place of a stack part: its layout centre raised by the stack gap. */ +export function restCenter(placed: PartBox): Vec3 { + return lift(placed.center, placed.stackIndex * REST_GAP); +} + +/** World box of the rest stack (centre and size), gaps included. */ +export function restBounds(layout: HiveLayout): { center: Vec3; size: Vec3 } { + const levels = layout.parts.length; + const height = layout.bounds.h + Math.max(0, levels - 1) * REST_GAP; + return { center: [0, height / 2, 0], size: [layout.bounds.w, height, layout.bounds.d] }; +} + +/** + * World centre a focused part flies to: in front of the hive and to the left, + * so the ghost hive stays visible behind it on the right (camera azimuth 30°). + */ +export function focusPlacement(bounds: HiveLayout["bounds"], size: Vec3): Vec3 { + return [ + -bounds.w * FOCUS_SHIFT, + (size[1] * FOCUS_SCALE) / 2 + FOCUS_FLOAT, + bounds.d / 2 + (size[2] * FOCUS_SCALE) / 2 + FOCUS_GAP, + ]; +} + +function hostOf(layout: HiveLayout, insert: PartBox): PartBox | undefined { + return layout.parts.find((candidate) => candidate.stackIndex === insert.stackIndex); +} + +const sameFrame = (frame: FrameBox, other: HoveredFrame | null | undefined): boolean => + other != null && other.boxId === frame.boxId && other.slot === frame.slot; + +interface FocusState { + id: string; + box: PartBox; + isInsert: boolean; + hostId: string | null; +} + +function findFocus(layout: HiveLayout, view: MotionView): FocusState | null { + const id = focusedIdOf(view); + if (!id) return null; + const stackPart = layout.parts.find((candidate) => candidate.id === id); + if (stackPart) return { id, box: stackPart, isInsert: false, hostId: null }; + const insert = layout.inserts.find((candidate) => candidate.id === id); + if (!insert) return null; + return { id, box: insert, isInsert: true, hostId: hostOf(layout, insert)?.id ?? null }; +} + +function focusInfo(layout: HiveLayout, focus: FocusState): FocusInfo { + const { box } = focus; + const center = focusPlacement(layout.bounds, box.size); + const size: Vec3 = [box.size[0] * FOCUS_SCALE, box.size[1] * FOCUS_SCALE, box.size[2] * FOCUS_SCALE]; + if (box.kind !== "BOX") return { id: focus.id, kind: box.kind, center, size }; + // Leave room above the box for a frame lifted by 40 % of its height. + const frameHeight = layout.frames.find((frame) => frame.boxId === box.id)?.size[1] ?? 0; + const headRoom = FRAME_LIFT_RATIO * frameHeight * FOCUS_SCALE; + return { + id: focus.id, + kind: box.kind, + center: lift(center, headRoom / 2), + size: [size[0], size[1] + headRoom, size[2]], + }; +} + +function stackTargets(layout: HiveLayout, view: MotionView, focus: FocusState | null): Map { + const nodes = new Map(); + for (const placed of layout.parts) { + const focused = focus !== null && !focus.isInsert && focus.id === placed.id; + if (focused) { + nodes.set(placed.id, { + position: focusPlacement(layout.bounds, placed.size), + scale: FOCUS_SCALE, + opacity: 1, + glow: GLOW.focus, + interactive: true, + content: true, + hostId: null, + lift: 0, + }); + continue; + } + const home = restCenter(placed); + if (focus) { + nodes.set(placed.id, { + position: home, + scale: 1, + opacity: GHOST_OPACITY, + glow: GLOW.ghost, + interactive: false, + content: false, + hostId: null, + lift: 0, + }); + continue; + } + const hovered = view.hoveredId === placed.id; + nodes.set(placed.id, { + position: hovered ? lift(home, HOVER_LIFT) : home, + scale: 1, + opacity: 1, + glow: hovered ? GLOW.hover : GLOW.rest, + interactive: true, + content: false, + hostId: null, + lift: hovered ? HOVER_LIFT : 0, + }); + } + return nodes; +} + +function insertTarget( + layout: HiveLayout, + view: MotionView, + focus: FocusState | null, + insert: PartBox, + host: PartBox, +): NodeTarget { + const local = sub(insert.center, host.center); + if (focus?.isInsert && focus.id === insert.id) { + return { + position: sub(focusPlacement(layout.bounds, insert.size), restCenter(host)), + scale: FOCUS_SCALE, + opacity: 1, + glow: GLOW.focus, + interactive: true, + content: true, + hostId: host.id, + lift: 0, + }; + } + const hostFocused = focus !== null && !focus.isInsert && focus.id === host.id; + if (hostFocused) { + const hovered = view.hoveredId === insert.id; + const rise = insert.kind === "FRAME_FEEDER" ? FRAME_LIFT_RATIO * insert.size[1] : HOVER_LIFT; + return { + position: hovered ? lift(local, rise) : local, + scale: 1, + opacity: 1, + glow: hovered ? GLOW.hover : GLOW.rest, + interactive: true, + content: true, + hostId: host.id, + lift: hovered ? rise : 0, + }; + } + return { + position: local, + scale: 1, + opacity: focus ? GHOST_OPACITY : 1, + glow: focus ? GLOW.ghost : GLOW.rest, + interactive: false, + content: false, + hostId: host.id, + lift: 0, + }; +} + +function frameTarget(frame: FrameBox, host: PartBox, view: MotionView, focus: FocusState | null): FrameTarget { + const local = sub(frame.center, host.center); + if (frame.type === "NONE") return { position: local, opacity: 0, look: "hidden", interactive: false }; + const open = focus !== null && !focus.isInsert && focus.id === host.id; + if (!open) { + return { position: local, opacity: focus ? GHOST_OPACITY : 1, look: "hint", interactive: false }; + } + const raised = sameFrame(frame, view.hoveredFrame) || sameFrame(frame, view.selectedFrame); + return { + position: raised ? lift(local, FRAME_LIFT_RATIO * frame.size[1]) : local, + opacity: 1, + look: raised ? "type" : "glass", + interactive: true, + }; +} + +/** Targets for every part, insert and frame of the layout in the given view state. */ +export function hiveMotionTargets(layout: HiveLayout, view: MotionView): HiveMotionTargets { + const focus = findFocus(layout, view); + const nodes = stackTargets(layout, view, focus); + for (const insert of layout.inserts) { + const host = hostOf(layout, insert); + if (host) nodes.set(insert.id, insertTarget(layout, view, focus, insert, host)); + } + const frames = new Map(); + for (const frame of layout.frames) { + const host = layout.parts.find((candidate) => candidate.id === frame.boxId); + if (host) frames.set(frameKey(frame.boxId, frame.slot), frameTarget(frame, host, view, focus)); + } + return { nodes, frames, focus: focus ? focusInfo(layout, focus) : null }; +} + +/** Damping rate: about 95 % of the way after 250 ms, settled at about 350 ms (spec 9.8). */ +export const DAMPING = 12; +/** Values closer than this count as arrived. */ +export const SETTLE_EPSILON = 1e-3; + +/** + * One frame of exponential damping toward a target (frame-rate independent), + * or the target itself when motion is not `full`. + */ +export function stepToward(current: number, target: number, deltaSeconds: number, damped: boolean): number { + if (!damped) return target; + const next = current + (target - current) * (1 - Math.exp(-DAMPING * deltaSeconds)); + return Math.abs(target - next) < SETTLE_EPSILON ? target : next; +} + +/** Linear fade used at motion level `subtle` (150 ms). */ +export const SUBTLE_FADE_SECONDS = 0.15; + +export function fadeToward(current: number, target: number, deltaSeconds: number, seconds: number): number { + if (seconds <= 0) return target; + const step = deltaSeconds / seconds; + if (Math.abs(target - current) <= step) return target; + return current + Math.sign(target - current) * step; +} + +export interface IdleConditions { + motion: "full" | "subtle" | "off"; + /** The card is in the viewport. */ + visible: boolean; + /** The tab is in the foreground. */ + tabVisible: boolean; +} + +/** Idle float, pointer tilt and liquid waves run only at `full`, on screen, in the foreground tab. */ +export function shouldIdle({ motion, visible, tabVisible }: IdleConditions): boolean { + return motion === "full" && visible && tabVisible; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/use-touch-select.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/use-touch-select.spec.ts new file mode 100644 index 0000000..778f1e8 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/use-touch-select.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useTouchSelect } from "./use-touch-select"; + +function setup() { + const hover = vi.fn(); + const select = vi.fn(); + const { result, rerender } = renderHook(({ onHover, onSelect }) => useTouchSelect({ onHover, onSelect }), { + initialProps: { onHover: hover, onSelect: select }, + }); + return { hover, select, result, rerender }; +} + +describe("useTouchSelect", () => { + it("hovers on the first tap and selects on the second tap of the same target", () => { + const { hover, select, result } = setup(); + result.current.tap("part:b1", "touch"); + expect(hover).toHaveBeenLastCalledWith("part:b1"); + expect(select).not.toHaveBeenCalled(); + result.current.tap("part:b1", "touch"); + expect(select).toHaveBeenCalledWith("part:b1"); + }); + + it("starts over after a selection", () => { + const { select, result } = setup(); + result.current.tap("part:b1", "touch"); + result.current.tap("part:b1", "touch"); + result.current.tap("part:b1", "touch"); + expect(select).toHaveBeenCalledTimes(1); + }); + + it("moves the hover to another target instead of selecting", () => { + const { hover, select, result } = setup(); + result.current.tap("part:b1", "touch"); + result.current.tap("frame:b1#4", "touch"); + expect(hover).toHaveBeenLastCalledWith("frame:b1#4"); + expect(select).not.toHaveBeenCalled(); + result.current.tap("frame:b1#4", "touch"); + expect(select).toHaveBeenCalledWith("frame:b1#4"); + }); + + it("resets on a tap elsewhere", () => { + const { hover, select, result } = setup(); + result.current.tap("part:b1", "touch"); + result.current.tap(null, "touch"); + expect(hover).toHaveBeenLastCalledWith(null); + result.current.tap("part:b1", "touch"); + expect(select).not.toHaveBeenCalled(); + }); + + it("selects at once with a mouse or pen", () => { + const { hover, select, result } = setup(); + result.current.tap("part:b1", "mouse"); + expect(select).toHaveBeenCalledWith("part:b1"); + result.current.tap("part:b2", "pen"); + expect(select).toHaveBeenLastCalledWith("part:b2"); + expect(hover).not.toHaveBeenCalled(); + }); + + it("keeps a stable api and uses the latest handlers", () => { + const { result, rerender } = setup(); + const first = result.current; + const nextSelect = vi.fn(); + rerender({ onHover: vi.fn(), onSelect: nextSelect }); + expect(result.current).toBe(first); + result.current.tap("part:x", "mouse"); + expect(nextSelect).toHaveBeenCalledWith("part:x"); + }); + + it("forgets the armed target on reset", () => { + const { select, result } = setup(); + result.current.tap("part:b1", "touch"); + result.current.reset(); + result.current.tap("part:b1", "touch"); + expect(select).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/use-touch-select.ts b/ApiCore-Frontend/src/components/hive/hive-scene/use-touch-select.ts new file mode 100644 index 0000000..a4a9642 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/use-touch-select.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; + +// Spec 9.4: on touch the first tap hovers (the target lifts and shows its +// label), the second tap on the same target selects it; a tap elsewhere +// starts over. Mouse and pen select on click, hover comes from pointer-over. + +export interface TouchSelectHandlers { + onHover(key: string | null): void; + onSelect(key: string): void; +} + +export interface TouchSelect { + /** A tap or click on `key` (null: on nothing) with the pointer type of its pointerdown. */ + tap(key: string | null, pointerType: string): void; + /** Forget the armed target (focus changed, scene left). */ + reset(): void; +} + +export function useTouchSelect(handlers: TouchSelectHandlers): TouchSelect { + const latest = useRef(handlers); + useEffect(() => { + latest.current = handlers; + }); + const armed = useRef(null); + + return useMemo( + () => ({ + tap(key, pointerType) { + if (pointerType !== "touch") { + armed.current = null; + if (key !== null) latest.current.onSelect(key); + return; + } + if (key !== null && armed.current === key) { + armed.current = null; + latest.current.onSelect(key); + return; + } + armed.current = key; + latest.current.onHover(key); + }, + reset() { + armed.current = null; + }, + }), + [], + ); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-visual-slot.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-visual-slot.spec.tsx new file mode 100644 index 0000000..b677393 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-visual-slot.spec.tsx @@ -0,0 +1,179 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { hiveLayout } from "@/lib/hive/layout"; +import { inlineCatalog, part } from "@/lib/hive/test-utils"; +import type { HiveState } from "@/lib/hive/types"; +import { HiveVisualSlot } from "./hive-visual-slot"; +import { createHiveViewStore, HiveViewProvider } from "./use-hive-view"; + +const webgl = vi.hoisted(() => ({ canUseWebGL: vi.fn(), shouldUse3D: vi.fn() })); +vi.mock("@/lib/hive/webgl", () => webgl); + +// The scene (three, WebGL) never runs in specs: next/dynamic returns a stub. +const canvasMounts = vi.hoisted(() => ({ count: 0 })); +vi.mock("next/dynamic", async () => { + const React = await import("react"); + return { + default: () => + function StubCanvas(props: { onReady: () => void; onContextLost: () => void }) { + React.useEffect(() => { + canvasMounts.count += 1; + }, []); + return ( +
+ + +
+ ); + }, + }; +}); + +const catalog = inlineCatalog(); +const state: HiveState = { schemaVersion: 1, stack: [part("f", "floor"), part("r", "roof")] }; +const layout = hiveLayout(state, catalog); + +interface ObserverEntry { + callback: IntersectionObserverCallback; + disconnected: boolean; +} +let observed: ObserverEntry | null = null; + +class FakeObserver { + entry: ObserverEntry; + constructor(callback: IntersectionObserverCallback) { + this.entry = { callback, disconnected: false }; + observed = this.entry; + } + observe() {} + unobserve() {} + takeRecords() { + return []; + } + disconnect() { + this.entry.disconnected = true; + } +} + +function enterViewport() { + act(() => { + observed!.callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver); + }); +} + +function renderSlot() { + return render( + + id} + onSelectPart={vi.fn()} + onSelectFrame={vi.fn()} + /> + , + ); +} + +function canvasLayer() { + return screen.getByTestId("canvas").parentElement!; +} + +beforeEach(() => { + observed = null; + canvasMounts.count = 0; + webgl.canUseWebGL.mockReturnValue(true); + webgl.shouldUse3D.mockReturnValue(true); + vi.stubGlobal("IntersectionObserver", FakeObserver); + window.localStorage.removeItem("apicore.motion"); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + window.localStorage.removeItem("apicore.motion"); +}); + +describe("HiveVisualSlot", () => { + it("shows the SVG at once and no canvas before the card is visible", () => { + const { container } = renderSlot(); + expect(container.querySelector("svg")).not.toBeNull(); + expect(screen.queryByTestId("canvas")).toBeNull(); + }); + + it("loads the scene when the card enters the viewport and 3D is allowed", () => { + renderSlot(); + enterViewport(); + expect(screen.getByTestId("canvas")).toBeInTheDocument(); + expect(observed!.disconnected).toBe(true); + }); + + it("never loads the scene when shouldUse3D is false", () => { + webgl.shouldUse3D.mockReturnValue(false); + const { container } = renderSlot(); + if (observed) enterViewport(); + expect(screen.queryByTestId("canvas")).toBeNull(); + expect(container.querySelector("svg")).not.toBeNull(); + }); + + it("passes WebGL support, motion level, cores and forced colors to shouldUse3D", () => { + window.localStorage.setItem("apicore.motion", "subtle"); + renderSlot(); + enterViewport(); + expect(webgl.shouldUse3D).toHaveBeenCalledWith( + expect.objectContaining({ webgl: true, motion: "subtle", forcedColors: false }), + ); + expect(typeof webgl.shouldUse3D.mock.calls.at(-1)![0].cores).toBe("number"); + }); + + it("keeps the SVG visible until the scene is ready, then cross-fades (400 ms at full)", () => { + renderSlot(); + enterViewport(); + expect(canvasLayer().style.opacity).toBe("0"); + fireEvent.click(screen.getByText("ready")); + expect(canvasLayer().style.opacity).toBe("1"); + expect(canvasLayer().style.transitionDuration).toBe("400ms"); + }); + + it("fades in 150 ms at subtle and instantly at off", () => { + window.localStorage.setItem("apicore.motion", "subtle"); + const subtle = renderSlot(); + enterViewport(); + fireEvent.click(screen.getByText("ready")); + expect(canvasLayer().style.transitionDuration).toBe("150ms"); + subtle.unmount(); + + window.localStorage.setItem("apicore.motion", "off"); + renderSlot(); + enterViewport(); + fireEvent.click(screen.getByText("ready")); + expect(canvasLayer().style.transitionDuration).toBe("0ms"); + }); + + it("lets the SVG keep the pointer until the scene is ready, then makes the scene interactive", () => { + renderSlot(); + enterViewport(); + expect(canvasLayer()).toHaveClass("pointer-events-none"); + fireEvent.click(screen.getByText("ready")); + expect(canvasLayer()).not.toHaveClass("pointer-events-none"); + }); + + it("remounts the scene once after the first context loss", () => { + renderSlot(); + enterViewport(); + expect(canvasMounts.count).toBe(1); + fireEvent.click(screen.getByText("lose")); + expect(screen.getByTestId("canvas")).toBeInTheDocument(); + expect(canvasMounts.count).toBe(2); + expect(canvasLayer().style.opacity).toBe("0"); + }); + + it("stays on the SVG after the second context loss", () => { + const { container } = renderSlot(); + enterViewport(); + fireEvent.click(screen.getByText("lose")); + fireEvent.click(screen.getByText("lose")); + expect(screen.queryByTestId("canvas")).toBeNull(); + expect(container.querySelector("svg")).not.toBeNull(); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx b/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx index 1053f34..298c497 100644 --- a/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx @@ -1,21 +1,99 @@ "use client"; +import dynamic from "next/dynamic"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { HiveLayout } from "@/lib/hive/layout"; -import type { HiveCatalog } from "@/lib/hive/types"; +import type { HiveCatalog, HiveState } from "@/lib/hive/types"; +import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl"; +import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference"; +import type { MotionLevel } from "@/lib/motion/motion-preference"; import { HiveSvg } from "./hive-svg"; +// three, fiber and friends live only in this chunk, fetched when the card is on screen. +const HiveCanvas = dynamic(() => import("./hive-scene/hive-canvas"), { ssr: false }); + +const FADE_MS: Record = { full: 400, subtle: 150, off: 0 }; +const FORCED_COLORS_QUERY = "(forced-colors: active)"; +/** First context loss remounts the scene once, the second keeps the SVG for good. */ +const MAX_CONTEXT_LOSSES = 1; + export interface HiveVisualSlotProps { layout: HiveLayout; + /** The state the layout was made from (levels and choices for the 3D content). */ + state: HiveState; catalog: HiveCatalog; partName: (id: string) => string; onSelectPart: (id: string) => void; onSelectFrame: (boxId: string, slot: number) => void; } +function allows3D(motion: MotionLevel): boolean { + return shouldUse3D({ + webgl: canUseWebGL(), + motion, + cores: navigator.hardwareConcurrency ?? 0, + forcedColors: window.matchMedia?.(FORCED_COLORS_QUERY).matches ?? false, + }); +} + /** - * The model of the hive. L2 shows the SVG; the 3D scene (with the SVG as - * fallback) replaces this body without changing the props. + * The model of the hive. Shows the SVG at once; when the card is visible and + * the device allows it, the 3D scene loads and cross-fades over it and takes + * the pointer. Any failure to keep a context leaves the SVG, which is always + * rendered below. */ -export function HiveVisualSlot({ layout, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) { - return ; +export function HiveVisualSlot({ layout, state, catalog, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) { + const motion = useEffectiveMotionLevel(); + const rootRef = useRef(null); + const [visible, setVisible] = useState(false); + const [losses, setLosses] = useState(0); + const [readyKey, setReadyKey] = useState(null); + + useEffect(() => { + const root = rootRef.current; + if (!root || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setVisible(true); + observer.disconnect(); + } + }); + observer.observe(root); + return () => observer.disconnect(); + }, []); + + const show3D = visible && losses <= MAX_CONTEXT_LOSSES && allows3D(motion); + const ready = readyKey === losses; + const onReady = useCallback(() => setReadyKey(losses), [losses]); + const onContextLost = useCallback(() => setLosses((count) => count + 1), []); + + return ( +
+ + {show3D ? ( +
+ +
+ ) : null} +
+ ); } diff --git a/ApiCore-Frontend/src/components/hive/part-sheet.spec.tsx b/ApiCore-Frontend/src/components/hive/part-sheet.spec.tsx index f6859e3..7eb402d 100644 --- a/ApiCore-Frontend/src/components/hive/part-sheet.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/part-sheet.spec.tsx @@ -29,10 +29,11 @@ interface HarnessProps { initialState?: HiveState; submit?: (ops: HiveOp[]) => Promise; startWithEvaporator?: boolean; + startWithFrameFeeder?: boolean; } /** Applies submitted ops to its own state, like the optimistic cache does. */ -function Harness({ partId, initialState = baseState(), submit, startWithEvaporator }: HarnessProps) { +function Harness({ partId, initialState = baseState(), submit, startWithEvaporator, startWithFrameFeeder }: HarnessProps) { const [state, setState] = useState(initialState); const [open, setOpen] = useState(true); const outlineRef = useRef(null); @@ -62,6 +63,7 @@ function Harness({ partId, initialState = baseState(), submit, startWithEvaporat submit={send} outlineRef={outlineRef} startWithEvaporator={startWithEvaporator} + startWithFrameFeeder={startWithFrameFeeder} /> ); @@ -329,4 +331,99 @@ describe("PartSheet", () => { expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toBeInTheDocument(); }); }); + + describe("frame feeder", () => { + const withPocket = (): HiveState => { + const s = baseState(); + s.stack[1] = part("b", "box", { + frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)), + inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 0.5 } })], + }); + return s; + }; + + it("inserts a frame feeder: setFrames clears the covered slots, addInsert hangs it", async () => { + const submit = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" })); + await user.click(screen.getByRole("radio", { name: "Plätze 3–4" })); + expect(screen.getByText(/werden zu „leer“/)).toBeInTheDocument(); + const syrup = within(dialog()).getByLabelText("Flüssigfutter"); + await user.clear(syrup); + await user.type(syrup, "1,5"); + await user.click(save()); + const expectedFrames = frames(10).map((f) => (f.slot === 3 || f.slot === 4 ? { ...f, type: "NONE" } : f)); + expect(submit).toHaveBeenCalledWith([ + { op: "setFrames", boxId: "b", frames: expectedFrames }, + { + op: "addInsert", + hostId: "b", + part: { id: expect.any(String), partTypeId: "frame-feeder", state: { syrupLiters: 1.5 }, slot: 3 }, + }, + ]); + }); + + it("skips setFrames when the covered slots are already empty", async () => { + const submit = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + const state = baseState(); + state.stack[1] = part("b", "box", { + frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)), + }); + render(); + await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" })); + expect(screen.queryByText(/werden zu „leer“/)).toBeNull(); + await user.click(save()); + expect(submit).toHaveBeenCalledWith([ + { op: "addInsert", hostId: "b", part: expect.objectContaining({ partTypeId: "frame-feeder", slot: 1 }) }, + ]); + }); + + it("offers only positions where the slots are still free", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" })); + expect(screen.queryByRole("radio", { name: "Plätze 1–2" })).toBeNull(); + expect(screen.queryByRole("radio", { name: "Plätze 2–3" })).toBeNull(); + expect(screen.getByRole("radio", { name: "Plätze 3–4" })).toBeInTheDocument(); + }); + + it("preselects the frame feeder when opened from the outline action", () => { + render(); + expect(screen.getByRole("radio", { name: "Futtertasche einsetzen" })).toBeChecked(); + }); + + it("moves focus to the new pocket item after inserting", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" })); + await user.click(save()); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await waitFor(() => expect(screen.getByRole("treeitem", { name: /^F1–2 · Futtertasche/ })).toHaveFocus()); + }); + + it("edits the pocket level with a stepper", async () => { + const submit = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + expect(screen.getByRole("dialog", { name: "Futtertasche" })).toBeInTheDocument(); + await user.click(within(dialog()).getByRole("button", { name: /Flüssigfutter erhöhen/ })); + await user.click(save()); + expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "ff", key: "syrupLiters", value: 0.75 }]); + }); + + it("asks before removing the pocket, then removes it and focuses the box", async () => { + const submit = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Futtertasche entfernen" })); + expect(submit).not.toHaveBeenCalled(); + expect(screen.getByRole("group", { name: "Futtertasche wirklich entfernen?" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Ja, entfernen" })); + expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "ff" }]); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await waitFor(() => expect(screen.getByRole("treeitem", { name: /Zarge/ })).toHaveFocus()); + }); + }); }); diff --git a/ApiCore-Frontend/src/components/hive/part-sheet.tsx b/ApiCore-Frontend/src/components/hive/part-sheet.tsx index a419bd6..9504311 100644 --- a/ApiCore-Frontend/src/components/hive/part-sheet.tsx +++ b/ApiCore-Frontend/src/components/hive/part-sheet.tsx @@ -5,9 +5,12 @@ import type { RefObject } from "react"; import { ChoiceGroup } from "@/components/form/choice-group"; import { Button } from "@/components/ui/button"; import { SheetFooter } from "@/components/ui/sheet"; +import { freeFeederStarts } from "@/lib/hive/feeder-slots"; import { partDisplayName } from "@/lib/hive/names"; import { newIdempotencyKey } from "@/lib/offline/idempotency"; import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types"; +import { FrameFeederPicker, frameFeederOps } from "./frame-feeder-picker"; +import type { FrameFeederChoice } from "./frame-feeder-picker"; import type { HiveOutlineHandle } from "./hive-outline"; import { HiveSheetShell } from "./hive-sheet-shell"; import { @@ -25,6 +28,11 @@ const INSERT_OPTIONS = [ { value: true, label: "drin" }, { value: false, label: "draußen" }, ]; +// Inserts the sheet can remove, with the noun used in its buttons. +const REMOVABLE_NOUNS: Partial> = { + EVAPORATOR: "Verdunster", + FRAME_FEEDER: "Futtertasche", +}; const EVAPORATOR_OPTIONS = [ { value: false, label: "Kein Verdunster" }, { value: true, label: "Verdunster einsetzen" }, @@ -42,6 +50,8 @@ export interface PartSheetProps { outlineRef: RefObject; /** Opens a box with the evaporator already chosen (the outline's „Verdunster einsetzen“ action). */ startWithEvaporator?: boolean; + /** Opens a box with the frame feeder already chosen (the outline's „Futtertasche einsetzen“ action). */ + startWithFrameFeeder?: boolean; } interface Located { @@ -78,18 +88,26 @@ interface PartFormProps { type: CatalogPartType; catalog: HiveCatalog; startWithEvaporator: boolean; + startWithFrameFeeder: boolean; submit: SubmitOps; /** `focusId` is the part whose item should get focus once the sheet is closed. */ onDone: (focusId: string) => void; } -function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone }: PartFormProps) { +function PartForm({ located, type, catalog, startWithEvaporator, startWithFrameFeeder, submit, onDone }: PartFormProps) { const { part, host } = located; const floorInsertType = type.kind === "FLOOR" ? activeTypeOf(catalog, "FLOOR_INSERT") : undefined; const currentFloorInsert = type.kind === "FLOOR" ? insertOfKind(part, catalog, "FLOOR_INSERT") : undefined; const evaporatorType = type.kind === "BOX" && !insertOfKind(part, catalog, "EVAPORATOR") ? activeTypeOf(catalog, "EVAPORATOR") : undefined; + const frameFeederType = type.kind === "BOX" ? activeTypeOf(catalog, "FRAME_FEEDER") : undefined; + const feederStarts = frameFeederType ? freeFeederStarts(part, type, frameFeederType, catalog) : []; + const [feederChoice, setFeederChoice] = useState(() => ({ + wants: startWithFrameFeeder && feederStarts.length > 0, + start: feederStarts[0] ?? 1, + draft: frameFeederType ? draftOf(frameFeederType, {}) : {}, + })); const [draft, setDraft] = useState(() => draftOf(type, part.state)); const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined); const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined); @@ -108,8 +126,13 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone wasConfirming.current = confirmingRemoval; }, [confirmingRemoval]); const { send, pending, error } = useSheetSubmit(submit); + const removalNoun = REMOVABLE_NOUNS[type.kind] ?? null; // Fixed per form, so a retry after an error sends the same ids. - const [newIds] = useState(() => ({ floorInsert: newIdempotencyKey(), evaporator: newIdempotencyKey() })); + const [newIds] = useState(() => ({ + floorInsert: newIdempotencyKey(), + evaporator: newIdempotencyKey(), + frameFeeder: newIdempotencyKey(), + })); const changedStateOps: HiveOp[] = type.stateSchema.flatMap((field) => isFieldChanged(field, draft[field.key] ?? "", part.state[field.key]) @@ -124,19 +147,29 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone } const evaporatorToInsert = evaporatorType && wantsEvaporator ? newPart(newIds.evaporator, evaporatorType, evaporatorDraft) : null; if (evaporatorToInsert) ops.push({ op: "addInsert", hostId: part.id, part: evaporatorToInsert }); + const feederToInsert = + frameFeederType && feederChoice.wants && feederStarts.includes(feederChoice.start) + ? newPart(newIds.frameFeeder, frameFeederType, feederChoice.draft) + : null; + if (frameFeederType && feederToInsert) { + ops.push(...frameFeederOps(part.id, part.frames ?? [], frameFeederType, feederChoice.start, feederToInsert)); + } const valid = areDraftFieldsValid(type, draft) && - (!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft)); + (!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft)) && + (!frameFeederType || !feederChoice.wants || areDraftFieldsValid(frameFeederType, feederChoice.draft)); async function save() { - // After inserting an evaporator, focus goes to the new item. + // After inserting an evaporator or a frame feeder, focus goes to the new item. const outcome = await send(ops); - if (outcome !== "failed") onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? part.id) : part.id); + if (outcome !== "failed") { + onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? feederToInsert?.id ?? part.id) : part.id); + } } async function remove() { - // An evaporator is an insert: focus goes to its box afterwards. + // An evaporator or frame feeder is an insert: focus goes to its box afterwards. const outcome = await send([{ op: "removePart", id: part.id }]); if (outcome !== "failed") onDone(outcome === "saved" ? (host?.id ?? part.id) : part.id); } @@ -197,6 +230,16 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone : null}
) : null} + {frameFeederType && feederStarts.length > 0 ? ( + + ) : null} {error ? (

{error} @@ -206,7 +249,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone - {type.kind === "EVAPORATOR" ? ( + {removalNoun ? ( confirmingRemoval ? (

- Verdunster wirklich entfernen? + {removalNoun} wirklich entfernen?

) ) : null} @@ -255,6 +298,7 @@ export function PartSheet({ submit, outlineRef, startWithEvaporator = false, + startWithFrameFeeder = false, }: PartSheetProps) { const located = locate(state, partId); const type = located ? catalog.partTypes.get(located.part.partTypeId) : undefined; @@ -279,6 +323,7 @@ export function PartSheet({ type={type} catalog={catalog} startWithEvaporator={startWithEvaporator} + startWithFrameFeeder={startWithFrameFeeder} submit={submit} onDone={(id) => { focusAfterSave.current = id; diff --git a/ApiCore-Frontend/src/components/hive/use-hive-view.spec.tsx b/ApiCore-Frontend/src/components/hive/use-hive-view.spec.tsx index 84142a8..dbc4df6 100644 --- a/ApiCore-Frontend/src/components/hive/use-hive-view.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/use-hive-view.spec.tsx @@ -1,13 +1,13 @@ import { describe, expect, it, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; import type { ReactNode } from "react"; -import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view"; +import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view"; describe("createHiveViewStore", () => { it("starts empty and keeps the same state object until something changes", () => { const store = createHiveViewStore(); const first = store.getState(); - expect(first).toEqual({ hoveredId: null, openBoxId: null, hoveredFrame: null }); + expect(first).toEqual({ hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null }); store.setHovered(null); expect(store.getState()).toBe(first); store.setHovered("a"); @@ -42,6 +42,27 @@ describe("createHiveViewStore", () => { expect(store.getState()).toMatchObject({ openBoxId: null, hoveredFrame: null }); }); + it("focusPart focuses a non-box part and closes an open box", () => { + const store = createHiveViewStore(); + store.openBox("b"); + store.setHoveredFrame({ boxId: "b", slot: 2 }); + store.focusPart("fe"); + expect(store.getState()).toMatchObject({ openBoxId: null, focusedPartId: "fe", hoveredFrame: null }); + expect(focusedIdOf(store.getState())).toBe("fe"); + }); + + it("openBox replaces a focused part, openBox(null) leaves focus mode completely", () => { + const store = createHiveViewStore(); + store.focusPart("fe"); + store.openBox("b"); + expect(store.getState()).toMatchObject({ openBoxId: "b", focusedPartId: null }); + expect(focusedIdOf(store.getState())).toBe("b"); + store.focusPart("fe"); + store.openBox(null); + expect(store.getState()).toMatchObject({ openBoxId: null, focusedPartId: null }); + expect(focusedIdOf(store.getState())).toBeNull(); + }); + it("treats an equal frame as no change", () => { const store = createHiveViewStore(); store.setHoveredFrame({ boxId: "b", slot: 2 }); @@ -63,6 +84,8 @@ describe("useHiveView", () => { expect(result.current.hoveredId).toBe("x"); act(() => result.current.openBox("b")); expect(result.current.openBoxId).toBe("b"); + act(() => result.current.focusPart("fe")); + expect(result.current.focusedPartId).toBe("fe"); }); it("throws outside a provider", () => { diff --git a/ApiCore-Frontend/src/components/hive/use-hive-view.ts b/ApiCore-Frontend/src/components/hive/use-hive-view.ts index f08be7a..2a35d49 100644 --- a/ApiCore-Frontend/src/components/hive/use-hive-view.ts +++ b/ApiCore-Frontend/src/components/hive/use-hive-view.ts @@ -13,22 +13,36 @@ export interface HoveredFrame { export interface HiveViewState { hoveredId: string | null; + /** The box in focus mode (its frames are shown). */ openBoxId: string | null; + /** Any other part in focus mode (feeder, floor, lid, insert …); never set together with openBoxId. */ + focusedPartId: string | null; hoveredFrame: HoveredFrame | null; } +/** The part in focus mode, box or not; null in the rest view. */ +export function focusedIdOf(state: HiveViewState): string | null { + return state.openBoxId ?? state.focusedPartId; +} + export interface HiveViewStore { getState(): HiveViewState; subscribe(listener: () => void): () => void; setHovered(id: string | null): void; - /** Opens a box (clears the hovered frame); null closes it and clears the hovered frame too. */ + /** + * Opens a box (clears the hovered frame and any focused part); null leaves + * focus mode completely (no open box, no focused part, no hovered frame). + */ openBox(id: string | null): void; + /** Focuses a part that is not a box (closes an open box, clears the hovered frame). */ + focusPart(id: string): void; setHoveredFrame(frame: HoveredFrame | null): void; } export interface HiveView extends HiveViewState { setHovered(id: string | null): void; openBox(id: string | null): void; + focusPart(id: string): void; setHoveredFrame(frame: HoveredFrame | null): void; } @@ -36,7 +50,7 @@ const sameFrame = (a: HoveredFrame | null, b: HoveredFrame | null): boolean => a === b || (a !== null && b !== null && a.boxId === b.boxId && a.slot === b.slot); export function createHiveViewStore(): HiveViewStore { - let state: HiveViewState = { hoveredId: null, openBoxId: null, hoveredFrame: null }; + let state: HiveViewState = { hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null }; const listeners = new Set<() => void>(); // The state object only changes when a value really changes, so @@ -46,6 +60,7 @@ export function createHiveViewStore(): HiveViewStore { if ( next.hoveredId === state.hoveredId && next.openBoxId === state.openBoxId && + next.focusedPartId === state.focusedPartId && sameFrame(next.hoveredFrame, state.hoveredFrame) ) { return; @@ -63,7 +78,8 @@ export function createHiveViewStore(): HiveViewStore { }; }, setHovered: (id) => update({ hoveredId: id }), - openBox: (id) => update({ openBoxId: id, hoveredFrame: null }), + openBox: (id) => update({ openBoxId: id, focusedPartId: null, hoveredFrame: null }), + focusPart: (id) => update({ openBoxId: null, focusedPartId: id, hoveredFrame: null }), setHoveredFrame: (frame) => update({ hoveredFrame: frame }), }; } @@ -83,6 +99,7 @@ export function useHiveView(): HiveView { ...state, setHovered: store.setHovered, openBox: store.openBox, + focusPart: store.focusPart, setHoveredFrame: store.setHoveredFrame, }), [state, store], diff --git a/ApiCore-Frontend/src/lib/hive/catalog-api.ts b/ApiCore-Frontend/src/lib/hive/catalog-api.ts index 5895385..96a3c05 100644 --- a/ApiCore-Frontend/src/lib/hive/catalog-api.ts +++ b/ApiCore-Frontend/src/lib/hive/catalog-api.ts @@ -24,6 +24,8 @@ export interface HivePartTypeInput { heightMm: number; boxDepth?: HiveBoxDepth; frameSlots?: number; + /** Only FRAME_FEEDER: frame slots the pocket covers (1-5). */ + slotWidth?: number; stateSchema?: StateField[]; } diff --git a/ApiCore-Frontend/src/lib/hive/catalog-form.ts b/ApiCore-Frontend/src/lib/hive/catalog-form.ts index 7466457..9555dba 100644 --- a/ApiCore-Frontend/src/lib/hive/catalog-form.ts +++ b/ApiCore-Frontend/src/lib/hive/catalog-form.ts @@ -8,6 +8,8 @@ import type { StateField } from "./types"; export const MAX_DIMENSION_MM = 5000; /** Upper bound of `frameSlots` in the backend DTO. */ export const MAX_FRAME_SLOTS = 20; +/** Upper bound of `slotWidth` (frame feeder) in the backend DTO. */ +export const MAX_SLOT_WIDTH = 5; export const MAX_KEY_LENGTH = 40; export const MAX_FIELD_LABEL_LENGTH = 60; export const MAX_UNIT_LENGTH = 10; diff --git a/ApiCore-Frontend/src/lib/hive/facts.ts b/ApiCore-Frontend/src/lib/hive/facts.ts index a18a89d..5a9d706 100644 --- a/ApiCore-Frontend/src/lib/hive/facts.ts +++ b/ApiCore-Frontend/src/lib/hive/facts.ts @@ -20,6 +20,7 @@ export function catalogFromSystem(system: HiveSystemDto): HiveCatalog { widthMm: row.widthMm, heightMm: row.heightMm, frameSlots: row.frameSlots, + slotWidth: row.slotWidth ?? null, stateSchema: row.stateSchema, active: row.active, })); diff --git a/ApiCore-Frontend/src/lib/hive/feeder-slots.spec.ts b/ApiCore-Frontend/src/lib/hive/feeder-slots.spec.ts new file mode 100644 index 0000000..4b9de96 --- /dev/null +++ b/ApiCore-Frontend/src/lib/hive/feeder-slots.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { feederRange, freeFeederStarts, pocketRanges, rangeText } from "./feeder-slots"; +import { frames, inlineCatalog, part } from "./test-utils"; + +const catalog = inlineCatalog(); +const boxType = catalog.partTypes.get("box")!; +const feederType = catalog.partTypes.get("frame-feeder")!; + +describe("feeder slots", () => { + it("reads the covered range from slot and slotWidth", () => { + expect(feederRange(part("p", "frame-feeder", { slot: 3 }), feederType)).toEqual({ from: 3, to: 4 }); + expect(feederRange(part("p", "frame-feeder"), feederType)).toBeNull(); + expect(feederRange(part("e", "evaporator", { slot: 1 }), catalog.partTypes.get("evaporator"))).toBeNull(); + }); + + it("offers every start in an empty box", () => { + const box = part("b", "box", { frames: frames(10) }); + expect(freeFeederStarts(box, boxType, feederType, catalog)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it("leaves out starts that overlap a pocket", () => { + const box = part("b", "box", { frames: frames(10), inserts: [part("p", "frame-feeder", { slot: 4 })] }); + expect(pocketRanges(box, catalog)).toEqual([{ from: 4, to: 5 }]); + expect(freeFeederStarts(box, boxType, feederType, catalog)).toEqual([1, 2, 6, 7, 8, 9]); + }); + + it("offers nothing in a box without slots", () => { + const empty = { ...boxType, frameSlots: 0 }; + expect(freeFeederStarts(part("b", "box"), empty, feederType, catalog)).toEqual([]); + }); + + it("words a range", () => { + expect(rangeText({ from: 1, to: 2 })).toBe("1–2"); + expect(rangeText({ from: 3, to: 3 })).toBe("3"); + }); +}); diff --git a/ApiCore-Frontend/src/lib/hive/feeder-slots.ts b/ApiCore-Frontend/src/lib/hive/feeder-slots.ts new file mode 100644 index 0000000..8523a02 --- /dev/null +++ b/ApiCore-Frontend/src/lib/hive/feeder-slots.ts @@ -0,0 +1,50 @@ +import type { CatalogPartType, HiveCatalog, HivePart } from "./types"; + +// Frame feeder (Futtertasche) positions in a box. A pocket hangs in place of +// `slotWidth` frames from its `slot` on (spec 4.1); the server rule is +// RULE_FEEDER_SLOT. + +export interface SlotRange { + from: number; + to: number; +} + +/** Slots a frame feeder insert covers; null without a usable slot or width. */ +export function feederRange(insert: HivePart, type: CatalogPartType | undefined): SlotRange | null { + if (type?.kind !== "FRAME_FEEDER") return null; + const width = type.slotWidth; + if (typeof insert.slot !== "number" || !width) return null; + return { from: insert.slot, to: insert.slot + width - 1 }; +} + +/** Ranges of the frame feeders already hanging in a box. */ +export function pocketRanges(box: HivePart, catalog: HiveCatalog): SlotRange[] { + return (box.inserts ?? []).flatMap((insert) => { + const range = feederRange(insert, catalog.partTypes.get(insert.partTypeId)); + return range ? [range] : []; + }); +} + +/** First slots where a new frame feeder of `feederType` still fits (inside the box, no overlap). */ +export function freeFeederStarts( + box: HivePart, + boxType: CatalogPartType, + feederType: CatalogPartType, + catalog: HiveCatalog, +): number[] { + const width = feederType.slotWidth ?? 0; + const slots = boxType.frameSlots ?? 0; + if (width < 1) return []; + const taken = pocketRanges(box, catalog); + const starts: number[] = []; + for (let from = 1; from + width - 1 <= slots; from++) { + const to = from + width - 1; + if (!taken.some((range) => from <= range.to && to >= range.from)) starts.push(from); + } + return starts; +} + +/** „1–2“ for a range, „3“ for a single slot. */ +export function rangeText(range: SlotRange): string { + return range.from === range.to ? String(range.from) : `${range.from}–${range.to}`; +} diff --git a/ApiCore-Frontend/src/lib/hive/follow-ups.spec.ts b/ApiCore-Frontend/src/lib/hive/follow-ups.spec.ts index 9b21cdd..39ca77e 100644 --- a/ApiCore-Frontend/src/lib/hive/follow-ups.spec.ts +++ b/ApiCore-Frontend/src/lib/hive/follow-ups.spec.ts @@ -114,4 +114,40 @@ describe("hiveFollowUps", () => { { kind: "feeding", amount: 1.5, unit: "kg", feedType: "FEED_PASTE" }, ]); }); + + describe("frame feeder", () => { + function withPocket(syrup?: number): HiveState { + const state = templateState(); + const frames = state.stack[1].frames!; + frames[0] = { slot: 1, type: "NONE" }; + frames[1] = { slot: 2, type: "NONE" }; + state.stack[1].inserts = [ + { ...part("ff", "frame-feeder", { slot: 1 }), state: syrup === undefined ? {} : { syrupLiters: syrup } }, + ]; + return state; + } + + it("suggests a feeding when the pocket level rises", () => { + const ops: HiveOp[] = [{ op: "setState", id: "ff", key: "syrupLiters", value: 1.5 }]; + expect(run(withPocket(), ops)).toEqual([ + { kind: "feeding", amount: 1.5, unit: "l", feedType: "SUGAR_WATER" }, + ]); + }); + + it("suggests nothing when the pocket level falls", () => { + const ops: HiveOp[] = [{ op: "setState", id: "ff", key: "syrupLiters", value: 0.5 }]; + expect(run(withPocket(1.5), ops)).toEqual([]); + }); + + it("suggests the filled amount for a pocket added already filled", () => { + const before = templateState(); + const frames = before.stack[1].frames!; + frames[0] = { slot: 1, type: "NONE" }; + frames[1] = { slot: 2, type: "NONE" }; + const filled = { ...part("ff", "frame-feeder", { slot: 1 }), state: { syrupLiters: 1 } }; + expect(run(before, [{ op: "addInsert", hostId: "b", part: filled }])).toEqual([ + { kind: "feeding", amount: 1, unit: "l", feedType: "SUGAR_WATER" }, + ]); + }); + }); }); diff --git a/ApiCore-Frontend/src/lib/hive/follow-ups.ts b/ApiCore-Frontend/src/lib/hive/follow-ups.ts index 97b4038..6c88877 100644 --- a/ApiCore-Frontend/src/lib/hive/follow-ups.ts +++ b/ApiCore-Frontend/src/lib/hive/follow-ups.ts @@ -10,6 +10,9 @@ export type FollowUp = const FEED_TYPES: readonly FeedType[] = ["SUGAR_WATER", "FEED_SYRUP", "FEED_PASTE", "OWN_HONEY", "OTHER"]; const ACID_KEY = "acidMl"; + +/** Part kinds that hold liquid or paste feed (stack feeder and frame feeder pocket). */ +const isFeedKind = (kind: string | undefined): boolean => kind === "FEEDER" || kind === "FRAME_FEEDER"; const ROUNDING = 1000; function findPart(state: HiveState, id: string): HivePart | undefined { @@ -89,13 +92,13 @@ export function hiveFollowUps( const was = findPart(before, op.id); const kind = typeOf(now, catalog)?.kind; if (now && kind === "EVAPORATOR" && op.key === ACID_KEY) followUps.push(treatmentFor(was, now)); - else if (now && kind === "FEEDER") followUps.push(feedingForPart(was, now, op.key, catalog)); + else if (now && isFeedKind(kind)) followUps.push(feedingForPart(was, now, op.key, catalog)); } else if (op.op === "addInsert" || op.op === "addPart") { const now = findPart(after, op.part.id); const type = typeOf(now, catalog); if (now && type?.kind === "EVAPORATOR" && once(now.id, ACID_KEY)) { followUps.push(treatmentFor(findPart(before, now.id), now)); - } else if (now && type?.kind === "FEEDER") { + } else if (now && type && isFeedKind(type.kind)) { for (const field of type.stateSchema) { if (field.kind === "level" && once(now.id, field.key)) { followUps.push(feedingForPart(findPart(before, now.id), now, field.key, catalog)); diff --git a/ApiCore-Frontend/src/lib/hive/labels.ts b/ApiCore-Frontend/src/lib/hive/labels.ts index ed07d9c..f4d9cbb 100644 --- a/ApiCore-Frontend/src/lib/hive/labels.ts +++ b/ApiCore-Frontend/src/lib/hive/labels.ts @@ -27,4 +27,5 @@ export const PART_KIND_LABELS: Record = { ROOF: "Dach", FLOOR_INSERT: "Bausperre", EVAPORATOR: "Verdunster", + FRAME_FEEDER: "Futtertasche", }; diff --git a/ApiCore-Frontend/src/lib/hive/layout.spec.ts b/ApiCore-Frontend/src/lib/hive/layout.spec.ts index 7a3b83e..7beae76 100644 --- a/ApiCore-Frontend/src/lib/hive/layout.spec.ts +++ b/ApiCore-Frontend/src/lib/hive/layout.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { hiveLayout } from "./layout"; +import { FLOOR_GRID_DEPTH, hiveLayout } from "./layout"; import { inlineCatalog, part, templateState } from "./test-utils"; describe("hiveLayout", () => { @@ -49,14 +49,16 @@ describe("hiveLayout", () => { }); }); - it("puts the floor insert 5 mm above the floor bottom and the evaporator on the box bottom", () => { + it("lays the floor insert on the floor's grid (40 mm below the floor top) and the evaporator on the box bottom", () => { const state = templateState(); state.stack[0].inserts = [part("fi", "floor-insert")]; state.stack[1].inserts = [part("ev", "evaporator")]; const layout = hiveLayout(state, catalog); const insert = layout.inserts.find((i) => i.id === "fi")!; const evaporator = layout.inserts.find((i) => i.id === "ev")!; - expect(insert.center[1]).toBeCloseTo(0.05, 9); + // Floor top 1.2, grid 40 mm (0.4 units) below it, Bausperre 5 mm thick lying on the grid. + expect(insert.center[1]).toBeCloseTo(1.2 - FLOOR_GRID_DEPTH + 0.025, 9); + expect(FLOOR_GRID_DEPTH).toBeCloseTo(0.4, 9); expect(insert.kind).toBe("FLOOR_INSERT"); expect(evaporator.center[1]).toBeCloseTo(1.2 + 0.6, 9); expect(evaporator.stackIndex).toBe(1); @@ -66,4 +68,27 @@ describe("hiveLayout", () => { const layout = hiveLayout({ schemaVersion: 1, stack: [] }, catalog); expect(layout).toEqual({ parts: [], inserts: [], frames: [], bounds: { w: 0, h: 0, d: 0 } }); }); + + it("lays a frame feeder over its slots with the frame height", () => { + const state = templateState(); + const frames = state.stack[1].frames!; + frames[2] = { slot: 3, type: "NONE" }; + frames[3] = { slot: 4, type: "NONE" }; + state.stack[1].inserts = [part("ff", "frame-feeder", { slot: 3 })]; + const layout = hiveLayout(state, catalog); + const pocket = layout.inserts.find((i) => i.id === "ff")!; + const inner = 5.25 - 0.4; + const pitch = inner / 10; + const box = layout.parts[1]; + expect(pocket.kind).toBe("FRAME_FEEDER"); + expect(pocket.stackIndex).toBe(1); + expect(pocket.size[0]).toBeCloseTo(2 * pitch, 9); + expect(pocket.size[1]).toBeCloseTo(2.2, 9); + expect(pocket.size[2]).toBeCloseTo(4.3 - 0.4, 9); + expect(pocket.center[0]).toBeCloseTo(-inner / 2 + pitch * 3, 9); + expect(pocket.center[1]).toBeCloseTo(box.center[1], 9); + // The pocket sits exactly where frames 3 and 4 would hang. + const covered = layout.frames.filter((f) => f.slot === 3 || f.slot === 4).map((f) => f.center[0]); + expect(pocket.center[0]).toBeCloseTo((covered[0] + covered[1]) / 2, 9); + }); }); diff --git a/ApiCore-Frontend/src/lib/hive/layout.ts b/ApiCore-Frontend/src/lib/hive/layout.ts index 7125645..ee11c33 100644 --- a/ApiCore-Frontend/src/lib/hive/layout.ts +++ b/ApiCore-Frontend/src/lib/hive/layout.ts @@ -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): 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: [-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 } }; diff --git a/ApiCore-Frontend/src/lib/hive/reason-text.ts b/ApiCore-Frontend/src/lib/hive/reason-text.ts index 62bdcc5..f7e9292 100644 --- a/ApiCore-Frontend/src/lib/hive/reason-text.ts +++ b/ApiCore-Frontend/src/lib/hive/reason-text.ts @@ -8,6 +8,7 @@ const TEXTS: Record = { FRAMES_LENGTH: "Dieses Rähmchen gibt es in der Zarge nicht.", RULE_INSERT_HOST: "Dieses Teil passt nicht an diese Stelle.", RULE_INSERT_LIMIT: "Davon passt nur eines an diese Stelle.", + RULE_FEEDER_SLOT: "Die Futtertasche passt dort nicht hin.", RULE_FLOOR_BOTTOM: "Der Boden muss ganz unten liegen.", RULE_ROOF_TOP: "Das Dach muss ganz oben liegen.", STATE_OUT_OF_RANGE: "Dieser Wert ist nicht erlaubt.", diff --git a/ApiCore-Frontend/src/lib/hive/test-utils.ts b/ApiCore-Frontend/src/lib/hive/test-utils.ts index 13f2505..02e261b 100644 --- a/ApiCore-Frontend/src/lib/hive/test-utils.ts +++ b/ApiCore-Frontend/src/lib/hive/test-utils.ts @@ -66,6 +66,7 @@ function type( widthMm, heightMm, frameSlots: null, + slotWidth: null, stateSchema: noState, active: true, ...extra, @@ -93,6 +94,12 @@ export function inlineCatalog(): HiveCatalog { }), type("insulation", "INSULATION", 465, 430, 20), type("roof", "ROOF", 525, 430, 60), + type("frame-feeder", "FRAME_FEEDER", 477, 70, 220, { + slotWidth: 2, + stateSchema: [ + { key: "syrupLiters", label: "Flüssigfutter", kind: "level", unit: "l", max: 2, step: 0.25, feedType: "SUGAR_WATER" }, + ], + }), type("evaporator", "EVAPORATOR", 200, 150, 120, { stateSchema: [{ key: "acidMl", label: "Säure", kind: "level", unit: "ml", max: 300, step: 10 }], }), diff --git a/ApiCore-Frontend/src/lib/hive/types.ts b/ApiCore-Frontend/src/lib/hive/types.ts index 33fc771..c274d10 100644 --- a/ApiCore-Frontend/src/lib/hive/types.ts +++ b/ApiCore-Frontend/src/lib/hive/types.ts @@ -14,7 +14,8 @@ export type HivePartKind = | "INSULATION" | "ROOF" | "FLOOR_INSERT" - | "EVAPORATOR"; + | "EVAPORATOR" + | "FRAME_FEEDER"; export type HiveBoxDepth = "FULL" | "SHALLOW"; @@ -61,6 +62,8 @@ export interface HivePart { state: Record; 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; } diff --git a/ApiCore-Frontend/src/lib/hive/validate.ts b/ApiCore-Frontend/src/lib/hive/validate.ts index 9403ba3..d344684 100644 --- a/ApiCore-Frontend/src/lib/hive/validate.ts +++ b/ApiCore-Frontend/src/lib/hive/validate.ts @@ -13,10 +13,12 @@ import { // 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. Text lengths are UTF-16 code units -// (`.length`). +// 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`). 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"]; type Obj = Record; @@ -144,10 +146,70 @@ function validatePartCommon( return { part, type }; } -function allowedInsertKind(hostType: CatalogPartType): string | null { - if (hostType.kind === "FLOOR") return "FLOOR_INSERT"; - if (hostType.kind === "BOX") return "EVAPORATOR"; - return null; +const INSERT_KINDS: Partial> = { + FLOOR: ["FLOOR_INSERT"], + BOX: ["EVAPORATOR", "FRAME_FEEDER"], +}; + +const INSERT_ONLY_KINDS: string[] = ["FLOOR_INSERT", "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 || width === undefined || !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, where: string, ctx: WalkContext): void { @@ -158,25 +220,34 @@ function validateInserts(host: Obj, hostType: CatalogPartType, where: string, ct out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`); return; } - const allowed = allowedInsertKind(hostType); + 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) 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 ")}`); } const nested = checked.part.inserts; if (nested !== undefined && (!Array.isArray(nested) || nested.length > 0)) { 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(kinds: (CatalogPartType | null)[], out: string[]): void { @@ -207,9 +278,12 @@ export function validateHiveState(state: unknown, catalog: HiveCatalog): string[ const checked = validatePartCommon(entry, where, ctx); types.push(checked?.type ?? null); if (!checked) return; - if (checked.type.kind === "FLOOR_INSERT" || checked.type.kind === "EVAPORATOR") { + if (INSERT_ONLY_KINDS.includes(checked.type.kind)) { 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); diff --git a/ApiCore-Frontend/src/lib/hive/webgl.spec.ts b/ApiCore-Frontend/src/lib/hive/webgl.spec.ts new file mode 100644 index 0000000..593acbe --- /dev/null +++ b/ApiCore-Frontend/src/lib/hive/webgl.spec.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { canUseWebGL, resetWebGLProbe, shouldUse3D } from "./webgl"; + +const base = { webgl: true, motion: "full", cores: 8, forcedColors: false } as const; + +describe("shouldUse3D", () => { + it("is true with WebGL and a normal setup", () => { + expect(shouldUse3D(base)).toBe(true); + }); + + it("is false without WebGL", () => { + expect(shouldUse3D({ ...base, webgl: false })).toBe(false); + }); + + it("is false with forced colors", () => { + expect(shouldUse3D({ ...base, forcedColors: true })).toBe(false); + }); + + it("is false with motion off on a weak device (2 cores or fewer)", () => { + expect(shouldUse3D({ ...base, motion: "off", cores: 2 })).toBe(false); + expect(shouldUse3D({ ...base, motion: "off", cores: 1 })).toBe(false); + }); + + it("keeps 3D with motion off on a stronger device", () => { + expect(shouldUse3D({ ...base, motion: "off", cores: 4 })).toBe(true); + }); + + it("keeps 3D on a weak device when motion is not off", () => { + expect(shouldUse3D({ ...base, motion: "subtle", cores: 2 })).toBe(true); + }); +}); + +describe("canUseWebGL", () => { + afterEach(() => { + vi.restoreAllMocks(); + resetWebGLProbe(); + }); + + it("is false when no context can be created (jsdom)", () => { + expect(canUseWebGL()).toBe(false); + }); + + it("is true when a context is available, and probes only once", () => { + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({} as unknown as RenderingContext); + expect(canUseWebGL()).toBe(true); + expect(canUseWebGL()).toBe(true); + expect(getContext).toHaveBeenCalledTimes(1); + }); + + it("is false when creating the context throws", () => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => { + throw new Error("blocked"); + }); + expect(canUseWebGL()).toBe(false); + }); +}); diff --git a/ApiCore-Frontend/src/lib/hive/webgl.ts b/ApiCore-Frontend/src/lib/hive/webgl.ts new file mode 100644 index 0000000..00743c8 --- /dev/null +++ b/ApiCore-Frontend/src/lib/hive/webgl.ts @@ -0,0 +1,37 @@ +import type { MotionLevel } from "@/lib/motion/motion-preference"; + +/** At or below this many cores, motion `off` keeps the card on the SVG (spec 9.7). */ +const WEAK_DEVICE_CORES = 2; + +let probed: boolean | undefined; + +/** Whether a WebGL context can be created here. Probed once, then cached. */ +export function canUseWebGL(): boolean { + if (probed !== undefined) return probed; + if (typeof document === "undefined") return false; + try { + const canvas = document.createElement("canvas"); + probed = Boolean(canvas.getContext("webgl2") ?? canvas.getContext("webgl")); + } catch { + probed = false; + } + return probed; +} + +/** Test hook: forget the cached probe result. */ +export function resetWebGLProbe(): void { + probed = undefined; +} + +export interface Use3DInput { + webgl: boolean; + motion: MotionLevel; + cores: number; + forcedColors: boolean; +} + +/** The card shows the 3D scene only when all of this holds; otherwise it stays on the SVG. */ +export function shouldUse3D({ webgl, motion, cores, forcedColors }: Use3DInput): boolean { + if (!webgl || forcedColors) return false; + return !(motion === "off" && cores <= WEAK_DEVICE_CORES); +} diff --git a/docs/superpowers/specs/2026-10-15-beute-3d-design.md b/docs/superpowers/specs/2026-10-15-beute-3d-design.md index 25bd9b6..ed6bf8e 100644 --- a/docs/superpowers/specs/2026-10-15-beute-3d-design.md +++ b/docs/superpowers/specs/2026-10-15-beute-3d-design.md @@ -93,6 +93,7 @@ Messbar fertig ist dieses Teilprojekt, wenn: | `ROOF` | Stapel | Platte mit Rand, über das Teil darunter gestülpt | Nur ganz oben. | | `FLOOR_INSERT` | Einlage im Boden | Gitterfläche im Boden | Bausperre. Höchstens eine je Boden. | | `EVAPORATOR` | Einsatz in einer Zarge | Flasche mit Pegel auf Docht-Platte | Ameisensäure-Verdunster. Liegt auf den Rähmchen der Zarge darunter, darum meist in einer Leerzarge. Höchstens einer je Zarge. | +| `FRAME_FEEDER` | Einsatz in einer Zarge, an Rähmchen-Plätzen | Futtertasche mit Pegel | Hängt anstelle von Rähmchen in der Zarge und belegt `slotWidth` Plätze (Teile-Art, Futtertasche doppelte Wabenbreite = 2) ab Platz `slot` (Feld am Einsatz). Die belegten Plätze müssen im `frames`-Array der Zarge den Typ `NONE` haben. Mehrere je Zarge erlaubt, ohne Überlappung. Zustandsfeld Flüssigfutter in l. | Alle anderen Reihenfolgen sind erlaubt. Ungewöhnliche Folgen (Absperrgitter direkt auf dem Boden, Dämmung unter der Zarge) sind Hinweise in der Oberfläche, keine Sperre, weil Imker umbauen, wie es die Lage verlangt. @@ -135,6 +136,7 @@ Maße aus den Holtermann-Produktseiten (abgerufen 2026-10-15). Alle Werte sind K | Styropor-Einlage (selbst gebaut) | INSULATION | 465 × 365 × 20 | — | | Edelstahldeckel konisch Liebig | ROOF | 525 × 430 × 60 (Innenmaß) | — | | Nassenheider Verdunster Professional | EVAPORATOR | 200 × 140 × 120 | Ameisensäure 0–290 ml | +| Holz-Futtertasche Zander, doppelte Wabenbreite (Inhaber 2026-10-16; Vorlage Holtermann Art. 7142 in Normalmaß: 370 × 221 mm, Oberträger 394 mm) | FRAME_FEEDER, `slotWidth` 2 | 477 × 70 × 220 (Zander-Annahme) | Flüssigfutter 0–2 l, Schritt 0,25 (Annahme, Holtermann nennt kein Volumen) | Die Startdaten kommen per Migration mit **festen Ids** (`hive-system-zander-liebig-10`, `hive-part-zander-liebig-10-floor`, …), damit die Vorlage stabile Verweise hat und eine Wiederherstellung aus dem Backup in eine frische Installation keine doppelten Namen erzeugt (Abschnitt 5.1). @@ -217,7 +219,8 @@ model HiveChange { @@index([colonyId, occurredOn]) } -enum HivePartKind { FLOOR BOX DIVIDER_BOARD QUEEN_EXCLUDER FEEDER COVER_SHEET PROPOLIS_SCREEN INNER_LID INSULATION ROOF FLOOR_INSERT EVAPORATOR } +enum HivePartKind { FLOOR BOX DIVIDER_BOARD QUEEN_EXCLUDER FEEDER COVER_SHEET PROPOLIS_SCREEN INNER_LID INSULATION ROOF FLOOR_INSERT EVAPORATOR FRAME_FEEDER } +// Nachtrag 2026-10-16: HivePartType.slotWidth Int? (nur FRAME_FEEDER), HivePart.slot (nur FRAME_FEEDER-Einsatz) enum HiveBoxDepth { FULL SHALLOW } enum HiveChangeSource { INIT CARD EDITOR INSPECTION AI_SUGGESTION } ``` @@ -378,7 +381,8 @@ Der Zustand der Ansicht (berührtes Teil, offene Zarge, berührtes Rähmchen) li Inhaber-Entscheidung 2026-10-16 nach Bild-Entwürfen (`.superpowers/mockups/`, Richtung D/E/F, Kalibrierung G/H/I): Die Karte ist eine **randlose, tiefe Holo-Bühne** im warmen App-Rahmen. Qualitätsanspruch: wirkt wie ein Produkt-Render aus einer Keynote, nicht wie eine Linienzeichnung. -- **Bühne:** füllt die ganze Karte (Titel „Beute“, Stand-Zeile und Knöpfe liegen als Milchglas-Elemente über der Bühne). Tiefes Mitternachtsblau mit Vignette, ein dezentes Waben-Raster als spiegelnder Boden, wenige langsam treibende Lichtpartikel, weiche Lichtquelle von unten. +- **Bühne:** füllt die ganze Karte (Titel „Beute“, Stand-Zeile und Knöpfe liegen als Milchglas-Elemente über der Bühne). Tiefes Mitternachtsblau mit Vignette, ein dezentes Waben-Raster als spiegelnder Boden, wenige langsam treibende Lichtpartikel. Licht nur kühl (Blau/Cyan/Weiß), **kein goldenes oder warmes Leuchten** in der Ruhe-Ansicht (Inhaber 2026-10-16); warme Farben nur als Inhalt (Futter, Rähmchen-Look). +- **Fuß der Beute ist der Liebig-Wanderboden** (Inhaber 2026-10-16): flacher Kasten 525 × 430 × 120 mm mit Edelstahl-Gitter oben, darunter von hinten eingeschobene Varroa-Schublade, Kontrollklappe, Flugloch-Schieber vorn. Keine Füße, kein Sockel, kein vorstehendes Anflugbrett. - **Ruhe-Ansicht: alles reines Glas.** Jedes Teil (Dach, Deckel, Dämmung, Folie, Futterzarge, Zargen, Boden, Einlagen) ist klares Glas mit scharfen, blau leuchtenden Kanten (Bloom), leichter Lichtbrechung und Spiegelung. Kein Holz, keine Bienen, keine Farben. Rähmchen sind nur als feine Glas-Umrisse angedeutet, Füllstände nicht sichtbar. - **Fokus-Ansicht:** Das gewählte Teil zeigt seinen **Inhalt**: Futterzarge mit leuchtendem, leicht wogendem goldenem Flüssigfutter und einem Futterteig-Block mit Etiketten „Flüssigfutter 2,5 l von 3 l“, „Futterteig 1 kg“; Verdunster mit Säurepegel; Boden mit Bausperre bzw. Varroa-Schublade; Deckel und Dach mit ihren Zuständen. Zargen zeigen ihre Rähmchen als klare Glasplatten. - **Rähmchen-Look:** Ein Rähmchen bekommt seine Art als Farbe **und** Muster erst, wenn es angehoben (Hover/Tippen) oder ausgewählt ist: Brutwabe bernstein mit verdeckelter Brut-Wabenstruktur, Drohnenwabe dunkleres Bernstein mit gewölbten Zellen, Honigwabe gold glänzend, Pollenwabe orange-gefleckt, Futterwabe hellgold, Mittelwand helles Wachs-Raster, Leerwabe leere Zellen, Baurahmen offener Rahmen, Schied massive Glasplatte. Die Art steht zusätzlich als Wort im Etikett (T5: Zustand nie nur über Farbe).