diff --git a/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx b/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx index 4ca673c..7f42721 100644 --- a/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx +++ b/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx @@ -11,6 +11,7 @@ import { berlinCalendarDay } from "@/lib/dates"; import { hiveConflictOf, hiveKeys } from "@/lib/hive/api"; import { updateHiveSystem } from "@/lib/hive/catalog-api"; import { describeHiveOps } from "@/lib/hive/describe"; +import { findPart } from "@/lib/hive/find-part"; import { hiveLayout } from "@/lib/hive/layout"; import { partDisplayName } from "@/lib/hive/names"; import { hiveReasonText } from "@/lib/hive/reason-text"; @@ -77,10 +78,6 @@ function historyFocusTarget(used: HTMLElement | null, other: HTMLElement | null, return enabled(used) ?? enabled(other) ?? heading; } -function hasPart(state: HiveState, id: string): boolean { - return state.stack.some((part) => part.id === id || (part.inserts ?? []).some((insert) => insert.id === id)); -} - /** True when the key event belongs to a text control or a dialog, where Ctrl+Z means something else. */ function isTypingTarget(target: EventTarget | null): boolean { return target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]') !== null; @@ -128,7 +125,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres const hasOps = draft.ops.length > 0; // A selection ends with its part (removed or undone). - const selectedId = pickedId !== null && hasPart(draft.state, pickedId) ? pickedId : null; + const selectedId = pickedId !== null && findPart(draft.state, pickedId) !== undefined ? pickedId : null; // Forget a vanished selection for good, so a redo does not bring it back. if (pickedId !== null && selectedId === null) setPickedId(null); const layout = useMemo(() => hiveLayout(draft.state, catalog), [draft.state, catalog]); diff --git a/ApiCore-Frontend/src/components/hive/hive-card.tsx b/ApiCore-Frontend/src/components/hive/hive-card.tsx index b53d508..ab8d2c1 100644 --- a/ApiCore-Frontend/src/components/hive/hive-card.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-card.tsx @@ -13,11 +13,12 @@ import { hiveErrorCode } from "@/lib/hive/api"; import { hiveReasonText } from "@/lib/hive/reason-text"; import { describeHiveOps } from "@/lib/hive/describe"; import { deriveHiveFacts } from "@/lib/hive/facts"; +import { findPart } from "@/lib/hive/find-part"; import { hiveLayout } from "@/lib/hive/layout"; import { partDisplayName } from "@/lib/hive/names"; import { applyHiveOps } from "@/lib/hive/ops"; import { withPendingOps } from "@/lib/hive/pending"; -import type { HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types"; +import type { CatalogPartType, HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types"; import { useConnectivity } from "@/lib/offline/connectivity"; import { FrameSheet } from "./frame-sheet"; import { HiveConflictSheet } from "./hive-conflict-sheet"; @@ -112,20 +113,20 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial if (hive.isError || systems.isError) { return ( -
-

{LOAD_FAILED_TEXT}

- -
+
+

{LOAD_FAILED_TEXT}

+ +
); } @@ -196,12 +197,14 @@ interface PastState { occurredOn: string; } -function findPartKind(state: HiveState, catalog: HiveCatalog, id: string) { - for (const part of state.stack) { - const hit = part.id === id ? part : part.inserts?.find((insert) => insert.id === id); - if (hit) return catalog.partTypes.get(hit.partTypeId); - } - return undefined; +function findPartType(state: HiveState, catalog: HiveCatalog, id: string): CatalogPartType | undefined { + const part = findPart(state, id); + return part ? catalog.partTypes.get(part.partTypeId) : undefined; +} + +/** Parts whose state the part sheet edits (a box is changed through its frames). */ +function hasPartSheet(type: CatalogPartType): boolean { + return type.kind !== "BOX" && (type.stateSchema.length > 0 || type.kind === "FLOOR"); } interface HiveCardBodyProps { @@ -282,10 +285,9 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in const partName = (id: string) => partDisplayName(shownState, catalog, id); const focusedId = focusedIdOf(view); const leaveFocus = () => view.openBox(null); - /** Parts whose state the part sheet edits (a box is changed through its frames). */ const hasSheet = (id: string) => { - const type = findPartKind(shownState, catalog, id); - return type !== undefined && type.kind !== "BOX" && (type.stateSchema.length > 0 || type.kind === "FLOOR"); + const type = findPartType(shownState, catalog, id); + return type !== undefined && hasPartSheet(type); }; function editFrame(boxId: string, slot: number) { @@ -297,18 +299,18 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in function editPart(id: string, intent?: "frameFeeder") { if (!editable) return; // The outline's add actions report their box; „Futtertasche einsetzen“ says so, the other is the evaporator. - const isBox = findPartKind(workingState, catalog, id)?.kind === "BOX"; + const isBox = findPartType(workingState, catalog, id)?.kind === "BOX"; setPartTarget({ id, evaporator: isBox && intent !== "frameFeeder", frameFeeder: isBox && intent === "frameFeeder" }); setPartOpen(true); } function selectPart(id: string) { - const type = findPartKind(shownState, catalog, id); + const type = findPartType(shownState, catalog, id); if (type?.kind === "BOX") { view.openBox(id); return; } - if (type && (type.stateSchema.length > 0 || type.kind === "FLOOR")) editPart(id); + if (type && hasPartSheet(type)) editPart(id); } const showRebuild = canEdit && !isInspection; @@ -364,7 +366,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, in {focusedId ? ( // In focus mode the top belongs to the focus bar; the facts stay readable here. // Phones keep the room for the part; the focus chips carry its state there. -
+
diff --git a/ApiCore-Frontend/src/components/hive/hive-focus-bar.tsx b/ApiCore-Frontend/src/components/hive/hive-focus-bar.tsx index c983c63..1d1ad87 100644 --- a/ApiCore-Frontend/src/components/hive/hive-focus-bar.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-focus-bar.tsx @@ -2,7 +2,8 @@ import { useEffect } from "react"; import { ArrowLeft, SlidersHorizontal } from "lucide-react"; -import type { HiveCatalog, HivePart, HiveState } from "@/lib/hive/types"; +import { findPart } from "@/lib/hive/find-part"; +import type { HiveCatalog, HiveState } from "@/lib/hive/types"; import { GLASS_PILL, GLASS_SURFACE } from "./hive-glass"; import { formatAmount } from "./hive-scene/scene-model"; @@ -15,15 +16,6 @@ export interface FocusChip { text: string; } -function findPart(state: HiveState, id: string): HivePart | undefined { - for (const part of state.stack) { - if (part.id === id) return part; - const insert = part.inserts?.find((candidate) => candidate.id === id); - if (insert) return insert; - } - return undefined; -} - /** Chips for the focused part: frame count of a box, every state field with its value. */ export function focusChips(state: HiveState, catalog: HiveCatalog, id: string): FocusChip[] { const part = findPart(state, id); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/fill-volume.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/fill-volume.tsx index b7f1310..a9e5845 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/fill-volume.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/fill-volume.tsx @@ -10,6 +10,7 @@ import { createCausticMaterial, createLiquidMaterial } from "./materials"; import { MAX_FRAME_SECONDS } from "./use-animated-node"; import { stepToward } from "./use-hive-motion"; import { noRaycast, useSceneKit } from "./scene-kit"; +import { seededRandom } from "./seeded-random"; import { placeFill, setUniform } from "./scene-mutations"; // Levels (spec 9.3): a glowing, half-transparent volume whose surface moves @@ -104,11 +105,7 @@ function fondantTexture(): CanvasTexture | null { canvas.height = 128; const context = canvas.getContext("2d"); if (!context) return null; - let seed = 97; - const next = () => { - seed = (seed * 16807) % 2147483647; - return seed / 2147483647; - }; + const next = seededRandom(97); context.fillStyle = "#d8d8d8"; context.fillRect(0, 0, 128, 128); for (let dot = 0; dot < 900; dot += 1) { diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx index 3b2a8c5..5698c7f 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef } from "react"; import { Color, MeshStandardMaterial } from "three"; import type { Group, ShaderMaterial } from "three"; +import { subVec3 } from "@/lib/hive/layout"; import type { FrameBox, Vec3 } from "@/lib/hive/layout"; import { HoloLabel } from "./hive-labels"; import { createEdgeMaterial, createGlassMaterial } from "./materials"; @@ -22,8 +23,6 @@ import type { FrameTarget } from "./use-hive-motion"; // box. In an open box each frame is a clear glass plate; hovered or selected // it rises and takes its type look (colour and comb pattern). -const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; - /** Faint top bars of all frames of a box, one merged mesh using the box's materials. */ export function FrameHints({ frames, @@ -39,7 +38,7 @@ export function FrameHints({ const geometries = useMemo(() => { const hints = frames .filter((frame) => frame.type !== "NONE") - .map((frame) => frameHintSpec(frame.size, sub(frame.center, hostCenter))); + .map((frame) => frameHintSpec(frame.size, subVec3(frame.center, hostCenter))); return { glass: buildGlassGeometry(hints.map((hint) => hint.slab)), edges: buildEdgeGeometry(hints.flatMap((hint) => hint.edges)), diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx index dbd431e..f373656 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx @@ -16,10 +16,10 @@ import { SRGBColorSpace, ShaderMaterial, } from "three"; -import type { Points } from "three"; import { createQualityMonitor } from "./quality"; import type { QualityVerdict, StageTier } from "./quality"; -import { useSceneKit } from "./scene-kit"; +import { noRaycast, useSceneKit } from "./scene-kit"; +import { seededRandom } from "./seeded-random"; import { markTimerFrame, setEffectBokeh, setEffectTarget, setSceneBackground, setUniform } from "./scene-mutations"; import type { HoloTheme } from "./theme"; @@ -279,19 +279,9 @@ void main() { } `; -/** Deterministic placement, so the stage looks the same on every visit (Park-Miller). */ -function seededRandom(start: number): () => number { - let seed = start; - return () => { - seed = (seed * 16807) % 2147483647; - return seed / 2147483647; - }; -} - function Particles() { const { theme } = useSceneKit(); const dpr = useThree((state) => state.viewport.dpr); - const points = useRef(null); const { geometry, material } = useMemo(() => { const next = seededRandom(7); const positions: number[] = []; @@ -332,7 +322,7 @@ function Particles() { setUniform(material, "uTime", state.clock.elapsedTime); setUniform(material, "uPixelRatio", dpr); }); - return {}} />; + return ; } function Lights() { @@ -448,7 +438,7 @@ function Haze() { material={material} renderOrder={-1} layers={OVERLAY_LAYER} - raycast={() => {}} + raycast={noRaycast} > diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts b/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts index 554bec9..5e2ea39 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts @@ -362,9 +362,3 @@ export function createLiquidMaterial(options: LiquidOptions): ShaderMaterial { depthWrite: false, }); } - -/** Sets the per-node uniforms shared by every material here. */ -export function setOpacity(material: ShaderMaterial, opacity: number): void { - const uniform = material.uniforms.uOpacity; - if (uniform) uniform.value = opacity; -} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/seeded-random.ts b/ApiCore-Frontend/src/components/hive/hive-scene/seeded-random.ts new file mode 100644 index 0000000..c7bcbf4 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/seeded-random.ts @@ -0,0 +1,8 @@ +/** Deterministic Park-Miller sequence in (0, 1), so procedural looks are the same on every visit. */ +export function seededRandom(start: number): () => number { + let seed = start; + return () => { + seed = (seed * 16807) % 2147483647; + return seed / 2147483647; + }; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts b/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts index f7f5add..0aaf856 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts @@ -1,3 +1,4 @@ +import { subVec3 } from "@/lib/hive/layout"; import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout"; import type { HivePartKind } from "@/lib/hive/types"; import type { HiveViewState, HoveredFrame } from "../use-hive-view"; @@ -74,7 +75,6 @@ export interface MotionView extends HiveViewState { export const frameKey = (boxId: string, slot: number): string => `${boxId}#${slot}`; -const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; const lift = (value: Vec3, dy: number): Vec3 => [value[0], value[1] + dy, value[2]]; /** Rest place of a stack part: its layout centre raised by the stack gap. */ @@ -200,10 +200,10 @@ function insertTarget( host: PartBox, gap: number, ): NodeTarget { - const local = sub(insert.center, host.center); + const local = subVec3(insert.center, host.center); if (focus?.isInsert && focus.id === insert.id) { return { - position: sub(focusPlacement(layout.bounds, insert.size), restCenter(host, gap)), + position: subVec3(focusPlacement(layout.bounds, insert.size), restCenter(host, gap)), scale: FOCUS_SCALE, opacity: 1, glow: GLOW.focus, @@ -241,7 +241,7 @@ function insertTarget( } function frameTarget(frame: FrameBox, host: PartBox, view: MotionView, focus: FocusState | null): FrameTarget { - const local = sub(frame.center, host.center); + const local = subVec3(frame.center, host.center); if (frame.type === "NONE") return { position: local, opacity: 0, look: "hidden", interactive: false }; const open = focus !== null && !focus.isInsert && focus.id === host.id; if (!open) { diff --git a/ApiCore-Frontend/src/lib/hive/find-part.spec.ts b/ApiCore-Frontend/src/lib/hive/find-part.spec.ts new file mode 100644 index 0000000..50b2360 --- /dev/null +++ b/ApiCore-Frontend/src/lib/hive/find-part.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { findPart } from "./find-part"; +import { part, templateState } from "./test-utils"; + +describe("findPart", () => { + it("finds stack parts and inserts, and nothing for an unknown id", () => { + const state = templateState(); + state.stack[1] = { ...state.stack[1], inserts: [part("e", "evaporator")] }; + + expect(findPart(state, "b")).toBe(state.stack[1]); + expect(findPart(state, "e")).toBe(state.stack[1].inserts![0]); + expect(findPart(state, "nope")).toBeUndefined(); + }); +}); diff --git a/ApiCore-Frontend/src/lib/hive/find-part.ts b/ApiCore-Frontend/src/lib/hive/find-part.ts new file mode 100644 index 0000000..9d82f8a --- /dev/null +++ b/ApiCore-Frontend/src/lib/hive/find-part.ts @@ -0,0 +1,11 @@ +import type { HivePart, HiveState } from "./types"; + +/** A stack part or an insert by id; undefined for an unknown id. */ +export function findPart(state: HiveState, id: string): HivePart | undefined { + for (const part of state.stack) { + if (part.id === id) return part; + const insert = part.inserts?.find((candidate) => candidate.id === id); + if (insert) return insert; + } + return undefined; +} diff --git a/ApiCore-Frontend/src/lib/hive/follow-ups.ts b/ApiCore-Frontend/src/lib/hive/follow-ups.ts index 1c13d97..1991093 100644 --- a/ApiCore-Frontend/src/lib/hive/follow-ups.ts +++ b/ApiCore-Frontend/src/lib/hive/follow-ups.ts @@ -1,4 +1,5 @@ import type { FeedType } from "@/lib/feedings/types"; +import { findPart } from "./find-part"; import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HiveState, LevelField } from "./types"; // Spec section 8: suggestions only. Nothing here books a feeding or treatment. @@ -15,15 +16,6 @@ const ACID_KEY = "acidMl"; const isFeedKind = (kind: string | undefined): boolean => kind === "FEEDER" || kind === "FRAME_FEEDER"; const ROUNDING = 1000; -function findPart(state: HiveState, id: string): HivePart | undefined { - for (const part of state.stack) { - if (part.id === id) return part; - const insert = part.inserts?.find((candidate) => candidate.id === id); - if (insert) return insert; - } - return undefined; -} - function typeOf(part: HivePart | undefined, catalog: HiveCatalog): CatalogPartType | undefined { return part ? catalog.partTypes.get(part.partTypeId) : undefined; } diff --git a/ApiCore-Frontend/src/lib/hive/layout.ts b/ApiCore-Frontend/src/lib/hive/layout.ts index ee11c33..0d81aeb 100644 --- a/ApiCore-Frontend/src/lib/hive/layout.ts +++ b/ApiCore-Frontend/src/lib/hive/layout.ts @@ -6,6 +6,11 @@ import type { CatalogPartType, FrameType, HiveCatalog, HivePart, HivePartKind, H export type Vec3 = [number, number, number]; +/** a − b, component by component. */ +export function subVec3(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + export interface PartBox { id: string; partTypeId: string;