From 304b0cbc9c05fbd907940271e05d31354d3e562c Mon Sep 17 00:00:00 2001 From: sundsoffice-tech Date: Thu, 20 Nov 2025 17:17:31 +0100 Subject: [PATCH] Add interaction rule profiles and centralized snapping --- .../src/components/Configurator3D.tsx | 586 ++++++++++-------- .../src/lib/interactionRules.ts | 159 +++++ 2 files changed, 502 insertions(+), 243 deletions(-) create mode 100644 ss-messebau-configurator/src/lib/interactionRules.ts diff --git a/ss-messebau-configurator/src/components/Configurator3D.tsx b/ss-messebau-configurator/src/components/Configurator3D.tsx index 5105b20..9e66318 100644 --- a/ss-messebau-configurator/src/components/Configurator3D.tsx +++ b/ss-messebau-configurator/src/components/Configurator3D.tsx @@ -2,8 +2,10 @@ import { Suspense, useEffect, + useMemo, useRef, useState, + useCallback, type ReactNode, type MutableRefObject, } from "react"; @@ -19,8 +21,14 @@ import { } from "@react-three/drei"; import * as THREE from "three"; import { useConfigStore } from "../store/configStore"; +import { + applyInteractionRules, + interactionProfiles, + type InteractionContext, + type InteractionOptions, + type WallSide, +} from "../lib/interactionRules"; -type WallSide = "back" | "left" | "right"; type CounterVariant = "basic" | "premium" | "corner"; /** Detaillierte, frei platzierbare Objekte (optionale Felder im Store) */ @@ -89,23 +97,21 @@ function useTransformKeyboard( return { mode, snap }; } -/** clamp X/Z in Standfläche, optional mit halben Abmessungen eines Objekts */ -function clampXZ( - x: number, - z: number, - width: number, - depth: number, - halfW = 0, - halfD = 0 -) { - const minX = -width / 2 + halfW; - const maxX = width / 2 - halfW; - const minZ = -depth / 2 + halfD; - const maxZ = depth / 2 - halfD; - return { - x: Math.min(maxX, Math.max(minX, x)), - z: Math.min(maxZ, Math.max(minZ, z)), - }; +/** Interaktions-Hook: kapselt Profile + Clamping auf Boden/Wände */ +function useInteractable(context: InteractionContext) { + return useCallback( + ( + profileKey: keyof typeof interactionProfiles, + options: InteractionOptions = {} + ) => + (pos: THREE.Vector3) => { + const profile = interactionProfiles[profileKey]; + const result = applyInteractionRules(profile, pos, context, options); + pos.copy(result.position); + options.onCommit?.(result); + }, + [context] + ); } /** Geometrien */ @@ -327,6 +333,13 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const wallThickness = 0.06; const panelGap = 0.01; + const interactionContext: InteractionContext = useMemo( + () => ({ width, depth, floorHeight, wallThickness, panelGap }), + [width, depth, floorHeight, wallThickness, panelGap] + ); + + const interactable = useInteractable(interactionContext); + // Innenpositionen der Wand-Frontflächen const backWallFrontZ = -depth / 2 + wallThickness + panelGap; const leftWallInnerX = -width / 2 + wallThickness + panelGap; @@ -531,6 +544,8 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { whiteSpace: "nowrap" }}> Edit (E) · Mode: {transformMode} (T/R/S) · Snap: {snapOn ? "0,1 m" : "aus"} (G) · ESC: Deselektieren +
+ Regeln: Screen snappt an nächste Wand und gleitet entlang · Counter/Kabine/Truss klemmen auf Bodenraster )} @@ -601,26 +616,40 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { {/* Lagerraum / Kabine (Drag-fähig im Edit-Modus) */} {cabinEnabled && cabin && ( + (() => { + const cabinResolved = applyInteractionRules( + interactionProfiles.cabin, + new THREE.Vector3(cabinPosX, cabinCenterY, cabinPosZ), + interactionContext, + { + mount: "floor", + halfSize: { x: cabinWidth / 2, z: cabinDepth / 2 }, + } + ); + + return ( { - const c = clampXZ(pos.x, pos.z, width, depth, cabinWidth / 2, cabinDepth / 2); - pos.set(c.x, pos.y, c.z); - setConfig({ - modules: { - cabin: { - position: { x: c.x, z: c.z }, - }, - } as any, - }); - }} + onChange={interactable("cabin", { + mount: "floor", + halfSize: { x: cabinWidth / 2, z: cabinDepth / 2 }, + onCommit: ({ position }) => { + setConfig({ + modules: { + cabin: { + position: { x: position.x, z: position.z }, + }, + } as any, + }); + }, + })} > ) => { e.stopPropagation(); setSelectedKey("cabin"); @@ -676,6 +705,8 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + ); + })() )} {/* Counters – Detailed bevorzugt, sonst Legacy */} @@ -690,6 +721,16 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const key = `ctr-d-${ctr.id}`; const selected = isSelected(key); + const resolved = applyInteractionRules( + interactionProfiles.counter, + new THREE.Vector3(px, floorHeight, pz), + interactionContext, + { + mount: "floor", + halfSize: { x: w / 2, z: d / 2 }, + } + ); + return ( }) { snap={snapOn} onDragStart={disableOrbit} onDragEnd={enableOrbit} - onChange={(pos) => { - const c = clampXZ(pos.x, pos.z, width, depth, w / 2, d / 2); - pos.set(c.x, pos.y, c.z); - const next = countersDetailed.map((c0) => - c0.id === ctr.id ? { ...c0, position: { ...c0.position, x: c.x, z: c.z } } : c0 - ); - setConfig({ modules: { countersDetailed: next } as any }); - }} + onChange={interactable("counter", { + mount: "floor", + halfSize: { x: w / 2, z: d / 2 }, + onCommit: ({ position }) => { + const next = countersDetailed.map((c0) => + c0.id === ctr.id + ? { ...c0, position: { ...c0.position, x: position.x, z: position.z } } + : c0 + ); + setConfig({ modules: { countersDetailed: next } as any }); + }, + })} > { e.stopPropagation(); @@ -745,10 +790,16 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const zPos = countersPlacement === "island" ? 0 : depth / 2 - 0.5; const variant = (modules as any).counterVariant ?? "basic"; const k = `legacy-counter-${idx}`; + const resolved = applyInteractionRules( + interactionProfiles.counter, + new THREE.Vector3(xPos, floorHeight, zPos), + interactionContext, + { mount: "floor", halfSize: { x: 0.45, z: 0.25 } } + ); return ( { e.stopPropagation(); convertLegacyCountersToDetailed(); @@ -826,36 +877,21 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const y = (scr.heightFromFloor ?? (floorHeight + 1.6)) - floorHeight; // lokaler Offset const key = `scr-d-${scr.id}`; const selected = isSelected(key); - - // Position & Rotation - let px = scr.position?.x ?? 0; - let pz = scr.position?.z ?? 0; - let rotY = scr.rotationY ?? 0; - - // Clamping je nach Mount - if (mount === "wall") { - const side = scr.wallSide ?? "back"; - if (side === "back") { - pz = backWallFrontZ; - const c = clampXZ(px, pz, width, depth, w / 2, 0.001); - px = c.x; - rotY = 0; - } else if (side === "left") { - px = leftWallInnerX; - const c = clampXZ(px, pz, width, depth, 0.001, h / 2); - pz = c.z; - rotY = Math.PI / 2; - } else { - px = rightWallInnerX; - const c = clampXZ(px, pz, width, depth, 0.001, h / 2); - pz = c.z; - rotY = -Math.PI / 2; + const resolved = applyInteractionRules( + interactionProfiles.screen, + new THREE.Vector3(scr.position?.x ?? 0, floorHeight + y, scr.position?.z ?? 0), + interactionContext, + { + mount, + wallSide: scr.wallSide, + halfSize: { x: w / 2, z: t / 2 }, + wallSnapPadding: { + back: { along: w / 2, away: t / 2 }, + left: { along: w / 2, away: t / 2 }, + right: { along: w / 2, away: t / 2 }, + }, } - } else if (mount === "floor") { - const c = clampXZ(px, pz, width, depth, w / 2, t / 2); - px = c.x; - pz = c.z; - } + ); return ( }) { snap={snapOn} onDragStart={disableOrbit} onDragEnd={enableOrbit} - onChange={(pos) => { - const c = clampXZ(pos.x, pos.z, width, depth, w / 2, t / 2); - pos.set(c.x, pos.y, c.z); - const next = screensDetailed.map((s0) => - s0.id === scr.id - ? { ...s0, position: { x: c.x, z: c.z } } - : s0 - ); - setConfig({ modules: { detailedScreens: next } as any }); - }} + onChange={interactable("screen", { + mount, + wallSide: scr.wallSide, + halfSize: { x: w / 2, z: t / 2 }, + wallSnapPadding: { + back: { along: w / 2, away: t / 2 }, + left: { along: w / 2, away: t / 2 }, + right: { along: w / 2, away: t / 2 }, + }, + onCommit: ({ position, rotationY, wallSide }) => { + const next = screensDetailed.map((s0) => + s0.id === scr.id + ? { + ...s0, + position: { x: position.x, z: position.z }, + rotationY: rotationY ?? s0.rotationY, + wallSide: wallSide ?? s0.wallSide, + } + : s0 + ); + setConfig({ modules: { detailedScreens: next } as any }); + }, + })} > { e.stopPropagation(); setSelectedKey(key); @@ -902,10 +951,24 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { if (screensWallSide === "back") { const spacing = width / (total + 1); const xPos = -width / 2 + spacing * (idx + 1); + const resolved = applyInteractionRules( + interactionProfiles.screen, + new THREE.Vector3(xPos, floorHeight + 1.6, backWallFrontZ), + interactionContext, + { + mount: "wall", + wallSide: "back", + halfSize: { x: 0.45, z: 0.01 }, + wallSnapPadding: { + back: { along: 0.45, away: 0.01 }, + }, + } + ); return ( { e.stopPropagation(); convertLegacyScreensToDetailed(); @@ -924,11 +987,24 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { if (screensWallSide === "left") { const spacing = depth / (total + 1); const zPos = -depth / 2 + spacing * (idx + 1); + const resolved = applyInteractionRules( + interactionProfiles.screen, + new THREE.Vector3(leftWallInnerX, floorHeight + 1.6, zPos), + interactionContext, + { + mount: "wall", + wallSide: "left", + halfSize: { x: 0.45, z: 0.01 }, + wallSnapPadding: { + left: { along: 0.45, away: 0.01 }, + }, + } + ); return ( { e.stopPropagation(); convertLegacyScreensToDetailed(); @@ -946,11 +1022,24 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const spacing = depth / (total + 1); const zPos = -depth / 2 + spacing * (idx + 1); + const resolved = applyInteractionRules( + interactionProfiles.screen, + new THREE.Vector3(rightWallInnerX, floorHeight + 1.6, zPos), + interactionContext, + { + mount: "wall", + wallSide: "right", + halfSize: { x: 0.45, z: 0.01 }, + wallSnapPadding: { + right: { along: 0.45, away: 0.01 }, + }, + } + ); return ( { e.stopPropagation(); convertLegacyScreensToDetailed(); @@ -1027,166 +1116,177 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { })} {/* Truss – Rahmen + Lampen + Bannerrahmen (mit Offset & Drag-Griff) */} - {trussEnabled && ( - - {/* Drag-Griff für Truss (EditMode) */} - { - const c = clampXZ(pos.x, pos.z, width, depth, 0.4, 0.4); - pos.set(c.x, pos.y, c.z); - setConfig({ modules: { trussOffset: { x: c.x, z: c.z } } as any }); - }} - > - { - e.stopPropagation(); - setSelectedKey("truss"); - }} - > - {/* Kleiner visueller Griff */} - {editMode && ( - - - - - )} - - - - {/* Truss-Rahmen */} - - - - - - - - - - - - - - - - - - {/* Truss-Lampen */} - {trussLightsFront > 0 && - Array.from({ length: trussLightsFront }).map((_, i) => { - const spacing = width / (trussLightsFront + 1); - const x = -width / 2 + spacing * (i + 1); - const y = trussHeight - 0.05; - const z = depth / 2 - 0.04; - return renderTrussLight(`truss-front-${i}`, x, y, z, x, y - 0.15, z - 0.25); - })} - - {trussLightsBack > 0 && - Array.from({ length: trussLightsBack }).map((_, i) => { - const spacing = width / (trussLightsBack + 1); - const x = -width / 2 + spacing * (i + 1); - const y = trussHeight - 0.05; - const z = -depth / 2 + 0.04; - return renderTrussLight(`truss-back-${i}`, x, y, z, x, y - 0.15, z + 0.25); - })} - - {trussLightsLeft > 0 && - Array.from({ length: trussLightsLeft }).map((_, i) => { - const spacing = depth / (trussLightsLeft + 1); - const z = -depth / 2 + spacing * (i + 1); - const y = trussHeight - 0.05; - const x = -width / 2 + 0.04; - return renderTrussLight(`truss-left-${i}`, x, y, z, x + 0.25, y - 0.15, z); - })} - - {trussLightsRight > 0 && - Array.from({ length: trussLightsRight }).map((_, i) => { - const spacing = depth / (trussLightsRight + 1); - const z = -depth / 2 + spacing * (i + 1); - const y = trussHeight - 0.05; - const x = width / 2 - 0.04; - return renderTrussLight(`truss-right-${i}`, x, y, z, x - 0.25, y - 0.15, z); - })} - - {/* Bannerrahmen */} - {(() => { - const bannerY = trussHeight - 0.4 - bannerHeight / 2; - const banners: ReactNode[] = []; - - const materialProps = bannerTexture - ? { map: bannerTexture as any } - : ({ color: "#111827", roughness: 0.5, metalness: 0.2 } as const); - - // Front - if (bannersFront > 0) { - Array.from({ length: bannersFront }).forEach((_, i) => { - const spacing = width / (bannersFront + 1); - const x = -width / 2 + spacing * (i + 1); - const z = depth / 2 - 0.05; - banners.push( - - - - - ); - }); - } - - // Back - if (bannersBack > 0) { - Array.from({ length: bannersBack }).forEach((_, i) => { - const spacing = width / (bannersBack + 1); - const x = -width / 2 + spacing * (i + 1); - const z = -depth / 2 + 0.05; - banners.push( - - - - - ); - }); - } + {trussEnabled && + (() => { + const resolvedTruss = applyInteractionRules( + interactionProfiles.truss, + new THREE.Vector3(trussOffsetX, 0, trussOffsetZ), + interactionContext, + { mount: "floor", halfSize: { x: 0.4, z: 0.4 } } + ); - // Left - if (bannersLeft > 0) { - Array.from({ length: bannersLeft }).forEach((_, i) => { - const spacing = depth / (bannersLeft + 1); - const z = -depth / 2 + spacing * (i + 1); - const x = -width / 2 + 0.05; - banners.push( - - - - - ); - }); - } + return ( + + {/* Drag-Griff für Truss (EditMode) */} + + setConfig({ modules: { trussOffset: { x: position.x, z: position.z } } as any }), + })} + > + { + e.stopPropagation(); + setSelectedKey("truss"); + }} + > + {/* Kleiner visueller Griff */} + {editMode && ( + + + + + )} + + - // Right - if (bannersRight > 0) { - Array.from({ length: bannersRight }).forEach((_, i) => { - const spacing = depth / (bannersRight + 1); - const z = -depth / 2 + spacing * (i + 1); - const x = width / 2 - 0.05; - banners.push( - - - - - ); - }); - } + {/* Truss-Rahmen */} + + + + + + + + + + + + + + + + - return banners; - })()} - - )} + {/* Truss-Lampen */} + {trussLightsFront > 0 && + Array.from({ length: trussLightsFront }).map((_, i) => { + const spacing = width / (trussLightsFront + 1); + const x = -width / 2 + spacing * (i + 1); + const y = trussHeight - 0.05; + const z = depth / 2 - 0.04; + return renderTrussLight(`truss-front-${i}`, x, y, z, x, y - 0.15, z - 0.25); + })} + + {trussLightsBack > 0 && + Array.from({ length: trussLightsBack }).map((_, i) => { + const spacing = width / (trussLightsBack + 1); + const x = -width / 2 + spacing * (i + 1); + const y = trussHeight - 0.05; + const z = -depth / 2 + 0.04; + return renderTrussLight(`truss-back-${i}`, x, y, z, x, y - 0.15, z + 0.25); + })} + + {trussLightsLeft > 0 && + Array.from({ length: trussLightsLeft }).map((_, i) => { + const spacing = depth / (trussLightsLeft + 1); + const z = -depth / 2 + spacing * (i + 1); + const y = trussHeight - 0.05; + const x = -width / 2 + 0.04; + return renderTrussLight(`truss-left-${i}`, x, y, z, x + 0.25, y - 0.15, z); + })} + + {trussLightsRight > 0 && + Array.from({ length: trussLightsRight }).map((_, i) => { + const spacing = depth / (trussLightsRight + 1); + const z = -depth / 2 + spacing * (i + 1); + const y = trussHeight - 0.05; + const x = width / 2 - 0.04; + return renderTrussLight(`truss-right-${i}`, x, y, z, x - 0.25, y - 0.15, z); + })} + + {/* Bannerrahmen */} + {(() => { + const bannerY = trussHeight - 0.4 - bannerHeight / 2; + const banners: ReactNode[] = []; + + const materialProps = bannerTexture + ? { map: bannerTexture as any } + : ({ color: "#111827", roughness: 0.5, metalness: 0.2 } as const); + + // Front + if (bannersFront > 0) { + Array.from({ length: bannersFront }).forEach((_, i) => { + const spacing = width / (bannersFront + 1); + const x = -width / 2 + spacing * (i + 1); + const z = depth / 2 - 0.05; + banners.push( + + + + + ); + }); + } + + // Back + if (bannersBack > 0) { + Array.from({ length: bannersBack }).forEach((_, i) => { + const spacing = width / (bannersBack + 1); + const x = -width / 2 + spacing * (i + 1); + const z = -depth / 2 + 0.05; + banners.push( + + + + + ); + }); + } + + // Left + if (bannersLeft > 0) { + Array.from({ length: bannersLeft }).forEach((_, i) => { + const spacing = depth / (bannersLeft + 1); + const z = -depth / 2 + spacing * (i + 1); + const x = -width / 2 + 0.05; + banners.push( + + + + + ); + }); + } + + // Right + if (bannersRight > 0) { + Array.from({ length: bannersRight }).forEach((_, i) => { + const spacing = depth / (bannersRight + 1); + const z = -depth / 2 + spacing * (i + 1); + const x = width / 2 - 0.05; + banners.push( + + + + + ); + }); + } + + return banners; + })()} + + ); + })()} ); } diff --git a/ss-messebau-configurator/src/lib/interactionRules.ts b/ss-messebau-configurator/src/lib/interactionRules.ts new file mode 100644 index 0000000..e0bb872 --- /dev/null +++ b/ss-messebau-configurator/src/lib/interactionRules.ts @@ -0,0 +1,159 @@ +import * as THREE from "three"; + +export type WallSide = "back" | "left" | "right"; + +export type InteractionProfileKey = "counter" | "screen" | "cabin" | "truss"; + +export type InteractionProfile = { + allowedLevels: ("floor" | "wall")[]; + snapToNearestWall?: boolean; + defaultRotation?: number; + rotationByWall?: Partial>; + gridSnap?: number; +}; + +export type InteractionContext = { + width: number; + depth: number; + floorHeight: number; + wallThickness: number; + panelGap: number; +}; + +export type InteractionOptions = { + mount?: "floor" | "wall" | "truss"; + wallSide?: WallSide; + halfSize?: { x?: number; z?: number }; + wallSnapPadding?: Partial>; + onCommit?: (result: InteractionResult) => void; +}; + +export type InteractionResult = { + position: THREE.Vector3; + rotationY?: number; + wallSide?: WallSide; +}; + +export const interactionProfiles: Record = { + counter: { + allowedLevels: ["floor"], + gridSnap: 0.05, + defaultRotation: 0, + }, + screen: { + allowedLevels: ["wall", "floor"], + snapToNearestWall: true, + gridSnap: 0.05, + defaultRotation: 0, + rotationByWall: { + back: 0, + left: Math.PI / 2, + right: -Math.PI / 2, + }, + }, + cabin: { + allowedLevels: ["floor"], + defaultRotation: 0, + }, + truss: { + allowedLevels: ["floor"], + defaultRotation: 0, + gridSnap: 0.05, + }, +}; + +const clampXZ = ( + x: number, + z: number, + context: InteractionContext, + halfX = 0, + halfZ = 0 +) => { + const { width, depth } = context; + const minX = -width / 2 + halfX; + const maxX = width / 2 - halfX; + const minZ = -depth / 2 + halfZ; + const maxZ = depth / 2 - halfZ; + + return { + x: Math.min(maxX, Math.max(minX, x)), + z: Math.min(maxZ, Math.max(minZ, z)), + }; +}; + +const snapToGrid = (value: number, grid?: number) => { + if (!grid) return value; + if (grid <= 0) return value; + return Math.round(value / grid) * grid; +}; + +const findNearestWallSide = (position: THREE.Vector3, context: InteractionContext): WallSide => { + const backZ = -context.depth / 2 + context.wallThickness + context.panelGap; + const leftX = -context.width / 2 + context.wallThickness + context.panelGap; + const rightX = context.width / 2 - context.wallThickness - context.panelGap; + + const distances: Record = { + back: Math.abs(position.z - backZ), + left: Math.abs(position.x - leftX), + right: Math.abs(position.x - rightX), + }; + + return (Object.entries(distances).sort((a, b) => a[1] - b[1])[0]?.[0] || "back") as WallSide; +}; + +export function applyInteractionRules( + profile: InteractionProfile, + position: THREE.Vector3, + context: InteractionContext, + options: InteractionOptions = {} +): InteractionResult { + const { mount = "floor", wallSide, halfSize, wallSnapPadding } = options; + const pos = position.clone(); + + pos.x = snapToGrid(pos.x, profile.gridSnap); + pos.z = snapToGrid(pos.z, profile.gridSnap); + + const backZ = -context.depth / 2 + context.wallThickness + context.panelGap; + const leftX = -context.width / 2 + context.wallThickness + context.panelGap; + const rightX = context.width / 2 - context.wallThickness - context.panelGap; + + if (mount === "wall" && profile.allowedLevels.includes("wall")) { + const targetSide = wallSide ?? (profile.snapToNearestWall ? findNearestWallSide(pos, context) : "back"); + const along = wallSnapPadding?.[targetSide]?.along ?? halfSize?.x ?? 0; + const away = wallSnapPadding?.[targetSide]?.away ?? halfSize?.z ?? 0.001; + + if (targetSide === "back") { + pos.z = backZ + away; + const clamped = clampXZ(pos.x, pos.z, context, along, 0); + pos.x = clamped.x; + } else if (targetSide === "left") { + pos.x = leftX + away; + const clamped = clampXZ(pos.x, pos.z, context, 0, along); + pos.z = clamped.z; + } else { + pos.x = rightX - away; + const clamped = clampXZ(pos.x, pos.z, context, 0, along); + pos.z = clamped.z; + } + + return { + position: pos, + rotationY: profile.rotationByWall?.[targetSide] ?? profile.defaultRotation, + wallSide: targetSide, + }; + } + + if (profile.allowedLevels.includes("floor")) { + const halfX = halfSize?.x ?? 0; + const halfZ = halfSize?.z ?? 0; + const clamped = clampXZ(pos.x, pos.z, context, halfX, halfZ); + pos.x = clamped.x; + pos.z = clamped.z; + } + + return { + position: pos, + rotationY: profile.defaultRotation, + wallSide, + }; +}