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..9412394 100644 --- a/ss-messebau-configurator/src/App.tsx +++ b/ss-messebau-configurator/src/App.tsx @@ -1,13 +1,43 @@ -import SidebarControls from "./components/SidebarControls"; +import { useState } from "react"; +import ConfiguratorPanel from "./components/ConfiguratorPanel"; import Configurator3D from "./components/Configurator3D"; +import MobileDrawer from "./components/MobileDrawer"; export default function App() { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + return (
- -
- +
Mobile Version aktiv
+ +
+

S&S Standkonfigurator

+ +
+ +
+
+ +
+
+ +
+ + setIsMobileMenuOpen(false)} + > + +
); } diff --git a/ss-messebau-configurator/src/components/Configurator3D.tsx b/ss-messebau-configurator/src/components/Configurator3D.tsx index 5105b20..d64fdf4 100644 --- a/ss-messebau-configurator/src/components/Configurator3D.tsx +++ b/ss-messebau-configurator/src/components/Configurator3D.tsx @@ -1,13 +1,15 @@ // src/components/Configurator3D.tsx import { Suspense, + useCallback, useEffect, + useMemo, useRef, useState, type ReactNode, type MutableRefObject, } from "react"; -import { Canvas, type ThreeEvent } from "@react-three/fiber"; +import { Canvas, type ThreeEvent, useThree } from "@react-three/fiber"; import { OrbitControls, Environment, @@ -19,6 +21,14 @@ import { } 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 useViewportSize from "../hooks/useViewportSize"; type WallSide = "back" | "left" | "right"; type CounterVariant = "basic" | "premium" | "corner"; @@ -52,6 +62,8 @@ type CabinWithPosition = { position?: { x?: number; z?: number }; }; +const MOBILE_HEADER_HEIGHT = 64; + /** Edit‑Modus Toggle (Taste 'E') */ function useEditModeHotkey(): boolean { const [edit, setEdit] = useState(false); @@ -174,6 +186,50 @@ function ScreenPanel({ w = 0.9, h = 0.55, t = 0.02 }) { ); } +function MobileViewportSync({ + width, + height, + isActive, +}: { + width: number; + height: number; + isActive: boolean; +}) { + const { camera, gl } = useThree(); + + useEffect(() => { + if (!isActive || width === 0 || height === 0) return; + + const perspectiveCamera = camera as THREE.PerspectiveCamera; + + perspectiveCamera.aspect = width / height; + perspectiveCamera.updateProjectionMatrix(); + gl.setSize(width, height, false); + }, [camera, gl, height, isActive, width]); + + return null; +} + +function CameraSettingsApplier({ + position, + fov, +}: { + position: readonly [number, number, number]; + fov: number; +}) { + const { camera } = useThree(); + + useEffect(() => { + const perspectiveCamera = camera as THREE.PerspectiveCamera; + + perspectiveCamera.position.set(...position); + perspectiveCamera.fov = fov; + perspectiveCamera.updateProjectionMatrix(); + }, [camera, fov, position]); + + return null; +} + /** TransformControls Wrapper: sperrt Orbit während Interaktion */ function Transformable({ enabled, @@ -287,6 +343,12 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const wallLightsLeft: number = mAny.wallLightsLeft ?? 0; const wallLightsRight: number = mAny.wallLightsRight ?? 0; + // Kollisionsabstand (konfigurierbar über modules.collisionClearance) + const collisionClearance: number = Math.max( + 0, + typeof mAny.collisionClearance === "number" ? mAny.collisionClearance : DEFAULT_CLEARANCE + ); + // Banner / Truss const bannersFront: number = mAny.trussBannersFront ?? 0; const bannersBack: number = mAny.trussBannersBack ?? 0; @@ -434,6 +496,86 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { 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 +606,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); @@ -609,6 +751,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 +826,34 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has("cabin") && ( + <> + + + + + +
+ Belegt – bitte verschieben +
+ + + )} )} @@ -700,6 +880,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,6 +924,30 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has(key) && ( + <> + + + + + +
+ Kollision erkannt +
+ + + )} ); @@ -867,6 +1081,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,6 +1133,22 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has(key) && ( + +
+ Screen kollidiert +
+ + )} ); @@ -1038,6 +1296,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,6 +1369,22 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has("truss") && ( + +
+ Truss kollidiert +
+ + )} @@ -1193,17 +1522,64 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { export default function Configurator3D() { const orbitRef = useRef(null); + const isMobile = useIsMobile(); + const { width, height, orientation } = useViewportSize(); + + const canvasWidth = isMobile ? width : undefined; + const canvasHeight = isMobile ? Math.max(height - MOBILE_HEADER_HEIGHT, 0) : undefined; + + const canvasStyle = useMemo( + () => + isMobile && canvasWidth && canvasHeight + ? { width: `${canvasWidth}px`, height: `${canvasHeight}px` } + : { width: "100%", height: "100%" }, + [canvasHeight, canvasWidth, isMobile] + ); + + const cameraSettings = useMemo( + () => + isMobile + ? { + position: + orientation === "portrait" + ? ([5.8, 5.6, 10] as const) + : ([5.5, 5.2, 8.5] as const), + fov: orientation === "portrait" ? 55 : 52, + maxDistance: 14, + minDistance: 3.8, + } + : { + position: [6, 5, 8] as const, + fov: 45, + maxDistance: 18, + minDistance: 4, + }, + [isMobile, orientation] + ); + + 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 +1587,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 +1618,7 @@ export default function Configurator3D() { height={20} blur={1.8} far={15} - resolution={1024} + resolution={contactShadowResolution} color="#000000" /> @@ -1251,8 +1627,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/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/hooks/useViewportSize.ts b/ss-messebau-configurator/src/hooks/useViewportSize.ts new file mode 100644 index 0000000..46e2d7f --- /dev/null +++ b/ss-messebau-configurator/src/hooks/useViewportSize.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; + +type Orientation = "portrait" | "landscape"; + +export type ViewportSize = { + width: number; + height: number; + orientation: Orientation; +}; + +const getViewportSize = (): ViewportSize => { + if (typeof window === "undefined") { + return { width: 0, height: 0, orientation: "landscape" }; + } + + const { innerWidth, innerHeight } = window; + const orientation: Orientation = innerWidth < innerHeight ? "portrait" : "landscape"; + + return { width: innerWidth, height: innerHeight, orientation }; +}; + +export default function useViewportSize(): ViewportSize { + const [viewportSize, setViewportSize] = useState(() => getViewportSize()); + + useEffect(() => { + if (typeof window === "undefined") return undefined; + + const handleResize = () => setViewportSize(getViewportSize()); + + window.addEventListener("resize", handleResize); + window.addEventListener("orientationchange", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + window.removeEventListener("orientationchange", handleResize); + }; + }, []); + + return viewportSize; +} 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/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..885c85c 100644 --- a/ss-messebau-configurator/src/styles.css +++ b/ss-messebau-configurator/src/styles.css @@ -4,25 +4,111 @@ box-sizing: border-box; } +:root { + --mobile-header-height: 64px; +} + html, body, #root { margin: 0; padding: 0; height: 100%; + min-height: 100%; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #020617; 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-drawer { + display: none; +} + /* SIDEBAR */ .sidebar { @@ -35,7 +121,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 +368,229 @@ 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; + padding-top: var(--mobile-header-height); } - .sidebar { + .app-sidebar { + display: none; + } + + html, + body, + #root, + .app-root, + .app-main { + height: 100%; + min-height: 100vh; + } + + .app-header { + position: fixed; + inset: 0 0 auto 0; + height: var(--mobile-header-height); + z-index: 10; + } + + .app-canvas-area { + width: 100vw; + height: calc(100vh - var(--mobile-header-height)); + overflow: hidden; + } + + .mobile-drawer { + position: fixed; + inset: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 30; + display: block; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + + .mobile-drawer.is-open { + opacity: 1; + pointer-events: auto; + } + + .mobile-drawer-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + } + + .mobile-drawer-content { + position: absolute; + left: 0; + right: 0; + bottom: 0; + max-height: 85vh; + background: rgba(15, 23, 42, 0.98); + border-radius: 14px 14px 0 0; + border: 1px solid #1f2937; + box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.55); + overflow: hidden; + display: flex; + flex-direction: column; + } + + .mobile-drawer .sidebar { width: 100%; - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6); + height: auto; + box-shadow: none; + border-right: 0; + } + + .mobile-drawer-header { + display: flex; + justify-content: flex-end; + padding: 10px 14px 0; + } + + .mobile-drawer-close { + background: #020617; + color: #e5e7eb; + border: 1px solid #1f2937; + border-radius: 999px; + padding: 6px 12px; + font-weight: 600; + cursor: pointer; + } + + .mobile-drawer-close:hover { + background: #0b1220; + border-color: #374151; + } + + .mobile-drawer-body { + padding: 10px 12px 12px; + overflow-y: auto; + height: 100%; + } +} + +@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-drawer-content { + max-width: 100%; + overflow-x: hidden; + } + + .mobile-drawer-body { + padding: 12px 14px 14px; + overflow-x: hidden; + } + + .mobile-drawer .sidebar { + max-width: 100%; + } + + .sidebar { + 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-drawer-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; } }