feat: Beute-Editor mit Ziehen und Ablegen in 3D (Beute L4, Task 18)
- 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:
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 },
|
||||
|
||||
@@ -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 } }]);
|
||||
});
|
||||
});
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user