From 45274f43018dcef5dd677bab1c93d683842da5f5 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 10:03:43 +0200 Subject: [PATCH] feat: Beute-Editor mit Ziehen und Ablegen in 3D (Beute L4, Task 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../hive/editor/add-part-dialog.tsx | 12 +- .../components/hive/editor/drop-slots.spec.ts | 121 +++++++++ .../src/components/hive/editor/drop-slots.ts | 106 ++++++++ .../hive/editor/editor-stage-overlay.spec.tsx | 28 +++ .../hive/editor/editor-stage-overlay.tsx | 100 ++++++++ .../components/hive/editor/hive-editor.tsx | 42 +++- .../hive/editor/part-palette.spec.tsx | 20 +- .../components/hive/editor/part-palette.tsx | 26 +- .../hive/editor/use-hive-drag.spec.ts | 178 +++++++++++++ .../components/hive/editor/use-hive-drag.ts | 221 +++++++++++++++++ .../components/hive/frame-feeder-picker.tsx | 32 +-- .../hive/hive-scene/camera-fit.spec.ts | 29 ++- .../components/hive/hive-scene/camera-fit.ts | 28 +++ .../components/hive/hive-scene/camera-rig.tsx | 15 +- .../hive/hive-scene/drop-anchors.spec.ts | 72 ++++++ .../hive/hive-scene/drop-anchors.ts | 122 +++++++++ .../hive/hive-scene/drop-targets.tsx | 234 ++++++++++++++++++ .../hive/hive-scene/hive-canvas.tsx | 10 +- .../hive-scene/hive-scene-content.spec.tsx | 130 +++++++++- .../hive/hive-scene/hive-scene-content.tsx | 64 ++++- .../components/hive/hive-scene/model-rig.tsx | 36 +-- .../hive/hive-scene/rig-pointer.spec.ts | 89 ++++++- .../components/hive/hive-scene/rig-pointer.ts | 63 +++++ .../hive/hive-scene/scene-editor.ts | 27 ++ .../components/hive/hive-scene/scene-kit.tsx | 11 +- .../hive/hive-scene/scene-mutations.ts | 17 +- .../components/hive/hive-scene/stack-part.tsx | 5 +- .../hive/hive-scene/use-hive-motion.ts | 27 +- .../components/hive/hive-visual-slot.spec.tsx | 13 +- .../src/components/hive/hive-visual-slot.tsx | 10 + .../src/components/hive/part-sheet.tsx | 3 +- .../src/lib/hive/describe.spec.ts | 11 + ApiCore-Frontend/src/lib/hive/describe.ts | 9 +- .../src/lib/hive/feeder-slots.spec.ts | 24 +- ApiCore-Frontend/src/lib/hive/feeder-slots.ts | 34 ++- .../src/lib/hive/follow-ups.spec.ts | 11 + ApiCore-Frontend/src/lib/hive/follow-ups.ts | 4 +- .../src/lib/hive/fresh-part.spec.ts | 31 +++ ApiCore-Frontend/src/lib/hive/fresh-part.ts | 22 ++ 39 files changed, 1923 insertions(+), 114 deletions(-) create mode 100644 ApiCore-Frontend/src/components/hive/editor/drop-slots.spec.ts create mode 100644 ApiCore-Frontend/src/components/hive/editor/drop-slots.ts create mode 100644 ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.spec.tsx create mode 100644 ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.tsx create mode 100644 ApiCore-Frontend/src/components/hive/editor/use-hive-drag.spec.ts create mode 100644 ApiCore-Frontend/src/components/hive/editor/use-hive-drag.ts create mode 100644 ApiCore-Frontend/src/components/hive/hive-scene/drop-anchors.spec.ts create mode 100644 ApiCore-Frontend/src/components/hive/hive-scene/drop-anchors.ts create mode 100644 ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx create mode 100644 ApiCore-Frontend/src/components/hive/hive-scene/scene-editor.ts create mode 100644 ApiCore-Frontend/src/lib/hive/fresh-part.spec.ts create mode 100644 ApiCore-Frontend/src/lib/hive/fresh-part.ts diff --git a/ApiCore-Frontend/src/components/hive/editor/add-part-dialog.tsx b/ApiCore-Frontend/src/components/hive/editor/add-part-dialog.tsx index 633888d..385e1dc 100644 --- a/ApiCore-Frontend/src/components/hive/editor/add-part-dialog.tsx +++ b/ApiCore-Frontend/src/components/hive/editor/add-part-dialog.tsx @@ -12,7 +12,8 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { feederRange, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots"; +import { feederRange, frameFeederOps, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots"; +import { freshPart } from "@/lib/hive/fresh-part"; import { PART_KIND_LABELS } from "@/lib/hive/labels"; import { partDisplayName } from "@/lib/hive/names"; import { hiveReasonText } from "@/lib/hive/reason-text"; @@ -20,13 +21,11 @@ import { newIdempotencyKey } from "@/lib/offline/idempotency"; import type { CatalogPartType, HiveCatalog, - HiveFrame, HiveOp, HivePart, HivePartKind, HiveState, } from "@/lib/hive/types"; -import { frameFeederOps } from "../frame-feeder-picker"; import { areDraftFieldsValid, draftOf, draftValue, StateFieldControl } from "../state-field-control"; import type { FieldDraft } from "../state-field-control"; import type { DraftApplyResult } from "./use-hive-draft"; @@ -117,10 +116,6 @@ function Field({ label, htmlFor, children }: { label: string; htmlFor: string; c ); } -function newBoxFrames(type: CatalogPartType): HiveFrame[] { - return Array.from({ length: type.frameSlots ?? 0 }, (_, index) => ({ slot: index + 1, type: "FOUNDATION" })); -} - function AddPartForm({ type, state, catalog, onAdd, onClose }: AddPartFormProps) { const formId = useId(); const [newId] = useState(newIdempotencyKey); @@ -142,8 +137,7 @@ function AddPartForm({ type, state, catalog, onAdd, onClose }: AddPartFormProps) function buildOps(): HiveOp[] { const partState: Record = {}; for (const field of type.stateSchema) partState[field.key] = draftValue(field, draft[field.key] ?? ""); - const fresh: HivePart = { id: newId, partTypeId: type.id, state: partState }; - if (type.kind === "BOX") fresh.frames = newBoxFrames(type); + const fresh = freshPart(type, newId, partState); if (!isInsert) return [{ op: "addPart", part: fresh, at: Number(position) }]; if (type.kind === "FRAME_FEEDER" && host && chosenStart !== null) { return frameFeederOps(host.id, host.frames ?? [], type, chosenStart, fresh); diff --git a/ApiCore-Frontend/src/components/hive/editor/drop-slots.spec.ts b/ApiCore-Frontend/src/components/hive/editor/drop-slots.spec.ts new file mode 100644 index 0000000..b9176ab --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/editor/drop-slots.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { applyHiveOps } from "@/lib/hive/ops"; +import { frames, inlineCatalog, part, templateState } from "@/lib/hive/test-utils"; +import type { HiveState } from "@/lib/hive/types"; +import { dropOps, dropSlots, sameSlot } from "./drop-slots"; + +const catalog = inlineCatalog(); + +function withInserts(): HiveState { + const state = templateState(); + state.stack[0].inserts = [part("fi", "floor-insert")]; + state.stack[1].inserts = [part("ev", "evaporator")]; + return state; +} + +describe("dropSlots (plan step 1)", () => { + it("offers a dragged box every place between floor and roof", () => { + const slots = dropSlots(templateState(), catalog, { partTypeId: "box" }); + expect(slots).toEqual([1, 2, 3, 4, 5].map((at) => ({ kind: "stack", at }))); + }); + + it("offers a dragged Bausperre one insert slot on the floor", () => { + expect(dropSlots(templateState(), catalog, { partTypeId: "floor-insert" })).toEqual([{ kind: "insert", hostId: "f" }]); + }); + + it("offers a dragged floor nothing (one floor, always at the bottom)", () => { + expect(dropSlots(templateState(), catalog, { partTypeId: "floor" })).toEqual([]); + expect(dropSlots(templateState(), catalog, { partId: "f" })).toEqual([]); + }); + + it("offers the existing roof nothing (it can only stay on top)", () => { + expect(dropSlots(templateState(), catalog, { partId: "r" })).toEqual([]); + }); +}); + +describe("dropSlots", () => { + it("moves an existing part to every other legal place, not to its own", () => { + const slots = dropSlots(templateState(), catalog, { partId: "c" }); + expect(slots).toEqual([1, 3, 4].map((at) => ({ kind: "stack", at }))); + }); + + it("offers no second Bausperre and no second evaporator", () => { + const state = withInserts(); + expect(dropSlots(state, catalog, { partTypeId: "floor-insert" })).toEqual([]); + expect(dropSlots(state, catalog, { partTypeId: "evaporator" })).toEqual([]); + }); + + it("offers a frame feeder every free start in each box, as the dialog does", () => { + const state = templateState(); + state.stack[1].inserts = [part("ff", "frame-feeder", { slot: 1 })]; + state.stack[1].frames = frames(10).map((frame) => (frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame)); + const slots = dropSlots(state, catalog, { partTypeId: "frame-feeder" }); + expect(slots.map((slot) => (slot.kind === "insert" ? slot.slot : null))).toEqual([3, 4, 5, 6, 7, 8, 9]); + expect(slots.every((slot) => slot.kind === "insert" && slot.hostId === "b")).toBe(true); + }); + + it("moves an existing insert to another host that can take it", () => { + const state = templateState(); + state.stack.splice(2, 0, part("b2", "box", { frames: frames(10) })); + state.stack[1].inserts = [part("ev", "evaporator")]; + expect(dropSlots(state, catalog, { partId: "ev" })).toEqual([{ kind: "insert", hostId: "b2" }]); + }); + + it("returns nothing for unknown parts and types", () => { + expect(dropSlots(templateState(), catalog, { partId: "nope" })).toEqual([]); + expect(dropSlots(templateState(), catalog, { partTypeId: "nope" })).toEqual([]); + }); +}); + +describe("dropOps", () => { + it("adds a new box with foundation frames at the slot", () => { + const ops = dropOps(templateState(), catalog, { partTypeId: "box" }, { kind: "stack", at: 2 }, "new-box"); + expect(ops).toEqual([ + { + op: "addPart", + at: 2, + part: { id: "new-box", partTypeId: "box", state: {}, frames: frames(10, "FOUNDATION") }, + }, + ]); + expect(applyHiveOps(templateState(), ops, catalog).ok).toBe(true); + }); + + it("moves an existing part", () => { + expect(dropOps(templateState(), catalog, { partId: "c" }, { kind: "stack", at: 4 }, "unused")).toEqual([ + { op: "movePart", id: "c", to: 4 }, + ]); + }); + + it("empties the covered frames and hangs a frame feeder at its slot", () => { + const ops = dropOps(templateState(), catalog, { partTypeId: "frame-feeder" }, { kind: "insert", hostId: "b", slot: 4 }, "ff"); + expect(ops[0]).toMatchObject({ op: "setFrames", boxId: "b" }); + // Same part as the add dialog makes: every state field at its default. + expect(ops[1]).toEqual({ + op: "addInsert", + hostId: "b", + part: { id: "ff", partTypeId: "frame-feeder", state: { syrupLiters: 0 }, slot: 4 }, + }); + expect(applyHiveOps(templateState(), ops, catalog).ok).toBe(true); + }); + + it("moves an insert by taking it out and putting the same part (id, state) into the new host", () => { + const state = templateState(); + state.stack.splice(2, 0, part("b2", "box", { frames: frames(10) })); + state.stack[1].inserts = [part("ev", "evaporator", { state: { acidMl: 120 } })]; + const ops = dropOps(state, catalog, { partId: "ev" }, { kind: "insert", hostId: "b2" }, "unused"); + expect(ops).toEqual([ + { op: "removePart", id: "ev" }, + { op: "addInsert", hostId: "b2", part: { id: "ev", partTypeId: "evaporator", state: { acidMl: 120 } } }, + ]); + expect(applyHiveOps(state, ops, catalog).ok).toBe(true); + }); +}); + +describe("sameSlot", () => { + it("compares slots by place", () => { + expect(sameSlot({ kind: "stack", at: 2 }, { kind: "stack", at: 2 })).toBe(true); + expect(sameSlot({ kind: "insert", hostId: "b", slot: 3 }, { kind: "insert", hostId: "b", slot: 4 })).toBe(false); + expect(sameSlot(null, null)).toBe(true); + expect(sameSlot({ kind: "stack", at: 1 }, null)).toBe(false); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/editor/drop-slots.ts b/ApiCore-Frontend/src/components/hive/editor/drop-slots.ts new file mode 100644 index 0000000..4e5df40 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/editor/drop-slots.ts @@ -0,0 +1,106 @@ +import { frameFeederOps, freeFeederStarts } from "@/lib/hive/feeder-slots"; +import { freshPart } from "@/lib/hive/fresh-part"; +import { applyHiveOps } from "@/lib/hive/ops"; +import type { HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types"; + +// Where a dragged part may land (spec 9.5, plan Task 18). Every candidate is +// applied to a copy and validated with the same rules as the server +// (applyHiveOps), so the drop slots never disagree with saving: one floor at +// the bottom, the roof only on top, one Bausperre per floor, one evaporator +// per box, frame feeders only over free frame slots. + +/** A new part from the parts bar, or a part already in the build. */ +export type DragSource = { partTypeId: string } | { partId: string }; + +export type DropSlot = + | { kind: "stack"; at: number } + /** `slot`: first frame slot of a frame feeder. */ + | { kind: "insert"; hostId: string; slot?: number }; + +const INSERT_KINDS: readonly HivePartKind[] = ["FLOOR_INSERT", "EVAPORATOR", "FRAME_FEEDER"]; + +export function sameSlot(a: DropSlot | null, b: DropSlot | null): boolean { + if (a === null || b === null) return a === b; + if (a.kind === "stack" && b.kind === "stack") return a.at === b.at; + if (a.kind === "insert" && b.kind === "insert") return a.hostId === b.hostId && a.slot === b.slot; + return false; +} + +interface Located { + part: HivePart; + index: number; + hostId: string | null; +} + +function locate(state: HiveState, id: string): Located | null { + for (const [index, part] of state.stack.entries()) { + if (part.id === id) return { part, index, hostId: null }; + const insert = part.inserts?.find((candidate) => candidate.id === id); + if (insert) return { part: insert, index, hostId: part.id }; + } + return null; +} + +/** The dragged part with its type, or null for unknown ids and types. */ +function draggedPart(state: HiveState, catalog: HiveCatalog, source: DragSource, newId: string) { + if ("partId" in source) { + const found = locate(state, source.partId); + const type = found ? catalog.partTypes.get(found.part.partTypeId) : undefined; + return found && type ? { part: found.part, type, found } : null; + } + const type = catalog.partTypes.get(source.partTypeId); + return type ? { part: freshPart(type, newId), type, found: null } : null; +} + +/** Ops that perform the drop; [] when the slot does not fit the source. */ +export function dropOps(state: HiveState, catalog: HiveCatalog, source: DragSource, slot: DropSlot, newId: string): HiveOp[] { + const dragged = draggedPart(state, catalog, source, newId); + if (!dragged) return []; + const { part, type, found } = dragged; + const isInsert = INSERT_KINDS.includes(type.kind); + if (slot.kind === "stack") { + if (isInsert) return []; + return found ? [{ op: "movePart", id: part.id, to: slot.at }] : [{ op: "addPart", part, at: slot.at }]; + } + if (!isInsert) return []; + // An insert moves by leaving its host and joining the new one as the same part. + const leave: HiveOp[] = found ? [{ op: "removePart", id: part.id }] : []; + const moved: HivePart = { ...part }; + delete moved.slot; + if (type.kind === "FRAME_FEEDER") { + const host = state.stack.find((candidate) => candidate.id === slot.hostId); + if (!host || slot.slot === undefined) return []; + return [...leave, ...frameFeederOps(host.id, host.frames ?? [], type, slot.slot, moved)]; + } + return [...leave, { op: "addInsert", hostId: slot.hostId, part: moved }]; +} + +function candidates(state: HiveState, catalog: HiveCatalog, source: DragSource): DropSlot[] { + const dragged = draggedPart(state, catalog, source, "candidate"); + if (!dragged) return []; + const { type, found } = dragged; + // The floor never moves (exactly one, always at the bottom). + if (type.kind === "FLOOR" && found) return []; + if (!INSERT_KINDS.includes(type.kind)) { + // Moving: every index but its own; adding: every gap including the top. + const count = found ? state.stack.length : state.stack.length + 1; + return Array.from({ length: count }, (_, at) => at) + .filter((at) => !found || at !== found.index) + .map((at) => ({ kind: "stack", at })); + } + return state.stack.flatMap((host): DropSlot[] => { + if (found && found.hostId === host.id) return []; + if (type.kind !== "FRAME_FEEDER") return [{ kind: "insert", hostId: host.id }]; + const hostType = catalog.partTypes.get(host.partTypeId); + if (hostType?.kind !== "BOX") return []; + return freeFeederStarts(host, hostType, type, catalog).map((slot) => ({ kind: "insert", hostId: host.id, slot })); + }); +} + +/** Legal drop slots for a dragged part: each candidate is applied to a copy and validated. */ +export function dropSlots(state: HiveState, catalog: HiveCatalog, source: DragSource): DropSlot[] { + return candidates(state, catalog, source).filter((slot) => { + const ops = dropOps(state, catalog, source, slot, "candidate"); + return ops.length > 0 && applyHiveOps(state, ops, catalog).ok; + }); +} diff --git a/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.spec.tsx b/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.spec.tsx new file mode 100644 index 0000000..492be53 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.spec.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ZoomControls } from "./editor-stage-overlay"; + +describe("ZoomControls", () => { + it("zooms in and out in steps", async () => { + const onZoom = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Vergrößern" })); + expect(onZoom).toHaveBeenLastCalledWith(1.25); + await userEvent.click(screen.getByRole("button", { name: "Verkleinern" })); + expect(onZoom).toHaveBeenLastCalledWith(0.8); + }); + + it("keeps the keyboard focus at the limit: aria-disabled, no change", async () => { + const onZoom = vi.fn(); + render(); + const zoomIn = screen.getByRole("button", { name: "Vergrößern" }); + expect(zoomIn).not.toBeDisabled(); + expect(zoomIn).toHaveAttribute("aria-disabled", "true"); + zoomIn.focus(); + await userEvent.keyboard("{Enter}"); + expect(onZoom).not.toHaveBeenCalled(); + expect(zoomIn).toHaveFocus(); + expect(screen.getByRole("button", { name: "Verkleinern" })).toHaveAttribute("aria-disabled", "false"); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.tsx b/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.tsx new file mode 100644 index 0000000..f44a276 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { RefObject } from "react"; +import { Trash2, ZoomIn, ZoomOut } from "lucide-react"; +import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass"; +import { ZOOM_MAX, ZOOM_MIN, stepZoom } from "../hive-scene/camera-fit"; +import type { DragPointer, HiveDragView } from "./use-hive-drag"; + +// DOM over the editor scene (spec 9.5): zoom buttons, the „Entfernen“ zone +// while a part of the build is dragged, and a name chip that follows the +// pointer while dragging. + +export function ZoomControls({ zoom, onZoom }: { zoom: number; onZoom: (zoom: number) => void }) { + // At a limit the button stays focusable (aria-disabled) so the keyboard focus does not fall to the page. + const atMin = zoom <= ZOOM_MIN + 1e-6; + const atMax = zoom >= ZOOM_MAX - 1e-6; + return ( +
+ + + {Math.round(zoom * 100)} % + + +
+ ); +} + +/** Drop a part of the build here to remove it; only shown while such a part is dragged. */ +export function RemoveZone({ zoneRef, active }: { zoneRef: RefObject; active: boolean }) { + return ( +
+ + Entfernen +
+ ); +} + +/** The dragged part's name next to the pointer (handy on touch, where the finger hides the preview). */ +export function DragChip({ drag, pointer }: { drag: HiveDragView; pointer: RefObject }) { + const chip = useRef(null); + // Over „Entfernen“ the chip goes above the pointer, so the zone's label stays readable. + const above = useRef(drag.overRemove); + useEffect(() => { + above.current = drag.overRemove; + }, [drag.overRemove]); + useEffect(() => { + let frame = 0; + const follow = () => { + const at = pointer.current; + const node = chip.current; + if (node && at) { + // Beside the pointer, but always inside the window (narrow phones). + const x = Math.max(8, Math.min(at.x + 14, window.innerWidth - node.offsetWidth - 8)); + const wanted = above.current ? at.y - node.offsetHeight - 14 : at.y + 14; + const y = Math.max(8, Math.min(wanted, window.innerHeight - node.offsetHeight - 8)); + node.style.transform = `translate(${x}px, ${y}px)`; + } + frame = requestAnimationFrame(follow); + }; + frame = requestAnimationFrame(follow); + return () => cancelAnimationFrame(frame); + }, [pointer]); + const where = drag.overRemove ? "Loslassen entfernt" : drag.hover ? "Loslassen setzt ein" : drag.slots.length === 0 ? "Kein Platz frei" : null; + return ( +
+ {drag.label} + {where ? {where} : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx b/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx index 3cb3acf..4ca673c 100644 --- a/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx +++ b/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx @@ -21,13 +21,17 @@ import { HiveVisualSlot } from "../hive-visual-slot"; import { useHiveMutation } from "../use-hive-mutation"; import { createHiveViewStore, HiveViewProvider } from "../use-hive-view"; import { SAVE_FAILED_TEXT } from "../use-sheet-submit"; +import type { SceneEditor } from "../hive-scene/scene-editor"; +import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { AddPartDialog } from "./add-part-dialog"; import { ConfirmDialog } from "./confirm-dialog"; import { EditorStackList } from "./editor-stack-list"; +import { DragChip, RemoveZone, ZoomControls } from "./editor-stage-overlay"; import { EditorToolbar } from "./editor-toolbar"; import { templateFromState } from "./hive-template"; import { PartPalette } from "./part-palette"; import { useHiveDraft } from "./use-hive-draft"; +import { useHiveDrag } from "./use-hive-drag"; import type { DraftApplyResult, DraftStepInfo } from "./use-hive-draft"; export interface HiveEditorProps { @@ -162,6 +166,34 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres ); const applyInDialog = useCallback((ops: HiveOp[]) => applyStep(ops, true), [applyStep]); + // Drag and drop in the scene (spec 9.5); the same draft step and announcement as the list. + const removeZoneRef = useRef(null); + const [zoom, setZoom] = useState(1); + const dragApply = useCallback((ops: HiveOp[]) => applyStep(ops, false), [applyStep]); + const drag = useHiveDrag({ + state: draft.state, + catalog, + apply: dragApply, + announce, + removeZone: removeZoneRef, + newId: newIdempotencyKey, + }); + const { begin: beginDrag, setHover: setDragHover, pointer: dragPointer, subscribePointer } = drag; + const dragView = drag.drag; + const sceneEditor = useMemo( + () => ({ + drag: dragView ? { source: dragView.source, slots: dragView.slots, hover: dragView.hover } : null, + pointer: dragPointer, + subscribePointer, + onHoverSlot: setDragHover, + onPartPress: (id, event) => beginDrag({ partId: id }, partName(id), event), + zoom, + onZoom: setZoom, + }), + [dragView, dragPointer, subscribePointer, setDragHover, beginDrag, partName, zoom], + ); + const draggingBuiltPart = dragView !== null && "partId" in dragView.source; + const { undo: undoDraft, redo: redoDraft } = draft; const describeStep = useCallback( (prefix: string, step: DraftStepInfo) => `${prefix}: ${describeHiveOps(step.ops, step.before, catalog)}.`, @@ -317,7 +349,10 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres partName={partName} onSelectPart={setPickedId} onSelectFrame={noop} + editor={sceneEditor} + sceneOverlay={} /> + {draggingBuiltPart ? : null} + {dragView ? : null}
{announcement.text}
diff --git a/ApiCore-Frontend/src/components/hive/editor/part-palette.spec.tsx b/ApiCore-Frontend/src/components/hive/editor/part-palette.spec.tsx index 1766bd0..1c13863 100644 --- a/ApiCore-Frontend/src/components/hive/editor/part-palette.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/editor/part-palette.spec.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { inlineCatalog } from "@/lib/hive/test-utils"; import { PartPalette } from "./part-palette"; @@ -33,4 +33,22 @@ describe("PartPalette", () => { expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({ id: "box", kind: "BOX" })); }); + + it("starts a pointer drag from a part's grip (the button stays the keyboard way)", () => { + const onDragStart = vi.fn(); + const { container } = render(); + const grip = container.querySelector('[data-drag-type="box"]')!; + expect(grip).toHaveAttribute("aria-hidden", "true"); + expect(grip).toHaveClass("touch-none"); + fireEvent.pointerDown(grip, { pointerId: 7, clientX: 12, clientY: 34, button: 0 }); + expect(onDragStart).toHaveBeenCalledWith( + expect.objectContaining({ id: "box" }), + expect.objectContaining({ clientX: 12, clientY: 34 }), + ); + }); + + it("shows no grip without a drag handler", () => { + const { container } = render(); + expect(container.querySelector("[data-drag-type]")).toBeNull(); + }); }); diff --git a/ApiCore-Frontend/src/components/hive/editor/part-palette.tsx b/ApiCore-Frontend/src/components/hive/editor/part-palette.tsx index efe25e3..8c706a4 100644 --- a/ApiCore-Frontend/src/components/hive/editor/part-palette.tsx +++ b/ApiCore-Frontend/src/components/hive/editor/part-palette.tsx @@ -1,5 +1,7 @@ "use client"; +import type { PointerEvent } from "react"; +import { GripVertical } from "lucide-react"; import { PART_KIND_LABELS } from "@/lib/hive/labels"; import type { CatalogPartType, HiveCatalog, HivePartKind } from "@/lib/hive/types"; import { GLASS_BUTTON } from "../hive-glass"; @@ -50,10 +52,12 @@ export interface PartPaletteProps { catalog: HiveCatalog; /** „Hinzufügen …“ of one part type: opens the dialog that asks for the position. */ onAdd: (type: CatalogPartType) => void; + /** Pointer drag from the part's grip into the scene (mouse, pen, finger). */ + onDragStart?: (type: CatalogPartType, event: PointerEvent) => void; } /** The parts bar of the editor (spec 9.5): active part types in four groups. */ -export function PartPalette({ catalog, onAdd }: PartPaletteProps) { +export function PartPalette({ catalog, onAdd, onDragStart }: PartPaletteProps) { const active = [...catalog.partTypes.values()].filter((type) => type.active); return (
@@ -70,7 +74,25 @@ export function PartPalette({ catalog, onAdd }: PartPaletteProps) {
    {types.map((type) => (
  • - + {onDragStart ? ( + // Pointer-only grip; „Hinzufügen …“ is the keyboard and screen reader way. + + ) : ( + + )} {type.name} {PART_KIND_LABELS[type.kind]} diff --git a/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.spec.ts b/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.spec.ts new file mode 100644 index 0000000..41c299e --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.spec.ts @@ -0,0 +1,178 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { inlineCatalog, templateState } from "@/lib/hive/test-utils"; +import type { HiveOp } from "@/lib/hive/types"; +import type { DragSource } from "./drop-slots"; +import { DRAG_START_PX, useHiveDrag } from "./use-hive-drag"; + +const catalog = inlineCatalog(); + +function pointer(type: string, clientX: number, clientY = 100, pointerId = 1): Event { + const event = new Event(type); + Object.assign(event, { clientX, clientY, pointerId }); + return event; +} + +function setup(removeRect = { left: 0, top: 500, right: 200, bottom: 600 }) { + const apply = vi.fn((ops: HiveOp[]) => { + void ops; + return { ok: true as const }; + }); + const announce = vi.fn(); + const zone = document.createElement("div"); + zone.getBoundingClientRect = () => ({ ...removeRect, width: 200, height: 100, x: 0, y: 500, toJSON: () => ({}) }) as DOMRect; + const removeZone = { current: zone }; + const { result } = renderHook(() => + useHiveDrag({ state: templateState(), catalog, apply, announce, removeZone, newId: () => "new-1" }), + ); + const begin = (source: DragSource, x = 100) => + act(() => result.current.begin(source, "Teil", { pointerId: 1, clientX: x, clientY: 100 })); + const move = (x: number, y = 100) => act(() => void window.dispatchEvent(pointer("pointermove", x, y))); + const up = (x: number, y = 100) => act(() => void window.dispatchEvent(pointer("pointerup", x, y))); + return { result, apply, announce, begin, move, up }; +} + +describe("useHiveDrag", () => { + it("only becomes a drag after the pointer moved a few pixels (a tap stays a tap)", () => { + const { result, apply, begin, move, up } = setup(); + begin({ partTypeId: "box" }); + move(100 + DRAG_START_PX - 2); + expect(result.current.drag).toBeNull(); + up(100); + expect(apply).not.toHaveBeenCalled(); + expect(result.current.drag).toBeNull(); + }); + + it("starts a drag with the legal slots and tracks the pointer for the scene", () => { + const { result, begin, move } = setup(); + begin({ partTypeId: "box" }); + move(140, 120); + expect(result.current.drag?.slots).toEqual([1, 2, 3, 4, 5].map((at) => ({ kind: "stack", at }))); + expect(result.current.pointer.current).toEqual({ x: 140, y: 120 }); + }); + + it("drops onto the slot the scene reports: adds the new part as one draft step", () => { + const { result, apply, begin, move, up } = setup(); + begin({ partTypeId: "box" }); + move(160); + act(() => result.current.setHover({ kind: "stack", at: 2 })); + expect(result.current.drag?.hover).toEqual({ kind: "stack", at: 2 }); + up(160); + expect(apply).toHaveBeenCalledTimes(1); + expect(apply.mock.calls[0][0]).toEqual([expect.objectContaining({ op: "addPart", at: 2, part: expect.objectContaining({ id: "new-1" }) })]); + expect(result.current.drag).toBeNull(); + }); + + it("moves an existing part", () => { + const { result, apply, begin, move, up } = setup(); + begin({ partId: "c" }); + move(160); + act(() => result.current.setHover({ kind: "stack", at: 4 })); + up(160); + expect(apply).toHaveBeenCalledWith([{ op: "movePart", id: "c", to: 4 }]); + }); + + it("removes an existing part dropped on „Entfernen“", () => { + const { result, apply, begin, move, up } = setup(); + begin({ partId: "c" }); + move(100, 550); + expect(result.current.drag?.overRemove).toBe(true); + up(100, 550); + expect(apply).toHaveBeenCalledWith([{ op: "removePart", id: "c" }]); + }); + + it("never removes a part that comes from the parts bar", () => { + const { result, apply, begin, move, up } = setup(); + begin({ partTypeId: "box" }); + move(100, 550); + expect(result.current.drag?.overRemove).toBe(false); + up(100, 550); + expect(apply).not.toHaveBeenCalled(); + }); + + it("cancels on Esc and says so", () => { + const { result, apply, announce, begin, move, up } = setup(); + begin({ partTypeId: "box" }); + move(160); + act(() => result.current.setHover({ kind: "stack", at: 2 })); + act(() => void window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))); + expect(result.current.drag).toBeNull(); + expect(announce).toHaveBeenCalledWith("Ziehen abgebrochen."); + up(160); + expect(apply).not.toHaveBeenCalled(); + }); + + it("drops nothing outside every slot and says so", () => { + const { apply, announce, begin, move, up } = setup(); + begin({ partTypeId: "box" }); + move(160); + up(160); + expect(apply).not.toHaveBeenCalled(); + expect(announce).toHaveBeenCalledWith("Nicht abgelegt."); + }); + + it("keeps the first press when a second one begins meanwhile (second finger, part below)", () => { + const { result, apply, begin, move, up } = setup(); + begin({ partId: "c" }); + act(() => result.current.begin({ partId: "b" }, "Zarge", { pointerId: 2, clientX: 300, clientY: 100 })); + move(160); + expect(result.current.drag?.source).toEqual({ partId: "c" }); + act(() => result.current.setHover({ kind: "stack", at: 4 })); + up(160); + expect(apply).toHaveBeenCalledWith([{ op: "movePart", id: "c", to: 4 }]); + }); + + it("cancels when the window loses focus (Alt+Tab) or the tab is hidden", () => { + const first = setup(); + first.begin({ partTypeId: "box" }); + first.move(160); + act(() => void window.dispatchEvent(new Event("blur"))); + expect(first.result.current.drag).toBeNull(); + first.up(160); + expect(first.apply).not.toHaveBeenCalled(); + + const second = setup(); + second.begin({ partTypeId: "box" }); + second.move(160); + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "hidden" }); + act(() => void document.dispatchEvent(new Event("visibilitychange"))); + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" }); + expect(second.result.current.drag).toBeNull(); + }); + + it("ends a mouse drag whose button was released outside the window (buttons = 0)", () => { + const { result, apply, begin, move } = setup(); + begin({ partTypeId: "box" }); + move(160); + act(() => result.current.setHover({ kind: "stack", at: 2 })); + const released = pointer("pointermove", 170); + Object.assign(released, { pointerType: "mouse", buttons: 0 }); + act(() => void window.dispatchEvent(released)); + expect(result.current.drag).toBeNull(); + expect(apply).not.toHaveBeenCalled(); + }); + + it("tells subscribers when the pointer moves (the scene renders only then)", () => { + const { result, begin, move } = setup(); + const listener = vi.fn(); + let off = () => {}; + act(() => { + off = result.current.subscribePointer(listener); + }); + begin({ partTypeId: "box" }); + move(160); + move(170); + expect(listener).toHaveBeenCalledTimes(2); + off(); + move(180); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("ignores a hover report for a slot that is not legal", () => { + const { result, begin, move } = setup(); + begin({ partTypeId: "box" }); + move(160); + act(() => result.current.setHover({ kind: "stack", at: 0 })); + expect(result.current.drag?.hover).toBeNull(); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.ts b/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.ts new file mode 100644 index 0000000..65191dc --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/editor/use-hive-drag.ts @@ -0,0 +1,221 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { RefObject } from "react"; +import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types"; +import { dropOps, dropSlots, sameSlot } from "./drop-slots"; +import type { DragSource, DropSlot } from "./drop-slots"; +import type { DraftApplyResult } from "./use-hive-draft"; + +// Drag and drop of the editor (spec 9.5, plan Task 18) with Pointer Events, +// so mouse, pen and finger behave the same. A press becomes a drag after a +// few pixels (a tap stays a tap); the scene reports the slot under the +// pointer; release drops there, on „Entfernen“ removes; Esc, leaving the +// window (Alt+Tab, hidden tab) or a mouse released outside cancel. +// Window listeners, no pointer capture: a finger is captured to the element it +// started on anyway, so the scene reads the position from `pointer`. + +/** Pixels before a press turns into a drag. */ +export const DRAG_START_PX = 6; + +export interface DragPointer { + x: number; + y: number; +} + +export interface HiveDragView { + source: DragSource; + /** Name of the dragged part (preview chip). */ + label: string; + /** Legal drop slots; they glow in the scene. */ + slots: DropSlot[]; + /** Slot under the pointer, reported by the scene. */ + hover: DropSlot | null; + /** Over the „Entfernen“ zone (only for parts already in the build). */ + overRemove: boolean; +} + +export interface UseHiveDragOptions { + state: HiveState; + catalog: HiveCatalog; + /** One draft step; refusals are shown by the caller. */ + apply: (ops: HiveOp[]) => DraftApplyResult; + /** Live region of the editor. */ + announce: (text: string) => void; + removeZone: RefObject; + /** Id for a new part. */ + newId: () => string; +} + +export interface HiveDrag { + drag: HiveDragView | null; + /** Latest pointer position (client pixels) while pressing or dragging. */ + pointer: RefObject; + begin(source: DragSource, label: string, event: { pointerId: number; clientX: number; clientY: number }): void; + setHover(slot: DropSlot | null): void; + cancel(): void; + /** Called on every pointer move of a press or drag; returns the unsubscribe. */ + subscribePointer(listener: () => void): () => void; +} + +interface Press { + source: DragSource; + label: string; + pointerId: number; + x: number; + y: number; +} + +function inside(element: HTMLElement | null, x: number, y: number): boolean { + if (!element) return false; + const rect = element.getBoundingClientRect(); + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +export function useHiveDrag(options: UseHiveDragOptions): HiveDrag { + const latest = useRef(options); + useEffect(() => { + latest.current = options; + }); + const [press, setPress] = useState(null); + const [drag, setDrag] = useState(null); + const dragRef = useRef(null); + const pointer = useRef(null); + const pressRef = useRef(null); + const [listeners] = useState(() => new Set<() => void>()); + const subscribePointer = useCallback( + (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + [listeners], + ); + + const update = useCallback((next: HiveDragView | null) => { + dragRef.current = next; + setDrag(next); + }, []); + + const end = useCallback(() => { + update(null); + pressRef.current = null; + setPress(null); + pointer.current = null; + }, [update]); + + const begin = useCallback((source, label, event) => { + // One press at a time: a second finger or a farther hit does not take over. + if (pressRef.current) return; + const next = { source, label, pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + pressRef.current = next; + pointer.current = { x: event.clientX, y: event.clientY }; + setPress(next); + }, []); + + const setHover = useCallback( + (slot: DropSlot | null) => { + const current = dragRef.current; + if (!current) return; + const legal = slot && current.slots.some((candidate) => sameSlot(candidate, slot)) ? slot : null; + if (sameSlot(current.hover, legal)) return; + update({ ...current, hover: legal }); + }, + [update], + ); + + const cancel = useCallback(() => end(), [end]); + + useEffect(() => { + if (!press) return; + const samePointer = (event: Event) => ((event as PointerEvent).pointerId ?? press.pointerId) === press.pointerId; + + const onMove = (event: Event) => { + if (!samePointer(event)) return; + const { clientX, clientY, pointerType, buttons } = event as PointerEvent; + // The mouse button went up outside the window: no drop, the drag ends. + if (pointerType === "mouse" && buttons === 0) { + abort(); + return; + } + pointer.current = { x: clientX, y: clientY }; + listeners.forEach((listener) => listener()); + const { state, catalog, removeZone } = latest.current; + let current = dragRef.current; + if (!current) { + if (Math.hypot(clientX - press.x, clientY - press.y) < DRAG_START_PX) return; + current = { + source: press.source, + label: press.label, + slots: dropSlots(state, catalog, press.source), + hover: null, + overRemove: false, + }; + update(current); + } + const overRemove = "partId" in current.source && inside(removeZone.current, clientX, clientY); + if (overRemove !== current.overRemove) update({ ...current, overRemove, hover: overRemove ? null : current.hover }); + }; + + const onUp = (event: Event) => { + if (!samePointer(event)) return; + const current = dragRef.current; + end(); + if (!current) return; + const { state, catalog, apply, announce, newId } = latest.current; + if (current.overRemove && "partId" in current.source) { + apply([{ op: "removePart", id: current.source.partId }]); + return; + } + if (!current.hover) { + announce("Nicht abgelegt."); + return; + } + const ops = dropOps(state, catalog, current.source, current.hover, newId()); + if (ops.length > 0) apply(ops); + }; + + const onCancel = (event: Event) => { + if (samePointer(event)) end(); + }; + + /** Ends without dropping; says so when a drag was running. */ + function abort() { + const active = dragRef.current !== null; + end(); + if (active) latest.current.announce("Ziehen abgebrochen."); + } + const onBlur = () => abort(); + const onVisibility = () => { + if (document.visibilityState === "hidden") abort(); + }; + + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + const active = dragRef.current !== null; + end(); + if (active) { + event.preventDefault(); + latest.current.announce("Ziehen abgebrochen."); + } + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("keydown", onKey); + window.addEventListener("blur", onBlur); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("blur", onBlur); + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("keydown", onKey); + }; + }, [press, end, update, listeners]); + + return { drag, pointer, begin, setHover, cancel, subscribePointer }; +} diff --git a/ApiCore-Frontend/src/components/hive/frame-feeder-picker.tsx b/ApiCore-Frontend/src/components/hive/frame-feeder-picker.tsx index bd4d613..e232a5d 100644 --- a/ApiCore-Frontend/src/components/hive/frame-feeder-picker.tsx +++ b/ApiCore-Frontend/src/components/hive/frame-feeder-picker.tsx @@ -1,8 +1,8 @@ "use client"; import { ChoiceGroup } from "@/components/form/choice-group"; -import { feederRange, rangeText } from "@/lib/hive/feeder-slots"; -import type { CatalogPartType, HiveFrame, HiveOp, HivePart } from "@/lib/hive/types"; +import { coveredSlots, feederRange, frameFeederOps, rangeText } from "@/lib/hive/feeder-slots"; +import type { CatalogPartType, HiveFrame } from "@/lib/hive/types"; import { StateFieldControl } from "./state-field-control"; import type { FieldDraft } from "./state-field-control"; @@ -25,32 +25,8 @@ function rangeAt(start: number, type: CatalogPartType) { return feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type); } -function coveredSlots(start: number, type: CatalogPartType): number[] { - const range = rangeAt(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 for the chosen pocket: setFrames only if a covered frame is not empty yet, then addInsert. */ -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; -} +/** Kept for existing imports; lives in lib/hive/feeder-slots. */ +export { frameFeederOps }; interface FrameFeederPickerProps { boxId: string; 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 index 677b67d..15d0fbe 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/camera-fit.spec.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/camera-fit.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { cameraBasis, cameraPosition, fitDistance, focusFrame, insetView } from "./camera-fit"; +import { ZOOM_MAX, ZOOM_MIN, cameraBasis, cameraPosition, clampZoom, fitDistance, focusFrame, insetView, pinchZoom, stepZoom, wheelZoom } from "./camera-fit"; describe("cameraBasis", () => { it("looks from 20° above and 30° to the side, with orthonormal axes", () => { @@ -79,3 +79,30 @@ describe("cameraPosition", () => { expect(z).toBeCloseTo(3 + back[2] * 10, 9); }); }); + +describe("editor zoom", () => { + it("stays between 0.6 and 2.5", () => { + expect(ZOOM_MIN).toBe(0.6); + expect(ZOOM_MAX).toBe(2.5); + expect(clampZoom(0.1)).toBe(0.6); + expect(clampZoom(9)).toBe(2.5); + expect(clampZoom(Number.NaN)).toBe(1); + }); + + it("zooms in on wheel up and out on wheel down", () => { + expect(wheelZoom(1, -100)).toBeGreaterThan(1); + expect(wheelZoom(1, 100)).toBeLessThan(1); + expect(wheelZoom(2.5, -1000)).toBe(2.5); + }); + + it("follows the pinch distance from its start", () => { + expect(pinchZoom(1, 100, 200)).toBe(2); + expect(pinchZoom(1, 100, 10)).toBe(0.6); + expect(pinchZoom(1.5, 0, 50)).toBe(1.5); + }); + + it("steps for the buttons", () => { + expect(stepZoom(1, 1)).toBeCloseTo(1.25, 9); + expect(stepZoom(1, -1)).toBeCloseTo(0.8, 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 index a5d5b6d..e957e69 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/camera-fit.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/camera-fit.ts @@ -135,3 +135,31 @@ export function cameraPosition(center: Vec3, distance: number, basis: CameraBasi center[2] + basis.back[2] * distance, ]; } + +// ---- Editor zoom (spec 9.5: mouse wheel, two fingers and buttons, 0.6–2.5) ---- + +export const ZOOM_MIN = 0.6; +export const ZOOM_MAX = 2.5; +/** Factor of one press of „Vergrößern“ / „Verkleinern“. */ +const ZOOM_STEP = 1.25; +/** Wheel sensitivity per pixel of deltaY. */ +const WHEEL_RATE = 0.0015; + +export function clampZoom(zoom: number): number { + if (!Number.isFinite(zoom)) return 1; + return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom)); +} + +export function wheelZoom(zoom: number, deltaY: number): number { + return clampZoom(zoom * Math.exp(-deltaY * WHEEL_RATE)); +} + +export function pinchZoom(startZoom: number, startDistance: number, distance: number): number { + if (!(startDistance > 0)) return clampZoom(startZoom); + return clampZoom((startZoom * distance) / startDistance); +} + +/** One button step: +1 zooms in, -1 out. */ +export function stepZoom(zoom: number, direction: 1 | -1): number { + return clampZoom(direction > 0 ? zoom * ZOOM_STEP : zoom / ZOOM_STEP); +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx index a3ac6e2..bf57977 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx @@ -3,7 +3,7 @@ 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, insetView } from "./camera-fit"; +import { FIT_MARGIN, FOCUS_FIT_MARGIN, clampZoom, fitDistance, focusFrame, insetView } from "./camera-fit"; import type { OverlayInsets } from "./camera-fit"; import { useSceneKit } from "./scene-kit"; import { offsetView, placeCamera, setFocusPoint } from "./scene-mutations"; @@ -22,9 +22,13 @@ export interface CameraRigProps { onPivot: (center: Vec3) => void; /** Card pixels covered by the frosted overlays. */ insets: OverlayInsets; + /** Gap between stack levels (opened while dragging in the editor). */ + gap?: number; + /** Editor zoom (1 = fitted). */ + zoom?: number; } -export function CameraRig({ layout, focus, onPivot, insets }: CameraRigProps) { +export function CameraRig({ layout, focus, onPivot, insets, gap, zoom = 1 }: CameraRigProps) { const { motion, signals } = useSceneKit(); const camera = useThree((state) => state.camera); const width = useThree((state) => state.size.width); @@ -34,14 +38,15 @@ export function CameraRig({ layout, focus, onPivot, insets }: CameraRigProps) { const goal = useMemo(() => { const aspect = height > 0 ? width / height : 1; const band = insetView(height, insets); - const bounds = restBounds(layout); - const fit = (size: Vec3, margin: number) => fitDistance(size, aspect, margin, undefined, undefined, band.fraction); + const bounds = restBounds(layout, gap); + const fit = (size: Vec3, margin: number) => + fitDistance(size, aspect, margin, undefined, undefined, band.fraction) / clampZoom(zoom); if (focus) { const frame = focusFrame(focus.center, focus.size, bounds.center); return { center: frame.center, distance: fit(frame.size, FOCUS_FIT_MARGIN), offsetY: band.offsetY }; } return { center: bounds.center, distance: fit(bounds.size, FIT_MARGIN), offsetY: band.offsetY }; - }, [focus, layout, width, height, insets]); + }, [focus, layout, width, height, insets, gap, zoom]); const current = useRef<{ center: Vec3; distance: number } | null>(null); useEffect(() => { diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/drop-anchors.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/drop-anchors.spec.ts new file mode 100644 index 0000000..7de364a --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/drop-anchors.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { FLOOR_GRID_DEPTH, hiveLayout } from "@/lib/hive/layout"; +import { inlineCatalog, templateState } from "@/lib/hive/test-utils"; +import { dropSlots } from "../editor/drop-slots"; +import { DRAG_GAP, SNAP_DISTANCE, pickAnchor, slotAnchors } from "./drop-anchors"; +import { restCenter } from "./use-hive-motion"; + +const catalog = inlineCatalog(); +const state = templateState(); +const layout = hiveLayout(state, catalog); +const part = (id: string) => layout.parts.find((candidate) => candidate.id === id)!; +const top = (id: string) => restCenter(part(id), DRAG_GAP)[1] + part(id).size[1] / 2; +const bottom = (id: string) => restCenter(part(id), DRAG_GAP)[1] - part(id).size[1] / 2; + +describe("slotAnchors", () => { + it("puts a stack slot in the middle of the opened gap between two parts", () => { + const source = { partTypeId: "box" }; + const anchors = slotAnchors(layout, state, catalog, source, dropSlots(state, catalog, source)); + const between = anchors.find((anchor) => anchor.slot.kind === "stack" && anchor.slot.at === 2)!; + // Slot 2 = between the box (index 1) and the cover (index 2). + expect(between.center[1]).toBeCloseTo((top("b") + bottom("c")) / 2, 9); + expect(between.size).toEqual([5.25, 2.3, 4.3]); + }); + + it("skips the dragged part itself when it moves", () => { + const source = { partId: "c" }; + const anchors = slotAnchors(layout, state, catalog, source, dropSlots(state, catalog, source)); + const atThree = anchors.find((anchor) => anchor.slot.kind === "stack" && anchor.slot.at === 3)!; + // movePart takes the cover out first: [floor, box, lid, insulation, roof]; index 3 is between lid and insulation. + expect(atThree.center[1]).toBeCloseTo((top("l") + bottom("i")) / 2, 9); + }); + + it("puts an insert slot where the insert would sit in its opened host", () => { + const source = { partTypeId: "floor-insert" }; + const [anchor] = slotAnchors(layout, state, catalog, source, dropSlots(state, catalog, source)); + const floorTop = restCenter(part("f"), DRAG_GAP)[1] + part("f").size[1] / 2; + expect(anchor.center[1]).toBeCloseTo(floorTop - FLOOR_GRID_DEPTH + 0.025, 9); + }); + + it("puts a frame feeder slot over its covered frames", () => { + const source = { partTypeId: "frame-feeder" }; + const anchors = slotAnchors(layout, state, catalog, source, dropSlots(state, catalog, source)); + const first = anchors[0]; + const covered = layout.frames.filter((frame) => frame.boxId === "b" && frame.slot <= 2); + expect(first.center[0]).toBeCloseTo((covered[0].center[0] + covered[1].center[0]) / 2, 9); + }); +}); + +describe("pickAnchor", () => { + const source = { partTypeId: "box" }; + const anchors = slotAnchors(layout, state, catalog, source, dropSlots(state, catalog, source)); + const target = anchors.find((anchor) => anchor.slot.kind === "stack" && anchor.slot.at === 2)!; + + it("picks the slot the ray goes through", () => { + const picked = pickAnchor(anchors, [0, target.center[1], 20], [0, 0, -1]); + expect(picked?.slot).toEqual({ kind: "stack", at: 2 }); + }); + + it("snaps within 0.5 units of a slot and lets go beyond", () => { + const near = pickAnchor(anchors, [5.25 / 2 + SNAP_DISTANCE - 0.05, target.center[1], 20], [0, 0, -1]); + expect(near?.slot).toEqual({ kind: "stack", at: 2 }); + const far = pickAnchor(anchors, [5.25 / 2 + SNAP_DISTANCE + 0.2, target.center[1], 20], [0, 0, -1]); + expect(far).toBeNull(); + expect(SNAP_DISTANCE).toBe(0.5); + }); + + it("takes the nearest slot along the ray", () => { + const picked = pickAnchor(anchors, [0, 40, 0], [0, -1, 0]); + // From above the first slot hit is the topmost (below the roof). + expect(picked?.slot).toEqual({ kind: "stack", at: 5 }); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/drop-anchors.ts b/ApiCore-Frontend/src/components/hive/hive-scene/drop-anchors.ts new file mode 100644 index 0000000..72ec899 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/drop-anchors.ts @@ -0,0 +1,122 @@ +import { hiveLayout } from "@/lib/hive/layout"; +import type { HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout"; +import { applyHiveOps } from "@/lib/hive/ops"; +import type { HiveCatalog, HiveState } from "@/lib/hive/types"; +import { dropOps } from "../editor/drop-slots"; +import type { DragSource, DropSlot } from "../editor/drop-slots"; +import { restCenter } from "./use-hive-motion"; + +// Where the drop slots sit in the opened stack (model space, the same space +// the parts are placed in) and which one the pointer ray points at (plan +// Task 18: the preview snaps to the nearest slot within 0.5 units). + +/** While dragging the stack opens to this gap between levels (60 mm, spec 9.5). */ +export const DRAG_GAP = 0.6; +/** A slot catches the pointer this far around it. */ +export const SNAP_DISTANCE = 0.5; +const PREVIEW_ID = "__drag-preview__"; + +export interface SlotAnchor { + slot: DropSlot; + /** Centre of the preview (model space). */ + center: Vec3; + /** Size of the dragged part, for the preview. */ + size: Vec3; + /** Box the pointer ray must pass through (before the snap margin). */ + hitCenter: Vec3; + hitSize: Vec3; +} + +const top = (placed: PartBox): number => restCenter(placed, DRAG_GAP)[1] + placed.size[1] / 2; +const bottom = (placed: PartBox): number => restCenter(placed, DRAG_GAP)[1] - placed.size[1] / 2; + +/** Part box of the dragged part in the build after the drop, or null. */ +function droppedBox(state: HiveState, catalog: HiveCatalog, source: DragSource, slot: DropSlot): { box: PartBox; layout: HiveLayout } | null { + const ops = dropOps(state, catalog, source, slot, PREVIEW_ID); + const applied = ops.length > 0 ? applyHiveOps(state, ops, catalog) : null; + if (!applied?.ok) return null; + const layout = hiveLayout(applied.state, catalog); + const id = "partId" in source ? source.partId : PREVIEW_ID; + const box = [...layout.parts, ...layout.inserts].find((candidate) => candidate.id === id); + return box ? { box, layout } : null; +} + +function stackAnchor(layout: HiveLayout, source: DragSource, slot: Extract, size: Vec3): SlotAnchor { + const draggedId = "partId" in source ? source.partId : null; + const others = layout.parts.filter((placed) => placed.id !== draggedId); + const lower = others[slot.at - 1]; + const upper = others[slot.at]; + const y = + lower && upper + ? (top(lower) + bottom(upper)) / 2 + : lower + ? top(lower) + DRAG_GAP / 2 + : upper + ? bottom(upper) - DRAG_GAP / 2 + : 0; + const center: Vec3 = [0, y, 0]; + return { slot, center, size, hitCenter: center, hitSize: [size[0], DRAG_GAP, size[2]] }; +} + +/** Anchors of the legal slots of a drag, in model space of the opened stack. */ +export function slotAnchors( + layout: HiveLayout, + state: HiveState, + catalog: HiveCatalog, + source: DragSource, + slots: DropSlot[], +): SlotAnchor[] { + return slots.flatMap((slot): SlotAnchor[] => { + const dropped = droppedBox(state, catalog, source, slot); + if (!dropped) return []; + const size = dropped.box.size; + if (slot.kind === "stack") return [stackAnchor(layout, source, slot, size)]; + // An insert sits in its host where the layout puts it, the host at its opened place. + const host = layout.parts.find((placed) => placed.id === slot.hostId); + const hostAfter = dropped.layout.parts.find((placed) => placed.id === slot.hostId); + if (!host || !hostAfter) return []; + const opened = restCenter(host, DRAG_GAP); + const center: Vec3 = [ + opened[0] + dropped.box.center[0] - hostAfter.center[0], + opened[1] + dropped.box.center[1] - hostAfter.center[1], + opened[2] + dropped.box.center[2] - hostAfter.center[2], + ]; + return [{ slot, center, size, hitCenter: center, hitSize: size }]; + }); +} + +/** Entry distance of a ray into an axis-aligned box, or null when it misses (slab test). */ +function rayBoxEntry(origin: Vec3, direction: Vec3, center: Vec3, half: Vec3): number | null { + let near = Number.NEGATIVE_INFINITY; + let far = Number.POSITIVE_INFINITY; + for (let axis = 0; axis < 3; axis += 1) { + const min = center[axis] - half[axis]; + const max = center[axis] + half[axis]; + if (Math.abs(direction[axis]) < 1e-9) { + if (origin[axis] < min || origin[axis] > max) return null; + continue; + } + const t1 = (min - origin[axis]) / direction[axis]; + const t2 = (max - origin[axis]) / direction[axis]; + near = Math.max(near, Math.min(t1, t2)); + far = Math.min(far, Math.max(t1, t2)); + if (near > far) return null; + } + if (far < 0) return null; + return Math.max(near, 0); +} + +/** The slot the ray points at (with the snap margin), nearest along the ray; null without one. */ +export function pickAnchor(anchors: SlotAnchor[], origin: Vec3, direction: Vec3, snap: number = SNAP_DISTANCE): SlotAnchor | null { + let best: SlotAnchor | null = null; + let bestT = Number.POSITIVE_INFINITY; + for (const anchor of anchors) { + const half: Vec3 = [anchor.hitSize[0] / 2 + snap, anchor.hitSize[1] / 2 + snap, anchor.hitSize[2] / 2 + snap]; + const t = rayBoxEntry(origin, direction, anchor.hitCenter, half); + if (t !== null && t < bestT) { + best = anchor; + bestT = t; + } + } + return best; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx new file mode 100644 index 0000000..a18527b --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import type { RefObject } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import { Raycaster, Vector2, Vector3 } from "three"; +import type { Group, ShaderMaterial } from "three"; +import type { Vec3 } from "@/lib/hive/layout"; +import { sameSlot } from "../editor/drop-slots"; +import type { DropSlot } from "../editor/drop-slots"; +import { pickAnchor } from "./drop-anchors"; +import type { SlotAnchor } from "./drop-anchors"; +import { clampZoom, pinchZoom, wheelZoom } from "./camera-fit"; +import { createEdgeMaterial, createGlassMaterial } from "./materials"; +import { buildEdgeGeometry, buildGlassGeometry } from "./part-geometry"; +import type { EdgeSegment } from "./part-geometry"; +import { noRaycast, useSceneKit } from "./scene-kit"; +import { setPinching, setUniform, setUniformColor } from "./scene-mutations"; + +// Editor drop targets (spec 9.5): every legal slot glows, the slot under the +// pointer lights up and a glass preview of the dragged part snaps into it. +// Sits inside the model rig, so slots turn with the model. + +/** Outline of a box as glow segments (no white-hot corners: these are hints, not parts). */ +function outline(size: Vec3, strength: number): EdgeSegment[] { + const [hx, hy, hz] = [size[0] / 2, size[1] / 2, size[2] / 2]; + const c = (x: number, y: number, z: number): Vec3 => [x * hx, y * hy, z * hz]; + const segments: EdgeSegment[] = []; + const add = (a: Vec3, b: Vec3) => segments.push({ a, b, hotA: 0, hotB: 0, strength }); + for (const y of [-1, 1]) { + add(c(-1, y, -1), c(1, y, -1)); + add(c(1, y, -1), c(1, y, 1)); + add(c(1, y, 1), c(-1, y, 1)); + add(c(-1, y, 1), c(-1, y, -1)); + } + if (hy > 0.02) for (const [x, z] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) add(c(x, -1, z), c(x, 1, z)); + return segments; +} + +/** The glowing place of one slot: a flat frame for a stack gap, a box outline for an insert. */ +function SlotMarker({ anchor, material }: { anchor: SlotAnchor; material: ShaderMaterial }) { + const flat = anchor.slot.kind === "stack"; + const [mx, my, mz] = flat ? [anchor.size[0], 0.001, anchor.size[2]] : anchor.size; + const geometry = useMemo(() => buildEdgeGeometry(outline([mx, my, mz], 1), 0.03), [mx, my, mz]); + useEffect(() => () => geometry.dispose(), [geometry]); + return ( + + ); +} + +/** Translucent glass ghost of the dragged part at the hovered slot. */ +function Preview({ anchor }: { anchor: SlotAnchor }) { + const { theme, env } = useSceneKit(); + const [sx, sy, sz] = anchor.size; + const geometry = useMemo( + () => buildGlassGeometry([{ size: [sx, sy, sz], at: [0, 0, 0], radius: 0.03 }]), + [sx, sy, sz], + ); + const edges = useMemo(() => buildEdgeGeometry(outline([sx, sy, sz], 1.2)), [sx, sy, sz]); + const glass = useMemo(() => createGlassMaterial(theme, { frost: 0.75, env }), [theme, env]); + const glow = useMemo(() => createEdgeMaterial(theme), [theme]); + useEffect( + () => () => { + geometry.dispose(); + edges.dispose(); + glass.dispose(); + glow.dispose(); + }, + [geometry, edges, glass, glow], + ); + useEffect(() => { + // Brighter than the parts around it, so the snapped place reads at once. + setUniform(glass, "uOpacity", 0.9); + setUniform(glass, "uGlow", 2.2); + setUniform(glow, "uIntensity", 3); + setUniformColor(glow, "uCyan", theme.edgeStrong); + }, [glass, glow, theme]); + return ( + + + + + ); +} + +export interface DropTargetsProps { + anchors: SlotAnchor[]; + hover: DropSlot | null; + pointer: RefObject<{ x: number; y: number } | null>; + /** Called on every pointer move; returns the unsubscribe. */ + subscribePointer: (listener: () => void) => () => void; + onHover: (slot: DropSlot | null) => void; +} + +/** Slots, hover and preview; picks the slot under the pointer on each rendered frame. */ +export function DropTargets({ anchors, hover, pointer, subscribePointer, onHover }: DropTargetsProps) { + const { theme } = useSceneKit(); + const group = useRef(null); + const camera = useThree((state) => state.camera); + const gl = useThree((state) => state.gl); + const size = useThree((state) => state.size); + const invalidate = useThree((state) => state.invalidate); + // Reused every frame (no allocations while dragging). + const scratch = useMemo( + () => ({ raycaster: new Raycaster(), ndc: new Vector2(), origin: new Vector3(), ahead: new Vector3() }), + [], + ); + const reported = useRef(hover); + const hovered = anchors.find((anchor) => sameSlot(anchor.slot, hover)) ?? null; + + // Two shared materials for every marker: idle and the one under the pointer. + const materials = useMemo(() => ({ idle: createEdgeMaterial(theme), active: createEdgeMaterial(theme) }), [theme]); + useEffect(() => { + setUniform(materials.idle, "uIntensity", 1.4); + setUniform(materials.active, "uIntensity", 3.2); + return () => { + materials.idle.dispose(); + materials.active.dispose(); + }; + }, [materials]); + + // The canvas rect only changes with the canvas size (the editor does not scroll). + const rect = useRef(null); + useEffect(() => { + const element = gl.domElement as HTMLElement | undefined; + rect.current = element?.getBoundingClientRect ? element.getBoundingClientRect() : null; + }, [gl, size.width, size.height]); + + // Render only when the pointer moved; flights and the opening stack request their own frames. + useEffect(() => subscribePointer(() => invalidate()), [subscribePointer, invalidate]); + + useFrame(() => { + const node = group.current; + const at = pointer.current; + const box = rect.current; + if (!node || !at || !box) return; + const inCanvas = at.x >= box.left && at.x <= box.right && at.y >= box.top && at.y <= box.bottom; + let slot: DropSlot | null = null; + if (inCanvas) { + const { raycaster, ndc, origin, ahead } = scratch; + ndc.set(((at.x - box.left) / box.width) * 2 - 1, -((at.y - box.top) / box.height) * 2 + 1); + raycaster.setFromCamera(ndc, camera); + // Into model space: the slots live inside the turning, floating rig. + node.worldToLocal(origin.copy(raycaster.ray.origin)); + node.worldToLocal(ahead.copy(raycaster.ray.origin).add(raycaster.ray.direction)); + ahead.sub(origin); + slot = pickAnchor(anchors, [origin.x, origin.y, origin.z], [ahead.x, ahead.y, ahead.z])?.slot ?? null; + } + if (!sameSlot(slot, reported.current)) { + reported.current = slot; + onHover(slot); + } + }); + + return ( + + {anchors.map((anchor, index) => ( + + ))} + {hovered ? : null} + + ); +} + +/** Wheel and two-finger pinch zoom on the canvas (editor only, 0.6 … 2.5). */ +export function EditorZoom({ zoom, onZoom }: { zoom: number; onZoom: (zoom: number) => void }) { + const gl = useThree((state) => state.gl); + const { signals } = useSceneKit(); + const latest = useRef({ zoom, onZoom }); + useEffect(() => { + latest.current = { zoom, onZoom }; + }); + + useEffect(() => { + const element = gl.domElement as HTMLElement | undefined; + if (!element || typeof element.addEventListener !== "function") return; + const touches = new Map(); + let pinch: { distance: number; zoom: number } | null = null; + const distance = () => { + const [a, b] = [...touches.values()]; + return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0; + }; + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + latest.current.onZoom(wheelZoom(latest.current.zoom, event.deltaY)); + }; + const onDown = (event: PointerEvent) => { + if (event.pointerType !== "touch") return; + touches.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (touches.size === 2) { + pinch = { distance: distance(), zoom: clampZoom(latest.current.zoom) }; + setPinching(signals, true); + } + }; + const onMove = (event: PointerEvent) => { + if (!touches.has(event.pointerId)) return; + touches.set(event.pointerId, { x: event.clientX, y: event.clientY }); + if (pinch && touches.size === 2) latest.current.onZoom(pinchZoom(pinch.zoom, pinch.distance, distance())); + }; + const onUp = (event: PointerEvent) => { + touches.delete(event.pointerId); + if (touches.size < 2) { + pinch = null; + setPinching(signals, false); + } + }; + element.addEventListener("wheel", onWheel, { passive: false }); + element.addEventListener("pointerdown", onDown); + element.addEventListener("pointermove", onMove); + element.addEventListener("pointerup", onUp); + element.addEventListener("pointercancel", onUp); + return () => { + element.removeEventListener("wheel", onWheel); + element.removeEventListener("pointerdown", onDown); + element.removeEventListener("pointermove", onMove); + element.removeEventListener("pointerup", onUp); + element.removeEventListener("pointercancel", onUp); + setPinching(signals, false); + }; + }, [gl, signals]); + + return null; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-canvas.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-canvas.tsx index 5afd62f..f69c41f 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-canvas.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-canvas.tsx @@ -12,6 +12,7 @@ import type { OverlayInsets } from "./camera-fit"; import { HiveSceneContent } from "./hive-scene-content"; import { HiveStage, detectStageTier } from "./hive-stage"; import type { QualityVerdict } from "./quality"; +import type { SceneEditor } from "./scene-editor"; import { SceneKitProvider } from "./scene-kit"; import { shouldIdle } from "./use-hive-motion"; @@ -28,6 +29,8 @@ export interface HiveCanvasProps { onContextLost: () => void; /** Card pixels covered by the frosted overlays (title/focus bar, buttons). */ insets?: OverlayInsets; + /** Editor route: drag and drop and zoom. */ + editor?: SceneEditor; } /** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */ @@ -66,6 +69,7 @@ export default function HiveCanvas({ onReady, onContextLost, insets, + editor, }: HiveCanvasProps) { const motion = useEffectiveMotionLevel(); // Only acts on the view (empty click); the scene content subscribes itself. @@ -95,8 +99,9 @@ export default function HiveCanvas({ frameloop="demand" dpr={[1, 2]} aria-hidden - // Vertical swipes scroll the page; horizontal drags turn the model. - style={{ touchAction: "pan-y" }} + // Card: vertical swipes scroll the page, horizontal drags turn the model. + // Editor: the canvas owns every gesture (drag parts, turn, pinch zoom). + style={{ touchAction: editor ? "none" : "pan-y" }} camera={{ fov: CAMERA_FOV_DEG, near: 0.1, far: 200, position: [8, 6, 14] }} gl={{ antialias: false, stencil: false, powerPreference: "high-performance" }} onPointerMissed={() => { @@ -130,6 +135,7 @@ export default function HiveCanvas({ idle={idle} onScreen={visible && tabVisible} insets={insets} + editor={editor} /> 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 index c29a52c..bd06ea9 100644 --- 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 @@ -10,12 +10,27 @@ 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 { dropSlots } from "../editor/drop-slots"; +import type { DropSlot } from "../editor/drop-slots"; import { HiveSceneContent } from "./hive-scene-content"; +import type { SceneEditor } from "./scene-editor"; 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. +const anchorCalls = vi.hoisted(() => ({ count: 0 })); +vi.mock("./drop-anchors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + slotAnchors: (...args: Parameters) => { + anchorCalls.count += 1; + return actual.slotAnchors(...args); + }, + }; +}); + vi.mock("./hive-labels", () => ({ HoloLabel: ({ text }: { text: string }) => , })); @@ -48,7 +63,7 @@ interface Rendered { let current: Rendered | null = null; -async function renderScene(motion: MotionLevel = "full"): Promise { +async function renderScene(motion: MotionLevel = "full", editor?: SceneEditor): Promise { const state = hiveState(); const layout = hiveLayout(state, catalog); const store = createHiveViewStore(); @@ -65,6 +80,7 @@ async function renderScene(motion: MotionLevel = "full"): Promise { onSelectPart={onSelectPart} onSelectFrame={onSelectFrame} idle={false} + editor={editor} /> , @@ -122,6 +138,32 @@ describe("HiveSceneContent", () => { expect((hint() as unknown as { geometry: unknown }).geometry).toBe(before); }); + it("gives a very thin part a hit box tall enough to grab (at least 12 mm)", async () => { + const thin: HiveState = { + schemaVersion: 1, + stack: [part("f", "floor"), part("b1", "box", { frames: frames(10) }), part("c", "cover"), part("r", "roof")], + }; + const layout = hiveLayout(thin, catalog); + const renderer = await ReactThreeTestRenderer.create( + + + id} + onSelectPart={vi.fn()} + onSelectFrame={vi.fn()} + idle={false} + /> + + , + ); + const cover = renderer.scene.find((node) => node.props.userData?.hit === "c"); + expect(cover.props.scale[1]).toBeGreaterThanOrEqual(0.12); + await renderer.unmount(); + }); + 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" }); @@ -222,6 +264,92 @@ describe("HiveSceneContent", () => { }); }); +describe("HiveSceneContent in the editor", () => { + const editorOf = (extra: Partial = {}): SceneEditor => ({ + drag: null, + pointer: { current: null }, + subscribePointer: () => () => {}, + onHoverSlot: vi.fn(), + onPartPress: vi.fn(), + zoom: 1, + onZoom: vi.fn(), + ...extra, + }); + + it("reports a press on a part, so it can become a drag, and stops it there (the nearest hit wins)", async () => { + const editor = editorOf(); + const { renderer } = await renderScene("full", editor); + const stopPropagation = vi.fn(); + await renderer.fireEvent(hit(renderer, "fe"), "onPointerDown", { + pointerType: "mouse", + pointerId: 3, + clientX: 10, + clientY: 20, + stopPropagation, + }); + expect(editor.onPartPress).toHaveBeenCalledWith("fe", { pointerId: 3, clientX: 10, clientY: 20 }); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it("shows one glowing marker per legal slot while dragging and opens the stack", async () => { + const rest = await renderScene("off"); + await rest.renderer.advanceFrames(1, 1 / 60); + const restY = hit(rest.renderer, "fe").parent!.instance.position.y; + await rest.renderer.unmount(); + current = null; + + const state = hiveState(); + const slots = dropSlots(state, catalog, { partTypeId: "box" }); + const { renderer } = await renderScene("off", editorOf({ drag: { source: { partTypeId: "box" }, slots, hover: null } })); + await renderer.advanceFrames(1, 1 / 60); + expect(renderer.scene.findAll((node) => node.props.name === "drop-slot")).toHaveLength(slots.length); + // The feeder (stack index 2) rises by 2 × (0.6 − 0.14) with the opened gaps. + expect(hit(renderer, "fe").parent!.instance.position.y - restY).toBeCloseTo(2 * (0.6 - 0.14), 6); + }); +}); + +describe("HiveSceneContent drop anchors", () => { + it("computes the slot anchors once per drag, not again on every hover change", async () => { + const state = hiveState(); + const layout = hiveLayout(state, catalog); + const store = createHiveViewStore(); + const source = { partTypeId: "box" }; + const slots = dropSlots(state, catalog, source); + const editor = (hover: DropSlot | null): SceneEditor => ({ + drag: { source, slots, hover }, + pointer: { current: null }, + subscribePointer: () => () => {}, + onHoverSlot: vi.fn(), + onPartPress: vi.fn(), + zoom: 1, + onZoom: vi.fn(), + }); + const element = (current: SceneEditor) => ( + + + partDisplayName(state, catalog, id)} + onSelectPart={vi.fn()} + onSelectFrame={vi.fn()} + idle={false} + editor={current} + /> + + + ); + anchorCalls.count = 0; + const renderer = await ReactThreeTestRenderer.create(element(editor(null))); + const afterMount = anchorCalls.count; + await renderer.update(element(editor(slots[0]))); + await renderer.update(element(editor(slots[1]))); + expect(anchorCalls.count).toBe(afterMount); + await renderer.unmount(); + }); +}); + describe("ModelRig", () => { async function renderRig(motion: MotionLevel, idle: boolean) { const pivot: Vec3 = [0, 1, 0]; 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 index 20ea552..0e6ff1b 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.tsx @@ -8,14 +8,18 @@ import { focusedIdOf, useHiveView } from "../use-hive-view"; import type { HoveredFrame } from "../use-hive-view"; import type { OverlayInsets } from "./camera-fit"; import { CameraRig } from "./camera-rig"; +import { DRAG_GAP, slotAnchors } from "./drop-anchors"; +import { DropTargets, EditorZoom } from "./drop-targets"; +import { useSceneKit } from "./scene-kit"; +import type { SceneEditor } from "./scene-editor"; 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 { markPartPress, setCanvasCursor } from "./scene-mutations"; import { StackPart } from "./stack-part"; import type { NodeHandlers } from "./stack-part"; -import { frameKey, hiveMotionTargets, restBounds } from "./use-hive-motion"; +import { REST_GAP, frameKey, hiveMotionTargets, restBounds } from "./use-hive-motion"; import type { HiveMotionTargets } from "./use-hive-motion"; import { useTouchSelect } from "./use-touch-select"; @@ -42,8 +46,13 @@ export interface HiveSceneContentProps { onScreen?: boolean; /** Card pixels covered by the frosted overlays; the camera fits the model between them. */ insets?: OverlayInsets; + /** Editor route only: drag and drop and zoom (spec 9.5). */ + editor?: SceneEditor; } +/** The part being moved stays at its place, dimmed, while the stack opens around it. */ +const DRAGGED_OPACITY = 0.35; + const NO_INSETS: OverlayInsets = { top: 0, bottom: 0 }; const partKey = (id: string): string => `part:${id}`; @@ -82,8 +91,10 @@ export function HiveSceneContent({ idle, onScreen = false, insets = NO_INSETS, + editor, }: HiveSceneContentProps) { const view = useHiveView(); + const { signals } = useSceneKit(); const focusedId = focusedIdOf(view); const gl = useThree((root) => root.gl); @@ -97,7 +108,24 @@ export function HiveSceneContent({ 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 drag = editor?.drag ?? null; + const gap = drag ? DRAG_GAP : REST_GAP; + const targets: HiveMotionTargets = useMemo(() => { + const computed = hiveMotionTargets(layout, motionView, gap); + const movedId = drag && "partId" in drag.source ? drag.source.partId : null; + const moved = movedId ? computed.nodes.get(movedId) : undefined; + if (!movedId || !moved) return computed; + const nodes = new Map(computed.nodes); + nodes.set(movedId, { ...moved, opacity: Math.min(moved.opacity, DRAGGED_OPACITY), interactive: false }); + return { ...computed, nodes }; + }, [layout, motionView, gap, drag]); + // Once per drag: the hover changes the drag object, not its source or its slots. + const dragSource = drag?.source ?? null; + const dragSlots = drag?.slots ?? null; + const anchors = useMemo( + () => (dragSource && dragSlots ? slotAnchors(layout, state, catalog, dragSource, dragSlots) : []), + [dragSource, dragSlots, layout, state, catalog], + ); const labels = useMemo(() => sceneLabels(model, layout, motionView), [model, layout, motionView]); const setCursor = (pointer: boolean) => setCanvasCursor(gl, pointer); @@ -151,7 +179,7 @@ export function HiveSceneContent({ const handlersFor = (key: string): NodeHandlers => ({ onPointerOver(event) { event.stopPropagation(); - if (event.pointerType === "touch") return; + if (event.pointerType === "touch" || drag) return; hoverKey(key); setCursor(true); }, @@ -170,6 +198,15 @@ export function HiveSceneContent({ }, onPointerDown(event) { pointerType.current = event.pointerType ?? "mouse"; + const parsed = parseKey(key); + if (editor && parsed?.kind === "part") { + // Only the nearest hit takes the press (inserts and frames inside a box, thin parts on top). + event.stopPropagation(); + if (signals.pinching) return; + // A press on a part may become a part drag: the rig must not turn the model for it. + markPartPress(signals, true); + editor.onPartPress(parsed.id, { pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY }); + } }, onClick(event) { event.stopPropagation(); @@ -193,7 +230,15 @@ export function HiveSceneContent({ return ( <> - + + {editor ? : null} {layout.parts.map((placed) => { const part = model.get(placed.id); @@ -244,6 +289,15 @@ export function HiveSceneContent({ ); })} + {drag && editor ? ( + + ) : null} ); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/model-rig.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/model-rig.tsx index 13fbbfb..fc7d349 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/model-rig.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/model-rig.tsx @@ -5,10 +5,10 @@ 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 { createRigPointer } from "./rig-pointer"; +import { attachRigPointer } from "./rig-pointer"; import type { RigInput } from "./rig-pointer"; import { useSceneKit } from "./scene-kit"; -import { capturePointer, markTimerFrame, releasePointer } from "./scene-mutations"; +import { markPartPress, markTimerFrame } from "./scene-mutations"; import { MAX_FRAME_SECONDS } from "./use-animated-node"; import { stepToward } from "./use-hive-motion"; @@ -25,6 +25,7 @@ export const IDLE_PERIOD_SECONDS = 6; export const IDLE_FRAME_MS = 33; function useRigInput(enabledTilt: boolean): MutableRefObject { + const { signals } = useSceneKit(); const gl = useThree((state) => state.gl); const invalidate = useThree((state) => state.invalidate); const input = useRef({ turn: 0, tiltX: 0, tiltY: 0, dragging: false }); @@ -32,34 +33,15 @@ function useRigInput(enabledTilt: boolean): MutableRefObject { useEffect(() => { const element = gl.domElement as HTMLElement | undefined; if (!element || typeof element.addEventListener !== "function") return; - const pointer = createRigPointer(input.current, { + return attachRigPointer(element, input.current, { tilt: enabledTilt, - rect: () => element.getBoundingClientRect(), - // Captured while dragging, so a release outside the canvas still ends the drag. - capture: (id) => capturePointer(element, id), - release: (id) => releasePointer(element, id), changed: invalidate, + // Editor: a press on a part may become a part drag, so it never turns the model. + canTurn: () => !signals.partPress, + pressEnded: () => markPartPress(signals, false), + paused: () => signals.pinching, }); - const onDown = (event: PointerEvent) => pointer.down(event); - const onMove = (event: PointerEvent) => pointer.move(event); - const onUp = (event: PointerEvent) => pointer.up(event); - const onLeave = () => pointer.leave(); - element.addEventListener("pointerdown", onDown); - element.addEventListener("pointermove", onMove); - element.addEventListener("pointerup", onUp); - element.addEventListener("pointercancel", onUp); - element.addEventListener("lostpointercapture", onUp); - element.addEventListener("pointerleave", onLeave); - return () => { - pointer.dispose(); - element.removeEventListener("pointerdown", onDown); - element.removeEventListener("pointermove", onMove); - element.removeEventListener("pointerup", onUp); - element.removeEventListener("pointercancel", onUp); - element.removeEventListener("lostpointercapture", onUp); - element.removeEventListener("pointerleave", onLeave); - }; - }, [gl, invalidate, enabledTilt]); + }, [gl, invalidate, enabledTilt, signals]); return input; } diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/rig-pointer.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/rig-pointer.spec.ts index 4c62440..ab4aca0 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/rig-pointer.spec.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/rig-pointer.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { MAX_TURN, SPRING_BACK_MS, createRigPointer } from "./rig-pointer"; +import { MAX_TURN, SPRING_BACK_MS, attachRigPointer, createRigPointer } from "./rig-pointer"; import type { PointerSample, RigInput } from "./rig-pointer"; function setup(tilt = true) { @@ -99,6 +99,48 @@ describe("createRigPointer", () => { expect(still.input.tiltY).toBe(0); }); + it("does not turn when the host refuses the turn as it would start (editor: the press was on a part)", () => { + let onPart = false; + const input: RigInput = { turn: 0, tiltX: 0, tiltY: 0, dragging: false }; + const pressEnded = vi.fn(); + const host = { + tilt: false, + rect: () => ({ left: 0, top: 0, width: 400, height: 300 }), + capture: vi.fn(), + release: vi.fn(), + changed: vi.fn(), + canTurn: () => !onPart, + pressEnded, + }; + const pointer = createRigPointer(input, host); + pointer.down(sample(100)); + // R3F marks the part press after the rig saw the pointerdown. + onPart = true; + pointer.move(sample(250)); + expect(input.dragging).toBe(false); + expect(input.turn).toBe(0); + pointer.up(sample(250)); + expect(pressEnded).toHaveBeenCalled(); + }); + + it("holds still while the host is paused (two-finger zoom)", () => { + let paused = false; + const input: RigInput = { turn: 0, tiltX: 0, tiltY: 0, dragging: false }; + const host = { + tilt: false, + rect: () => ({ left: 0, top: 0, width: 400, height: 300 }), + capture: vi.fn(), + release: vi.fn(), + changed: vi.fn(), + paused: () => paused, + }; + const pointer = createRigPointer(input, host); + pointer.down(sample(100, { pointerType: "touch" })); + paused = true; + pointer.move(sample(250, { pointerType: "touch" })); + expect(input.turn).toBe(0); + }); + it("clears its timers on dispose", () => { const { input, pointer } = setup(); pointer.down(sample(100)); @@ -108,4 +150,49 @@ describe("createRigPointer", () => { vi.advanceTimersByTime(SPRING_BACK_MS); expect(input.turn).toBeGreaterThan(0); }); + + it("keeps the real DOM order: rig on the canvas, the part handler on the wrapper after it", () => { + const wrapper = document.createElement("div"); + const canvas = document.createElement("canvas"); + wrapper.appendChild(canvas); + document.body.appendChild(wrapper); + canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 300, right: 400, bottom: 300, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect; + const signals = { partPress: false }; + // R3F 9 listens on the wrapper: its pointerdown runs after the canvas listener (bubbling). + wrapper.addEventListener("pointerdown", (event) => { + if ((event as PointerEvent & { onPart?: boolean }).onPart) signals.partPress = true; + }); + const input: RigInput = { turn: 0, tiltX: 0, tiltY: 0, dragging: false }; + const detach = attachRigPointer(canvas, input, { + tilt: false, + changed: vi.fn(), + canTurn: () => !signals.partPress, + pressEnded: () => { + signals.partPress = false; + }, + }); + const fire = (type: string, clientX: number, onPart = false) => { + const event = new Event(type, { bubbles: true }); + Object.assign(event, { clientX, clientY: 100, pointerId: 1, pointerType: "mouse", buttons: type === "pointerup" ? 0 : 1, onPart }); + canvas.dispatchEvent(event); + }; + + // A horizontal drag that starts on a part: no turn. + fire("pointerdown", 100, true); + fire("pointermove", 250); + expect(input.turn).toBe(0); + fire("pointerup", 250); + vi.advanceTimersByTime(1); + expect(signals.partPress).toBe(false); + + // A stale mark (part drag released outside the canvas) does not block the next press. + signals.partPress = true; + // The next drag on empty space turns (the flag of the previous press is gone). + fire("pointerdown", 100); + fire("pointermove", 250); + expect(input.turn).toBeGreaterThan(0); + fire("pointerup", 250); + detach(); + wrapper.remove(); + }); }); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/rig-pointer.ts b/ApiCore-Frontend/src/components/hive/hive-scene/rig-pointer.ts index 386a28a..4186ab0 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/rig-pointer.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/rig-pointer.ts @@ -35,6 +35,16 @@ export interface RigPointerHost { release(pointerId: number): void; /** Something changed: request a frame. */ changed(): void; + /** + * May this press turn the model? Asked when the turn would start, not on + * pointerdown: R3F handles its pointerdown on the wrapper after the canvas + * listeners, so the editor's "press was on a part" mark only exists by then. + */ + canTurn?(): boolean; + /** The press ended (up, cancel, lost capture): the host clears its per-press marks. */ + pressEnded?(): void; + /** Turning holds still meanwhile (editor: two-finger zoom). */ + paused?(): boolean; } export interface RigPointer { @@ -56,12 +66,15 @@ export function createRigPointer(input: RigInput, host: RigPointerHost): RigPoin const pointer: RigPointer = { down(event) { + // A mark left from a press that ended outside the canvas; R3F sets this press's mark after us. + host.pressEnded?.(); press = { x: event.clientX, y: event.clientY, turn: input.turn, id: event.pointerId, captured: false }; if (springBack) clearTimeout(springBack); springBack = null; }, move(event) { if (press && press.id === event.pointerId) { + if (host.paused?.()) return; // The mouse button went up outside the canvas: end the drag now. if (event.pointerType === "mouse" && event.buttons === 0) { pointer.up(event); @@ -70,6 +83,11 @@ export function createRigPointer(input: RigInput, host: RigPointerHost): RigPoin const dx = event.clientX - press.x; const dy = event.clientY - press.y; if (!input.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) { + if (host.canTurn && !host.canTurn()) { + // The press belongs to something else (a part drag): give it up for good. + press = null; + return; + } input.dragging = true; press.captured = true; host.capture(press.id); @@ -90,6 +108,7 @@ export function createRigPointer(input: RigInput, host: RigPointerHost): RigPoin } }, up() { + host.pressEnded?.(); if (!press) return; if (press.captured) host.release(press.id); if (input.dragging) { @@ -116,3 +135,47 @@ export function createRigPointer(input: RigInput, host: RigPointerHost): RigPoin }; return pointer; } + +export type AttachHost = Omit; + +/** + * Wires a rig pointer to a canvas element (captures the pointer while turning, + * so a release outside the canvas still ends the turn). Returns the detach. + */ +export function attachRigPointer(element: HTMLElement, input: RigInput, host: AttachHost): () => void { + const pointer = createRigPointer(input, { + ...host, + rect: () => element.getBoundingClientRect(), + capture: (id) => { + try { + element.setPointerCapture?.(id); + } catch { + // The pointer is gone already; buttons === 0 on the next move ends the turn instead. + } + }, + release: (id) => { + try { + if (element.hasPointerCapture?.(id)) element.releasePointerCapture(id); + } catch { + // Already released. + } + }, + }); + const onDown = (event: PointerEvent) => pointer.down(event); + const onMove = (event: PointerEvent) => pointer.move(event); + const onUp = (event: PointerEvent) => pointer.up(event); + const onLeave = () => pointer.leave(); + const events: [string, (event: PointerEvent) => void][] = [ + ["pointerdown", onDown], + ["pointermove", onMove], + ["pointerup", onUp], + ["pointercancel", onUp], + ["lostpointercapture", onUp], + ["pointerleave", onLeave], + ]; + for (const [type, listener] of events) element.addEventListener(type, listener as EventListener); + return () => { + pointer.dispose(); + for (const [type, listener] of events) element.removeEventListener(type, listener as EventListener); + }; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/scene-editor.ts b/ApiCore-Frontend/src/components/hive/hive-scene/scene-editor.ts new file mode 100644 index 0000000..b0f571c --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/scene-editor.ts @@ -0,0 +1,27 @@ +import type { RefObject } from "react"; +import type { DragSource, DropSlot } from "../editor/drop-slots"; + +// What the editor route hands the scene (spec 9.5): the running drag, where +// the pointer is, and zoom. Types only, so the editor bundle does not pull +// three.js in. + +export interface SceneDrag { + source: DragSource; + slots: DropSlot[]; + hover: DropSlot | null; +} + +export interface SceneEditor { + drag: SceneDrag | null; + /** Latest pointer position in client pixels while dragging. */ + pointer: RefObject<{ x: number; y: number } | null>; + /** Called on every pointer move of a drag; returns the unsubscribe. */ + subscribePointer: (listener: () => void) => () => void; + /** The slot under the pointer (null: none) - the drop goes there. */ + onHoverSlot: (slot: DropSlot | null) => void; + /** A press on a part in the scene; it becomes a drag when the pointer moves. */ + onPartPress: (partId: string, event: { pointerId: number; clientX: number; clientY: number }) => void; + /** 0.6 … 2.5 */ + zoom: number; + onZoom: (zoom: number) => void; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx index 7190170..79f30a7 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/scene-kit.tsx @@ -27,6 +27,10 @@ export interface SceneSignals { timerFrame: boolean; /** World point the camera frames in focus mode (depth of field focuses there). */ focusPoint: Vec3 | null; + /** The press that just started was on a part (editor: it may become a part drag, not a turn). */ + partPress: boolean; + /** Two fingers are zooming (editor): no turning meanwhile. */ + pinching: boolean; } export interface SceneKit { @@ -54,7 +58,12 @@ export function SceneKitProvider({ motion, tier = "high", children }: SceneKitPr const [theme] = useState(readHoloTheme); const [geometries] = useState(createGeometryCache); const [textures] = useState(() => createFrameTextureCache()); - const [signals] = useState(() => ({ timerFrame: false, focusPoint: null })); + const [signals] = useState(() => ({ + timerFrame: false, + focusPoint: null, + partPress: false, + pinching: false, + })); const [env, setEnv] = useState(null); useEffect(() => { diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts b/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts index ca8f90c..e1a1953 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts @@ -93,19 +93,10 @@ export function setFocusPoint(signals: SceneSignals, point: Vec3 | null): void { signals.focusPoint = point; } -/** Pointer capture on the canvas (missing in some test DOMs, refused for unknown ids). */ -export function capturePointer(element: HTMLElement, pointerId: number): void { - try { - element.setPointerCapture?.(pointerId); - } catch { - // The pointer is gone already; buttons === 0 on the next move ends the drag instead. - } +export function setPinching(signals: SceneSignals, value: boolean): void { + signals.pinching = value; } -export function releasePointer(element: HTMLElement, pointerId: number): void { - try { - if (element.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId); - } catch { - // Already released. - } +export function markPartPress(signals: SceneSignals, value: boolean): void { + signals.partPress = value; } diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx index 9b339d3..3aba347 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx @@ -32,6 +32,9 @@ export interface NodeHandlers { onClick(event: ThreeEvent): void; } +/** Very thin parts (sheets, screens) still get a hit box a finger or a mouse can find (12 mm). */ +const MIN_HIT_HEIGHT = 0.12; + /** Edge brightness at glow 1 (rest); bloom picks it up above the threshold. */ const EDGE_INTENSITY = 1.25; @@ -109,7 +112,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames, {/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */} [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); +export function restCenter(placed: PartBox, gap: number = REST_GAP): Vec3 { + return lift(placed.center, placed.stackIndex * gap); } /** World box of the rest stack (centre and size), gaps included. */ -export function restBounds(layout: HiveLayout): { center: Vec3; size: Vec3 } { +export function restBounds(layout: HiveLayout, gap: number = REST_GAP): { center: Vec3; size: Vec3 } { const levels = layout.parts.length; - const height = layout.bounds.h + Math.max(0, levels - 1) * REST_GAP; + const height = layout.bounds.h + Math.max(0, levels - 1) * gap; return { center: [0, height / 2, 0], size: [layout.bounds.w, height, layout.bounds.d] }; } @@ -141,7 +141,12 @@ function focusInfo(layout: HiveLayout, focus: FocusState): FocusInfo { }; } -function stackTargets(layout: HiveLayout, view: MotionView, focus: FocusState | null): Map { +function stackTargets( + layout: HiveLayout, + view: MotionView, + focus: FocusState | null, + gap: number, +): Map { const nodes = new Map(); for (const placed of layout.parts) { const focused = focus !== null && !focus.isInsert && focus.id === placed.id; @@ -158,7 +163,7 @@ function stackTargets(layout: HiveLayout, view: MotionView, focus: FocusState | }); continue; } - const home = restCenter(placed); + const home = restCenter(placed, gap); if (focus) { nodes.set(placed.id, { position: home, @@ -193,11 +198,12 @@ function insertTarget( focus: FocusState | null, insert: PartBox, host: PartBox, + gap: number, ): 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)), + position: sub(focusPlacement(layout.bounds, insert.size), restCenter(host, gap)), scale: FOCUS_SCALE, opacity: 1, glow: GLOW.focus, @@ -251,12 +257,13 @@ function frameTarget(frame: FrameBox, host: PartBox, view: MotionView, focus: Fo } /** Targets for every part, insert and frame of the layout in the given view state. */ -export function hiveMotionTargets(layout: HiveLayout, view: MotionView): HiveMotionTargets { +/** `gap`: space between stack levels; the editor opens the stack while dragging (DRAG_GAP). */ +export function hiveMotionTargets(layout: HiveLayout, view: MotionView, gap: number = REST_GAP): HiveMotionTargets { const focus = findFocus(layout, view); - const nodes = stackTargets(layout, view, focus); + const nodes = stackTargets(layout, view, focus, gap); for (const insert of layout.inserts) { const host = hostOf(layout, insert); - if (host) nodes.set(insert.id, insertTarget(layout, view, focus, insert, host)); + if (host) nodes.set(insert.id, insertTarget(layout, view, focus, insert, host, gap)); } const frames = new Map(); for (const frame of layout.frames) { diff --git a/ApiCore-Frontend/src/components/hive/hive-visual-slot.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-visual-slot.spec.tsx index b677393..3913641 100644 --- a/ApiCore-Frontend/src/components/hive/hive-visual-slot.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-visual-slot.spec.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { hiveLayout } from "@/lib/hive/layout"; import { inlineCatalog, part } from "@/lib/hive/test-utils"; @@ -61,7 +62,7 @@ function enterViewport() { }); } -function renderSlot() { +function renderSlot(sceneOverlay?: ReactNode) { return render( id} onSelectPart={vi.fn()} onSelectFrame={vi.fn()} + sceneOverlay={sceneOverlay} /> , ); @@ -158,6 +160,15 @@ describe("HiveVisualSlot", () => { expect(canvasLayer()).not.toHaveClass("pointer-events-none"); }); + it("shows the scene overlay (editor zoom buttons) only while the 3D scene is up", () => { + renderSlot(); + expect(screen.queryByRole("button", { name: "Vergrößern" })).toBeNull(); + enterViewport(); + expect(screen.queryByRole("button", { name: "Vergrößern" })).toBeNull(); + fireEvent.click(screen.getByText("ready")); + expect(screen.getByRole("button", { name: "Vergrößern" })).toBeInTheDocument(); + }); + it("remounts the scene once after the first context loss", () => { renderSlot(); enterViewport(); diff --git a/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx b/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx index 7ecf621..23abbfa 100644 --- a/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx @@ -2,9 +2,11 @@ import dynamic from "next/dynamic"; import { useCallback, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; import type { HiveLayout } from "@/lib/hive/layout"; import type { HiveCatalog, HiveState } from "@/lib/hive/types"; import type { OverlayInsets } from "./hive-scene/camera-fit"; +import type { SceneEditor } from "./hive-scene/scene-editor"; import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl"; import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference"; import type { MotionLevel } from "@/lib/motion/motion-preference"; @@ -28,6 +30,10 @@ export interface HiveVisualSlotProps { onSelectFrame: (boxId: string, slot: number) => void; /** Card pixels covered by the frosted overlays; the 3D camera fits the model between them. */ insets?: OverlayInsets; + /** Editor route: drag and drop and zoom in the scene. */ + editor?: SceneEditor; + /** DOM over the 3D scene (editor zoom buttons); shown only while the scene is up, not over the SVG. */ + sceneOverlay?: ReactNode; } function allows3D(motion: MotionLevel): boolean { @@ -53,6 +59,8 @@ export function HiveVisualSlot({ onSelectPart, onSelectFrame, insets, + editor, + sceneOverlay, }: HiveVisualSlotProps) { const motion = useEffectiveMotionLevel(); const rootRef = useRef(null); @@ -103,7 +111,9 @@ export function HiveVisualSlot({ onReady={onReady} onContextLost={onContextLost} insets={insets} + editor={editor} /> + {ready ? sceneOverlay : null}
) : null} diff --git a/ApiCore-Frontend/src/components/hive/part-sheet.tsx b/ApiCore-Frontend/src/components/hive/part-sheet.tsx index bf6a918..3dad97c 100644 --- a/ApiCore-Frontend/src/components/hive/part-sheet.tsx +++ b/ApiCore-Frontend/src/components/hive/part-sheet.tsx @@ -9,7 +9,8 @@ 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 { frameFeederOps } from "@/lib/hive/feeder-slots"; +import { FrameFeederPicker } from "./frame-feeder-picker"; import type { FrameFeederChoice } from "./frame-feeder-picker"; import type { HiveOutlineHandle } from "./hive-outline"; import { HiveSheetShell } from "./hive-sheet-shell"; diff --git a/ApiCore-Frontend/src/lib/hive/describe.spec.ts b/ApiCore-Frontend/src/lib/hive/describe.spec.ts index 3ad8921..793cae4 100644 --- a/ApiCore-Frontend/src/lib/hive/describe.spec.ts +++ b/ApiCore-Frontend/src/lib/hive/describe.spec.ts @@ -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 }, diff --git a/ApiCore-Frontend/src/lib/hive/describe.ts b/ApiCore-Frontend/src/lib/hive/describe.ts index d502cbf..ac6cd1c 100644 --- a/ApiCore-Frontend/src/lib/hive/describe.ts +++ b/ApiCore-Frontend/src/lib/hive/describe.ts @@ -83,7 +83,14 @@ export function describeHiveOps( const known = new Map(); 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`; diff --git a/ApiCore-Frontend/src/lib/hive/feeder-slots.spec.ts b/ApiCore-Frontend/src/lib/hive/feeder-slots.spec.ts index 4b9de96..7533695 100644 --- a/ApiCore-Frontend/src/lib/hive/feeder-slots.spec.ts +++ b/ApiCore-Frontend/src/lib/hive/feeder-slots.spec.ts @@ -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 } }]); + }); +}); diff --git a/ApiCore-Frontend/src/lib/hive/feeder-slots.ts b/ApiCore-Frontend/src/lib/hive/feeder-slots.ts index 8523a02..a5fcd9f 100644 --- a/ApiCore-Frontend/src/lib/hive/feeder-slots.ts +++ b/ApiCore-Frontend/src/lib/hive/feeder-slots.ts @@ -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; +} diff --git a/ApiCore-Frontend/src/lib/hive/follow-ups.spec.ts b/ApiCore-Frontend/src/lib/hive/follow-ups.spec.ts index 39ca77e..6a02ab3 100644 --- a/ApiCore-Frontend/src/lib/hive/follow-ups.spec.ts +++ b/ApiCore-Frontend/src/lib/hive/follow-ups.spec.ts @@ -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[] = [ diff --git a/ApiCore-Frontend/src/lib/hive/follow-ups.ts b/ApiCore-Frontend/src/lib/hive/follow-ups.ts index 6c88877..1c13d97 100644 --- a/ApiCore-Frontend/src/lib/hive/follow-ups.ts +++ b/ApiCore-Frontend/src/lib/hive/follow-ups.ts @@ -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); } diff --git a/ApiCore-Frontend/src/lib/hive/fresh-part.spec.ts b/ApiCore-Frontend/src/lib/hive/fresh-part.spec.ts new file mode 100644 index 0000000..d431213 --- /dev/null +++ b/ApiCore-Frontend/src/lib/hive/fresh-part.spec.ts @@ -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, + }); + }); +}); diff --git a/ApiCore-Frontend/src/lib/hive/fresh-part.ts b/ApiCore-Frontend/src/lib/hive/fresh-part.ts new file mode 100644 index 0000000..4258260 --- /dev/null +++ b/ApiCore-Frontend/src/lib/hive/fresh-part.ts @@ -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; +}