feat: Beute-Editor mit Ziehen und Ablegen in 3D (Beute L4, Task 18)
Backend Image / build-and-push (push) Successful in 7m57s
Frontend Image / build-and-push (push) Successful in 18m33s
Manifests / validate (push) Successful in 23s

- Teile aus der Palette in die Szene ziehen: Stapel öffnet sich, erlaubte
  Plätze leuchten, Vorschau rastet ein; erlaubte Plätze per
  applyHiveOps auf einer Kopie (gleiche Regeln wie der Server)
- Vorhandene Teile verschieben, auf „Entfernen“ ziehen; Einsätze aus
  offenen Zargen ziehen, dünne Teile mit Mindest-Trefferhöhe
- Drücken auf ein Teil dreht das Modell nie (Prüfung erst beim Dreh-
  Beginn, passend zur Ereignisfolge von R3F); Abbruch mit Esc, Fenster-
  wechsel oder Maus ohne Taste
- Zoom 0,6–2,5 per Knöpfen (Fokus bleibt an den Grenzen), Rad und Pinch
- Verdunster verschieben fragt nicht mehr nach Behandlungsende;
  Ansage „… verschoben“; gemeinsame Helfer fresh-part und feeder-slots
- Neuzeichnen nur bei Zeigerbewegung, geteilte Materialien

Gates: Frontend lint, typecheck, 2804 Tests, build; 3D-Chunk 292 kB gzip.
Review (Opus) und Browserprüfung Maus/Touch.

Refs #21

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 10:03:43 +02:00
1 parent 61985afc4b
commit 45274f4301
39 files changed
+1923 -114

No files matched your search

@@ -73,6 +73,17 @@ describe("describeHiveOps", () => {
);
});
it("describes an insert taken out and put into another host (same id) as moved", () => {
const state = templateState();
state.stack.splice(2, 0, part("b2", "box"));
state.stack[1].inserts = [part("ev", "evaporator")];
const ops: HiveOp[] = [
{ op: "removePart", id: "ev" },
{ op: "addInsert", hostId: "b2", part: part("ev", "evaporator") },
];
expect(describeHiveOps(ops, state, catalog)).toBe("Verdunster verschoben");
});
it("resolves parts added earlier in the same batch", () => {
const ops: HiveOp[] = [
{ op: "addPart", part: { ...part("n", "box"), label: "Aufsatz" }, at: 2 },
+8 -1
View File
@@ -83,7 +83,14 @@ export function describeHiveOps(
const known = new Map<string, Named>();
for (const part of seen) collect(part, catalog, known);
for (const part of before.stack) collect(part, catalog, known);
const texts = ops.map((op) => describeOne(op, known, catalog));
// An insert taken out and put back in with the same id (drag to another host) is one move.
const reAdded = new Set(ops.flatMap((op) => (op.op === "addInsert" ? [op.part.id] : [])));
const moved = new Set(ops.flatMap((op) => (op.op === "removePart" && reAdded.has(op.id) ? [op.id] : [])));
const texts = ops.flatMap((op) => {
if (op.op === "removePart" && moved.has(op.id)) return [`${nameOf(known.get(op.id))} verschoben`];
if (op.op === "addInsert" && moved.has(op.part.id)) return [];
return [describeOne(op, known, catalog)];
});
if (texts.length <= MAX_SHOWN_OPS) return texts.join(", ");
const rest = texts.length - MAX_SHOWN_OPS;
return `${texts.slice(0, MAX_SHOWN_OPS).join(", ")} und ${rest} weitere`;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { feederRange, freeFeederStarts, pocketRanges, rangeText } from "./feeder-slots";
import { feederRange, frameFeederOps, freeFeederStarts, pocketRanges, rangeText } from "./feeder-slots";
import { frames, inlineCatalog, part } from "./test-utils";
const catalog = inlineCatalog();
@@ -34,3 +34,25 @@ describe("feeder slots", () => {
expect(rangeText({ from: 3, to: 3 })).toBe("3");
});
});
describe("frameFeederOps", () => {
const catalogForOps = inlineCatalog();
const pocketType = catalogForOps.partTypes.get("frame-feeder")!;
it("empties the covered frames first, then hangs the pocket at its slot", () => {
const boxFrames = frames(10);
const ops = frameFeederOps("b", boxFrames, pocketType, 3, { id: "ff", partTypeId: "frame-feeder", state: {} });
expect(ops[0]).toEqual({
op: "setFrames",
boxId: "b",
frames: boxFrames.map((frame) => (frame.slot === 3 || frame.slot === 4 ? { slot: frame.slot, type: "NONE" } : frame)),
});
expect(ops[1]).toEqual({ op: "addInsert", hostId: "b", part: { id: "ff", partTypeId: "frame-feeder", state: {}, slot: 3 } });
});
it("skips setFrames when the covered slots are empty already", () => {
const boxFrames = frames(10).map((frame) => (frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame));
const ops = frameFeederOps("b", boxFrames, pocketType, 1, { id: "ff", partTypeId: "frame-feeder", state: {} });
expect(ops).toEqual([{ op: "addInsert", hostId: "b", part: { id: "ff", partTypeId: "frame-feeder", state: {}, slot: 1 } }]);
});
});
+33 -1
View File
@@ -1,4 +1,4 @@
import type { CatalogPartType, HiveCatalog, HivePart } from "./types";
import type { CatalogPartType, HiveCatalog, HiveFrame, HiveOp, 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
@@ -48,3 +48,35 @@ export function freeFeederStarts(
export function rangeText(range: SlotRange): string {
return range.from === range.to ? String(range.from) : `${range.from}–${range.to}`;
}
/** Frame slots a frame feeder starting at `start` covers. */
export function coveredSlots(start: number, type: CatalogPartType): number[] {
const range = feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type);
if (!range) return [];
return Array.from({ length: range.to - range.from + 1 }, (_, index) => range.from + index);
}
/** Frames of the box with the covered slots set to NONE (names of covered frames are dropped). */
function emptiedFrames(frames: HiveFrame[], covered: number[]): HiveFrame[] {
return frames.map((frame) => (covered.includes(frame.slot) ? { slot: frame.slot, type: "NONE" } : frame));
}
/**
* Ops that hang a frame feeder at `start`: setFrames only if a covered frame is
* not empty yet, then addInsert. Shared by the box sheet, the add dialog and
* drag and drop.
*/
export function frameFeederOps(
boxId: string,
frames: HiveFrame[],
type: CatalogPartType,
start: number,
pocket: HivePart,
): HiveOp[] {
const covered = coveredSlots(start, type);
const needsEmptying = frames.some((frame) => covered.includes(frame.slot) && frame.type !== "NONE");
const ops: HiveOp[] = [];
if (needsEmptying) ops.push({ op: "setFrames", boxId, frames: emptiedFrames(frames, covered) });
ops.push({ op: "addInsert", hostId: boxId, part: { ...pocket, slot: start } });
return ops;
}
@@ -72,6 +72,17 @@ describe("hiveFollowUps", () => {
expect(run(before, [{ op: "removePart", id: "ev" }])).toEqual([{ kind: "endTreatment" }]);
});
it("suggests nothing when an evaporator only moves to another box (remove + add with the same id)", () => {
const before = templateState();
before.stack.splice(2, 0, part("b2", "box", { frames: before.stack[1].frames }));
before.stack[1].inserts = [{ ...part("ev", "evaporator"), state: { acidMl: 200 } }];
const ops: HiveOp[] = [
{ op: "removePart", id: "ev" },
{ op: "addInsert", hostId: "b2", part: { ...part("ev", "evaporator"), state: { acidMl: 200 } } },
];
expect(run(before, ops)).toEqual([]);
});
it("gives one treatment when an evaporator is added and raised in one batch", () => {
const evaporator = { ...part("ev", "evaporator"), state: { acidMl: 250 } };
const ops: HiveOp[] = [
+3 -1
View File
@@ -106,7 +106,9 @@ export function hiveFollowUps(
}
}
} else if (op.op === "removePart") {
if (typeOf(findPart(before, op.id), catalog)?.kind === "EVAPORATOR") followUps.push({ kind: "endTreatment" });
// Net difference: an evaporator that is back in the result (moved to another box) ends nothing.
const gone = typeOf(findPart(before, op.id), catalog)?.kind === "EVAPORATOR" && !findPart(after, op.id);
if (gone) followUps.push({ kind: "endTreatment" });
}
for (const followUp of followUps) if (followUp) out.push(followUp);
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { defaultPartState, freshPart } from "./fresh-part";
import { inlineCatalog } from "./test-utils";
const catalog = inlineCatalog();
describe("freshPart", () => {
it("gives a new box foundation frames for every slot and no state", () => {
const box = freshPart(catalog.partTypes.get("box")!, "b-new");
expect(box.state).toEqual({});
expect(box.frames).toHaveLength(10);
expect(box.frames!.every((frame) => frame.type === "FOUNDATION")).toBe(true);
});
it("fills every state field with its default: level 0, the first choice", () => {
expect(defaultPartState(catalog.partTypes.get("feeder")!)).toEqual({ syrupLiters: 0, fondantKg: 0 });
expect(defaultPartState(catalog.partTypes.get("inner-lid")!)).toEqual({ feedHole: "OPEN" });
expect(freshPart(catalog.partTypes.get("inner-lid")!, "l-new")).toEqual({
id: "l-new",
partTypeId: "inner-lid",
state: { feedHole: "OPEN" },
});
});
it("takes the given state instead of the defaults", () => {
expect(freshPart(catalog.partTypes.get("feeder")!, "f", { syrupLiters: 2, fondantKg: 0 }).state).toEqual({
syrupLiters: 2,
fondantKg: 0,
});
});
});
@@ -0,0 +1,22 @@
import type { CatalogPartType, HiveFrame, HivePart } from "./types";
// A new part as the editor adds it (dialog and drag and drop alike): every
// state field set to its default, a new box filled with foundation frames.
/** Level 0, the first option of a choice. */
export function defaultPartState(type: CatalogPartType): HivePart["state"] {
const state: HivePart["state"] = {};
for (const field of type.stateSchema) state[field.key] = field.kind === "level" ? 0 : (field.options[0] ?? "");
return state;
}
/** Frames of a new box: every slot a foundation (spec 9.5). */
export function newBoxFrames(type: CatalogPartType): HiveFrame[] {
return Array.from({ length: type.frameSlots ?? 0 }, (_, index): HiveFrame => ({ slot: index + 1, type: "FOUNDATION" }));
}
export function freshPart(type: CatalogPartType, id: string, state: HivePart["state"] = defaultPartState(type)): HivePart {
const part: HivePart = { id, partTypeId: type.id, state };
if (type.kind === "BOX") part.frames = newBoxFrames(type);
return part;
}