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((prev) => !prev)}
+ >
+ Menü
+
+
+
+
+
+
+
+
+
+
+
+ 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 (
+
+
+
+
S&S 3D Standkonfigurator
+ Beta · intern
+
+
Richtkalkulation für System- & Individualstände
+
+
+ {/* Presets */}
+
+
+ Schnellstart
+ Typische Standgrößen
+
+
+ applyPreset("small")}
+ >
+ 9 m²
+ 3×3 · Reihenstand
+
+ applyPreset("medium")}
+ >
+ 24 m²
+ 6×4 · Eckstand
+
+ applyPreset("premium")}
+ >
+ 40 m²
+ 8×5 · Kopfstand Premium
+
+
+
+ replaceConfig(collisionPlayground)}
+ title="Lädt den Mock-Stand mit eng stehenden Modulen, um Kollisionen zu testen"
+ >
+ Kollisions-Playground
+ Mock-Stand mit vielen Objekten
+
+
+
+
+
+
+ Kollisionsschutz
+ AABB + Mindestabstand
+
+
+ Bewegte Objekte (Tresen, Screens, Kabine, Truss-Griff) prallen an einem
+ AABB-Sicherheitsabstand ab. Bei drohender Überschneidung erscheint ein
+ roter Wireframe + Hinweis. Der Mindestabstand lässt sich über
+ modules.collisionClearance im Store konfigurieren
+ (Playground: 0,25 m).
+
+
+
+ {/* Grunddaten */}
+
+
+ {/* Module */}
+
+
+ Module
+
+ Boden, Wände, LED, Counter, Screens, Licht
+
+
+
+
+ {/* Boden */}
+
+ Bodenbelag
+ {
+ const type = e.target.value as
+ | "carpet"
+ | "laminate"
+ | "vinyl"
+ | "wood";
+ patchModules({
+ floor: {
+ ...(config.modules.floor ?? {}),
+ type,
+ raised: floorRaised,
+ },
+ });
+ }}
+ >
+ Teppich
+ Laminat
+ Vinyl
+ Holz
+
+
+
+ {/* Wände – feste Logik */}
+
+ Geschlossene Seiten
+
+
+ {config.type === "row" &&
+ "Reihenstand: 3 geschlossene Seiten (Rückwand + 2 Seitenwände)."}
+ {config.type === "corner" &&
+ "Eckstand: 2 geschlossene Seiten (Rückwand + eine Seitenwand)."}
+ {config.type === "head" &&
+ "Kopfstand: 1 geschlossene Rückwand, Seiten offen."}
+ {config.type === "island" &&
+ "Inselstand: keine festen Wände, rundum offen."}
+
+
+
+ {/* Wand-Design + Wandstrahler */}
+ {config.modules.wallsClosedSides >= 1 && (
+ <>
+
+ Wanddesign Rückwand
+
+ updateWallSurface(
+ "back",
+ e.target.value as "system" | "wood" | "banner" | "seg" | "led"
+ )
+ }
+ >
+ Systemwand (weiß)
+ Holzwand
+ Bannerfläche
+ SEG / Textilrahmen
+ LED-Wand
+
+
+
+ Strahler Rückwand
+
+ patchModules({
+ wallLightsBack: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+ >
+ )}
+
+ {config.modules.wallsClosedSides >= 2 && (
+ <>
+
+ Wanddesign linke Wand
+
+ updateWallSurface(
+ "left",
+ e.target.value as "system" | "wood" | "banner" | "seg" | "led"
+ )
+ }
+ >
+ Systemwand (weiß)
+ Holzwand
+ Bannerfläche
+ SEG / Textilrahmen
+ LED-Wand
+
+
+
+ Strahler linke Wand
+
+ patchModules({
+ wallLightsLeft: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+ >
+ )}
+
+ {config.modules.wallsClosedSides >= 3 && (
+ <>
+
+ Wanddesign rechte Wand
+
+ updateWallSurface(
+ "right",
+ e.target.value as "system" | "wood" | "banner" | "seg" | "led"
+ )
+ }
+ >
+ Systemwand (weiß)
+ Holzwand
+ Bannerfläche
+ SEG / Textilrahmen
+ LED-Wand
+
+
+
+ Strahler rechte Wand
+
+ patchModules({
+ wallLightsRight: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+ >
+ )}
+
+ {/* Lagerraum */}
+
+ patchModules({ storageRoom: e.target.checked })}
+ />
+ Lagerraum (Kabine)
+
+
+ {config.modules.storageRoom && (
+
+ Tür Position Lagerraum
+
+ patchModules({
+ storageDoorSide: e.target.value as any,
+ })
+ }
+ >
+ Front
+ Rückwand
+ Links
+ Rechts
+
+
+ )}
+
+ {/* LED-Rahmen */}
+
+ LED-Rahmen
+
+ stepModule("ledFrames", -1, 0)}
+ >
+ –
+
+ {config.modules.ledFrames}
+ stepModule("ledFrames", 1, 0)}
+ >
+ +
+
+
+
+
+ {config.modules.ledFrames > 0 && (
+
+ LED-Rahmen an Wand
+
+ patchModules({
+ ledWall: e.target.value as "back" | "left" | "right",
+ })
+ }
+ >
+ Rückwand
+ Linke Wand
+ Rechte Wand
+
+
+ )}
+
+ {/* Counter */}
+
+ Counter / Infotresen
+
+
+
+ stepModule("counters", -1, 0)}
+ >
+ –
+
+ {config.modules.counters}
+ stepModule("counters", 1, 0, 3)}
+ >
+ +
+
+
+
+
+
+
+ patchModules({ countersWithPower: e.target.checked })
+ }
+ />
+ Strom / Steckdosen am Counter
+
+
+
+
+
+ {config.modules.counters > 0 && (
+
+ Counter an Wand
+
+ patchModules({
+ countersWall: e.target.value as any,
+ })
+ }
+ >
+ Front
+ Rückwand
+ Linke Wand
+ Rechte Wand
+
+
+ )}
+
+ {/* Screens */}
+
+ Screens / Monitore
+
+
+ stepModule("screens", -1, 0)}
+ >
+ –
+
+ {config.modules.screens}
+ stepModule("screens", 1, 0)}
+ >
+ +
+
+
+
+
+
+ {config.modules.screens > 0 && (
+
+ Screens an Wand
+
+ patchModules({
+ screensWall: e.target.value as "back" | "left" | "right",
+ })
+ }
+ >
+ Rückwand
+ Linke Wand
+ Rechte Wand
+
+
+ )}
+
+ {/* Truss & Banner */}
+
+ patchModules({ truss: e.target.checked })}
+ />
+ Traversen-Hängepunkte (Truss)
+
+
+ {config.modules.truss && (
+ <>
+
+ Lampentyp Truss
+
+ patchModules({ trussLightType: e.target.value } as any)
+ }
+ >
+ Spots
+ Fluter / Wash
+
+
+
+ {/* Truss-Höhe */}
+
+ Truss-Höhe (m)
+
+ patchModules({ trussHeight: Number(e.target.value) || 0 } as any)
+ }
+ />
+
+ Höhe der Traverse (Mitte) über Boden. Standard:
+ Wandhöhe + 0,5 m.
+
+
+
+
+ Lampen Truss – Front
+
+ patchModules({
+ trussLightsFront: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+
+ Lampen Truss – Back
+
+ patchModules({
+ trussLightsBack: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+
+ Lampen Truss – Links
+
+ patchModules({
+ trussLightsLeft: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+
+ Lampen Truss – Rechts
+
+ patchModules({
+ trussLightsRight: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+
+ {/* Banner an der Truss */}
+
+ Bannerrahmen Breite (m)
+
+ patchModules({
+ trussBannerWidth: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+
+ Bannerrahmen Höhe (m)
+
+ patchModules({
+ trussBannerHeight: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+
+
+ Bannerrahmen Front
+
+ patchModules({
+ trussBannersFront: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+
+ Bannerrahmen Back
+
+ patchModules({
+ trussBannersBack: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+
+ Bannerrahmen Links
+
+ patchModules({
+ trussBannersLeft: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+
+ Bannerrahmen Rechts
+
+ patchModules({
+ trussBannersRight: Number(e.target.value) || 0,
+ } as any)
+ }
+ />
+
+ >
+ )}
+
+
+
+ {/* Preise */}
+
+
+ Preisindikator
+ Brutto
+
+
+
+
+ {price.toLocaleString("de-DE")} €
+
+
+ Richtwert inkl. MwSt.
+
+
+
+ Systembau
+ Richtpreis
+ + Individualmodule
+
+
+
+ {/* Export / Anfrage */}
+
+
+ );
+}
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 (
+
+
+
+
+
+ Schließen
+
+
+
{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 (
-
-
-
-
S&S 3D Standkonfigurator
- Beta · intern
-
-
Richtkalkulation für System- & Individualstände
-
-
- {/* Presets */}
-
-
- Schnellstart
- Typische Standgrößen
-
-
- applyPreset("small")}
- >
- 9 m²
- 3×3 · Reihenstand
-
- applyPreset("medium")}
- >
- 24 m²
- 6×4 · Eckstand
-
- applyPreset("premium")}
- >
- 40 m²
- 8×5 · Kopfstand Premium
-
-
-
-
- {/* Grunddaten */}
-
-
- {/* Module */}
-
-
- Module
-
- Boden, Wände, LED, Counter, Screens, Licht
-
-
-
-
- {/* Boden */}
-
- Bodenbelag
- {
- const type = e.target.value as
- | "carpet"
- | "laminate"
- | "vinyl"
- | "wood";
- patchModules({
- floor: {
- ...(config.modules.floor ?? {}),
- type,
- raised: floorRaised,
- },
- });
- }}
- >
- Teppich
- Laminat
- Vinyl
- Holz
-
-
-
- {/* Wände – feste Logik */}
-
- Geschlossene Seiten
-
-
- {config.type === "row" &&
- "Reihenstand: 3 geschlossene Seiten (Rückwand + 2 Seitenwände)."}
- {config.type === "corner" &&
- "Eckstand: 2 geschlossene Seiten (Rückwand + eine Seitenwand)."}
- {config.type === "head" &&
- "Kopfstand: 1 geschlossene Rückwand, Seiten offen."}
- {config.type === "island" &&
- "Inselstand: keine festen Wände, rundum offen."}
-
-
-
- {/* Wand-Design + Wandstrahler */}
- {config.modules.wallsClosedSides >= 1 && (
- <>
-
- Wanddesign Rückwand
-
- updateWallSurface(
- "back",
- e.target.value as "system" | "wood" | "banner" | "seg" | "led"
- )
- }
- >
- Systemwand (weiß)
- Holzwand
- Bannerfläche
- SEG / Textilrahmen
- LED-Wand
-
-
-
- Strahler Rückwand
-
- patchModules({
- wallLightsBack: Number(e.target.value) || 0,
- } as any)
- }
- />
-
- >
- )}
-
- {config.modules.wallsClosedSides >= 2 && (
- <>
-
- Wanddesign linke Wand
-
- updateWallSurface(
- "left",
- e.target.value as "system" | "wood" | "banner" | "seg" | "led"
- )
- }
- >
- Systemwand (weiß)
- Holzwand
- Bannerfläche
- SEG / Textilrahmen
- LED-Wand
-
-
-
- Strahler linke Wand
-
- patchModules({
- wallLightsLeft: Number(e.target.value) || 0,
- } as any)
- }
- />
-
- >
- )}
-
- {config.modules.wallsClosedSides >= 3 && (
- <>
-
- Wanddesign rechte Wand
-
- updateWallSurface(
- "right",
- e.target.value as "system" | "wood" | "banner" | "seg" | "led"
- )
- }
- >
- Systemwand (weiß)
- Holzwand
- Bannerfläche
- SEG / Textilrahmen
- LED-Wand
-
-
-
- Strahler rechte Wand
-
- patchModules({
- wallLightsRight: Number(e.target.value) || 0,
- } as any)
- }
- />
-
- >
- )}
-
- {/* Lagerraum */}
-
- patchModules({ storageRoom: e.target.checked })}
- />
- Lagerraum / Kabine
-
-
- {config.modules.storageRoom && (
- <>
- {/* Kabine – Maße */}
-
- Kabine Breite (m)
-
- patchModules({
- cabin: { width: Number(e.target.value) || 0 } as any,
- })
- }
- />
-
-
- Kabine Tiefe (m)
-
- patchModules({
- cabin: { depth: Number(e.target.value) || 0 } as any,
- })
- }
- />
-
-
- {/* Kabine – Position */}
-
- Kabine X‑Position (m)
-
- patchModules({
- cabin: { position: { x: Number(e.target.value) } } as any,
- })
- }
- />
-
-
- Kabine Z‑Position (m)
-
- patchModules({
- cabin: { position: { z: Number(e.target.value) } } as any,
- })
- }
- />
-
-
-
- Türposition Lagerraum
-
- patchModules({
- storageDoorSide: e.target.value as "front" | "left" | "right",
- })
- }
- >
- Front
- Links
- Rechts
-
-
- >
- )}
-
- {/* LED-Rahmen */}
-
- LED-Rahmen
-
-
-
- patchModules({ ledFrames: Number(e.target.value) || 0 })
- }
- />
-
- stepModule("ledFrames", -1, 0)}
- >
- –
-
- stepModule("ledFrames", 1, 0)}
- >
- +
-
-
-
-
-
-
- {config.modules.ledFrames > 0 && (
-
- LED-Rahmen an Wand
-
- patchModules({
- ledWall: e.target.value as "back" | "left" | "right",
- })
- }
- >
- Rückwand
- Linke Wand
- Rechte Wand
-
-
- )}
-
- {/* Counters / Theken */}
-
- Counters / Theken
-
-
-
- patchModules({ counters: Number(e.target.value) || 0 })
- }
- />
-
- stepModule("counters", -1, 0)}
- >
- –
-
- stepModule("counters", 1, 0)}
- >
- +
-
-
-
-
-
-
- {config.modules.counters > 0 && (
- <>
-
- Counter-Position
-
- patchModules({
- countersWall: e.target.value as "front" | "island",
- })
- }
- >
- Front (Besucherkante)
- Insel (Mitte)
-
-
-
-
- Counter-Design
-
- patchModules({
- counterVariant: e.target.value as "basic" | "premium" | "corner",
- })
- }
- >
- Basic Tresen
- Premium Tresen
- Eck-Tresen (L-Form)
-
-
-
-
-
- patchModules({ countersWithPower: e.target.checked })
- }
- />
- Tresen mit Strompaket
-
- >
- )}
-
- {/* Screens / Monitore */}
-
- Screens / Monitore
-
-
-
- patchModules({ screens: Number(e.target.value) || 0 })
- }
- />
-
- stepModule("screens", -1, 0)}
- >
- –
-
- stepModule("screens", 1, 0)}
- >
- +
-
-
-
-
-
-
- {config.modules.screens > 0 && (
-
- Screens an Wand
-
- patchModules({
- screensWall: e.target.value as "back" | "left" | "right",
- })
- }
- >
- Rückwand
- Linke Wand
- Rechte Wand
-
-
- )}
-
- {/* Truss & Banner */}
-
- patchModules({ truss: e.target.checked })}
- />
- Traversen-Hängepunkte (Truss)
-
-
- {config.modules.truss && (
- <>
-
- Lampentyp Truss
-
- patchModules({ trussLightType: e.target.value } as any)
- }
- >
- Spots
- Fluter / Wash
-
-
-
- {/* Truss-Höhe */}
-
- Truss-Höhe (m)
-
- patchModules({ trussHeight: Number(e.target.value) || 0 } as any)
- }
- />
-
- Höhe der Traverse (Mitte) über Boden. Standard:
- Wandhöhe + 0,5 m.
-
-
-
-
- Lampen Truss – Front
-
- patchModules({
- trussLightsFront: Number(e.target.value) || 0,
- } as any)
- }
- />
-
-
- Lampen Truss – Back
-
- patchModules({
- trussLightsBack: Number(e.target.value) || 0,
- } as any)
- }
- />
-
-
- Lampen Truss – Links
-
- patchModules({
- trussLightsLeft: Number(e.target.value) || 0,
- } as any)
- }
- />
-
-
- Lampen Truss – Rechts
-
- patchModules({
- trussLightsRight: Number(e.target.value) || 0,
- } as any)
- }
- />
-
-
-
- Banner-Breite (m)
-
- patchModules({
- trussBannerWidth: Number(e.target.value) || 0,
- } as any)
- }
- />
-
-
- Banner-Höhe (m)
-
- patchModules({
- trussBannerHeight: Number(e.target.value) || 0,
- } as any)
- }
- />
-
-
-
- Bannerrahmen – Front
-
- patchModules({
- trussBannersFront: Number(e.target.value) || 0,
- } as any)
- }
- />
-
-
- Bannerrahmen – Back
-
- patchModules({
- trussBannersBack: Number(e.target.value) || 0,
- } as any)
- }
- />
-
-
- Bannerrahmen – Links
-
- patchModules({
- trussBannersLeft: Number(e.target.value) || 0,
- } as any)
- }
- />
-
-
- Bannerrahmen – Rechts
-
- patchModules({
- trussBannersRight: Number(e.target.value) || 0,
- } as any)
- }
- />
-
-
-
- Banner-Bild (optional)
- {
- const file = e.target.files?.[0];
- if (!file) return;
- const url = URL.createObjectURL(file);
- patchModules({ trussBannerImageUrl: url } as any);
- }}
- />
-
- Bild wird lokal im Browser geladen (kein Upload zum Server).
-
-
- >
- )}
-
- {/* Doppelboden */}
-
-
- patchModules({
- raisedFloor: e.target.checked,
- floor: {
- ...(config.modules.floor ?? { type: floorType }),
- raised: e.target.checked,
- },
- })
- }
- />
- Doppelboden
-
-
-
-
- {/* Preisbox */}
-
-
Unverbindliche Richtkalkulation
-
- {price.toLocaleString("de-DE")} €
- Projektpreis inkl. Aufbau
-
-
- Endgültige Preise je nach Messe, Technik und Detailumfang.
-
-
-
-
- Konfiguration kopieren
-
-
- {/* Anfrageformular */}
-
-
- );
-}
+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;
}
}