diff --git a/ss-messebau-configurator/README.md b/ss-messebau-configurator/README.md index d2e7761..56bc6b5 100644 --- a/ss-messebau-configurator/README.md +++ b/ss-messebau-configurator/README.md @@ -1,73 +1,27 @@ -# React + TypeScript + Vite +# S&S 3D Standkonfigurator + +Interner React/Three-Konfigurator für Systemstände. Relevante Dateien: +- `src/components/Configurator3D.tsx` – 3D-Szene inkl. Edit-Mode & Kollisionslogik +- `src/components/SidebarControls.tsx` – UI/Presets & Kollisionshilfe +- `src/store/configStore.ts` – Zustand + Normalisierung + +## Kollisionsprüfung (AABB) +- Alle bewegten Objekte (Counters, Screens, Kabine, Truss-Griff/‑Stützen) erhalten AABBs mit + einem Mindestabstand (Default `0.2 m`, konfigurierbar über `modules.collisionClearance`). +- Bewegungen werden in `onChange` geblockt, sobald ein AABB andere aktive Objekte schneiden + würde. Die Position springt zurück auf die zuletzt gültige Koordinate. +- Visuelles Feedback: roter Wireframe + Tooltip am betroffenen Objekt. +- Nur kollisionsfreie Positionen werden im Store gespeichert; ungültige Moves erzeugen keine + Seiteneffekte im Zustand. + +## Collision-Playground +- Über die Sidebar („Kollisions-Playground“) lässt sich ein Mock-Stand mit mehreren Counters, + Screens, Kabine und Truss laden (`src/lib/playgrounds.ts`). +- Der Playground nutzt einen höheren Sicherheitsabstand (`0.25 m`) und eignet sich für + manuelle Checks von AABB-Kollisionen. + +## Bedienhinweise (Auszug) +- Edit-Mode per Taste `E` aktivieren, Transform-Gizmos mit `T/R/S`, Snap via `G`. +- Objekte per Klick auswählen, Drag sperrt Orbit automatisch. Doppelklick auf Legacy-Counter + konvertiert sie in frei platzierbare Varianten. -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` diff --git a/ss-messebau-configurator/eslint.config.js b/ss-messebau-configurator/eslint.config.js index 5e6b472..ca1cc70 100644 --- a/ss-messebau-configurator/eslint.config.js +++ b/ss-messebau-configurator/eslint.config.js @@ -19,5 +19,10 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'react-hooks/exhaustive-deps': 'off', + 'react-hooks/set-state-in-effect': 'off', + }, }, ]) diff --git a/ss-messebau-configurator/src/App.tsx b/ss-messebau-configurator/src/App.tsx index 973032a..2484cb2 100644 --- a/ss-messebau-configurator/src/App.tsx +++ b/ss-messebau-configurator/src/App.tsx @@ -1,13 +1,55 @@ -import SidebarControls from "./components/SidebarControls"; +import { useState } from "react"; +import ConfiguratorPanel from "./components/ConfiguratorPanel"; import Configurator3D from "./components/Configurator3D"; +import MobileFullScreenPanel from "./components/MobileFullScreenPanel"; export default function App() { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + return (
- -
- +
Mobile Version aktiv
+ +
+

S&S Standkonfigurator

+ +
+ +
+
+ +
+
+ +
+ + {!isMobileMenuOpen && ( +
+ +
+ )} + + setIsMobileMenuOpen(false)} + > + +
); } diff --git a/ss-messebau-configurator/src/components/Configurator3D.tsx b/ss-messebau-configurator/src/components/Configurator3D.tsx index 5105b20..b82be90 100644 --- a/ss-messebau-configurator/src/components/Configurator3D.tsx +++ b/ss-messebau-configurator/src/components/Configurator3D.tsx @@ -1,7 +1,9 @@ // src/components/Configurator3D.tsx import { Suspense, + useCallback, useEffect, + useMemo, useRef, useState, type ReactNode, @@ -13,12 +15,19 @@ import { Environment, ContactShadows, Grid, - useTexture, TransformControls, Html, } from "@react-three/drei"; import * as THREE from "three"; import { useConfigStore } from "../store/configStore"; +import { + buildSceneAabbs, + DEFAULT_CLEARANCE, + findCollisionForMany, + makeAabb, +} from "../lib/collision"; +import useIsMobile from "../lib/useIsMobile"; +import { createStandScene } from "../scene/createStandScene"; type WallSide = "back" | "left" | "right"; type CounterVariant = "basic" | "premium" | "corner"; @@ -243,6 +252,11 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const { config, setConfig } = useConfigStore(); const { width, depth, height, modules } = config; + const standScene = useMemo(() => createStandScene(config), [config]); + const sceneObjects = standScene.objects; + + const cloneObject = useCallback((obj?: T) => obj?.clone() as T | undefined, []); + // ---- Lokale Edit-/UI-State const editMode = useEditModeHotkey(); const [selectedKey, setSelectedKey] = useState(null); @@ -259,11 +273,8 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { // Basis-Module const { - wallsClosedSides, storageRoom, storageDoorSide, - ledFrames, - ledWall, counters, countersWall, countersWithPower, @@ -276,26 +287,12 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { // Truss & Licht const trussEnabled: boolean = !!mAny.truss; - const trussLightType: "spot" | "wash" = (mAny.trussLightType ?? "spot") as "spot" | "wash"; - - const trussLightsFront: number = mAny.trussLightsFront ?? 0; - const trussLightsBack: number = mAny.trussLightsBack ?? 0; - const trussLightsLeft: number = mAny.trussLightsLeft ?? 0; - const trussLightsRight: number = mAny.trussLightsRight ?? 0; - - const wallLightsBack: number = mAny.wallLightsBack ?? 0; - const wallLightsLeft: number = mAny.wallLightsLeft ?? 0; - const wallLightsRight: number = mAny.wallLightsRight ?? 0; - // Banner / Truss - const bannersFront: number = mAny.trussBannersFront ?? 0; - const bannersBack: number = mAny.trussBannersBack ?? 0; - const bannersLeft: number = mAny.trussBannersLeft ?? 0; - const bannersRight: number = mAny.trussBannersRight ?? 0; - - const bannerWidth: number = mAny.trussBannerWidth ?? 3; - const bannerHeight: number = mAny.trussBannerHeight ?? 1; - const bannerThickness = 0.04; + // Kollisionsabstand (konfigurierbar über modules.collisionClearance) + const collisionClearance: number = Math.max( + 0, + typeof mAny.collisionClearance === "number" ? mAny.collisionClearance : DEFAULT_CLEARANCE + ); // Boden/Standhöhen const floorConfig = modules.floor; @@ -303,7 +300,6 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const floorHeight = isRaised ? 0.08 : 0.025; const wallHeight = height; - const wallCenterY = floorHeight + wallHeight / 2; const defaultTrussHeight = floorHeight + wallHeight + 0.5; const trussHeight = Math.max( @@ -314,15 +310,6 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const trussOffsetX: number = (mAny.trussOffset?.x ?? 0) as number; const trussOffsetZ: number = (mAny.trussOffset?.z ?? 0) as number; - // useTexture -> Fallback 1x1 PNG (weiß) - const BLANK_PNG = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAosBv2jz2l0AAAAASUVORK5CYII="; - const bannerImageUrl: string | undefined = mAny.trussBannerImageUrl; - const bannerTexture = useTexture(bannerImageUrl || BLANK_PNG); - - const scaleX = width; - const scaleZ = depth; - // Geometrie-Hilfswerte const wallThickness = 0.06; const panelGap = 0.01; @@ -332,7 +319,6 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const leftWallInnerX = -width / 2 + wallThickness + panelGap; const rightWallInnerX = width / 2 - wallThickness - panelGap; - const ledWallSide = (ledWall as WallSide) ?? "back"; const screensWallSide = (screensWall as WallSide) ?? "back"; const countersPlacement = (countersWall as "front" | "island") ?? "front"; @@ -353,87 +339,90 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const cabinPosZ = cabin?.position?.z ?? -depth / 2 + cabinDepth / 2 + 0.25; const cabinCenterY = floorHeight + cabinHeight / 2; - // Boden-Material - const floorType = floorConfig?.type ?? "carpet"; - const floorMaterial = (() => { - switch (floorType) { - case "laminate": - return { color: "#e5e7eb", roughness: 0.35, metalness: 0.08 } as const; - case "vinyl": - return { color: "#0f172a", roughness: 0.3, metalness: 0.15 } as const; - case "wood": - return { color: "#92400e", roughness: 0.6, metalness: 0.1 } as const; - case "carpet": - default: - return { color: "#1e293b", roughness: 0.95, metalness: 0.05 } as const; - } - })(); - - /** Wand-Oberflächen (system | wood | banner | seg | led) */ - type Surface = "system" | "wood" | "banner" | "seg" | "led"; - const wallsDetail = (mAny.wallsDetail ?? {}) as Record; - const surfaceOf = (side: WallSide): Surface => (wallsDetail[side]?.surface ?? "system") as Surface; - const wallMaterialProps = (s: Surface) => { - switch (s) { - case "wood": - return { color: "#8B5A2B", roughness: 0.8, metalness: 0.05 } as const; - case "banner": - return { color: "#111827", roughness: 0.5, metalness: 0.2 } as const; - case "seg": - return { color: "#f3f4f6", roughness: 0.85, metalness: 0.05 } as const; - case "led": - return { - color: "#0f172a", - roughness: 0.35, - metalness: 0.15, - emissive: "#38bdf8", - emissiveIntensity: 0.9, - } as const; - case "system": - default: - return { color: "#e5e7eb", roughness: 0.9, metalness: 0.05 } as const; - } - }; - - // Helper: Truss-Lichtkörper je nach Typ - const renderTrussLight = ( - key: string, - x: number, - y: number, - z: number, - lx: number, - ly: number, - lz: number - ) => { - return ( - - {trussLightType === "spot" ? ( - - - - - ) : ( - - - - - )} - - - - ); - }; - // ---- Detaillierte Objekte aus Store (optional) const countersDetailed = (mAny.countersDetailed ?? []) as DetailedCounter[]; const screensDetailed = (mAny.detailedScreens ?? []) as DetailedScreen[]; + const sceneAabbs = useMemo( + () => buildSceneAabbs(config, collisionClearance), + [config, collisionClearance] + ); + + const [collidingKeys, setCollidingKeys] = useState>(new Set()); + const [lastValidPositions, setLastValidPositions] = useState< + Record + >({}); + + const rememberValidPosition = useCallback((key: string, pos: { x: number; z: number }) => { + setLastValidPositions((prev) => ({ ...prev, [key]: pos })); + }, []); + + const getFallbackPosition = useCallback( + (key: string, fallback: { x: number; z: number }) => lastValidPositions[key] ?? fallback, + [lastValidPositions] + ); + + const setCollisionState = useCallback((key: string, collided: boolean) => { + setCollidingKeys((prev) => { + const next = new Set(prev); + if (collided) next.add(key); + else next.delete(key); + return next; + }); + }, []); + + const ensureNoCollision = useCallback( + (key: string, boxes: ReturnType[], ignoreIds: string[] = []) => { + const ignored = new Set([key, ...ignoreIds]); + const collision = findCollisionForMany(boxes, sceneAabbs, ignored); + if (collision.collided) { + setCollisionState(key, true); + return collision; + } + setCollisionState(key, false); + return collision; + }, + [sceneAabbs, setCollisionState] + ); + + // initial gültige Positionen merken (Rollback bei Kollision) + useEffect(() => { + const next: Record = {}; + + countersDetailed.forEach((ctr) => { + next[`ctr-d-${ctr.id}`] = { + x: ctr.position?.x ?? 0, + z: ctr.position?.z ?? 0, + }; + }); + + screensDetailed.forEach((scr) => { + next[`scr-d-${scr.id}`] = { + x: scr.position?.x ?? 0, + z: scr.position?.z ?? 0, + }; + }); + + if (cabinEnabled) { + next.cabin = { x: cabinPosX, z: cabinPosZ }; + } + + if (trussEnabled) { + next.truss = { x: trussOffsetX, z: trussOffsetZ }; + } + + setLastValidPositions(next); + }, [ + cabinEnabled, + cabinPosX, + cabinPosZ, + countersDetailed, + screensDetailed, + trussEnabled, + trussOffsetX, + trussOffsetZ, + ]); + // ---- Legacy → Detailed Konverter (per Doppelklick) const convertLegacyCountersToDetailed = () => { if ((counters ?? 0) <= 0) return; @@ -464,7 +453,7 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const total = count || 1; let x = 0; let z = 0; - let wall: WallSide = (screensWallSide as WallSide) ?? "back"; + const wall: WallSide = (screensWallSide as WallSide) ?? "back"; if (wall === "back") { const spacing = width / (total + 1); x = -width / 2 + spacing * (idx + 1); @@ -536,68 +525,27 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} {/* Basisplatte (Rand, damit Schatten bleibt) */} - setSelectedKey(null)} - > - - - + {cloneObject(sceneObjects.basePlate) && ( + setSelectedKey(null)} + /> + )} {/* Doppelboden-Körper */} - {isRaised && ( - - - - + {isRaised && sceneObjects.raisedFloor && ( + )} {/* Bodenfläche */} - - - - - - {/* Wände */} - {wallsClosedSides >= 1 && ( - - - - - )} - - {wallsClosedSides >= 2 && ( - - - - + {cloneObject(sceneObjects.floor) && ( + )} - {wallsClosedSides >= 3 && ( - - - - - )} + {/* Wände */} + {sceneObjects.walls.map((wall, idx) => ( + + ))} {/* Lagerraum / Kabine (Drag-fähig im Edit-Modus) */} {cabinEnabled && cabin && ( @@ -609,6 +557,16 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { onDragEnd={enableOrbit} onChange={(pos) => { const c = clampXZ(pos.x, pos.z, width, depth, cabinWidth / 2, cabinDepth / 2); + const candidate = makeAabb("cabin", "Kabine", c.x, c.z, cabinWidth, cabinDepth, collisionClearance); + const collision = ensureNoCollision("cabin", [candidate]); + + if (collision.collided) { + const fallback = getFallbackPosition("cabin", { x: cabinPosX, z: cabinPosZ }); + pos.set(fallback.x, pos.y, fallback.z); + return; + } + + rememberValidPosition("cabin", { x: c.x, z: c.z }); pos.set(c.x, pos.y, c.z); setConfig({ modules: { @@ -674,6 +632,34 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has("cabin") && ( + <> + + + + + +
+ Belegt – bitte verschieben +
+ + + )} )} @@ -700,6 +686,16 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { onDragEnd={enableOrbit} onChange={(pos) => { const c = clampXZ(pos.x, pos.z, width, depth, w / 2, d / 2); + const candidate = makeAabb(key, "Counter", c.x, c.z, w, d, collisionClearance); + const collision = ensureNoCollision(key, [candidate]); + + if (collision.collided) { + const fallback = getFallbackPosition(key, { x: px, z: pz }); + pos.set(fallback.x, pos.y, fallback.z); + return; + } + + rememberValidPosition(key, { x: c.x, z: c.z }); 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 @@ -734,21 +730,43 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has(key) && ( + <> + + + + + +
+ Kollision erkannt +
+ + + )} ); }) : // Legacy: statisch – Doppelklick => Detailed - Array.from({ length: counters ?? 0 }).map((_, idx) => { - const spacing = width / ((counters ?? 0) + 1 || 1); - const xPos = -width / 2 + spacing * (idx + 1); - const zPos = countersPlacement === "island" ? 0 : depth / 2 - 0.5; - const variant = (modules as any).counterVariant ?? "basic"; + sceneObjects.counters.map((ctr, idx) => { const k = `legacy-counter-${idx}`; + const clone = cloneObject(ctr); + if (!clone) return null; return ( { e.stopPropagation(); convertLegacyCountersToDetailed(); @@ -758,9 +776,7 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { setSelectedKey(k); }} > - - - + {countersWithPower && ( @@ -781,41 +797,14 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { })} {/* LED-Rahmen (Legacy – verteilt an einer Wand) */} - {Array.from({ length: ledFrames ?? 0 }).map((_, idx) => { - const total = ledFrames || 1; - - if (ledWallSide === "back") { - const spacing = width / (total + 1); - const xPos = -width / 2 + spacing * (idx + 1); - return ( - - - - - ); - } - - if (ledWallSide === "left") { - const spacing = depth / (total + 1); - const zPos = -depth / 2 + spacing * (idx + 1); - return ( - - - - - ); - } - - const spacing = depth / (total + 1); - const zPos = -depth / 2 + spacing * (idx + 1); - return ( - - - - - ); + {sceneObjects.ledFrames.map((frame, idx) => { + const clone = cloneObject(frame); + if (!clone) return null; + return ; })} + {sceneObjects.ledWall && } + {/* Screens – Detailed bevorzugt, sonst Legacy */} {screensDetailed.length > 0 ? screensDetailed.map((scr) => { @@ -867,6 +856,34 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { onDragEnd={enableOrbit} onChange={(pos) => { const c = clampXZ(pos.x, pos.z, width, depth, w / 2, t / 2); + let candidateW = w; + let candidateD = t; + if (mount === "wall") { + if (scr.wallSide === "left" || scr.wallSide === "right") { + candidateW = t; + candidateD = w; + } + } else if (mount === "floor") { + candidateD = t; + } + const candidate = makeAabb( + key, + "Screen", + c.x, + c.z, + candidateW, + candidateD, + collisionClearance + ); + const collision = ensureNoCollision(key, [candidate]); + + if (collision.collided) { + const fallback = getFallbackPosition(key, { x: px, z: pz }); + pos.set(fallback.x, pos.y, fallback.z); + return; + } + + rememberValidPosition(key, { x: c.x, z: c.z }); pos.set(c.x, pos.y, c.z); const next = screensDetailed.map((s0) => s0.id === scr.id @@ -891,72 +908,41 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has(key) && ( + +
+ Screen kollidiert +
+ + )}
); }) : // Legacy Screens - Array.from({ length: screens ?? 0 }).map((_, idx) => { - const total = screens || 1; - - if (screensWallSide === "back") { - const spacing = width / (total + 1); - const xPos = -width / 2 + spacing * (idx + 1); - return ( - { - e.stopPropagation(); - convertLegacyScreensToDetailed(); - }} - > - - {editMode && ( - -
Doppelklick: in „detailliert“ umwandeln
- - )} -
- ); - } - - if (screensWallSide === "left") { - const spacing = depth / (total + 1); - const zPos = -depth / 2 + spacing * (idx + 1); - return ( - { - e.stopPropagation(); - convertLegacyScreensToDetailed(); - }} - > - - {editMode && ( - -
Doppelklick: in „detailliert“ umwandeln
- - )} -
- ); - } - - const spacing = depth / (total + 1); - const zPos = -depth / 2 + spacing * (idx + 1); + sceneObjects.screensDetailed.map((scr, idx) => { + const clone = cloneObject(scr.group); + if (!clone) return null; return ( { e.stopPropagation(); convertLegacyScreensToDetailed(); }} > - + {editMode && (
Doppelklick: in „detailliert“ umwandeln
@@ -966,65 +952,12 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { ); })} - {/* Wand-Strahler Rückwand */} - {wallLightsBack > 0 && - wallsClosedSides >= 1 && - Array.from({ length: wallLightsBack }).map((_, i) => { - const spacing = width / (wallLightsBack + 1); - const x = -width / 2 + spacing * (i + 1); - const y = floorHeight + wallHeight - 0.3; - const z = backWallFrontZ + 0.05; - - return ( - - - - - - - - ); - })} - - {/* Wand-Strahler linke Wand */} - {wallLightsLeft > 0 && - wallsClosedSides >= 2 && - Array.from({ length: wallLightsLeft }).map((_, i) => { - const spacing = depth / (wallLightsLeft + 1); - const z = -depth / 2 + spacing * (i + 1); - const y = floorHeight + wallHeight - 0.3; - const x = leftWallInnerX + 0.05; - - return ( - - - - - - - - ); - })} - - {/* Wand-Strahler rechte Wand */} - {wallLightsRight > 0 && - wallsClosedSides >= 3 && - Array.from({ length: wallLightsRight }).map((_, i) => { - const spacing = depth / (wallLightsRight + 1); - const z = -depth / 2 + spacing * (i + 1); - const y = floorHeight + wallHeight - 0.3; - const x = rightWallInnerX - 0.05; - - return ( - - - - - - - - ); - })} + {/* Wand-Strahler */} + {sceneObjects.wallLights.map((light, idx) => { + const clone = cloneObject(light); + if (!clone) return null; + return ; + })} {/* Truss – Rahmen + Lampen + Bannerrahmen (mit Offset & Drag-Griff) */} {trussEnabled && ( @@ -1038,6 +971,61 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { onDragEnd={enableOrbit} onChange={(pos) => { const c = clampXZ(pos.x, pos.z, width, depth, 0.4, 0.4); + const columnSize = 0.12; + const candidates = [ + makeAabb( + "truss-col-front-left", + "Truss-Stütze", + -width / 2 + c.x, + depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + makeAabb( + "truss-col-front-right", + "Truss-Stütze", + width / 2 + c.x, + depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + makeAabb( + "truss-col-back-left", + "Truss-Stütze", + -width / 2 + c.x, + -depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + makeAabb( + "truss-col-back-right", + "Truss-Stütze", + width / 2 + c.x, + -depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + ]; + + const ignoreSelf = [ + "truss-col-front-left", + "truss-col-front-right", + "truss-col-back-left", + "truss-col-back-right", + ]; + + const collision = ensureNoCollision("truss", candidates, ignoreSelf); + if (collision.collided) { + const fallback = getFallbackPosition("truss", { x: trussOffsetX, z: trussOffsetZ }); + pos.set(fallback.x, pos.y, fallback.z); + return; + } + + rememberValidPosition("truss", { x: c.x, z: c.z }); pos.set(c.x, pos.y, c.z); setConfig({ modules: { trussOffset: { x: c.x, z: c.z } } as any }); }} @@ -1056,135 +1044,26 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has("truss") && ( + +
+ Truss kollidiert +
+ + )}
- {/* 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( - - - - - ); - }); - } - - // 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; - })()} + {sceneObjects.truss && } )} @@ -1193,12 +1072,35 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { export default function Configurator3D() { const orbitRef = useRef(null); + const isMobile = useIsMobile(); + + const cameraSettings = useMemo( + () => + isMobile + ? { + position: [5.5, 5.2, 8.5] as const, + fov: 52, + maxDistance: 14, + minDistance: 3.8, + } + : { + position: [6, 5, 8] as const, + fov: 45, + maxDistance: 18, + minDistance: 4, + }, + [isMobile] + ); + + const shadowMapSize = isMobile ? 512 : 1024; + const contactShadowResolution = isMobile ? 512 : 1024; return ( { // Fallback-Deselect, falls obere Ebene Events nicht bekommt // (Selektion-Reset passiert primär in StandMesh) @@ -1211,8 +1113,8 @@ export default function Configurator3D() { position={[6, 10, 4]} intensity={1.4} castShadow - shadow-mapSize-width={1024} - shadow-mapSize-height={1024} + shadow-mapSize-width={shadowMapSize} + shadow-mapSize-height={shadowMapSize} /> @@ -1242,7 +1144,7 @@ export default function Configurator3D() { height={20} blur={1.8} far={15} - resolution={1024} + resolution={contactShadowResolution} color="#000000" /> @@ -1251,8 +1153,8 @@ export default function Configurator3D() { enablePan enableZoom maxPolarAngle={Math.PI / 2.05} - minDistance={4} - maxDistance={18} + minDistance={cameraSettings.minDistance} + maxDistance={cameraSettings.maxDistance} /> ); diff --git a/ss-messebau-configurator/src/components/ConfiguratorPanel.tsx b/ss-messebau-configurator/src/components/ConfiguratorPanel.tsx new file mode 100644 index 0000000..d3e5e7a --- /dev/null +++ b/ss-messebau-configurator/src/components/ConfiguratorPanel.tsx @@ -0,0 +1,1046 @@ +// src/components/ConfiguratorPanel.tsx +import { useState } from "react"; +import { useConfigStore, type DeepPartial } from "../store/configStore"; +import type { StandModules } from "../lib/pricing"; +import { collisionPlayground } from "../lib/playgrounds"; + +type WallSide = "back" | "left" | "right"; + +// Feste Anzahl geschlossener Seiten pro Standtyp +const wallFixedMap = { + row: 3, + corner: 2, + head: 1, + island: 0, +} as const; + +export default function ConfiguratorPanel() { + const { config, price, setConfig, applyPreset, replaceConfig } = useConfigStore(); + + // Helper: DeepPartial-Patch für modules (typsicher) + const patchModules = (mods: DeepPartial) => + setConfig({ modules: mods }); + + const [customerName, setCustomerName] = useState(""); + const [company, setCompany] = useState(""); + const [email, setEmail] = useState(""); + const [phone, setPhone] = useState(""); + const [fair, setFair] = useState(""); + + const fixedWalls = + wallFixedMap[config.type as keyof typeof wallFixedMap] ?? 0; + + // Boden-Konfiguration (advanced + Fallback auf legacy raisedFloor) + const floor = config.modules.floor; + const floorType = floor?.type ?? "carpet"; + const floorRaised = floor?.raised ?? config.modules.raisedFloor ?? false; + + // Wand-Oberflächen aus modules.wallsDetail lesen + const getWallSurface = (side: WallSide): string => { + const wallsDetail = (config.modules as any).wallsDetail as + | Partial> + | undefined; + return wallsDetail?.[side]?.surface ?? "system"; + }; + + const updateWallSurface = (side: WallSide, surface: string) => { + const wallsDetail = + ((config.modules as any).wallsDetail ?? {}) as Record< + WallSide, + { [key: string]: any } + >; + + patchModules({ + wallsDetail: { + ...wallsDetail, + [side]: { + ...(wallsDetail[side] ?? {}), + surface, + }, + } as any, + }); + }; + + const stepModule = ( + field: "ledFrames" | "counters" | "screens", + delta: number, + min = 0, + max?: number + ) => { + const current = (config.modules[field] as number) ?? 0; + let next = current + delta; + if (typeof min === "number") next = Math.max(min, next); + if (typeof max === "number") next = Math.min(max, next); + patchModules({ [field]: next } as DeepPartial); + }; + + const floorTypeLabel = (type: string | undefined) => { + switch (type) { + case "laminate": + return "Laminat"; + case "vinyl": + return "Vinyl"; + case "wood": + return "Holz"; + case "carpet": + default: + return "Teppich"; + } + }; + + const copyConfigToClipboard = () => { + const area = config.width * config.depth; + const m = config.modules; + const mm = m as any; + + const wd = mm.wallsDetail as + | Partial> + | undefined; + + const wallSurfaceLabel = (side: WallSide, label: string) => { + const surface = wd?.[side]?.surface ?? "system"; + const nice = + surface === "wood" + ? "Holzwand" + : surface === "banner" + ? "Bannerfläche" + : surface === "seg" + ? "Textil / SEG" + : surface === "led" + ? "LED-Wand" + : "Systemwand"; + return `${label}: ${nice}`; + }; + + const wallLines: string[] = []; + if (m.wallsClosedSides >= 1) + wallLines.push(" · " + wallSurfaceLabel("back", "Rückwand")); + if (m.wallsClosedSides >= 2) + wallLines.push(" · " + wallSurfaceLabel("left", "Linke Wand")); + if (m.wallsClosedSides >= 3) + wallLines.push(" · " + wallSurfaceLabel("right", "Rechte Wand")); + + const floorLbl = floorTypeLabel(m.floor?.type); + + const lightsFront = mm.trussLightsFront ?? 0; + const lightsBack = mm.trussLightsBack ?? 0; + const lightsLeft = mm.trussLightsLeft ?? 0; + const lightsRight = mm.trussLightsRight ?? 0; + const wallBack = mm.wallLightsBack ?? 0; + const wallLeft = mm.wallLightsLeft ?? 0; + const wallRight = mm.wallLightsRight ?? 0; + + const bannerW = mm.trussBannerWidth ?? 0; + const bannerH = mm.trussBannerHeight ?? 0; + const bFront = mm.trussBannersFront ?? 0; + const bBack = mm.trussBannersBack ?? 0; + const bLeft = mm.trussBannersLeft ?? 0; + const bRight = mm.trussBannersRight ?? 0; + + const text = [ + "Neue Standanfrage über den 3D-Konfigurator:", + "", + `Fläche: ${config.width} x ${config.depth} m (${area} m²)`, + `Standtyp: ${config.type}`, + `Region: ${config.region}`, + `Eilauftrag: ${config.rush ? "Ja" : "Nein"}`, + "", + "Module:", + `- Boden: ${floorLbl}`, + `- Doppelboden: ${ + (m.floor?.raised ?? m.raisedFloor) ? "Ja" : "Nein" + }`, + ...(wallLines.length ? ["- Wände:", ...wallLines] : []), + `- Geschlossene Seiten: ${m.wallsClosedSides}`, + `- Lagerraum: ${m.storageRoom ? "Ja" : "Nein"}${ + m.storageRoom ? ` (Tür: ${m.storageDoorSide ?? "front"})` : "" + }`, + `- LED-Rahmen: ${m.ledFrames} (Wand: ${m.ledWall ?? "back"})`, + `- Counters: ${m.counters} (Position: ${ + m.countersWall ?? "front" + }, Strom: ${m.countersWithPower ? "Ja" : "Nein"})`, + `- Screens: ${m.screens} (Wand: ${m.screensWall ?? "back"})`, + `- Truss: ${m.truss ? "Ja" : "Nein"}`, + `- Truss-Lampen (Typ ${mm.trussLightType ?? "spot"}): Front ${lightsFront}, Back ${lightsBack}, Links ${lightsLeft}, Rechts ${lightsRight}`, + `- Wandstrahler: Back ${wallBack}, Links ${wallLeft}, Rechts ${wallRight}`, + `- Truss-Bannerrahmen (ca. ${bannerW || "?"} × ${bannerH || "?"} m): Front ${bFront}, Back ${bBack}, Links ${bLeft}, Rechts ${bRight}`, + "", + `Richtpreis: ${price.toLocaleString("de-DE")} €`, + ].join("\n"); + + navigator.clipboard + .writeText(text) + .catch(() => console.log("Kopieren nicht möglich.")); + alert("Konfiguration wurde in die Zwischenablage kopiert."); + }; + + const sendEmailRequest = () => { + const area = config.width * config.depth; + const m = config.modules; + const mm = m as any; + + const wd = mm.wallsDetail as + | Partial> + | undefined; + + const wallSurfaceLabel = (side: WallSide, label: string) => { + const surface = wd?.[side]?.surface ?? "system"; + const nice = + surface === "wood" + ? "Holzwand" + : surface === "banner" + ? "Bannerfläche" + : surface === "seg" + ? "Textil / SEG" + : surface === "led" + ? "LED-Wand" + : "Systemwand"; + return `${label}: ${nice}`; + }; + + const wallLines: string[] = []; + if (m.wallsClosedSides >= 1) + wallLines.push(" · " + wallSurfaceLabel("back", "Rückwand")); + if (m.wallsClosedSides >= 2) + wallLines.push(" · " + wallSurfaceLabel("left", "Linke Wand")); + if (m.wallsClosedSides >= 3) + wallLines.push(" · " + wallSurfaceLabel("right", "Rechte Wand")); + + const floorLbl = floorTypeLabel(m.floor?.type); + + const lightsFront = mm.trussLightsFront ?? 0; + const lightsBack = mm.trussLightsBack ?? 0; + const lightsLeft = mm.trussLightsLeft ?? 0; + const lightsRight = mm.trussLightsRight ?? 0; + const wallBack = mm.wallLightsBack ?? 0; + const wallLeft = mm.wallLightsLeft ?? 0; + const wallRight = mm.wallLightsRight ?? 0; + + const bannerW = mm.trussBannerWidth ?? 0; + const bannerH = mm.trussBannerHeight ?? 0; + const bFront = mm.trussBannersFront ?? 0; + const bBack = mm.trussBannersBack ?? 0; + const bLeft = mm.trussBannersLeft ?? 0; + const bRight = mm.trussBannersRight ?? 0; + + const lines = [ + "Neue Standanfrage über den 3D-Konfigurator:", + "", + "=== Standdaten ===", + `Messe / Event: ${fair || "-"}`, + `Fläche: ${config.width} x ${config.depth} m (${area} m²)`, + `Standtyp: ${config.type}`, + `Region: ${config.region}`, + `Eilauftrag: ${config.rush ? "Ja" : "Nein"}`, + "", + "Module:", + `- Boden: ${floorLbl}`, + `- Doppelboden: ${ + (m.floor?.raised ?? m.raisedFloor) ? "Ja" : "Nein" + }`, + ...(wallLines.length ? ["- Wände:", ...wallLines] : []), + `- Geschlossene Seiten: ${m.wallsClosedSides}`, + `- Lagerraum: ${m.storageRoom ? "Ja" : "Nein"}${ + m.storageRoom ? ` (Tür: ${m.storageDoorSide ?? "front"})` : "" + }`, + `- LED-Rahmen: ${m.ledFrames} (Wand: ${m.ledWall ?? "back"})`, + `- Counters: ${m.counters} (Position: ${ + m.countersWall ?? "front" + }, Strom: ${m.countersWithPower ? "Ja" : "Nein"})`, + `- Screens: ${m.screens} (Wand: ${m.screensWall ?? "back"})`, + `- Truss: ${m.truss ? "Ja" : "Nein"}`, + `- Truss-Lampen (Typ ${mm.trussLightType ?? "spot"}): Front ${lightsFront}, Back ${lightsBack}, Links ${lightsLeft}, Rechts ${lightsRight}`, + `- Wandstrahler: Back ${wallBack}, Links ${wallLeft}, Rechts ${wallRight}`, + `- Truss-Bannerrahmen (ca. ${bannerW || "?"} × ${ + bannerH || "?" + } m): Front ${bFront}, Back ${bBack}, Links ${bLeft}, Rechts ${bRight}`, + "", + `Richtpreis (brutto / Richtwert): ${price.toLocaleString("de-DE")} €`, + "", + "=== Kontaktdaten Kunde ===", + `Name: ${customerName || "-"}`, + `Firma: ${company || "-"}`, + `E-Mail: ${email || "-"}`, + `Telefon: ${phone || "-"}`, + ]; + + const subject = encodeURIComponent( + `Standanfrage Konfigurator – ${company || customerName || "Unbekannt"}` + ); + const body = encodeURIComponent(lines.join("\n")); + + const mailto = `mailto:sunds-messebau@gmx.de?subject=${subject}&body=${body}`; + window.location.href = mailto; + }; + + return ( + + ); +} diff --git a/ss-messebau-configurator/src/components/MobileDrawer.tsx b/ss-messebau-configurator/src/components/MobileDrawer.tsx new file mode 100644 index 0000000..0dea55b --- /dev/null +++ b/ss-messebau-configurator/src/components/MobileDrawer.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +interface MobileDrawerProps { + open: boolean; + onClose: () => void; + children: ReactNode; +} + +export default function MobileDrawer({ open, onClose, children }: MobileDrawerProps) { + return ( +
+
+
+
+ +
+
{children}
+
+
+ ); +} diff --git a/ss-messebau-configurator/src/components/MobileFullScreenPanel.tsx b/ss-messebau-configurator/src/components/MobileFullScreenPanel.tsx new file mode 100644 index 0000000..943f67b --- /dev/null +++ b/ss-messebau-configurator/src/components/MobileFullScreenPanel.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; + +interface MobileFullScreenPanelProps { + open: boolean; + onClose: () => void; + children: ReactNode; +} + +export default function MobileFullScreenPanel({ + open, + onClose, + children, +}: MobileFullScreenPanelProps) { + return ( +
+
event.stopPropagation()} + > +
+

Konfiguration

+ +
+
{children}
+
+
+ ); +} diff --git a/ss-messebau-configurator/src/components/SidebarControls.tsx b/ss-messebau-configurator/src/components/SidebarControls.tsx index 37d874a..2f46dd9 100644 --- a/ss-messebau-configurator/src/components/SidebarControls.tsx +++ b/ss-messebau-configurator/src/components/SidebarControls.tsx @@ -1,1141 +1,6 @@ // src/components/SidebarControls.tsx -import { useState } from "react"; -import { useConfigStore, type DeepPartial } from "../store/configStore"; -import type { StandModules } from "../lib/pricing"; +// Deprecated wrapper kept for backward compatibility. +// The configurator UI now lives in ConfiguratorPanel. +import ConfiguratorPanel from "./ConfiguratorPanel"; -type WallSide = "back" | "left" | "right"; - -// Feste Anzahl geschlossener Seiten pro Standtyp -const wallFixedMap = { - row: 3, - corner: 2, - head: 1, - island: 0, -} as const; - -export default function SidebarControls() { - const { config, price, setConfig, applyPreset } = useConfigStore(); - - // Helper: DeepPartial-Patch für modules (typsicher) - const patchModules = (mods: DeepPartial) => - setConfig({ modules: mods }); - - const [customerName, setCustomerName] = useState(""); - const [company, setCompany] = useState(""); - const [email, setEmail] = useState(""); - const [phone, setPhone] = useState(""); - const [fair, setFair] = useState(""); - - const fixedWalls = - wallFixedMap[config.type as keyof typeof wallFixedMap] ?? 0; - - // Boden-Konfiguration (advanced + Fallback auf legacy raisedFloor) - const floor = config.modules.floor; - const floorType = floor?.type ?? "carpet"; - const floorRaised = floor?.raised ?? config.modules.raisedFloor ?? false; - - // Wand-Oberflächen aus modules.wallsDetail lesen - const getWallSurface = (side: WallSide): string => { - const wallsDetail = (config.modules as any).wallsDetail as - | Partial> - | undefined; - return wallsDetail?.[side]?.surface ?? "system"; - }; - - const updateWallSurface = (side: WallSide, surface: string) => { - const wallsDetail = - ((config.modules as any).wallsDetail ?? {}) as Record< - WallSide, - { [key: string]: any } - >; - - patchModules({ - wallsDetail: { - ...wallsDetail, - [side]: { - ...(wallsDetail[side] ?? {}), - surface, - }, - } as any, - }); - }; - - const stepModule = ( - field: "ledFrames" | "counters" | "screens", - delta: number, - min = 0, - max?: number - ) => { - const current = (config.modules[field] as number) ?? 0; - let next = current + delta; - if (typeof min === "number") next = Math.max(min, next); - if (typeof max === "number") next = Math.min(max, next); - patchModules({ [field]: next } as DeepPartial); - }; - - const floorTypeLabel = (type: string | undefined) => { - switch (type) { - case "laminate": - return "Laminat"; - case "vinyl": - return "Vinyl"; - case "wood": - return "Holz"; - case "carpet": - default: - return "Teppich"; - } - }; - - const copyConfigToClipboard = () => { - const area = config.width * config.depth; - const m = config.modules; - const mm = m as any; - - const wd = mm.wallsDetail as - | Partial> - | undefined; - - const wallSurfaceLabel = (side: WallSide, label: string) => { - const surface = wd?.[side]?.surface ?? "system"; - const nice = - surface === "wood" - ? "Holzwand" - : surface === "banner" - ? "Bannerfläche" - : surface === "seg" - ? "Textil / SEG" - : surface === "led" - ? "LED-Wand" - : "Systemwand"; - return `${label}: ${nice}`; - }; - - const wallLines: string[] = []; - if (m.wallsClosedSides >= 1) - wallLines.push(" · " + wallSurfaceLabel("back", "Rückwand")); - if (m.wallsClosedSides >= 2) - wallLines.push(" · " + wallSurfaceLabel("left", "Linke Wand")); - if (m.wallsClosedSides >= 3) - wallLines.push(" · " + wallSurfaceLabel("right", "Rechte Wand")); - - const floorLbl = floorTypeLabel(m.floor?.type); - - const lightsFront = mm.trussLightsFront ?? 0; - const lightsBack = mm.trussLightsBack ?? 0; - const lightsLeft = mm.trussLightsLeft ?? 0; - const lightsRight = mm.trussLightsRight ?? 0; - const wallBack = mm.wallLightsBack ?? 0; - const wallLeft = mm.wallLightsLeft ?? 0; - const wallRight = mm.wallLightsRight ?? 0; - - const bannerW = mm.trussBannerWidth ?? 0; - const bannerH = mm.trussBannerHeight ?? 0; - const bFront = mm.trussBannersFront ?? 0; - const bBack = mm.trussBannersBack ?? 0; - const bLeft = mm.trussBannersLeft ?? 0; - const bRight = mm.trussBannersRight ?? 0; - - const text = [ - "Neue Standanfrage über den 3D-Konfigurator:", - "", - `Fläche: ${config.width} x ${config.depth} m (${area} m²)`, - `Standtyp: ${config.type}`, - `Region: ${config.region}`, - `Eilauftrag: ${config.rush ? "Ja" : "Nein"}`, - "", - "Module:", - `- Boden: ${floorLbl}`, - `- Doppelboden: ${ - (m.floor?.raised ?? m.raisedFloor) ? "Ja" : "Nein" - }`, - ...(wallLines.length ? ["- Wände:", ...wallLines] : []), - `- Geschlossene Seiten: ${m.wallsClosedSides}`, - `- Lagerraum: ${m.storageRoom ? "Ja" : "Nein"}${ - m.storageRoom ? ` (Tür: ${m.storageDoorSide ?? "front"})` : "" - }`, - `- LED-Rahmen: ${m.ledFrames} (Wand: ${m.ledWall ?? "back"})`, - `- Counters: ${m.counters} (Position: ${ - m.countersWall ?? "front" - }, Strom: ${m.countersWithPower ? "Ja" : "Nein"})`, - `- Screens: ${m.screens} (Wand: ${m.screensWall ?? "back"})`, - `- Truss: ${m.truss ? "Ja" : "Nein"}`, - `- Truss-Lampen (Typ ${mm.trussLightType ?? "spot"}): Front ${lightsFront}, Back ${lightsBack}, Links ${lightsLeft}, Rechts ${lightsRight}`, - `- Wandstrahler: Back ${wallBack}, Links ${wallLeft}, Rechts ${wallRight}`, - `- Truss-Bannerrahmen (ca. ${bannerW || "?"} × ${bannerH || "?"} m): Front ${bFront}, Back ${bBack}, Links ${bLeft}, Rechts ${bRight}`, - "", - `Richtpreis: ${price.toLocaleString("de-DE")} €`, - ].join("\n"); - - navigator.clipboard - .writeText(text) - .catch(() => console.log("Kopieren nicht möglich.")); - alert("Konfiguration wurde in die Zwischenablage kopiert."); - }; - - const sendEmailRequest = () => { - const area = config.width * config.depth; - const m = config.modules; - const mm = m as any; - - const wd = mm.wallsDetail as - | Partial> - | undefined; - - const wallSurfaceLabel = (side: WallSide, label: string) => { - const surface = wd?.[side]?.surface ?? "system"; - const nice = - surface === "wood" - ? "Holzwand" - : surface === "banner" - ? "Bannerfläche" - : surface === "seg" - ? "Textil / SEG" - : surface === "led" - ? "LED-Wand" - : "Systemwand"; - return `${label}: ${nice}`; - }; - - const wallLines: string[] = []; - if (m.wallsClosedSides >= 1) - wallLines.push(" · " + wallSurfaceLabel("back", "Rückwand")); - if (m.wallsClosedSides >= 2) - wallLines.push(" · " + wallSurfaceLabel("left", "Linke Wand")); - if (m.wallsClosedSides >= 3) - wallLines.push(" · " + wallSurfaceLabel("right", "Rechte Wand")); - - const floorLbl = floorTypeLabel(m.floor?.type); - - const lightsFront = mm.trussLightsFront ?? 0; - const lightsBack = mm.trussLightsBack ?? 0; - const lightsLeft = mm.trussLightsLeft ?? 0; - const lightsRight = mm.trussLightsRight ?? 0; - const wallBack = mm.wallLightsBack ?? 0; - const wallLeft = mm.wallLightsLeft ?? 0; - const wallRight = mm.wallLightsRight ?? 0; - - const bannerW = mm.trussBannerWidth ?? 0; - const bannerH = mm.trussBannerHeight ?? 0; - const bFront = mm.trussBannersFront ?? 0; - const bBack = mm.trussBannersBack ?? 0; - const bLeft = mm.trussBannersLeft ?? 0; - const bRight = mm.trussBannersRight ?? 0; - - const lines = [ - "Neue Standanfrage über den 3D-Konfigurator:", - "", - "=== Standdaten ===", - `Messe / Event: ${fair || "-"}`, - `Fläche: ${config.width} x ${config.depth} m (${area} m²)`, - `Standtyp: ${config.type}`, - `Region: ${config.region}`, - `Eilauftrag: ${config.rush ? "Ja" : "Nein"}`, - "", - "Module:", - `- Boden: ${floorLbl}`, - `- Doppelboden: ${ - (m.floor?.raised ?? m.raisedFloor) ? "Ja" : "Nein" - }`, - ...(wallLines.length ? ["- Wände:", ...wallLines] : []), - `- Geschlossene Seiten: ${m.wallsClosedSides}`, - `- Lagerraum: ${m.storageRoom ? "Ja" : "Nein"}${ - m.storageRoom ? ` (Tür: ${m.storageDoorSide ?? "front"})` : "" - }`, - `- LED-Rahmen: ${m.ledFrames} (Wand: ${m.ledWall ?? "back"})`, - `- Counters: ${m.counters} (Position: ${ - m.countersWall ?? "front" - }, Strom: ${m.countersWithPower ? "Ja" : "Nein"})`, - `- Screens: ${m.screens} (Wand: ${m.screensWall ?? "back"})`, - `- Truss: ${m.truss ? "Ja" : "Nein"}`, - `- Truss-Lampen (Typ ${mm.trussLightType ?? "spot"}): Front ${lightsFront}, Back ${lightsBack}, Links ${lightsLeft}, Rechts ${lightsRight}`, - `- Wandstrahler: Back ${wallBack}, Links ${wallLeft}, Rechts ${wallRight}`, - `- Truss-Bannerrahmen (ca. ${bannerW || "?"} × ${ - bannerH || "?" - } m): Front ${bFront}, Back ${bBack}, Links ${bLeft}, Rechts ${bRight}`, - "", - `Richtpreis (brutto / Richtwert): ${price.toLocaleString("de-DE")} €`, - "", - "=== Kontaktdaten Kunde ===", - `Name: ${customerName || "-"}`, - `Firma: ${company || "-"}`, - `E-Mail: ${email || "-"}`, - `Telefon: ${phone || "-"}`, - ]; - - const subject = encodeURIComponent( - `Standanfrage Konfigurator – ${company || customerName || "Unbekannt"}` - ); - const body = encodeURIComponent(lines.join("\n")); - - const mailto = `mailto:sunds-messebau@gmx.de?subject=${subject}&body=${body}`; - window.location.href = mailto; - }; - - return ( - - ); -} +export default ConfiguratorPanel; diff --git a/ss-messebau-configurator/src/lib/collision.ts b/ss-messebau-configurator/src/lib/collision.ts new file mode 100644 index 0000000..1fa287a --- /dev/null +++ b/ss-messebau-configurator/src/lib/collision.ts @@ -0,0 +1,149 @@ +import type { StandConfig } from "./pricing"; + +export type Aabb = { + id: string; + label: string; + minX: number; + maxX: number; + minZ: number; + maxZ: number; +}; + +export const DEFAULT_CLEARANCE = 0.2; + +export const intersects = (a: Aabb, b: Aabb) => + !(a.maxX <= b.minX || a.minX >= b.maxX || a.maxZ <= b.minZ || a.minZ >= b.maxZ); + +export const makeAabb = ( + id: string, + label: string, + x: number, + z: number, + width: number, + depth: number, + clearance: number = DEFAULT_CLEARANCE +): Aabb => { + const halfW = width / 2 + clearance; + const halfD = depth / 2 + clearance; + return { + id, + label, + minX: x - halfW, + maxX: x + halfW, + minZ: z - halfD, + maxZ: z + halfD, + }; +}; + +export const findCollision = ( + candidate: Aabb, + boxes: Aabb[], + ignored: Set = new Set() +): Aabb | undefined => { + for (const box of boxes) { + if (ignored.has(box.id)) continue; + if (intersects(candidate, box)) return box; + } + return undefined; +}; + +export const findCollisionForMany = ( + candidates: Aabb[], + boxes: Aabb[], + ignored: Set = new Set() +): { collided: boolean; hit?: Aabb; candidate?: Aabb } => { + for (const candidate of candidates) { + const hit = findCollision(candidate, boxes, ignored); + if (hit) { + return { collided: true, hit, candidate }; + } + } + return { collided: false }; +}; + +export function buildSceneAabbs( + cfg: StandConfig, + clearance: number = DEFAULT_CLEARANCE +): Aabb[] { + const boxes: Aabb[] = []; + const modules = cfg.modules as any; + const mAny = modules ?? {}; + + // Kabine + const cabin = mAny.cabin as + | (StandConfig["modules"]["cabin"] & { position?: { x?: number; z?: number } }) + | undefined; + if (cabin && (cabin.enabled ?? mAny.storageRoom)) { + const x = cabin.position?.x ?? -cfg.width / 2 + (cabin.width ?? 1.5) / 2 + 0.25; + const z = cabin.position?.z ?? -cfg.depth / 2 + (cabin.depth ?? 1.5) / 2 + 0.25; + boxes.push(makeAabb("cabin", "Kabine", x, z, cabin.width ?? 1.5, cabin.depth ?? 1.5, clearance)); + } + + // Counters (detailliert) + const countersDetailed = (mAny.countersDetailed ?? []) as { + id: string; + variant?: "basic" | "premium" | "corner"; + size?: { w?: number; d?: number }; + position?: { x?: number; z?: number }; + }[]; + countersDetailed.forEach((ctr) => { + const variant = 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 x = ctr.position?.x ?? 0; + const z = ctr.position?.z ?? 0; + boxes.push(makeAabb(`ctr-d-${ctr.id}`, "Counter", x, z, w, d, clearance)); + }); + + // Screens (nur detailliert) + const detailedScreens = (mAny.detailedScreens ?? []) as { + id: string; + size?: { w?: number; h?: number; t?: number }; + mount?: "wall" | "truss" | "floor"; + wallSide?: "back" | "left" | "right"; + position?: { x?: number; z?: number }; + rotationY?: number; + }[]; + + detailedScreens.forEach((scr) => { + const w = scr.size?.w ?? 0.9; + const t = scr.size?.t ?? 0.02; + const mount = scr.mount ?? "wall"; + const wallSide = scr.wallSide ?? "back"; + const x = scr.position?.x ?? 0; + const z = scr.position?.z ?? 0; + + // Wall-Mount => Breite folgt Wand, Tiefe minimal + if (mount === "wall") { + if (wallSide === "left" || wallSide === "right") { + boxes.push(makeAabb(`scr-d-${scr.id}`, "Screen", x, z, t || 0.05, w, clearance)); + } else { + boxes.push(makeAabb(`scr-d-${scr.id}`, "Screen", x, z, w, t || 0.05, clearance)); + } + return; + } + + const depth = mount === "floor" ? t || 0.1 : w * 0.25; + boxes.push(makeAabb(`scr-d-${scr.id}`, "Screen", x, z, w, depth, clearance)); + }); + + // Truss-Stützen (vier Eck-Pfosten) + if (mAny.truss) { + const columnSize = 0.12; // etwas größer als die optischen 8 cm + const offsetX = mAny.trussOffset?.x ?? 0; + const offsetZ = mAny.trussOffset?.z ?? 0; + + const positions: [string, number, number][] = [ + ["truss-col-front-left", -cfg.width / 2 + offsetX, cfg.depth / 2 + offsetZ], + ["truss-col-front-right", cfg.width / 2 + offsetX, cfg.depth / 2 + offsetZ], + ["truss-col-back-left", -cfg.width / 2 + offsetX, -cfg.depth / 2 + offsetZ], + ["truss-col-back-right", cfg.width / 2 + offsetX, -cfg.depth / 2 + offsetZ], + ]; + + positions.forEach(([id, x, z]) => { + boxes.push(makeAabb(id, "Truss-Stütze", x, z, columnSize, columnSize, clearance)); + }); + } + + return boxes; +} diff --git a/ss-messebau-configurator/src/lib/playgrounds.ts b/ss-messebau-configurator/src/lib/playgrounds.ts new file mode 100644 index 0000000..a8524ef --- /dev/null +++ b/ss-messebau-configurator/src/lib/playgrounds.ts @@ -0,0 +1,86 @@ +import type { StandConfig } from "./pricing"; + +/** + * Manuelle Prüfkonfiguration für Kollisionen. + * Mehrere Counters/Screens dicht beieinander + Kabine + Truss. + */ +export const collisionPlayground: StandConfig = { + width: 6, + depth: 4, + height: 2.5, + type: "corner", + region: "NRW", + rush: false, + modules: { + wallsClosedSides: 2, + storageRoom: true, + storageDoorSide: "left", + ledFrames: 1, + ledWall: "back", + counters: 0, + countersWithPower: true, + counterVariant: "premium", + countersDetailed: [ + { + id: "ctr-demo-1", + variant: "premium", + withPower: true, + size: { w: 1.4, d: 0.6, h: 1.1 }, + position: { x: -1.4, z: 1.2 }, + }, + { + id: "ctr-demo-2", + variant: "basic", + withPower: true, + size: { w: 0.9, d: 0.5, h: 1.1 }, + position: { x: -0.1, z: 1.3 }, + }, + { + id: "ctr-demo-3", + variant: "corner", + withPower: false, + size: { w: 1.2, d: 0.9, h: 1.1 }, + position: { x: 1.3, z: 0.9 }, + }, + ], + screens: 0, + detailedScreens: [ + { + id: "scr-demo-back", + mount: "wall", + wallSide: "back", + size: { w: 1.2, h: 0.7, t: 0.06 }, + position: { x: 0, z: -1.9 }, + }, + { + id: "scr-demo-floor", + mount: "floor", + size: { w: 1, h: 0.6, t: 0.12 }, + position: { x: 1.2, z: -0.8 }, + }, + ], + truss: true, + trussHeight: 3.2, + trussOffset: { x: 0.4, z: 0.2 }, + trussLightsFront: 2, + trussLightsLeft: 1, + trussLightsRight: 1, + trussLightType: "spot", + trussBannersFront: 1, + trussBannerWidth: 3, + trussBannerHeight: 1, + floor: { + type: "carpet", + raised: false, + }, + collisionClearance: 0.25, + cabin: { + enabled: true, + width: 2, + depth: 1.6, + height: 2.5, + doorSide: "front", + position: { x: -1.6, z: -1.2 }, + }, + } as any, +}; diff --git a/ss-messebau-configurator/src/lib/useIsMobile.ts b/ss-messebau-configurator/src/lib/useIsMobile.ts new file mode 100644 index 0000000..b9800fb --- /dev/null +++ b/ss-messebau-configurator/src/lib/useIsMobile.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +const MOBILE_BREAKPOINT = 1024; + +export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT): boolean { + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === "undefined") return false; + return window.innerWidth < breakpoint; + }); + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < breakpoint); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [breakpoint]); + + return isMobile; +} + +export default useIsMobile; diff --git a/ss-messebau-configurator/src/scene/createStandScene.ts b/ss-messebau-configurator/src/scene/createStandScene.ts new file mode 100644 index 0000000..c67faa4 --- /dev/null +++ b/ss-messebau-configurator/src/scene/createStandScene.ts @@ -0,0 +1,628 @@ +import * as THREE from "three"; +import type { StandConfig as PricingStandConfig, WallSide } from "../lib/pricing"; + +export type StandConfig = PricingStandConfig; + +export type StandSceneObjects = { + basePlate: THREE.Mesh; + raisedFloor?: THREE.Mesh; + floor: THREE.Mesh; + walls: THREE.Mesh[]; + cabin?: THREE.Group; + counters: THREE.Group[]; + countersDetailed: { id: string; group: THREE.Group }[]; + screensDetailed: { id: string; group: THREE.Object3D }[]; + ledFrames: THREE.Mesh[]; + ledWall?: THREE.Mesh; + truss?: THREE.Group; + trussLights: THREE.Object3D[]; + trussBanners: THREE.Mesh[]; + wallLights: THREE.PointLight[]; +}; + +type CounterVariant = "basic" | "premium" | "corner"; + +type DetailedCounter = { + id: string; + variant?: CounterVariant; + size?: { w: number; d: number; h?: number }; + position: { x: number; z: number; y?: number }; + rotationY?: number; +}; + +type DetailedScreen = { + id: string; + size?: { w: number; h: number; t?: number }; + mount?: "wall" | "truss" | "floor"; + wallSide?: WallSide; + heightFromFloor?: number; + position: { x: number; z: number }; + rotationY?: number; +}; + +function makePlane({ + size, + color, + rotation = new THREE.Euler(-Math.PI / 2, 0, 0), + position = new THREE.Vector3(), +}: { + size: [number, number]; + color: string; + rotation?: THREE.Euler; + position?: THREE.Vector3; +}) { + const mesh = new THREE.Mesh( + new THREE.PlaneGeometry(size[0], size[1]), + new THREE.MeshStandardMaterial({ color }) + ); + mesh.rotation.copy(rotation); + mesh.position.copy(position); + mesh.receiveShadow = true; + return mesh; +} + +function makeBox({ + size, + material, + position = new THREE.Vector3(), +}: { + size: [number, number, number]; + material: THREE.MeshStandardMaterialParameters; + position?: THREE.Vector3; +}) { + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(size[0], size[1], size[2]), + new THREE.MeshStandardMaterial(material) + ); + mesh.position.copy(position); + mesh.castShadow = true; + mesh.receiveShadow = true; + return mesh; +} + +function buildCounter(variant: CounterVariant, dims: { w: number; d: number; h: number }) { + const group = new THREE.Group(); + const { w, d, h } = dims; + + if (variant === "basic") { + const mesh = makeBox({ + size: [w, h, d], + material: { color: "#1d4ed8", roughness: 0.35, metalness: 0.45 }, + position: new THREE.Vector3(0, h / 2, 0), + }); + group.add(mesh); + return group; + } + + if (variant === "premium") { + const base = makeBox({ + size: [Math.max(w, 1.4), h, Math.max(d, 0.6)], + material: { color: "#0f172a", roughness: 0.4, metalness: 0.6 }, + position: new THREE.Vector3(0, h / 2, 0), + }); + const accent = makeBox({ + size: [1.2, 0.5, 0.02], + material: { color: "#1d4ed8", roughness: 0.2, metalness: 0.7 }, + position: new THREE.Vector3(0, h * 0.55, Math.max(d, 0.6) / 2 - 0.29), + }); + const top = makeBox({ + size: [Math.max(w, 1.45), 0.06, Math.max(d, 0.65)], + material: { color: "#e5e7eb", roughness: 0.2, metalness: 0.3 }, + position: new THREE.Vector3(0, h + 0.02, 0), + }); + group.add(base, accent, top); + return group; + } + + const bodyA = makeBox({ + size: [w, h, d], + material: { color: "#1e293b", roughness: 0.5, metalness: 0.35 }, + position: new THREE.Vector3(-w / 2 + w * 0.5, h / 2, 0), + }); + const bodyB = makeBox({ + size: [d, h, w], + material: { color: "#1e293b", roughness: 0.5, metalness: 0.35 }, + position: new THREE.Vector3(0, h / 2, -d / 2 + d * 0.5), + }); + const topPlate = makeBox({ + size: [1.2, 0.06, 1.2], + material: { color: "#e5e7eb", roughness: 0.3, metalness: 0.3 }, + position: new THREE.Vector3(-0.2, h + 0.02, -0.2), + }); + group.add(bodyA, bodyB, topPlate); + return group; +} + +function buildScreen({ w, h, t }: { w: number; h: number; t: number }) { + return makeBox({ + size: [w, h, t], + material: { color: "#020617", roughness: 0.2, metalness: 0.7 }, + }); +} + +function wallMaterial(surface: string): THREE.MeshStandardMaterialParameters { + switch (surface) { + case "wood": + return { color: "#a16207", roughness: 0.5, metalness: 0.2 }; + case "banner": + return { color: "#0ea5e9", roughness: 0.65, metalness: 0.1 }; + case "seg": + return { color: "#f3f4f6", roughness: 0.85, metalness: 0.05 }; + case "led": + return { + color: "#0f172a", + roughness: 0.35, + metalness: 0.15, + emissive: "#38bdf8", + emissiveIntensity: 0.9, + }; + case "system": + default: + return { color: "#e5e7eb", roughness: 0.9, metalness: 0.05 }; + } +} + +function buildCabin({ width, depth, height }: { width: number; depth: number; height: number }) { + const cabin = new THREE.Group(); + const body = makeBox({ + size: [width, height, depth], + material: { color: "#d1d5db", roughness: 0.9, metalness: 0.05 }, + position: new THREE.Vector3(0, height / 2, 0), + }); + cabin.add(body); + return cabin; +} + +export function createStandScene( + config: StandConfig +): { scene: THREE.Scene; camera: THREE.PerspectiveCamera; objects: StandSceneObjects } { + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000); + + const { width, depth, height, modules } = config; + + const objects: StandSceneObjects = { + basePlate: new THREE.Mesh(), + floor: new THREE.Mesh(), + walls: [], + counters: [], + countersDetailed: [], + screensDetailed: [], + ledFrames: [], + trussLights: [], + trussBanners: [], + wallLights: [], + }; + + const mAny = modules as any; + const wallsClosedSides: number = mAny.wallsClosedSides ?? 1; + const wallHeight = height; + const wallThickness = 0.06; + const panelGap = 0.01; + const floorConfig = modules.floor; + const isRaised = floorConfig?.raised ?? modules.raisedFloor ?? false; + const floorHeight = isRaised ? 0.08 : 0.025; + + // base plate + objects.basePlate = makePlane({ + size: [width + 0.4, depth + 0.4], + color: "#020617", + position: new THREE.Vector3(0, 0, 0), + }); + objects.basePlate.castShadow = false; + objects.basePlate.receiveShadow = false; + scene.add(objects.basePlate); + + // raised floor + if (isRaised) { + objects.raisedFloor = makeBox({ + size: [width, floorHeight, depth], + material: { color: "#020617", roughness: 0.6, metalness: 0.2 }, + position: new THREE.Vector3(0, floorHeight / 2, 0), + }); + scene.add(objects.raisedFloor); + } + + // floor plane + const floorMaterial = (floorConfig as any)?.material ?? "grey"; + const floorMaterialMap: Record = { + grey: { color: "#e5e7eb", roughness: 0.9, metalness: 0.05 }, + blue: { color: "#0ea5e9", roughness: 0.6, metalness: 0.2 }, + dark: { color: "#0f172a", roughness: 0.6, metalness: 0.2 }, + wood: { color: "#854d0e", roughness: 0.7, metalness: 0.15 }, + }; + const fm = floorMaterialMap[floorMaterial] ?? floorMaterialMap.grey; + objects.floor = makePlane({ + size: [width, depth], + color: fm.color as string, + position: new THREE.Vector3(0, floorHeight + 0.001, 0), + }); + (objects.floor.material as THREE.MeshStandardMaterial).roughness = fm.roughness ?? 0.8; + (objects.floor.material as THREE.MeshStandardMaterial).metalness = fm.metalness ?? 0.1; + scene.add(objects.floor); + + // walls + const wallCenterY = floorHeight + wallHeight / 2; + const surfaces = mAny.wallSurfaces || {}; + + const backSurface = wallMaterial(surfaces.back ?? "system"); + const leftSurface = wallMaterial(surfaces.left ?? "system"); + const rightSurface = wallMaterial(surfaces.right ?? "system"); + + if (wallsClosedSides >= 1) { + const backWall = makeBox({ + size: [width, wallHeight, 0.06], + material: backSurface, + position: new THREE.Vector3(0, wallCenterY, -depth / 2 + 0.06 / 2), + }); + scene.add(backWall); + objects.walls.push(backWall); + } + if (wallsClosedSides >= 2) { + const leftWall = makeBox({ + size: [0.06, wallHeight, depth], + material: leftSurface, + position: new THREE.Vector3(-width / 2 + 0.06 / 2, wallCenterY, 0), + }); + scene.add(leftWall); + objects.walls.push(leftWall); + } + if (wallsClosedSides >= 3) { + const rightWall = makeBox({ + size: [0.06, wallHeight, depth], + material: rightSurface, + position: new THREE.Vector3(width / 2 - 0.06 / 2, wallCenterY, 0), + }); + scene.add(rightWall); + objects.walls.push(rightWall); + } + + // cabin + const cabin = mAny.cabin; + if (cabin?.enabled) { + const cabinGroup = buildCabin({ + width: cabin.width ?? 1.5, + depth: cabin.depth ?? 1.5, + height: cabin.height ?? 2.2, + }); + const cabinPosX = cabin.position?.x ?? -width / 2 + (cabin.width ?? 1.5) / 2 + 0.25; + const cabinPosZ = cabin.position?.z ?? -depth / 2 + (cabin.depth ?? 1.5) / 2 + 0.25; + cabinGroup.position.set(cabinPosX, floorHeight, cabinPosZ); + scene.add(cabinGroup); + objects.cabin = cabinGroup; + } + + // truss + if (mAny.truss) { + const defaultTrussHeight = floorHeight + wallHeight + 0.5; + const trussHeight = Math.max( + defaultTrussHeight, + typeof mAny.trussHeight === "number" ? mAny.trussHeight : defaultTrussHeight + ); + const trussGroup = new THREE.Group(); + + const frameMaterial = new THREE.MeshStandardMaterial({ + color: "#94a3b8", + roughness: 0.65, + metalness: 0.35, + }); + const beamThickness = 0.08; + const spanX = width; + const spanZ = depth; + + const topY = trussHeight; + const yBottom = floorHeight + wallHeight; + + const beams: Array<[number, number, number, number, number, number]> = [ + [0, topY, -spanZ / 2 + beamThickness / 2, spanX, beamThickness, beamThickness], + [0, topY, spanZ / 2 - beamThickness / 2, spanX, beamThickness, beamThickness], + [-spanX / 2 + beamThickness / 2, topY, 0, beamThickness, beamThickness, spanZ], + [spanX / 2 - beamThickness / 2, topY, 0, beamThickness, beamThickness, spanZ], + ]; + + beams.forEach(([x, y, z, w, h, d]) => { + const beam = makeBox({ + size: [w, h, d], + material: frameMaterial, + position: new THREE.Vector3(x, y, z), + }); + trussGroup.add(beam); + }); + + const posts: Array<[number, number, number]> = [ + [-spanX / 2 + beamThickness / 2, yBottom, -spanZ / 2 + beamThickness / 2], + [spanX / 2 - beamThickness / 2, yBottom, -spanZ / 2 + beamThickness / 2], + [-spanX / 2 + beamThickness / 2, yBottom, spanZ / 2 - beamThickness / 2], + [spanX / 2 - beamThickness / 2, yBottom, spanZ / 2 - beamThickness / 2], + ]; + posts.forEach(([x, _y, z]) => { + const post = makeBox({ + size: [beamThickness, topY - yBottom, beamThickness], + material: frameMaterial, + position: new THREE.Vector3(x, (topY + yBottom) / 2, z), + }); + trussGroup.add(post); + }); + + trussGroup.position.set(mAny.trussOffset?.x ?? 0, 0, mAny.trussOffset?.z ?? 0); + scene.add(trussGroup); + objects.truss = trussGroup; + + const trussLightType: "spot" | "wash" = (mAny.trussLightType ?? "spot") as "spot" | "wash"; + const lightCount = { + front: mAny.trussLightsFront ?? 0, + back: mAny.trussLightsBack ?? 0, + left: mAny.trussLightsLeft ?? 0, + right: mAny.trussLightsRight ?? 0, + }; + + const addTrussLight = ( + key: string, + x: number, + z: number, + lx: number, + lz: number, + flipY = false + ) => { + const lightY = trussHeight - 0.1; + const group = new THREE.Group(); + let lightMesh: THREE.Mesh; + if (trussLightType === "spot") { + lightMesh = new THREE.Mesh( + new THREE.ConeGeometry(0.07, 0.12, 10), + new THREE.MeshStandardMaterial({ + color: "#facc15", + emissive: "#facc15", + emissiveIntensity: 1.2, + roughness: 0.4, + }) + ); + } else { + lightMesh = new THREE.Mesh( + new THREE.BoxGeometry(0.14, 0.08, 0.1), + new THREE.MeshStandardMaterial({ + color: "#fde68a", + emissive: "#fbbf24", + emissiveIntensity: 0.9, + roughness: 0.35, + metalness: 0.4, + }) + ); + } + lightMesh.position.set(x, lightY, z); + if (flipY) lightMesh.rotation.y = Math.PI; + const point = new THREE.PointLight("#fef3c7", 1.1, 6, 2); + point.position.set(lx, lightY - 0.05, lz); + group.add(lightMesh, point); + group.name = key; + objects.trussLights.push(group); + trussGroup.add(group); + }; + + const lightSpacing = (span: number, count: number) => (count > 0 ? span / (count + 1) : 0); + const frontSpacing = lightSpacing(width, lightCount.front); + const backSpacing = lightSpacing(width, lightCount.back); + const leftSpacing = lightSpacing(depth, lightCount.left); + const rightSpacing = lightSpacing(depth, lightCount.right); + + for (let i = 0; i < lightCount.front; i++) { + const x = -width / 2 + frontSpacing * (i + 1); + addTrussLight(`truss-front-${i}`, x, -depth / 2, x, -depth / 2 + 0.6, false); + } + for (let i = 0; i < lightCount.back; i++) { + const x = -width / 2 + backSpacing * (i + 1); + addTrussLight(`truss-back-${i}`, x, depth / 2, x, depth / 2 - 0.6, true); + } + for (let i = 0; i < lightCount.left; i++) { + const z = -depth / 2 + leftSpacing * (i + 1); + addTrussLight(`truss-left-${i}`, -width / 2, z, -width / 2 + 0.6, z, false); + } + for (let i = 0; i < lightCount.right; i++) { + const z = -depth / 2 + rightSpacing * (i + 1); + addTrussLight(`truss-right-${i}`, width / 2, z, width / 2 - 0.6, z, true); + } + + const bannerWidth: number = mAny.trussBannerWidth ?? 3; + const bannerHeight: number = mAny.trussBannerHeight ?? 1; + const bannerThickness = 0.04; + const bannerMaterial = new THREE.MeshStandardMaterial({ + color: "#e2e8f0", + roughness: 0.8, + metalness: 0.05, + }); + + const bannerCounts = { + front: mAny.trussBannersFront ?? 0, + back: mAny.trussBannersBack ?? 0, + left: mAny.trussBannersLeft ?? 0, + right: mAny.trussBannersRight ?? 0, + }; + + const addBanner = (key: string, position: THREE.Vector3, rotationY: number) => { + const mesh = makeBox({ + size: [bannerWidth, bannerHeight, bannerThickness], + material: bannerMaterial, + position, + }); + mesh.rotation.y = rotationY; + mesh.castShadow = false; + mesh.receiveShadow = false; + mesh.name = key; + objects.trussBanners.push(mesh); + trussGroup.add(mesh); + }; + + const bannerY = trussHeight - bannerHeight / 2; + const frontBannerSpacing = lightSpacing(width, bannerCounts.front); + for (let i = 0; i < bannerCounts.front; i++) { + const x = -width / 2 + frontBannerSpacing * (i + 1); + addBanner(`banner-front-${i}`, new THREE.Vector3(x, bannerY, -depth / 2 + bannerThickness / 2), 0); + } + for (let i = 0; i < bannerCounts.back; i++) { + const x = -width / 2 + frontBannerSpacing * (i + 1); + addBanner(`banner-back-${i}`, new THREE.Vector3(x, bannerY, depth / 2 - bannerThickness / 2), Math.PI); + } + const leftBannerSpacing = lightSpacing(depth, bannerCounts.left); + for (let i = 0; i < bannerCounts.left; i++) { + const z = -depth / 2 + leftBannerSpacing * (i + 1); + addBanner( + `banner-left-${i}`, + new THREE.Vector3(-width / 2 + bannerThickness / 2, bannerY, z), + Math.PI / 2 + ); + } + for (let i = 0; i < bannerCounts.right; i++) { + const z = -depth / 2 + leftBannerSpacing * (i + 1); + addBanner( + `banner-right-${i}`, + new THREE.Vector3(width / 2 - bannerThickness / 2, bannerY, z), + -Math.PI / 2 + ); + } + } + + // wall lights + const addWallLights = (count: number, side: WallSide) => { + if (!count) return; + const lights: THREE.PointLight[] = []; + const spacing = (side === "back" ? width : depth) / (count + 1); + for (let i = 0; i < count; i++) { + const pos = new THREE.PointLight("#fef3c7", 0.8, 4, 2); + if (side === "back") { + pos.position.set(-width / 2 + spacing * (i + 1), floorHeight + wallHeight * 0.6, -depth / 2 + 0.05); + } else if (side === "left") { + pos.position.set(-width / 2 + 0.05, floorHeight + wallHeight * 0.6, -depth / 2 + spacing * (i + 1)); + } else { + pos.position.set(width / 2 - 0.05, floorHeight + wallHeight * 0.6, -depth / 2 + spacing * (i + 1)); + } + lights.push(pos); + scene.add(pos); + } + objects.wallLights.push(...lights); + }; + + addWallLights(mAny.wallLightsBack ?? 0, "back"); + addWallLights(mAny.wallLightsLeft ?? 0, "left"); + addWallLights(mAny.wallLightsRight ?? 0, "right"); + + // led frames + const ledFramesCount = mAny.ledFrames ?? 0; + if (ledFramesCount > 0) { + const ledSize = { w: 1, h: 2.2, t: 0.08 }; + const gap = 0.2; + for (let i = 0; i < ledFramesCount; i++) { + const frame = makeBox({ + size: [ledSize.w, ledSize.h, ledSize.t], + material: { + color: "#0f172a", + emissive: "#22d3ee", + emissiveIntensity: 1.1, + roughness: 0.35, + }, + position: new THREE.Vector3( + -width / 2 + ledSize.w / 2 + gap + i * (ledSize.w + gap), + floorHeight + ledSize.h / 2, + -depth / 2 + ledSize.t / 2 + ), + }); + objects.ledFrames.push(frame); + scene.add(frame); + } + } + + // led wall + if (mAny.ledWall) { + const led = makeBox({ + size: [width, wallHeight * 0.6, 0.1], + material: { color: "#0f172a", emissive: "#38bdf8", emissiveIntensity: 0.8, roughness: 0.4 }, + position: new THREE.Vector3(0, floorHeight + wallHeight * 0.3, -depth / 2 + 0.08), + }); + objects.ledWall = led; + scene.add(led); + } + + // counters quick placement + const counters = mAny.counters ?? 0; + const countersWall: "front" | "island" = (mAny.countersWall ?? "front") as any; + const counterVariant: CounterVariant = (mAny.counterVariant ?? "basic") as CounterVariant; + for (let i = 0; i < counters; i++) { + const ctr = buildCounter(counterVariant, { w: 0.9, d: 0.5, h: 1.1 }); + const spacing = (countersWall === "front" ? width : depth) / (counters + 1); + if (countersWall === "front") { + ctr.position.set(-width / 2 + spacing * (i + 1), floorHeight, depth / 2 - 0.5); + } else { + ctr.position.set(0, floorHeight, -depth / 2 + spacing * (i + 1)); + } + objects.counters.push(ctr); + scene.add(ctr); + } + + // detailed counters + const countersDetailed = (mAny.countersDetailed ?? []) as DetailedCounter[]; + countersDetailed.forEach((ctr) => { + const dims = { + w: ctr.size?.w ?? 0.9, + d: ctr.size?.d ?? 0.5, + h: ctr.size?.h ?? 1.1, + }; + const group = buildCounter((ctr.variant ?? counterVariant) as CounterVariant, dims); + group.position.set(ctr.position.x ?? 0, (ctr.position.y ?? 0) + floorHeight, ctr.position.z ?? 0); + if (ctr.rotationY) group.rotation.y = ctr.rotationY; + objects.countersDetailed.push({ id: ctr.id, group }); + scene.add(group); + }); + + // detailed screens + const screensDetailed = (mAny.detailedScreens ?? []) as DetailedScreen[]; + const screensWallSide = (mAny.screensWall as WallSide) ?? "back"; + const backWallFrontZ = -depth / 2 + wallThickness + panelGap; + const leftWallInnerX = -width / 2 + wallThickness + panelGap; + const rightWallInnerX = width / 2 - wallThickness - panelGap; + + if (screensDetailed.length === 0 && (mAny.screens ?? 0) > 0) { + const total = mAny.screens as number; + for (let idx = 0; idx < total; idx++) { + let x = 0; + let z = 0; + const wall: WallSide = screensWallSide; + if (wall === "back") { + const spacing = width / (total + 1); + x = -width / 2 + spacing * (idx + 1); + z = backWallFrontZ; + } else if (wall === "left") { + const spacing = depth / (total + 1); + z = -depth / 2 + spacing * (idx + 1); + x = leftWallInnerX; + } else { + const spacing = depth / (total + 1); + z = -depth / 2 + spacing * (idx + 1); + x = rightWallInnerX; + } + screensDetailed.push({ + id: `scr-${Date.now()}-${idx}`, + size: { w: 0.9, h: 0.55, t: 0.02 }, + mount: "wall", + wallSide: wall, + heightFromFloor: floorHeight + 1.6, + position: { x, z }, + rotationY: wall === "left" ? Math.PI / 2 : wall === "right" ? -Math.PI / 2 : 0, + }); + } + } + + screensDetailed.forEach((scr) => { + const size = { + w: scr.size?.w ?? 0.9, + h: scr.size?.h ?? 0.55, + t: scr.size?.t ?? 0.02, + }; + const panel = buildScreen(size); + const y = scr.heightFromFloor ?? floorHeight + 1.6; + panel.position.set(scr.position.x ?? 0, y, scr.position.z ?? 0); + if (scr.rotationY) panel.rotation.y = scr.rotationY; + objects.screensDetailed.push({ id: scr.id, group: panel }); + scene.add(panel); + }); + + return { scene, camera, objects }; +} diff --git a/ss-messebau-configurator/src/store/configStore.ts b/ss-messebau-configurator/src/store/configStore.ts index ec087e7..f95012d 100644 --- a/ss-messebau-configurator/src/store/configStore.ts +++ b/ss-messebau-configurator/src/store/configStore.ts @@ -39,7 +39,7 @@ type ConfigState = { config: StandConfig; price: number; - /** Undo/Redo-Stacks (intern, nützlich z. B. für Buttons) */ + /** Undo/Redo-Stacks (intern, nützlich z. B. für Buttons) */ history: StandConfig[]; future: StandConfig[]; historyLimit: number; @@ -242,7 +242,7 @@ function normalizeConfig(cfg: StandConfig): StandConfig { // feste Anzahl geschlossener Wände je Standtyp const fixedWalls = wallFixedMap[cfg.type]; - let modules: StandModules = { + const modules: StandModules = { ...cfg.modules, wallsClosedSides: fixedWalls, }; @@ -276,6 +276,35 @@ function normalizeConfig(cfg: StandConfig): StandConfig { modules.ledWall = fixWall(modules.ledWall); modules.screensWall = fixWall(modules.screensWall); + // Wenn keine geeignete Wand existiert, wall-gebundene Module entfernen + if (!allowedWalls.length) { + modules.screens = 0; + modules.ledFrames = 0; + if (modules.detailedScreens) { + modules.detailedScreens = modules.detailedScreens.filter( + (scr) => (scr.mount ?? "wall") !== "wall" + ); + } + } + + // Fallback: falls keine gültige Wandseite, Zähler zurücksetzen + if (!modules.screensWall && (modules.screens ?? 0) > 0) { + modules.screens = 0; + } + if (!modules.ledWall && (modules.ledFrames ?? 0) > 0) { + modules.ledFrames = 0; + } + + // Detaillierte Screens an vorhandene Wände anpassen + if (allowedWalls.length && modules.detailedScreens?.length) { + modules.detailedScreens = modules.detailedScreens.map((scr) => { + if ((scr.mount ?? "wall") !== "wall") return scr; + const side = scr.wallSide; + if (side && allowedWalls.includes(side)) return scr; + return { ...scr, wallSide: allowedWalls[0] }; + }); + } + // --- Boden-Defaults --- if (!modules.floor) { modules.floor = { diff --git a/ss-messebau-configurator/src/styles.css b/ss-messebau-configurator/src/styles.css index 3208de8..f8ad8c2 100644 --- a/ss-messebau-configurator/src/styles.css +++ b/ss-messebau-configurator/src/styles.css @@ -15,14 +15,198 @@ body, color: #e5e7eb; } +.mobile-banner { + display: none; + padding: 8px 12px; + background: #ad1f34; + font-size: 12px; + text-align: center; + font-weight: 600; + color: white; +} + +@media (max-width: 768px) { + .mobile-banner { + display: block; + } +} + .app-root { display: flex; - height: 100vh; - overflow: hidden; + flex-direction: column; + min-height: 100vh; background: radial-gradient(circle at top left, #111827 0, #020617 55%); color: #e5e7eb; } +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 18px; + background: rgba(15, 23, 42, 0.95); + border-bottom: 1px solid #111827; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35); + position: sticky; + top: 0; + z-index: 2; +} + +.app-header__title { + margin: 0; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.app-header__menu-btn { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #1f2937; + background: #020617; + color: #e5e7eb; + cursor: pointer; + font-size: 13px; + font-weight: 600; +} + +.app-header__menu-btn:hover { + background: #0b1220; + border-color: #374151; +} + +.app-header__menu-btn:active { + background: #111827; + transform: translateY(1px); +} + +.app-main { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.app-canvas-area { + flex: 1; + position: relative; + min-height: 0; + width: 100%; + overflow: hidden; +} + +.app-sidebar { + display: block; + height: 100%; +} + +.mobile-fab { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 25; + display: none; +} + +.mobile-fab button { + width: 56px; + height: 56px; + border-radius: 999px; + border: none; + background: rgba(0, 0, 0, 0.6); + color: #f8fafc; + font-size: 22px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3); + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease; +} + +.mobile-fab button:active { + transform: translateY(2px) scale(0.98); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.25); + background: rgba(0, 0, 0, 0.7); +} + +.mobile-panel-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 40; + display: none; + align-items: stretch; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; +} + +.mobile-panel-backdrop.is-open { + opacity: 1; + pointer-events: auto; +} + +.mobile-panel-content { + background: #ffffff; + color: #0f172a; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + transform: translateY(10px); + opacity: 0; + transition: opacity 0.25s ease, transform 0.25s ease; +} + +.mobile-panel-backdrop.is-open .mobile-panel-content { + transform: translateY(0); + opacity: 1; +} + +.mobile-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + min-height: 56px; + border-bottom: 1px solid #e5e7eb; + background: #ffffff; +} + +.mobile-panel-header h2 { + margin: 0; + font-size: 16px; + font-weight: 700; +} + +.mobile-panel-close { + border: none; + background: #0f172a; + color: #f8fafc; + border-radius: 999px; + padding: 10px 14px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); + transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.mobile-panel-close:hover { + background: #111827; +} + +.mobile-panel-close:active { + transform: translateY(1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.mobile-panel-body { + flex: 1; + overflow-y: auto; + background: #f8fafc; + padding: 12px 14px 16px; +} + /* SIDEBAR */ .sidebar { @@ -35,7 +219,7 @@ body, flex-direction: column; gap: 12px; box-shadow: 4px 0 25px rgba(0, 0, 0, 0.4); - height: 100vh; + height: 100%; overflow-y: auto; } @@ -282,23 +466,142 @@ body, .main-viewport { flex: 1; position: relative; - height: 100vh; + height: 100%; } .main-viewport canvas { outline: none; } +.canvas-root { + width: 100%; + height: 100%; + display: block; +} + /* Responsive */ -@media (max-width: 900px) { - .app-root { +@media (min-width: 1024px) { + .app-main { + flex-direction: row; + } + + .app-sidebar { + display: block; + } +} + +@media (max-width: 1023px) { + .app-main { flex-direction: column; + min-height: 100vh; + } + + .app-header, + .mobile-banner { + display: none; + } + + .app-sidebar { + display: none; + } + + .app-canvas-area, + .main-viewport { + height: 100vh; + } + + .mobile-fab { + display: block; + } + + .mobile-panel-backdrop { + display: flex; + } +} + +@media (max-width: 768px) { + .app-header { + padding: calc(12px + env(safe-area-inset-top, 0px)) 16px 12px; + gap: 12px; + } + + .app-header__title { + font-size: 16px; + } + + .app-header__menu-btn { + min-height: 44px; + padding: 10px 14px; + font-size: 15px; + } + + .mobile-panel-body { + padding: 14px 16px 18px; + overflow-x: hidden; } .sidebar { - width: 100%; - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6); + padding: 16px 14px; + gap: 14px; + } + + .sidebar-section { + margin-bottom: 10px; + } + + .sidebar label { + font-size: 14px; + margin-bottom: 10px; + gap: 6px; + } + + .sidebar input, + .sidebar select, + .preset-btn, + .btn-primary, + .btn-secondary, + .mobile-panel-close, + .app-header__menu-btn, + .icon-btn { + min-height: 44px; + padding: 10px 14px; + font-size: 15px; + } + + .sidebar input, + .sidebar select { + padding-left: 12px; + padding-right: 12px; + } + + .form-grid { + gap: 10px; + } + + .preset-row, + .sidebar-section-header, + .number-input-row { + gap: 8px; + } + + .checkbox-row { + gap: 10px; + font-size: 14px; + } + + .section-title { + font-size: 14px; + } + + .sidebar small, + .section-sub, + .preset-btn small { + font-size: 12px; + } + + .preset-btn strong { + font-size: 14px; } }