diff --git a/ss-messebau-configurator/src/components/Configurator3D.tsx b/ss-messebau-configurator/src/components/Configurator3D.tsx index 5105b20..8495e68 100644 --- a/ss-messebau-configurator/src/components/Configurator3D.tsx +++ b/ss-messebau-configurator/src/components/Configurator3D.tsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, + useMemo, useRef, useState, type ReactNode, @@ -19,8 +20,16 @@ import { } from "@react-three/drei"; import * as THREE from "three"; import { useConfigStore } from "../store/configStore"; - -type WallSide = "back" | "left" | "right"; +import type { WallSide } from "../lib/pricing"; +import { + buildAABB, + clampToStand, + hasCollision, + normalizePlacement, + type AABB, + type InteractionProfile, + type StandArea, +} from "../lib/interactionRules"; type CounterVariant = "basic" | "premium" | "corner"; /** Detaillierte, frei platzierbare Objekte (optionale Felder im Store) */ @@ -89,25 +98,6 @@ 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)), - }; -} - /** Geometrien */ function CounterBlock({ variant, @@ -246,6 +236,7 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { // ---- Lokale Edit-/UI-State const editMode = useEditModeHotkey(); const [selectedKey, setSelectedKey] = useState(null); + const [collidingIds, setCollidingIds] = useState>(new Set()); // Transform‑Shortcuts (T/R/S/G/Esc) const { mode: transformMode, snap: snapOn } = useTransformKeyboard(setSelectedKey); @@ -257,6 +248,17 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const disableOrbit = () => setOrbitEnabled(false); const enableOrbit = () => setOrbitEnabled(true); + const markCollision = (id: string, collided: boolean) => { + setCollidingIds((prev) => { + const next = new Set(prev); + if (collided) next.add(id); + else next.delete(id); + return next; + }); + }; + + const isColliding = (id: string) => collidingIds.has(id); + // Basis-Module const { wallsClosedSides, @@ -327,6 +329,13 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const wallThickness = 0.06; const panelGap = 0.01; + const standArea: StandArea = useMemo( + () => ({ width, depth, wallThickness, panelGap }), + [width, depth, wallThickness, panelGap] + ); + + const collisionPadding = 0.05; + // Innenpositionen der Wand-Frontflächen const backWallFrontZ = -depth / 2 + wallThickness + panelGap; const leftWallInnerX = -width / 2 + wallThickness + panelGap; @@ -353,6 +362,13 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const cabinPosZ = cabin?.position?.z ?? -depth / 2 + cabinDepth / 2 + 0.25; const cabinCenterY = floorHeight + cabinHeight / 2; + const cabinProfile: InteractionProfile = { + size: { w: cabinWidth, d: cabinDepth }, + mount: "floor", + padding: collisionPadding, + }; + const cabinPlacement = normalizePlacement({ x: cabinPosX, z: cabinPosZ }, cabinProfile, standArea); + // Boden-Material const floorType = floorConfig?.type ?? "carpet"; const floorMaterial = (() => { @@ -434,6 +450,55 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const countersDetailed = (mAny.countersDetailed ?? []) as DetailedCounter[]; const screensDetailed = (mAny.detailedScreens ?? []) as DetailedScreen[]; + const activeCollisionBoxes = useMemo(() => { + const boxes: AABB[] = []; + + if (cabinEnabled && cabin) { + boxes.push( + buildAABB(cabinPlacement.position, { w: cabinWidth, d: cabinDepth }, collisionPadding, "cabin") + ); + } + + countersDetailed.forEach((ctr) => { + const variant: CounterVariant = ctr.variant ?? (mAny.counterVariant ?? "basic"); + const w = ctr.size?.w ?? (variant === "premium" ? 1.4 : 0.9); + const d = ctr.size?.d ?? (variant === "premium" ? 0.6 : 0.5); + const placement = normalizePlacement( + { x: ctr.position?.x ?? 0, z: ctr.position?.z ?? 0 }, + { size: { w, d }, mount: "floor", padding: collisionPadding }, + standArea + ); + boxes.push(buildAABB(placement.position, { w, d }, collisionPadding, `ctr-d-${ctr.id}`)); + }); + + screensDetailed.forEach((scr) => { + const mount = scr.mount ?? "wall"; + if (mount !== "floor") return; + const w = scr.size?.w ?? 0.9; + const t = scr.size?.t ?? 0.02; + const placement = normalizePlacement( + { x: scr.position?.x ?? 0, z: scr.position?.z ?? 0 }, + { size: { w, d: t }, mount: "floor", padding: collisionPadding }, + standArea + ); + boxes.push(buildAABB(placement.position, { w, d: t }, collisionPadding, `scr-d-${scr.id}`)); + }); + + return boxes; + }, [ + cabin, + cabinDepth, + cabinEnabled, + cabinPosX, + cabinPosZ, + cabinWidth, + collisionPadding, + countersDetailed, + mAny.counterVariant, + screensDetailed, + standArea, + ]); + // ---- Legacy → Detailed Konverter (per Doppelklick) const convertLegacyCountersToDetailed = () => { if ((counters ?? 0) <= 0) return; @@ -608,19 +673,26 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { onDragStart={disableOrbit} onDragEnd={enableOrbit} onChange={(pos) => { - const c = clampXZ(pos.x, pos.z, width, depth, cabinWidth / 2, cabinDepth / 2); - pos.set(c.x, pos.y, c.z); + const normalized = normalizePlacement({ x: pos.x, z: pos.z }, cabinProfile, standArea); + const candidate = buildAABB(normalized.position, cabinProfile.size, collisionPadding, "cabin"); + const collides = hasCollision(candidate, activeCollisionBoxes, "cabin"); + const finalPos = collides ? cabinPlacement.position : normalized.position; + + pos.set(finalPos.x, pos.y, finalPos.z); + markCollision("cabin", collides); + if (collides) return; + setConfig({ modules: { cabin: { - position: { x: c.x, z: c.z }, + position: { x: finalPos.x, z: finalPos.z }, }, } as any, }); }} > ) => { e.stopPropagation(); setSelectedKey("cabin"); @@ -671,7 +743,7 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { {isSelected("cabin") && ( - + )} @@ -690,6 +762,13 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const key = `ctr-d-${ctr.id}`; const selected = isSelected(key); + const counterProfile: InteractionProfile = { + size: { w, d }, + mount: "floor", + padding: collisionPadding, + }; + const normalizedPos = normalizePlacement({ x: px, z: pz }, counterProfile, standArea).position; + return ( }) { 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 normalized = normalizePlacement({ x: pos.x, z: pos.z }, counterProfile, standArea); + const candidate = buildAABB(normalized.position, counterProfile.size, collisionPadding, key); + const collides = hasCollision(candidate, activeCollisionBoxes, key); + const finalPos = collides ? normalizedPos : normalized.position; + + pos.set(finalPos.x, pos.y, finalPos.z); + markCollision(key, collides); + if (collides) return; + const next = countersDetailed.map((c0) => - c0.id === ctr.id ? { ...c0, position: { ...c0.position, x: c.x, z: c.z } } : c0 + c0.id === ctr.id + ? { ...c0, position: { ...c0.position, x: finalPos.x, z: finalPos.z } } + : c0 ); setConfig({ modules: { countersDetailed: next } as any }); }} > { e.stopPropagation(); @@ -731,7 +819,10 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { {selected && ( - + )} @@ -827,35 +918,25 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { 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; - } - } else if (mount === "floor") { - const c = clampXZ(px, pz, width, depth, w / 2, t / 2); - px = c.x; - pz = c.z; - } + const wallSideForMount = (scr.wallSide as WallSide | undefined) ?? "back"; + const profileDepth = mount === "wall" ? (wallSideForMount === "back" ? t : w) : t; + const screenProfile: InteractionProfile = { + size: { w, d: profileDepth }, + mount: mount === "wall" ? "wall" : "floor", + wallSide: mount === "wall" ? wallSideForMount : undefined, + snapGap: mount === "wall" ? t / 2 : undefined, + stickToWall: mount === "wall", + padding: collisionPadding, + }; + + const normalized = normalizePlacement( + { x: scr.position?.x ?? 0, z: scr.position?.z ?? 0 }, + screenProfile, + standArea + ); + const px = normalized.position.x; + const pz = normalized.position.z; + const rotY = normalized.rotationY ?? scr.rotationY ?? 0; return ( }) { 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 normalizedPos = normalizePlacement({ x: pos.x, z: pos.z }, screenProfile, standArea); + const candidate = buildAABB(normalizedPos.position, screenProfile.size, collisionPadding, key); + const collides = hasCollision(candidate, activeCollisionBoxes, key); + const finalPos = collides ? { x: px, z: pz } : normalizedPos.position; + + pos.set(finalPos.x, pos.y, finalPos.z); + markCollision(key, collides); + if (collides) return; + const next = screensDetailed.map((s0) => - s0.id === scr.id - ? { ...s0, position: { x: c.x, z: c.z } } - : s0 + s0.id === scr.id ? { ...s0, position: { x: finalPos.x, z: finalPos.z } } : s0 ); setConfig({ modules: { detailedScreens: next } as any }); }} @@ -888,7 +974,7 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { {selected && ( - + )} @@ -1037,7 +1123,7 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { onDragStart={disableOrbit} onDragEnd={enableOrbit} onChange={(pos) => { - const c = clampXZ(pos.x, pos.z, width, depth, 0.4, 0.4); + const c = clampToStand(pos.x, pos.z, standArea, 0.4, 0.4); pos.set(c.x, pos.y, c.z); setConfig({ modules: { trussOffset: { x: c.x, z: c.z } } as any }); }} diff --git a/ss-messebau-configurator/src/lib/interactionRules.ts b/ss-messebau-configurator/src/lib/interactionRules.ts new file mode 100644 index 0000000..e53dbae --- /dev/null +++ b/ss-messebau-configurator/src/lib/interactionRules.ts @@ -0,0 +1,129 @@ +import type { WallSide } from "./pricing"; + +export type StandArea = { + width: number; + depth: number; + wallThickness?: number; + panelGap?: number; +}; + +export type InteractionProfile = { + size: { w: number; d: number }; + mount: "floor" | "wall"; + wallSide?: WallSide; + stickToWall?: boolean; + snapGap?: number; + padding?: number; +}; + +export type NormalizedPlacement = { + position: { x: number; z: number }; + rotationY?: number; +}; + +export type AABB = { + id?: string; + minX: number; + maxX: number; + minZ: number; + maxZ: number; +}; + +export function clampToStand( + x: number, + z: number, + stand: StandArea, + halfW = 0, + halfD = 0 +): { x: number; z: number } { + const minX = -stand.width / 2 + halfW; + const maxX = stand.width / 2 - halfW; + const minZ = -stand.depth / 2 + halfD; + const maxZ = stand.depth / 2 - halfD; + + return { + x: Math.min(maxX, Math.max(minX, x)), + z: Math.min(maxZ, Math.max(minZ, z)), + }; +} + +export function wallAnchor( + side: WallSide, + stand: StandArea, + offset = 0 +): { x?: number; z?: number; rotationY: number } { + const thickness = stand.wallThickness ?? 0; + const gap = stand.panelGap ?? 0; + + if (side === "back") { + return { z: -stand.depth / 2 + thickness + gap + offset, rotationY: 0 }; + } + if (side === "left") { + return { x: -stand.width / 2 + thickness + gap + offset, rotationY: Math.PI / 2 }; + } + return { x: stand.width / 2 - thickness - gap - offset, rotationY: -Math.PI / 2 }; +} + +export function normalizePlacement( + target: { x: number; z: number }, + profile: InteractionProfile, + stand: StandArea +): NormalizedPlacement { + const halfW = profile.size.w / 2; + const halfD = profile.size.d / 2; + let { x, z } = target; + let rotationY: number | undefined; + + if (profile.mount === "wall" && profile.wallSide) { + const anchor = wallAnchor(profile.wallSide, stand, profile.snapGap ?? 0); + rotationY = anchor.rotationY; + + if (profile.wallSide === "back") { + z = anchor.z ?? z; + const clamped = clampToStand(x, z, stand, halfW, 0.001); + x = clamped.x; + } else { + x = anchor.x ?? x; + const clamped = clampToStand(x, z, stand, 0.001, halfD); + z = clamped.z; + } + } else { + const clamped = clampToStand(x, z, stand, halfW, halfD); + x = clamped.x; + z = clamped.z; + + if (profile.stickToWall && profile.wallSide) { + const anchor = wallAnchor(profile.wallSide, stand, profile.snapGap ?? 0); + if (anchor.x !== undefined) x = anchor.x; + if (anchor.z !== undefined) z = anchor.z; + rotationY = anchor.rotationY; + } + } + + return { position: { x, z }, rotationY }; +} + +export function buildAABB( + position: { x: number; z: number }, + size: { w: number; d: number }, + padding = 0, + id?: string +): AABB { + const halfW = size.w / 2 + padding; + const halfD = size.d / 2 + padding; + return { + id, + minX: position.x - halfW, + maxX: position.x + halfW, + minZ: position.z - halfD, + maxZ: position.z + halfD, + }; +} + +export function intersects(a: AABB, b: AABB): boolean { + return a.minX < b.maxX && a.maxX > b.minX && a.minZ < b.maxZ && a.maxZ > b.minZ; +} + +export function hasCollision(candidate: AABB, others: AABB[], ignoreId?: string): boolean { + return others.some((box) => box.id !== ignoreId && intersects(candidate, box)); +}