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

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

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

Refs #21

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

No files matched your search

@@ -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<string, number | string> = {};
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);
@@ -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);
});
});
@@ -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;
});
}
@@ -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(<ZoomControls zoom={1} onZoom={onZoom} />);
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(<ZoomControls zoom={2.5} onZoom={onZoom} />);
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");
});
});
@@ -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 (
<div className="pointer-events-auto absolute end-2 top-2 flex items-center gap-1.5">
<button
type="button"
aria-label="Verkleinern"
aria-disabled={atMin}
onClick={() => {
if (!atMin) onZoom(stepZoom(zoom, -1));
}}
className={`${GLASS_BUTTON} aria-disabled:cursor-not-allowed aria-disabled:opacity-60`}
>
<ZoomOut aria-hidden />
</button>
<span className={`rounded-lg px-2 py-1 text-xs tabular-nums ${GLASS_SURFACE}`} aria-live="off">
{Math.round(zoom * 100)} %
</span>
<button
type="button"
aria-label="Vergrößern"
aria-disabled={atMax}
onClick={() => {
if (!atMax) onZoom(stepZoom(zoom, 1));
}}
className={`${GLASS_BUTTON} aria-disabled:cursor-not-allowed aria-disabled:opacity-60`}
>
<ZoomIn aria-hidden />
</button>
</div>
);
}
/** 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<HTMLDivElement | null>; active: boolean }) {
return (
<div
ref={zoneRef}
className={`pointer-events-none absolute inset-x-0 bottom-3 mx-auto flex w-fit items-center gap-2 rounded-2xl border-2 border-dashed px-6 py-4 text-sm font-medium transition-colors duration-150 ${
active ? "border-red-300 bg-red-500/35 text-white" : `border-white/30 ${GLASS_SURFACE}`
}`}
>
<Trash2 aria-hidden className="size-5" />
Entfernen
</div>
);
}
/** 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<DragPointer | null> }) {
const chip = useRef<HTMLDivElement>(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 (
<div
ref={chip}
aria-hidden
className={`pointer-events-none fixed start-0 top-0 z-50 rounded-xl px-3 py-1.5 text-sm font-medium ${GLASS_SURFACE}`}
>
{drag.label}
{where ? <span className="block text-xs text-holo-label-fg/80">{where}</span> : null}
</div>
);
}
@@ -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<HTMLDivElement>(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<SceneEditor>(
() => ({
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={<ZoomControls zoom={zoom} onZoom={setZoom} />}
/>
{draggingBuiltPart ? <RemoveZone zoneRef={removeZoneRef} active={dragView.overRemove} /> : null}
</div>
<aside
aria-label="Teile und Aufbau"
@@ -337,7 +372,11 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
<h2 id="editor-parts" className="text-base font-semibold">
Teile
</h2>
<PartPalette catalog={catalog} onAdd={openAdd} />
<PartPalette
catalog={catalog}
onAdd={openAdd}
onDragStart={(type, event) => beginDrag({ partTypeId: type.id }, type.name, event)}
/>
</section>
<section aria-labelledby="editor-stack" className="flex flex-col gap-3">
<h2 id="editor-stack" ref={stackHeadingRef} tabIndex={-1} className="text-base font-semibold outline-none">
@@ -353,6 +392,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
</section>
</aside>
</div>
{dragView ? <DragChip drag={dragView} pointer={dragPointer} /> : null}
<div role="status" aria-live="polite" className="sr-only">
<span key={announcement.nonce}>{announcement.text}</span>
</div>
@@ -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(<PartPalette catalog={inlineCatalog()} onAdd={vi.fn()} onDragStart={onDragStart} />);
const grip = container.querySelector<HTMLElement>('[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(<PartPalette catalog={inlineCatalog()} onAdd={vi.fn()} />);
expect(container.querySelector("[data-drag-type]")).toBeNull();
});
});
@@ -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<HTMLElement>) => 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 (
<div className="flex flex-col gap-4">
@@ -70,7 +74,25 @@ export function PartPalette({ catalog, onAdd }: PartPaletteProps) {
<ul className="flex flex-col gap-1.5">
{types.map((type) => (
<li key={type.id} className="flex items-center gap-2">
<PartSymbol type={type} />
{onDragStart ? (
// Pointer-only grip; „Hinzufügen …“ is the keyboard and screen reader way.
<span
aria-hidden="true"
title="In die Beute ziehen"
data-drag-type={type.id}
onPointerDown={(event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
onDragStart(type, event);
}}
className="flex h-11 shrink-0 cursor-grab touch-none items-center gap-0.5 rounded-lg pe-1 select-none hover:bg-white/5 active:cursor-grabbing"
>
<GripVertical className="size-4 text-holo-label-fg/60" />
<PartSymbol type={type} />
</span>
) : (
<PartSymbol type={type} />
)}
<span className="min-w-0 flex-1 text-sm">
<span className="block truncate">{type.name}</span>
<span className="block text-xs text-holo-label-fg/70">{PART_KIND_LABELS[type.kind]}</span>
@@ -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();
});
});
@@ -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<HTMLElement | null>;
/** Id for a new part. */
newId: () => string;
}
export interface HiveDrag {
drag: HiveDragView | null;
/** Latest pointer position (client pixels) while pressing or dragging. */
pointer: RefObject<DragPointer | null>;
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<Press | null>(null);
const [drag, setDrag] = useState<HiveDragView | null>(null);
const dragRef = useRef<HiveDragView | null>(null);
const pointer = useRef<DragPointer | null>(null);
const pressRef = useRef<Press | null>(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<HiveDrag["begin"]>((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 };
}
@@ -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;
@@ -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);
});
});
@@ -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);
}
@@ -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(() => {
@@ -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 });
});
});
@@ -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<DropSlot, { kind: "stack" }>, 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;
}
@@ -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 (
<mesh
name="drop-slot"
geometry={geometry}
material={material}
position={flat ? [anchor.hitCenter[0], anchor.hitCenter[1], anchor.hitCenter[2]] : anchor.center}
raycast={noRaycast}
renderOrder={6}
/>
);
}
/** 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 (
<group position={anchor.center}>
<mesh geometry={geometry} material={glass} raycast={noRaycast} renderOrder={7} />
<mesh geometry={edges} material={glow} raycast={noRaycast} renderOrder={8} />
</group>
);
}
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<Group>(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<DropSlot | null>(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<DOMRect | null>(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 (
<group ref={group}>
{anchors.map((anchor, index) => (
<SlotMarker
key={index}
anchor={anchor}
material={sameSlot(anchor.slot, hover) ? materials.active : materials.idle}
/>
))}
{hovered ? <Preview anchor={hovered} /> : null}
</group>
);
}
/** 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<number, { x: number; y: number }>();
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;
}
@@ -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}
/>
</SceneKitProvider>
</Canvas>
@@ -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<typeof import("./drop-anchors")>();
return {
...actual,
slotAnchors: (...args: Parameters<typeof actual.slotAnchors>) => {
anchorCalls.count += 1;
return actual.slotAnchors(...args);
},
};
});
vi.mock("./hive-labels", () => ({
HoloLabel: ({ text }: { text: string }) => <group name={`label:${text}`} />,
}));
@@ -48,7 +63,7 @@ interface Rendered {
let current: Rendered | null = null;
async function renderScene(motion: MotionLevel = "full"): Promise<Rendered> {
async function renderScene(motion: MotionLevel = "full", editor?: SceneEditor): Promise<Rendered> {
const state = hiveState();
const layout = hiveLayout(state, catalog);
const store = createHiveViewStore();
@@ -65,6 +80,7 @@ async function renderScene(motion: MotionLevel = "full"): Promise<Rendered> {
onSelectPart={onSelectPart}
onSelectFrame={onSelectFrame}
idle={false}
editor={editor}
/>
</SceneKitProvider>
</HiveViewProvider>,
@@ -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(
<HiveViewProvider store={createHiveViewStore()}>
<SceneKitProvider motion="off">
<HiveSceneContent
layout={layout}
state={thin}
catalog={catalog}
partName={(id) => id}
onSelectPart={vi.fn()}
onSelectFrame={vi.fn()}
idle={false}
/>
</SceneKitProvider>
</HiveViewProvider>,
);
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> = {}): 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) => (
<HiveViewProvider store={store}>
<SceneKitProvider motion="off">
<HiveSceneContent
layout={layout}
state={state}
catalog={catalog}
partName={(id) => partDisplayName(state, catalog, id)}
onSelectPart={vi.fn()}
onSelectFrame={vi.fn()}
idle={false}
editor={current}
/>
</SceneKitProvider>
</HiveViewProvider>
);
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];
@@ -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 (
<>
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} insets={insets} />
<CameraRig
layout={layout}
focus={targets.focus}
onPivot={setPivot}
insets={insets}
gap={gap}
zoom={editor?.zoom ?? 1}
/>
{editor ? <EditorZoom zoom={editor.zoom} onZoom={editor.onZoom} /> : null}
<ModelRig getPivot={getPivot} idle={idle} onScreen={onScreen}>
{layout.parts.map((placed) => {
const part = model.get(placed.id);
@@ -244,6 +289,15 @@ export function HiveSceneContent({
</StackPart>
);
})}
{drag && editor ? (
<DropTargets
anchors={anchors}
hover={drag.hover}
pointer={editor.pointer}
subscribePointer={editor.subscribePointer}
onHover={editor.onHoverSlot}
/>
) : null}
</ModelRig>
</>
);
@@ -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<RigInput> {
const { signals } = useSceneKit();
const gl = useThree((state) => state.gl);
const invalidate = useThree((state) => state.invalidate);
const input = useRef<RigInput>({ turn: 0, tiltX: 0, tiltY: 0, dragging: false });
@@ -32,34 +33,15 @@ function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
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;
}
@@ -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();
});
});
@@ -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<RigPointerHost, "rect" | "capture" | "release">;
/**
* 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);
};
}
@@ -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;
}
@@ -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<SceneSignals>(() => ({ timerFrame: false, focusPoint: null }));
const [signals] = useState<SceneSignals>(() => ({
timerFrame: false,
focusPoint: null,
partPress: false,
pinching: false,
}));
const [env, setEnv] = useState<CubeTexture | null>(null);
useEffect(() => {
@@ -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;
}
@@ -32,6 +32,9 @@ export interface NodeHandlers {
onClick(event: ThreeEvent<MouseEvent>): 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. */}
<mesh
position={[0, -target.lift / 2, 0]}
scale={[box.size[0], box.size[1] + target.lift, box.size[2]]}
scale={[box.size[0], Math.max(box.size[1], MIN_HIT_HEIGHT) + target.lift, box.size[2]]}
raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut}
@@ -78,14 +78,14 @@ const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const lift = (value: Vec3, dy: number): Vec3 => [value[0], value[1] + dy, value[2]];
/** Rest place of a stack part: its layout centre raised by the stack gap. */
export function restCenter(placed: PartBox): Vec3 {
return lift(placed.center, placed.stackIndex * REST_GAP);
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<string, NodeTarget> {
function stackTargets(
layout: HiveLayout,
view: MotionView,
focus: FocusState | null,
gap: number,
): Map<string, NodeTarget> {
const nodes = new Map<string, NodeTarget>();
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<string, FrameTarget>();
for (const frame of layout.frames) {
@@ -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(
<HiveViewProvider store={createHiveViewStore()}>
<HiveVisualSlot
@@ -71,6 +72,7 @@ function renderSlot() {
partName={(id) => id}
onSelectPart={vi.fn()}
onSelectFrame={vi.fn()}
sceneOverlay={sceneOverlay}
/>
</HiveViewProvider>,
);
@@ -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(<button type="button">Vergrößern</button>);
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();
@@ -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<HTMLDivElement>(null);
@@ -103,7 +111,9 @@ export function HiveVisualSlot({
onReady={onReady}
onContextLost={onContextLost}
insets={insets}
editor={editor}
/>
{ready ? sceneOverlay : null}
</div>
) : null}
</div>
@@ -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";
@@ -73,6 +73,17 @@ describe("describeHiveOps", () => {
);
});
it("describes an insert taken out and put into another host (same id) as moved", () => {
const state = templateState();
state.stack.splice(2, 0, part("b2", "box"));
state.stack[1].inserts = [part("ev", "evaporator")];
const ops: HiveOp[] = [
{ op: "removePart", id: "ev" },
{ op: "addInsert", hostId: "b2", part: part("ev", "evaporator") },
];
expect(describeHiveOps(ops, state, catalog)).toBe("Verdunster verschoben");
});
it("resolves parts added earlier in the same batch", () => {
const ops: HiveOp[] = [
{ op: "addPart", part: { ...part("n", "box"), label: "Aufsatz" }, at: 2 },
+8 -1
View File
@@ -83,7 +83,14 @@ export function describeHiveOps(
const known = new Map<string, Named>();
for (const part of seen) collect(part, catalog, known);
for (const part of before.stack) collect(part, catalog, known);
const texts = ops.map((op) => describeOne(op, known, catalog));
// An insert taken out and put back in with the same id (drag to another host) is one move.
const reAdded = new Set(ops.flatMap((op) => (op.op === "addInsert" ? [op.part.id] : [])));
const moved = new Set(ops.flatMap((op) => (op.op === "removePart" && reAdded.has(op.id) ? [op.id] : [])));
const texts = ops.flatMap((op) => {
if (op.op === "removePart" && moved.has(op.id)) return [`${nameOf(known.get(op.id))} verschoben`];
if (op.op === "addInsert" && moved.has(op.part.id)) return [];
return [describeOne(op, known, catalog)];
});
if (texts.length <= MAX_SHOWN_OPS) return texts.join(", ");
const rest = texts.length - MAX_SHOWN_OPS;
return `${texts.slice(0, MAX_SHOWN_OPS).join(", ")} und ${rest} weitere`;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { feederRange, freeFeederStarts, pocketRanges, rangeText } from "./feeder-slots";
import { feederRange, frameFeederOps, freeFeederStarts, pocketRanges, rangeText } from "./feeder-slots";
import { frames, inlineCatalog, part } from "./test-utils";
const catalog = inlineCatalog();
@@ -34,3 +34,25 @@ describe("feeder slots", () => {
expect(rangeText({ from: 3, to: 3 })).toBe("3");
});
});
describe("frameFeederOps", () => {
const catalogForOps = inlineCatalog();
const pocketType = catalogForOps.partTypes.get("frame-feeder")!;
it("empties the covered frames first, then hangs the pocket at its slot", () => {
const boxFrames = frames(10);
const ops = frameFeederOps("b", boxFrames, pocketType, 3, { id: "ff", partTypeId: "frame-feeder", state: {} });
expect(ops[0]).toEqual({
op: "setFrames",
boxId: "b",
frames: boxFrames.map((frame) => (frame.slot === 3 || frame.slot === 4 ? { slot: frame.slot, type: "NONE" } : frame)),
});
expect(ops[1]).toEqual({ op: "addInsert", hostId: "b", part: { id: "ff", partTypeId: "frame-feeder", state: {}, slot: 3 } });
});
it("skips setFrames when the covered slots are empty already", () => {
const boxFrames = frames(10).map((frame) => (frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame));
const ops = frameFeederOps("b", boxFrames, pocketType, 1, { id: "ff", partTypeId: "frame-feeder", state: {} });
expect(ops).toEqual([{ op: "addInsert", hostId: "b", part: { id: "ff", partTypeId: "frame-feeder", state: {}, slot: 1 } }]);
});
});
+33 -1
View File
@@ -1,4 +1,4 @@
import type { CatalogPartType, HiveCatalog, HivePart } from "./types";
import type { CatalogPartType, HiveCatalog, HiveFrame, HiveOp, HivePart } from "./types";
// Frame feeder (Futtertasche) positions in a box. A pocket hangs in place of
// `slotWidth` frames from its `slot` on (spec 4.1); the server rule is
@@ -48,3 +48,35 @@ export function freeFeederStarts(
export function rangeText(range: SlotRange): string {
return range.from === range.to ? String(range.from) : `${range.from}–${range.to}`;
}
/** Frame slots a frame feeder starting at `start` covers. */
export function coveredSlots(start: number, type: CatalogPartType): number[] {
const range = feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type);
if (!range) return [];
return Array.from({ length: range.to - range.from + 1 }, (_, index) => range.from + index);
}
/** Frames of the box with the covered slots set to NONE (names of covered frames are dropped). */
function emptiedFrames(frames: HiveFrame[], covered: number[]): HiveFrame[] {
return frames.map((frame) => (covered.includes(frame.slot) ? { slot: frame.slot, type: "NONE" } : frame));
}
/**
* Ops that hang a frame feeder at `start`: setFrames only if a covered frame is
* not empty yet, then addInsert. Shared by the box sheet, the add dialog and
* drag and drop.
*/
export function frameFeederOps(
boxId: string,
frames: HiveFrame[],
type: CatalogPartType,
start: number,
pocket: HivePart,
): HiveOp[] {
const covered = coveredSlots(start, type);
const needsEmptying = frames.some((frame) => covered.includes(frame.slot) && frame.type !== "NONE");
const ops: HiveOp[] = [];
if (needsEmptying) ops.push({ op: "setFrames", boxId, frames: emptiedFrames(frames, covered) });
ops.push({ op: "addInsert", hostId: boxId, part: { ...pocket, slot: start } });
return ops;
}
@@ -72,6 +72,17 @@ describe("hiveFollowUps", () => {
expect(run(before, [{ op: "removePart", id: "ev" }])).toEqual([{ kind: "endTreatment" }]);
});
it("suggests nothing when an evaporator only moves to another box (remove + add with the same id)", () => {
const before = templateState();
before.stack.splice(2, 0, part("b2", "box", { frames: before.stack[1].frames }));
before.stack[1].inserts = [{ ...part("ev", "evaporator"), state: { acidMl: 200 } }];
const ops: HiveOp[] = [
{ op: "removePart", id: "ev" },
{ op: "addInsert", hostId: "b2", part: { ...part("ev", "evaporator"), state: { acidMl: 200 } } },
];
expect(run(before, ops)).toEqual([]);
});
it("gives one treatment when an evaporator is added and raised in one batch", () => {
const evaporator = { ...part("ev", "evaporator"), state: { acidMl: 250 } };
const ops: HiveOp[] = [
+3 -1
View File
@@ -106,7 +106,9 @@ export function hiveFollowUps(
}
}
} else if (op.op === "removePart") {
if (typeOf(findPart(before, op.id), catalog)?.kind === "EVAPORATOR") followUps.push({ kind: "endTreatment" });
// Net difference: an evaporator that is back in the result (moved to another box) ends nothing.
const gone = typeOf(findPart(before, op.id), catalog)?.kind === "EVAPORATOR" && !findPart(after, op.id);
if (gone) followUps.push({ kind: "endTreatment" });
}
for (const followUp of followUps) if (followUp) out.push(followUp);
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { defaultPartState, freshPart } from "./fresh-part";
import { inlineCatalog } from "./test-utils";
const catalog = inlineCatalog();
describe("freshPart", () => {
it("gives a new box foundation frames for every slot and no state", () => {
const box = freshPart(catalog.partTypes.get("box")!, "b-new");
expect(box.state).toEqual({});
expect(box.frames).toHaveLength(10);
expect(box.frames!.every((frame) => frame.type === "FOUNDATION")).toBe(true);
});
it("fills every state field with its default: level 0, the first choice", () => {
expect(defaultPartState(catalog.partTypes.get("feeder")!)).toEqual({ syrupLiters: 0, fondantKg: 0 });
expect(defaultPartState(catalog.partTypes.get("inner-lid")!)).toEqual({ feedHole: "OPEN" });
expect(freshPart(catalog.partTypes.get("inner-lid")!, "l-new")).toEqual({
id: "l-new",
partTypeId: "inner-lid",
state: { feedHole: "OPEN" },
});
});
it("takes the given state instead of the defaults", () => {
expect(freshPart(catalog.partTypes.get("feeder")!, "f", { syrupLiters: 2, fondantKg: 0 }).state).toEqual({
syrupLiters: 2,
fondantKg: 0,
});
});
});
@@ -0,0 +1,22 @@
import type { CatalogPartType, HiveFrame, HivePart } from "./types";
// A new part as the editor adds it (dialog and drag and drop alike): every
// state field set to its default, a new box filled with foundation frames.
/** Level 0, the first option of a choice. */
export function defaultPartState(type: CatalogPartType): HivePart["state"] {
const state: HivePart["state"] = {};
for (const field of type.stateSchema) state[field.key] = field.kind === "level" ? 0 : (field.options[0] ?? "");
return state;
}
/** Frames of a new box: every slot a foundation (spec 9.5). */
export function newBoxFrames(type: CatalogPartType): HiveFrame[] {
return Array.from({ length: type.frameSlots ?? 0 }, (_, index): HiveFrame => ({ slot: index + 1, type: "FOUNDATION" }));
}
export function freshPart(type: CatalogPartType, id: string, state: HivePart["state"] = defaultPartState(type)): HivePart {
const part: HivePart = { id, partTypeId: type.id, state };
if (type.kind === "BOX") part.frames = newBoxFrames(type);
return part;
}