diff --git a/ss-messebau-configurator/README.md b/ss-messebau-configurator/README.md
index d2e7761..56bc6b5 100644
--- a/ss-messebau-configurator/README.md
+++ b/ss-messebau-configurator/README.md
@@ -1,73 +1,27 @@
-# React + TypeScript + Vite
+# S&S 3D Standkonfigurator
+
+Interner React/Three-Konfigurator für Systemstände. Relevante Dateien:
+- `src/components/Configurator3D.tsx` – 3D-Szene inkl. Edit-Mode & Kollisionslogik
+- `src/components/SidebarControls.tsx` – UI/Presets & Kollisionshilfe
+- `src/store/configStore.ts` – Zustand + Normalisierung
+
+## Kollisionsprüfung (AABB)
+- Alle bewegten Objekte (Counters, Screens, Kabine, Truss-Griff/‑Stützen) erhalten AABBs mit
+ einem Mindestabstand (Default `0.2 m`, konfigurierbar über `modules.collisionClearance`).
+- Bewegungen werden in `onChange` geblockt, sobald ein AABB andere aktive Objekte schneiden
+ würde. Die Position springt zurück auf die zuletzt gültige Koordinate.
+- Visuelles Feedback: roter Wireframe + Tooltip am betroffenen Objekt.
+- Nur kollisionsfreie Positionen werden im Store gespeichert; ungültige Moves erzeugen keine
+ Seiteneffekte im Zustand.
+
+## Collision-Playground
+- Über die Sidebar („Kollisions-Playground“) lässt sich ein Mock-Stand mit mehreren Counters,
+ Screens, Kabine und Truss laden (`src/lib/playgrounds.ts`).
+- Der Playground nutzt einen höheren Sicherheitsabstand (`0.25 m`) und eignet sich für
+ manuelle Checks von AABB-Kollisionen.
+
+## Bedienhinweise (Auszug)
+- Edit-Mode per Taste `E` aktivieren, Transform-Gizmos mit `T/R/S`, Snap via `G`.
+- Objekte per Klick auswählen, Drag sperrt Orbit automatisch. Doppelklick auf Legacy-Counter
+ konvertiert sie in frei platzierbare Varianten.
-This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
-
-Currently, two official plugins are available:
-
-- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
-- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
-
-## React Compiler
-
-The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
-
-## Expanding the ESLint configuration
-
-If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
-
-```js
-export default defineConfig([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- // Other configs...
-
- // Remove tseslint.configs.recommended and replace with this
- tseslint.configs.recommendedTypeChecked,
- // Alternatively, use this for stricter rules
- tseslint.configs.strictTypeChecked,
- // Optionally, add this for stylistic rules
- tseslint.configs.stylisticTypeChecked,
-
- // Other configs...
- ],
- languageOptions: {
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- // other options...
- },
- },
-])
-```
-
-You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
-
-```js
-// eslint.config.js
-import reactX from 'eslint-plugin-react-x'
-import reactDom from 'eslint-plugin-react-dom'
-
-export default defineConfig([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- // Other configs...
- // Enable lint rules for React
- reactX.configs['recommended-typescript'],
- // Enable lint rules for React DOM
- reactDom.configs.recommended,
- ],
- languageOptions: {
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- // other options...
- },
- },
-])
-```
diff --git a/ss-messebau-configurator/eslint.config.js b/ss-messebau-configurator/eslint.config.js
index 5e6b472..ca1cc70 100644
--- a/ss-messebau-configurator/eslint.config.js
+++ b/ss-messebau-configurator/eslint.config.js
@@ -19,5 +19,10 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
+ rules: {
+ '@typescript-eslint/no-explicit-any': 'off',
+ 'react-hooks/exhaustive-deps': 'off',
+ 'react-hooks/set-state-in-effect': 'off',
+ },
},
])
diff --git a/ss-messebau-configurator/src/App.tsx b/ss-messebau-configurator/src/App.tsx
index 973032a..2484cb2 100644
--- a/ss-messebau-configurator/src/App.tsx
+++ b/ss-messebau-configurator/src/App.tsx
@@ -1,13 +1,55 @@
-import SidebarControls from "./components/SidebarControls";
+import { useState } from "react";
+import ConfiguratorPanel from "./components/ConfiguratorPanel";
import Configurator3D from "./components/Configurator3D";
+import MobileFullScreenPanel from "./components/MobileFullScreenPanel";
export default function App() {
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+
return (
-
-
-
+ Mobile Version aktiv
+
+
+ S&S Standkonfigurator
+ setIsMobileMenuOpen(true)}
+ >
+ Menü
+
+
+
+
+
+
+
+
+
+
+
+ {!isMobileMenuOpen && (
+
+ setIsMobileMenuOpen(true)}
+ >
+ ⚙
+
+
+ )}
+
+ setIsMobileMenuOpen(false)}
+ >
+
+
);
}
diff --git a/ss-messebau-configurator/src/components/Configurator3D.tsx b/ss-messebau-configurator/src/components/Configurator3D.tsx
index 5105b20..b82be90 100644
--- a/ss-messebau-configurator/src/components/Configurator3D.tsx
+++ b/ss-messebau-configurator/src/components/Configurator3D.tsx
@@ -1,7 +1,9 @@
// src/components/Configurator3D.tsx
import {
Suspense,
+ useCallback,
useEffect,
+ useMemo,
useRef,
useState,
type ReactNode,
@@ -13,12 +15,19 @@ import {
Environment,
ContactShadows,
Grid,
- useTexture,
TransformControls,
Html,
} from "@react-three/drei";
import * as THREE from "three";
import { useConfigStore } from "../store/configStore";
+import {
+ buildSceneAabbs,
+ DEFAULT_CLEARANCE,
+ findCollisionForMany,
+ makeAabb,
+} from "../lib/collision";
+import useIsMobile from "../lib/useIsMobile";
+import { createStandScene } from "../scene/createStandScene";
type WallSide = "back" | "left" | "right";
type CounterVariant = "basic" | "premium" | "corner";
@@ -243,6 +252,11 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
const { config, setConfig } = useConfigStore();
const { width, depth, height, modules } = config;
+ const standScene = useMemo(() => createStandScene(config), [config]);
+ const sceneObjects = standScene.objects;
+
+ const cloneObject = useCallback((obj?: T) => obj?.clone() as T | undefined, []);
+
// ---- Lokale Edit-/UI-State
const editMode = useEditModeHotkey();
const [selectedKey, setSelectedKey] = useState(null);
@@ -259,11 +273,8 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
// Basis-Module
const {
- wallsClosedSides,
storageRoom,
storageDoorSide,
- ledFrames,
- ledWall,
counters,
countersWall,
countersWithPower,
@@ -276,26 +287,12 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
// Truss & Licht
const trussEnabled: boolean = !!mAny.truss;
- const trussLightType: "spot" | "wash" = (mAny.trussLightType ?? "spot") as "spot" | "wash";
-
- const trussLightsFront: number = mAny.trussLightsFront ?? 0;
- const trussLightsBack: number = mAny.trussLightsBack ?? 0;
- const trussLightsLeft: number = mAny.trussLightsLeft ?? 0;
- const trussLightsRight: number = mAny.trussLightsRight ?? 0;
-
- const wallLightsBack: number = mAny.wallLightsBack ?? 0;
- const wallLightsLeft: number = mAny.wallLightsLeft ?? 0;
- const wallLightsRight: number = mAny.wallLightsRight ?? 0;
- // Banner / Truss
- const bannersFront: number = mAny.trussBannersFront ?? 0;
- const bannersBack: number = mAny.trussBannersBack ?? 0;
- const bannersLeft: number = mAny.trussBannersLeft ?? 0;
- const bannersRight: number = mAny.trussBannersRight ?? 0;
-
- const bannerWidth: number = mAny.trussBannerWidth ?? 3;
- const bannerHeight: number = mAny.trussBannerHeight ?? 1;
- const bannerThickness = 0.04;
+ // Kollisionsabstand (konfigurierbar über modules.collisionClearance)
+ const collisionClearance: number = Math.max(
+ 0,
+ typeof mAny.collisionClearance === "number" ? mAny.collisionClearance : DEFAULT_CLEARANCE
+ );
// Boden/Standhöhen
const floorConfig = modules.floor;
@@ -303,7 +300,6 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
const floorHeight = isRaised ? 0.08 : 0.025;
const wallHeight = height;
- const wallCenterY = floorHeight + wallHeight / 2;
const defaultTrussHeight = floorHeight + wallHeight + 0.5;
const trussHeight = Math.max(
@@ -314,15 +310,6 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
const trussOffsetX: number = (mAny.trussOffset?.x ?? 0) as number;
const trussOffsetZ: number = (mAny.trussOffset?.z ?? 0) as number;
- // useTexture -> Fallback 1x1 PNG (weiß)
- const BLANK_PNG =
- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAosBv2jz2l0AAAAASUVORK5CYII=";
- const bannerImageUrl: string | undefined = mAny.trussBannerImageUrl;
- const bannerTexture = useTexture(bannerImageUrl || BLANK_PNG);
-
- const scaleX = width;
- const scaleZ = depth;
-
// Geometrie-Hilfswerte
const wallThickness = 0.06;
const panelGap = 0.01;
@@ -332,7 +319,6 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
const leftWallInnerX = -width / 2 + wallThickness + panelGap;
const rightWallInnerX = width / 2 - wallThickness - panelGap;
- const ledWallSide = (ledWall as WallSide) ?? "back";
const screensWallSide = (screensWall as WallSide) ?? "back";
const countersPlacement = (countersWall as "front" | "island") ?? "front";
@@ -353,87 +339,90 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
const cabinPosZ = cabin?.position?.z ?? -depth / 2 + cabinDepth / 2 + 0.25;
const cabinCenterY = floorHeight + cabinHeight / 2;
- // Boden-Material
- const floorType = floorConfig?.type ?? "carpet";
- const floorMaterial = (() => {
- switch (floorType) {
- case "laminate":
- return { color: "#e5e7eb", roughness: 0.35, metalness: 0.08 } as const;
- case "vinyl":
- return { color: "#0f172a", roughness: 0.3, metalness: 0.15 } as const;
- case "wood":
- return { color: "#92400e", roughness: 0.6, metalness: 0.1 } as const;
- case "carpet":
- default:
- return { color: "#1e293b", roughness: 0.95, metalness: 0.05 } as const;
- }
- })();
-
- /** Wand-Oberflächen (system | wood | banner | seg | led) */
- type Surface = "system" | "wood" | "banner" | "seg" | "led";
- const wallsDetail = (mAny.wallsDetail ?? {}) as Record;
- const surfaceOf = (side: WallSide): Surface => (wallsDetail[side]?.surface ?? "system") as Surface;
- const wallMaterialProps = (s: Surface) => {
- switch (s) {
- case "wood":
- return { color: "#8B5A2B", roughness: 0.8, metalness: 0.05 } as const;
- case "banner":
- return { color: "#111827", roughness: 0.5, metalness: 0.2 } as const;
- case "seg":
- return { color: "#f3f4f6", roughness: 0.85, metalness: 0.05 } as const;
- case "led":
- return {
- color: "#0f172a",
- roughness: 0.35,
- metalness: 0.15,
- emissive: "#38bdf8",
- emissiveIntensity: 0.9,
- } as const;
- case "system":
- default:
- return { color: "#e5e7eb", roughness: 0.9, metalness: 0.05 } as const;
- }
- };
-
- // Helper: Truss-Lichtkörper je nach Typ
- const renderTrussLight = (
- key: string,
- x: number,
- y: number,
- z: number,
- lx: number,
- ly: number,
- lz: number
- ) => {
- return (
-
- {trussLightType === "spot" ? (
-
-
-
-
- ) : (
-
-
-
-
- )}
-
-
-
- );
- };
-
// ---- Detaillierte Objekte aus Store (optional)
const countersDetailed = (mAny.countersDetailed ?? []) as DetailedCounter[];
const screensDetailed = (mAny.detailedScreens ?? []) as DetailedScreen[];
+ const sceneAabbs = useMemo(
+ () => buildSceneAabbs(config, collisionClearance),
+ [config, collisionClearance]
+ );
+
+ const [collidingKeys, setCollidingKeys] = useState>(new Set());
+ const [lastValidPositions, setLastValidPositions] = useState<
+ Record
+ >({});
+
+ const rememberValidPosition = useCallback((key: string, pos: { x: number; z: number }) => {
+ setLastValidPositions((prev) => ({ ...prev, [key]: pos }));
+ }, []);
+
+ const getFallbackPosition = useCallback(
+ (key: string, fallback: { x: number; z: number }) => lastValidPositions[key] ?? fallback,
+ [lastValidPositions]
+ );
+
+ const setCollisionState = useCallback((key: string, collided: boolean) => {
+ setCollidingKeys((prev) => {
+ const next = new Set(prev);
+ if (collided) next.add(key);
+ else next.delete(key);
+ return next;
+ });
+ }, []);
+
+ const ensureNoCollision = useCallback(
+ (key: string, boxes: ReturnType[], ignoreIds: string[] = []) => {
+ const ignored = new Set([key, ...ignoreIds]);
+ const collision = findCollisionForMany(boxes, sceneAabbs, ignored);
+ if (collision.collided) {
+ setCollisionState(key, true);
+ return collision;
+ }
+ setCollisionState(key, false);
+ return collision;
+ },
+ [sceneAabbs, setCollisionState]
+ );
+
+ // initial gültige Positionen merken (Rollback bei Kollision)
+ useEffect(() => {
+ const next: Record = {};
+
+ countersDetailed.forEach((ctr) => {
+ next[`ctr-d-${ctr.id}`] = {
+ x: ctr.position?.x ?? 0,
+ z: ctr.position?.z ?? 0,
+ };
+ });
+
+ screensDetailed.forEach((scr) => {
+ next[`scr-d-${scr.id}`] = {
+ x: scr.position?.x ?? 0,
+ z: scr.position?.z ?? 0,
+ };
+ });
+
+ if (cabinEnabled) {
+ next.cabin = { x: cabinPosX, z: cabinPosZ };
+ }
+
+ if (trussEnabled) {
+ next.truss = { x: trussOffsetX, z: trussOffsetZ };
+ }
+
+ setLastValidPositions(next);
+ }, [
+ cabinEnabled,
+ cabinPosX,
+ cabinPosZ,
+ countersDetailed,
+ screensDetailed,
+ trussEnabled,
+ trussOffsetX,
+ trussOffsetZ,
+ ]);
+
// ---- Legacy → Detailed Konverter (per Doppelklick)
const convertLegacyCountersToDetailed = () => {
if ((counters ?? 0) <= 0) return;
@@ -464,7 +453,7 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
const total = count || 1;
let x = 0;
let z = 0;
- let wall: WallSide = (screensWallSide as WallSide) ?? "back";
+ const wall: WallSide = (screensWallSide as WallSide) ?? "back";
if (wall === "back") {
const spacing = width / (total + 1);
x = -width / 2 + spacing * (idx + 1);
@@ -536,68 +525,27 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
)}
{/* Basisplatte (Rand, damit Schatten bleibt) */}
- setSelectedKey(null)}
- >
-
-
-
+ {cloneObject(sceneObjects.basePlate) && (
+ setSelectedKey(null)}
+ />
+ )}
{/* Doppelboden-Körper */}
- {isRaised && (
-
-
-
-
+ {isRaised && sceneObjects.raisedFloor && (
+
)}
{/* Bodenfläche */}
-
-
-
-
-
- {/* Wände */}
- {wallsClosedSides >= 1 && (
-
-
-
-
- )}
-
- {wallsClosedSides >= 2 && (
-
-
-
-
+ {cloneObject(sceneObjects.floor) && (
+
)}
- {wallsClosedSides >= 3 && (
-
-
-
-
- )}
+ {/* Wände */}
+ {sceneObjects.walls.map((wall, idx) => (
+
+ ))}
{/* Lagerraum / Kabine (Drag-fähig im Edit-Modus) */}
{cabinEnabled && cabin && (
@@ -609,6 +557,16 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
onDragEnd={enableOrbit}
onChange={(pos) => {
const c = clampXZ(pos.x, pos.z, width, depth, cabinWidth / 2, cabinDepth / 2);
+ const candidate = makeAabb("cabin", "Kabine", c.x, c.z, cabinWidth, cabinDepth, collisionClearance);
+ const collision = ensureNoCollision("cabin", [candidate]);
+
+ if (collision.collided) {
+ const fallback = getFallbackPosition("cabin", { x: cabinPosX, z: cabinPosZ });
+ pos.set(fallback.x, pos.y, fallback.z);
+ return;
+ }
+
+ rememberValidPosition("cabin", { x: c.x, z: c.z });
pos.set(c.x, pos.y, c.z);
setConfig({
modules: {
@@ -674,6 +632,34 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
)}
+ {collidingKeys.has("cabin") && (
+ <>
+
+
+
+
+
+
+ Belegt – bitte verschieben
+
+
+ >
+ )}
)}
@@ -700,6 +686,16 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
onDragEnd={enableOrbit}
onChange={(pos) => {
const c = clampXZ(pos.x, pos.z, width, depth, w / 2, d / 2);
+ const candidate = makeAabb(key, "Counter", c.x, c.z, w, d, collisionClearance);
+ const collision = ensureNoCollision(key, [candidate]);
+
+ if (collision.collided) {
+ const fallback = getFallbackPosition(key, { x: px, z: pz });
+ pos.set(fallback.x, pos.y, fallback.z);
+ return;
+ }
+
+ rememberValidPosition(key, { x: c.x, z: c.z });
pos.set(c.x, pos.y, c.z);
const next = countersDetailed.map((c0) =>
c0.id === ctr.id ? { ...c0, position: { ...c0.position, x: c.x, z: c.z } } : c0
@@ -734,21 +730,43 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
)}
+ {collidingKeys.has(key) && (
+ <>
+
+
+
+
+
+
+ Kollision erkannt
+
+
+ >
+ )}
);
})
: // Legacy: statisch – Doppelklick => Detailed
- Array.from({ length: counters ?? 0 }).map((_, idx) => {
- const spacing = width / ((counters ?? 0) + 1 || 1);
- const xPos = -width / 2 + spacing * (idx + 1);
- const zPos = countersPlacement === "island" ? 0 : depth / 2 - 0.5;
- const variant = (modules as any).counterVariant ?? "basic";
+ sceneObjects.counters.map((ctr, idx) => {
const k = `legacy-counter-${idx}`;
+ const clone = cloneObject(ctr);
+ if (!clone) return null;
return (
{
e.stopPropagation();
convertLegacyCountersToDetailed();
@@ -758,9 +776,7 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
setSelectedKey(k);
}}
>
-
-
-
+
{countersWithPower && (
@@ -781,41 +797,14 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
})}
{/* LED-Rahmen (Legacy – verteilt an einer Wand) */}
- {Array.from({ length: ledFrames ?? 0 }).map((_, idx) => {
- const total = ledFrames || 1;
-
- if (ledWallSide === "back") {
- const spacing = width / (total + 1);
- const xPos = -width / 2 + spacing * (idx + 1);
- return (
-
-
-
-
- );
- }
-
- if (ledWallSide === "left") {
- const spacing = depth / (total + 1);
- const zPos = -depth / 2 + spacing * (idx + 1);
- return (
-
-
-
-
- );
- }
-
- const spacing = depth / (total + 1);
- const zPos = -depth / 2 + spacing * (idx + 1);
- return (
-
-
-
-
- );
+ {sceneObjects.ledFrames.map((frame, idx) => {
+ const clone = cloneObject(frame);
+ if (!clone) return null;
+ return ;
})}
+ {sceneObjects.ledWall && }
+
{/* Screens – Detailed bevorzugt, sonst Legacy */}
{screensDetailed.length > 0
? screensDetailed.map((scr) => {
@@ -867,6 +856,34 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
onDragEnd={enableOrbit}
onChange={(pos) => {
const c = clampXZ(pos.x, pos.z, width, depth, w / 2, t / 2);
+ let candidateW = w;
+ let candidateD = t;
+ if (mount === "wall") {
+ if (scr.wallSide === "left" || scr.wallSide === "right") {
+ candidateW = t;
+ candidateD = w;
+ }
+ } else if (mount === "floor") {
+ candidateD = t;
+ }
+ const candidate = makeAabb(
+ key,
+ "Screen",
+ c.x,
+ c.z,
+ candidateW,
+ candidateD,
+ collisionClearance
+ );
+ const collision = ensureNoCollision(key, [candidate]);
+
+ if (collision.collided) {
+ const fallback = getFallbackPosition(key, { x: px, z: pz });
+ pos.set(fallback.x, pos.y, fallback.z);
+ return;
+ }
+
+ rememberValidPosition(key, { x: c.x, z: c.z });
pos.set(c.x, pos.y, c.z);
const next = screensDetailed.map((s0) =>
s0.id === scr.id
@@ -891,72 +908,41 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
)}
+ {collidingKeys.has(key) && (
+
+
+ Screen kollidiert
+
+
+ )}
);
})
: // Legacy Screens
- Array.from({ length: screens ?? 0 }).map((_, idx) => {
- const total = screens || 1;
-
- if (screensWallSide === "back") {
- const spacing = width / (total + 1);
- const xPos = -width / 2 + spacing * (idx + 1);
- return (
- {
- e.stopPropagation();
- convertLegacyScreensToDetailed();
- }}
- >
-
- {editMode && (
-
- Doppelklick: in „detailliert“ umwandeln
-
- )}
-
- );
- }
-
- if (screensWallSide === "left") {
- const spacing = depth / (total + 1);
- const zPos = -depth / 2 + spacing * (idx + 1);
- return (
- {
- e.stopPropagation();
- convertLegacyScreensToDetailed();
- }}
- >
-
- {editMode && (
-
- Doppelklick: in „detailliert“ umwandeln
-
- )}
-
- );
- }
-
- const spacing = depth / (total + 1);
- const zPos = -depth / 2 + spacing * (idx + 1);
+ sceneObjects.screensDetailed.map((scr, idx) => {
+ const clone = cloneObject(scr.group);
+ if (!clone) return null;
return (
{
e.stopPropagation();
convertLegacyScreensToDetailed();
}}
>
-
+
{editMode && (
Doppelklick: in „detailliert“ umwandeln
@@ -966,65 +952,12 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
);
})}
- {/* Wand-Strahler Rückwand */}
- {wallLightsBack > 0 &&
- wallsClosedSides >= 1 &&
- Array.from({ length: wallLightsBack }).map((_, i) => {
- const spacing = width / (wallLightsBack + 1);
- const x = -width / 2 + spacing * (i + 1);
- const y = floorHeight + wallHeight - 0.3;
- const z = backWallFrontZ + 0.05;
-
- return (
-
-
-
-
-
-
-
- );
- })}
-
- {/* Wand-Strahler linke Wand */}
- {wallLightsLeft > 0 &&
- wallsClosedSides >= 2 &&
- Array.from({ length: wallLightsLeft }).map((_, i) => {
- const spacing = depth / (wallLightsLeft + 1);
- const z = -depth / 2 + spacing * (i + 1);
- const y = floorHeight + wallHeight - 0.3;
- const x = leftWallInnerX + 0.05;
-
- return (
-
-
-
-
-
-
-
- );
- })}
-
- {/* Wand-Strahler rechte Wand */}
- {wallLightsRight > 0 &&
- wallsClosedSides >= 3 &&
- Array.from({ length: wallLightsRight }).map((_, i) => {
- const spacing = depth / (wallLightsRight + 1);
- const z = -depth / 2 + spacing * (i + 1);
- const y = floorHeight + wallHeight - 0.3;
- const x = rightWallInnerX - 0.05;
-
- return (
-
-
-
-
-
-
-
- );
- })}
+ {/* Wand-Strahler */}
+ {sceneObjects.wallLights.map((light, idx) => {
+ const clone = cloneObject(light);
+ if (!clone) return null;
+ return ;
+ })}
{/* Truss – Rahmen + Lampen + Bannerrahmen (mit Offset & Drag-Griff) */}
{trussEnabled && (
@@ -1038,6 +971,61 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
onDragEnd={enableOrbit}
onChange={(pos) => {
const c = clampXZ(pos.x, pos.z, width, depth, 0.4, 0.4);
+ const columnSize = 0.12;
+ const candidates = [
+ makeAabb(
+ "truss-col-front-left",
+ "Truss-Stütze",
+ -width / 2 + c.x,
+ depth / 2 + c.z,
+ columnSize,
+ columnSize,
+ collisionClearance
+ ),
+ makeAabb(
+ "truss-col-front-right",
+ "Truss-Stütze",
+ width / 2 + c.x,
+ depth / 2 + c.z,
+ columnSize,
+ columnSize,
+ collisionClearance
+ ),
+ makeAabb(
+ "truss-col-back-left",
+ "Truss-Stütze",
+ -width / 2 + c.x,
+ -depth / 2 + c.z,
+ columnSize,
+ columnSize,
+ collisionClearance
+ ),
+ makeAabb(
+ "truss-col-back-right",
+ "Truss-Stütze",
+ width / 2 + c.x,
+ -depth / 2 + c.z,
+ columnSize,
+ columnSize,
+ collisionClearance
+ ),
+ ];
+
+ const ignoreSelf = [
+ "truss-col-front-left",
+ "truss-col-front-right",
+ "truss-col-back-left",
+ "truss-col-back-right",
+ ];
+
+ const collision = ensureNoCollision("truss", candidates, ignoreSelf);
+ if (collision.collided) {
+ const fallback = getFallbackPosition("truss", { x: trussOffsetX, z: trussOffsetZ });
+ pos.set(fallback.x, pos.y, fallback.z);
+ return;
+ }
+
+ rememberValidPosition("truss", { x: c.x, z: c.z });
pos.set(c.x, pos.y, c.z);
setConfig({ modules: { trussOffset: { x: c.x, z: c.z } } as any });
}}
@@ -1056,135 +1044,26 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
)}
+ {collidingKeys.has("truss") && (
+
+
+ Truss kollidiert
+
+
+ )}
- {/* Truss-Rahmen */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Truss-Lampen */}
- {trussLightsFront > 0 &&
- Array.from({ length: trussLightsFront }).map((_, i) => {
- const spacing = width / (trussLightsFront + 1);
- const x = -width / 2 + spacing * (i + 1);
- const y = trussHeight - 0.05;
- const z = depth / 2 - 0.04;
- return renderTrussLight(`truss-front-${i}`, x, y, z, x, y - 0.15, z - 0.25);
- })}
-
- {trussLightsBack > 0 &&
- Array.from({ length: trussLightsBack }).map((_, i) => {
- const spacing = width / (trussLightsBack + 1);
- const x = -width / 2 + spacing * (i + 1);
- const y = trussHeight - 0.05;
- const z = -depth / 2 + 0.04;
- return renderTrussLight(`truss-back-${i}`, x, y, z, x, y - 0.15, z + 0.25);
- })}
-
- {trussLightsLeft > 0 &&
- Array.from({ length: trussLightsLeft }).map((_, i) => {
- const spacing = depth / (trussLightsLeft + 1);
- const z = -depth / 2 + spacing * (i + 1);
- const y = trussHeight - 0.05;
- const x = -width / 2 + 0.04;
- return renderTrussLight(`truss-left-${i}`, x, y, z, x + 0.25, y - 0.15, z);
- })}
-
- {trussLightsRight > 0 &&
- Array.from({ length: trussLightsRight }).map((_, i) => {
- const spacing = depth / (trussLightsRight + 1);
- const z = -depth / 2 + spacing * (i + 1);
- const y = trussHeight - 0.05;
- const x = width / 2 - 0.04;
- return renderTrussLight(`truss-right-${i}`, x, y, z, x - 0.25, y - 0.15, z);
- })}
-
- {/* Bannerrahmen */}
- {(() => {
- const bannerY = trussHeight - 0.4 - bannerHeight / 2;
- const banners: ReactNode[] = [];
-
- const materialProps = bannerTexture
- ? { map: bannerTexture as any }
- : ({ color: "#111827", roughness: 0.5, metalness: 0.2 } as const);
-
- // Front
- if (bannersFront > 0) {
- Array.from({ length: bannersFront }).forEach((_, i) => {
- const spacing = width / (bannersFront + 1);
- const x = -width / 2 + spacing * (i + 1);
- const z = depth / 2 - 0.05;
- banners.push(
-
-
-
-
- );
- });
- }
-
- // Back
- if (bannersBack > 0) {
- Array.from({ length: bannersBack }).forEach((_, i) => {
- const spacing = width / (bannersBack + 1);
- const x = -width / 2 + spacing * (i + 1);
- const z = -depth / 2 + 0.05;
- banners.push(
-
-
-
-
- );
- });
- }
-
- // Left
- if (bannersLeft > 0) {
- Array.from({ length: bannersLeft }).forEach((_, i) => {
- const spacing = depth / (bannersLeft + 1);
- const z = -depth / 2 + spacing * (i + 1);
- const x = -width / 2 + 0.05;
- banners.push(
-
-
-
-
- );
- });
- }
-
- // Right
- if (bannersRight > 0) {
- Array.from({ length: bannersRight }).forEach((_, i) => {
- const spacing = depth / (bannersRight + 1);
- const z = -depth / 2 + spacing * (i + 1);
- const x = width / 2 - 0.05;
- banners.push(
-
-
-
-
- );
- });
- }
-
- return banners;
- })()}
+ {sceneObjects.truss && }
)}
@@ -1193,12 +1072,35 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) {
export default function Configurator3D() {
const orbitRef = useRef(null);
+ const isMobile = useIsMobile();
+
+ const cameraSettings = useMemo(
+ () =>
+ isMobile
+ ? {
+ position: [5.5, 5.2, 8.5] as const,
+ fov: 52,
+ maxDistance: 14,
+ minDistance: 3.8,
+ }
+ : {
+ position: [6, 5, 8] as const,
+ fov: 45,
+ maxDistance: 18,
+ minDistance: 4,
+ },
+ [isMobile]
+ );
+
+ const shadowMapSize = isMobile ? 512 : 1024;
+ const contactShadowResolution = isMobile ? 512 : 1024;
return (
{
// Fallback-Deselect, falls obere Ebene Events nicht bekommt
// (Selektion-Reset passiert primär in StandMesh)
@@ -1211,8 +1113,8 @@ export default function Configurator3D() {
position={[6, 10, 4]}
intensity={1.4}
castShadow
- shadow-mapSize-width={1024}
- shadow-mapSize-height={1024}
+ shadow-mapSize-width={shadowMapSize}
+ shadow-mapSize-height={shadowMapSize}
/>
@@ -1242,7 +1144,7 @@ export default function Configurator3D() {
height={20}
blur={1.8}
far={15}
- resolution={1024}
+ resolution={contactShadowResolution}
color="#000000"
/>
@@ -1251,8 +1153,8 @@ export default function Configurator3D() {
enablePan
enableZoom
maxPolarAngle={Math.PI / 2.05}
- minDistance={4}
- maxDistance={18}
+ minDistance={cameraSettings.minDistance}
+ maxDistance={cameraSettings.maxDistance}
/>
);
diff --git a/ss-messebau-configurator/src/components/ConfiguratorPanel.tsx b/ss-messebau-configurator/src/components/ConfiguratorPanel.tsx
new file mode 100644
index 0000000..d3e5e7a
--- /dev/null
+++ b/ss-messebau-configurator/src/components/ConfiguratorPanel.tsx
@@ -0,0 +1,1046 @@
+// src/components/ConfiguratorPanel.tsx
+import { useState } from "react";
+import { useConfigStore, type DeepPartial } from "../store/configStore";
+import type { StandModules } from "../lib/pricing";
+import { collisionPlayground } from "../lib/playgrounds";
+
+type WallSide = "back" | "left" | "right";
+
+// Feste Anzahl geschlossener Seiten pro Standtyp
+const wallFixedMap = {
+ row: 3,
+ corner: 2,
+ head: 1,
+ island: 0,
+} as const;
+
+export default function ConfiguratorPanel() {
+ const { config, price, setConfig, applyPreset, replaceConfig } = useConfigStore();
+
+ // Helper: DeepPartial-Patch für modules (typsicher)
+ const patchModules = (mods: DeepPartial) =>
+ setConfig({ modules: mods });
+
+ const [customerName, setCustomerName] = useState("");
+ const [company, setCompany] = useState("");
+ const [email, setEmail] = useState("");
+ const [phone, setPhone] = useState("");
+ const [fair, setFair] = useState("");
+
+ const fixedWalls =
+ wallFixedMap[config.type as keyof typeof wallFixedMap] ?? 0;
+
+ // Boden-Konfiguration (advanced + Fallback auf legacy raisedFloor)
+ const floor = config.modules.floor;
+ const floorType = floor?.type ?? "carpet";
+ const floorRaised = floor?.raised ?? config.modules.raisedFloor ?? false;
+
+ // Wand-Oberflächen aus modules.wallsDetail lesen
+ const getWallSurface = (side: WallSide): string => {
+ const wallsDetail = (config.modules as any).wallsDetail as
+ | Partial>
+ | undefined;
+ return wallsDetail?.[side]?.surface ?? "system";
+ };
+
+ const updateWallSurface = (side: WallSide, surface: string) => {
+ const wallsDetail =
+ ((config.modules as any).wallsDetail ?? {}) as Record<
+ WallSide,
+ { [key: string]: any }
+ >;
+
+ patchModules({
+ wallsDetail: {
+ ...wallsDetail,
+ [side]: {
+ ...(wallsDetail[side] ?? {}),
+ surface,
+ },
+ } as any,
+ });
+ };
+
+ const stepModule = (
+ field: "ledFrames" | "counters" | "screens",
+ delta: number,
+ min = 0,
+ max?: number
+ ) => {
+ const current = (config.modules[field] as number) ?? 0;
+ let next = current + delta;
+ if (typeof min === "number") next = Math.max(min, next);
+ if (typeof max === "number") next = Math.min(max, next);
+ patchModules({ [field]: next } as DeepPartial);
+ };
+
+ const floorTypeLabel = (type: string | undefined) => {
+ switch (type) {
+ case "laminate":
+ return "Laminat";
+ case "vinyl":
+ return "Vinyl";
+ case "wood":
+ return "Holz";
+ case "carpet":
+ default:
+ return "Teppich";
+ }
+ };
+
+ const copyConfigToClipboard = () => {
+ const area = config.width * config.depth;
+ const m = config.modules;
+ const mm = m as any;
+
+ const wd = mm.wallsDetail as
+ | Partial>
+ | undefined;
+
+ const wallSurfaceLabel = (side: WallSide, label: string) => {
+ const surface = wd?.[side]?.surface ?? "system";
+ const nice =
+ surface === "wood"
+ ? "Holzwand"
+ : surface === "banner"
+ ? "Bannerfläche"
+ : surface === "seg"
+ ? "Textil / SEG"
+ : surface === "led"
+ ? "LED-Wand"
+ : "Systemwand";
+ return `${label}: ${nice}`;
+ };
+
+ const wallLines: string[] = [];
+ if (m.wallsClosedSides >= 1)
+ wallLines.push(" · " + wallSurfaceLabel("back", "Rückwand"));
+ if (m.wallsClosedSides >= 2)
+ wallLines.push(" · " + wallSurfaceLabel("left", "Linke Wand"));
+ if (m.wallsClosedSides >= 3)
+ wallLines.push(" · " + wallSurfaceLabel("right", "Rechte Wand"));
+
+ const floorLbl = floorTypeLabel(m.floor?.type);
+
+ const lightsFront = mm.trussLightsFront ?? 0;
+ const lightsBack = mm.trussLightsBack ?? 0;
+ const lightsLeft = mm.trussLightsLeft ?? 0;
+ const lightsRight = mm.trussLightsRight ?? 0;
+ const wallBack = mm.wallLightsBack ?? 0;
+ const wallLeft = mm.wallLightsLeft ?? 0;
+ const wallRight = mm.wallLightsRight ?? 0;
+
+ const bannerW = mm.trussBannerWidth ?? 0;
+ const bannerH = mm.trussBannerHeight ?? 0;
+ const bFront = mm.trussBannersFront ?? 0;
+ const bBack = mm.trussBannersBack ?? 0;
+ const bLeft = mm.trussBannersLeft ?? 0;
+ const bRight = mm.trussBannersRight ?? 0;
+
+ const text = [
+ "Neue Standanfrage über den 3D-Konfigurator:",
+ "",
+ `Fläche: ${config.width} x ${config.depth} m (${area} m²)`,
+ `Standtyp: ${config.type}`,
+ `Region: ${config.region}`,
+ `Eilauftrag: ${config.rush ? "Ja" : "Nein"}`,
+ "",
+ "Module:",
+ `- Boden: ${floorLbl}`,
+ `- Doppelboden: ${
+ (m.floor?.raised ?? m.raisedFloor) ? "Ja" : "Nein"
+ }`,
+ ...(wallLines.length ? ["- Wände:", ...wallLines] : []),
+ `- Geschlossene Seiten: ${m.wallsClosedSides}`,
+ `- Lagerraum: ${m.storageRoom ? "Ja" : "Nein"}${
+ m.storageRoom ? ` (Tür: ${m.storageDoorSide ?? "front"})` : ""
+ }`,
+ `- LED-Rahmen: ${m.ledFrames} (Wand: ${m.ledWall ?? "back"})`,
+ `- Counters: ${m.counters} (Position: ${
+ m.countersWall ?? "front"
+ }, Strom: ${m.countersWithPower ? "Ja" : "Nein"})`,
+ `- Screens: ${m.screens} (Wand: ${m.screensWall ?? "back"})`,
+ `- Truss: ${m.truss ? "Ja" : "Nein"}`,
+ `- Truss-Lampen (Typ ${mm.trussLightType ?? "spot"}): Front ${lightsFront}, Back ${lightsBack}, Links ${lightsLeft}, Rechts ${lightsRight}`,
+ `- Wandstrahler: Back ${wallBack}, Links ${wallLeft}, Rechts ${wallRight}`,
+ `- Truss-Bannerrahmen (ca. ${bannerW || "?"} × ${bannerH || "?"} m): Front ${bFront}, Back ${bBack}, Links ${bLeft}, Rechts ${bRight}`,
+ "",
+ `Richtpreis: ${price.toLocaleString("de-DE")} €`,
+ ].join("\n");
+
+ navigator.clipboard
+ .writeText(text)
+ .catch(() => console.log("Kopieren nicht möglich."));
+ alert("Konfiguration wurde in die Zwischenablage kopiert.");
+ };
+
+ const sendEmailRequest = () => {
+ const area = config.width * config.depth;
+ const m = config.modules;
+ const mm = m as any;
+
+ const wd = mm.wallsDetail as
+ | Partial>
+ | undefined;
+
+ const wallSurfaceLabel = (side: WallSide, label: string) => {
+ const surface = wd?.[side]?.surface ?? "system";
+ const nice =
+ surface === "wood"
+ ? "Holzwand"
+ : surface === "banner"
+ ? "Bannerfläche"
+ : surface === "seg"
+ ? "Textil / SEG"
+ : surface === "led"
+ ? "LED-Wand"
+ : "Systemwand";
+ return `${label}: ${nice}`;
+ };
+
+ const wallLines: string[] = [];
+ if (m.wallsClosedSides >= 1)
+ wallLines.push(" · " + wallSurfaceLabel("back", "Rückwand"));
+ if (m.wallsClosedSides >= 2)
+ wallLines.push(" · " + wallSurfaceLabel("left", "Linke Wand"));
+ if (m.wallsClosedSides >= 3)
+ wallLines.push(" · " + wallSurfaceLabel("right", "Rechte Wand"));
+
+ const floorLbl = floorTypeLabel(m.floor?.type);
+
+ const lightsFront = mm.trussLightsFront ?? 0;
+ const lightsBack = mm.trussLightsBack ?? 0;
+ const lightsLeft = mm.trussLightsLeft ?? 0;
+ const lightsRight = mm.trussLightsRight ?? 0;
+ const wallBack = mm.wallLightsBack ?? 0;
+ const wallLeft = mm.wallLightsLeft ?? 0;
+ const wallRight = mm.wallLightsRight ?? 0;
+
+ const bannerW = mm.trussBannerWidth ?? 0;
+ const bannerH = mm.trussBannerHeight ?? 0;
+ const bFront = mm.trussBannersFront ?? 0;
+ const bBack = mm.trussBannersBack ?? 0;
+ const bLeft = mm.trussBannersLeft ?? 0;
+ const bRight = mm.trussBannersRight ?? 0;
+
+ const lines = [
+ "Neue Standanfrage über den 3D-Konfigurator:",
+ "",
+ "=== Standdaten ===",
+ `Messe / Event: ${fair || "-"}`,
+ `Fläche: ${config.width} x ${config.depth} m (${area} m²)`,
+ `Standtyp: ${config.type}`,
+ `Region: ${config.region}`,
+ `Eilauftrag: ${config.rush ? "Ja" : "Nein"}`,
+ "",
+ "Module:",
+ `- Boden: ${floorLbl}`,
+ `- Doppelboden: ${
+ (m.floor?.raised ?? m.raisedFloor) ? "Ja" : "Nein"
+ }`,
+ ...(wallLines.length ? ["- Wände:", ...wallLines] : []),
+ `- Geschlossene Seiten: ${m.wallsClosedSides}`,
+ `- Lagerraum: ${m.storageRoom ? "Ja" : "Nein"}${
+ m.storageRoom ? ` (Tür: ${m.storageDoorSide ?? "front"})` : ""
+ }`,
+ `- LED-Rahmen: ${m.ledFrames} (Wand: ${m.ledWall ?? "back"})`,
+ `- Counters: ${m.counters} (Position: ${
+ m.countersWall ?? "front"
+ }, Strom: ${m.countersWithPower ? "Ja" : "Nein"})`,
+ `- Screens: ${m.screens} (Wand: ${m.screensWall ?? "back"})`,
+ `- Truss: ${m.truss ? "Ja" : "Nein"}`,
+ `- Truss-Lampen (Typ ${mm.trussLightType ?? "spot"}): Front ${lightsFront}, Back ${lightsBack}, Links ${lightsLeft}, Rechts ${lightsRight}`,
+ `- Wandstrahler: Back ${wallBack}, Links ${wallLeft}, Rechts ${wallRight}`,
+ `- Truss-Bannerrahmen (ca. ${bannerW || "?"} × ${
+ bannerH || "?"
+ } m): Front ${bFront}, Back ${bBack}, Links ${bLeft}, Rechts ${bRight}`,
+ "",
+ `Richtpreis (brutto / Richtwert): ${price.toLocaleString("de-DE")} €`,
+ "",
+ "=== Kontaktdaten Kunde ===",
+ `Name: ${customerName || "-"}`,
+ `Firma: ${company || "-"}`,
+ `E-Mail: ${email || "-"}`,
+ `Telefon: ${phone || "-"}`,
+ ];
+
+ const subject = encodeURIComponent(
+ `Standanfrage Konfigurator – ${company || customerName || "Unbekannt"}`
+ );
+ const body = encodeURIComponent(lines.join("\n"));
+
+ const mailto = `mailto:sunds-messebau@gmx.de?subject=${subject}&body=${body}`;
+ window.location.href = mailto;
+ };
+
+ return (
+
+
+
+
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/MobileFullScreenPanel.tsx b/ss-messebau-configurator/src/components/MobileFullScreenPanel.tsx
new file mode 100644
index 0000000..943f67b
--- /dev/null
+++ b/ss-messebau-configurator/src/components/MobileFullScreenPanel.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from "react";
+
+interface MobileFullScreenPanelProps {
+ open: boolean;
+ onClose: () => void;
+ children: ReactNode;
+}
+
+export default function MobileFullScreenPanel({
+ open,
+ onClose,
+ children,
+}: MobileFullScreenPanelProps) {
+ return (
+
+
event.stopPropagation()}
+ >
+
+ Konfiguration
+
+ ✕
+
+
+
{children}
+
+
+ );
+}
diff --git a/ss-messebau-configurator/src/components/SidebarControls.tsx b/ss-messebau-configurator/src/components/SidebarControls.tsx
index 37d874a..2f46dd9 100644
--- a/ss-messebau-configurator/src/components/SidebarControls.tsx
+++ b/ss-messebau-configurator/src/components/SidebarControls.tsx
@@ -1,1141 +1,6 @@
// src/components/SidebarControls.tsx
-import { useState } from "react";
-import { useConfigStore, type DeepPartial } from "../store/configStore";
-import type { StandModules } from "../lib/pricing";
+// Deprecated wrapper kept for backward compatibility.
+// The configurator UI now lives in ConfiguratorPanel.
+import ConfiguratorPanel from "./ConfiguratorPanel";
-type WallSide = "back" | "left" | "right";
-
-// Feste Anzahl geschlossener Seiten pro Standtyp
-const wallFixedMap = {
- row: 3,
- corner: 2,
- head: 1,
- island: 0,
-} as const;
-
-export default function SidebarControls() {
- const { config, price, setConfig, applyPreset } = useConfigStore();
-
- // Helper: DeepPartial-Patch für modules (typsicher)
- const patchModules = (mods: DeepPartial) =>
- setConfig({ modules: mods });
-
- const [customerName, setCustomerName] = useState("");
- const [company, setCompany] = useState("");
- const [email, setEmail] = useState("");
- const [phone, setPhone] = useState("");
- const [fair, setFair] = useState("");
-
- const fixedWalls =
- wallFixedMap[config.type as keyof typeof wallFixedMap] ?? 0;
-
- // Boden-Konfiguration (advanced + Fallback auf legacy raisedFloor)
- const floor = config.modules.floor;
- const floorType = floor?.type ?? "carpet";
- const floorRaised = floor?.raised ?? config.modules.raisedFloor ?? false;
-
- // Wand-Oberflächen aus modules.wallsDetail lesen
- const getWallSurface = (side: WallSide): string => {
- const wallsDetail = (config.modules as any).wallsDetail as
- | Partial>
- | undefined;
- return wallsDetail?.[side]?.surface ?? "system";
- };
-
- const updateWallSurface = (side: WallSide, surface: string) => {
- const wallsDetail =
- ((config.modules as any).wallsDetail ?? {}) as Record<
- WallSide,
- { [key: string]: any }
- >;
-
- patchModules({
- wallsDetail: {
- ...wallsDetail,
- [side]: {
- ...(wallsDetail[side] ?? {}),
- surface,
- },
- } as any,
- });
- };
-
- const stepModule = (
- field: "ledFrames" | "counters" | "screens",
- delta: number,
- min = 0,
- max?: number
- ) => {
- const current = (config.modules[field] as number) ?? 0;
- let next = current + delta;
- if (typeof min === "number") next = Math.max(min, next);
- if (typeof max === "number") next = Math.min(max, next);
- patchModules({ [field]: next } as DeepPartial);
- };
-
- const floorTypeLabel = (type: string | undefined) => {
- switch (type) {
- case "laminate":
- return "Laminat";
- case "vinyl":
- return "Vinyl";
- case "wood":
- return "Holz";
- case "carpet":
- default:
- return "Teppich";
- }
- };
-
- const copyConfigToClipboard = () => {
- const area = config.width * config.depth;
- const m = config.modules;
- const mm = m as any;
-
- const wd = mm.wallsDetail as
- | Partial>
- | undefined;
-
- const wallSurfaceLabel = (side: WallSide, label: string) => {
- const surface = wd?.[side]?.surface ?? "system";
- const nice =
- surface === "wood"
- ? "Holzwand"
- : surface === "banner"
- ? "Bannerfläche"
- : surface === "seg"
- ? "Textil / SEG"
- : surface === "led"
- ? "LED-Wand"
- : "Systemwand";
- return `${label}: ${nice}`;
- };
-
- const wallLines: string[] = [];
- if (m.wallsClosedSides >= 1)
- wallLines.push(" · " + wallSurfaceLabel("back", "Rückwand"));
- if (m.wallsClosedSides >= 2)
- wallLines.push(" · " + wallSurfaceLabel("left", "Linke Wand"));
- if (m.wallsClosedSides >= 3)
- wallLines.push(" · " + wallSurfaceLabel("right", "Rechte Wand"));
-
- const floorLbl = floorTypeLabel(m.floor?.type);
-
- const lightsFront = mm.trussLightsFront ?? 0;
- const lightsBack = mm.trussLightsBack ?? 0;
- const lightsLeft = mm.trussLightsLeft ?? 0;
- const lightsRight = mm.trussLightsRight ?? 0;
- const wallBack = mm.wallLightsBack ?? 0;
- const wallLeft = mm.wallLightsLeft ?? 0;
- const wallRight = mm.wallLightsRight ?? 0;
-
- const bannerW = mm.trussBannerWidth ?? 0;
- const bannerH = mm.trussBannerHeight ?? 0;
- const bFront = mm.trussBannersFront ?? 0;
- const bBack = mm.trussBannersBack ?? 0;
- const bLeft = mm.trussBannersLeft ?? 0;
- const bRight = mm.trussBannersRight ?? 0;
-
- const text = [
- "Neue Standanfrage über den 3D-Konfigurator:",
- "",
- `Fläche: ${config.width} x ${config.depth} m (${area} m²)`,
- `Standtyp: ${config.type}`,
- `Region: ${config.region}`,
- `Eilauftrag: ${config.rush ? "Ja" : "Nein"}`,
- "",
- "Module:",
- `- Boden: ${floorLbl}`,
- `- Doppelboden: ${
- (m.floor?.raised ?? m.raisedFloor) ? "Ja" : "Nein"
- }`,
- ...(wallLines.length ? ["- Wände:", ...wallLines] : []),
- `- Geschlossene Seiten: ${m.wallsClosedSides}`,
- `- Lagerraum: ${m.storageRoom ? "Ja" : "Nein"}${
- m.storageRoom ? ` (Tür: ${m.storageDoorSide ?? "front"})` : ""
- }`,
- `- LED-Rahmen: ${m.ledFrames} (Wand: ${m.ledWall ?? "back"})`,
- `- Counters: ${m.counters} (Position: ${
- m.countersWall ?? "front"
- }, Strom: ${m.countersWithPower ? "Ja" : "Nein"})`,
- `- Screens: ${m.screens} (Wand: ${m.screensWall ?? "back"})`,
- `- Truss: ${m.truss ? "Ja" : "Nein"}`,
- `- Truss-Lampen (Typ ${mm.trussLightType ?? "spot"}): Front ${lightsFront}, Back ${lightsBack}, Links ${lightsLeft}, Rechts ${lightsRight}`,
- `- Wandstrahler: Back ${wallBack}, Links ${wallLeft}, Rechts ${wallRight}`,
- `- Truss-Bannerrahmen (ca. ${bannerW || "?"} × ${bannerH || "?"} m): Front ${bFront}, Back ${bBack}, Links ${bLeft}, Rechts ${bRight}`,
- "",
- `Richtpreis: ${price.toLocaleString("de-DE")} €`,
- ].join("\n");
-
- navigator.clipboard
- .writeText(text)
- .catch(() => console.log("Kopieren nicht möglich."));
- alert("Konfiguration wurde in die Zwischenablage kopiert.");
- };
-
- const sendEmailRequest = () => {
- const area = config.width * config.depth;
- const m = config.modules;
- const mm = m as any;
-
- const wd = mm.wallsDetail as
- | Partial>
- | undefined;
-
- const wallSurfaceLabel = (side: WallSide, label: string) => {
- const surface = wd?.[side]?.surface ?? "system";
- const nice =
- surface === "wood"
- ? "Holzwand"
- : surface === "banner"
- ? "Bannerfläche"
- : surface === "seg"
- ? "Textil / SEG"
- : surface === "led"
- ? "LED-Wand"
- : "Systemwand";
- return `${label}: ${nice}`;
- };
-
- const wallLines: string[] = [];
- if (m.wallsClosedSides >= 1)
- wallLines.push(" · " + wallSurfaceLabel("back", "Rückwand"));
- if (m.wallsClosedSides >= 2)
- wallLines.push(" · " + wallSurfaceLabel("left", "Linke Wand"));
- if (m.wallsClosedSides >= 3)
- wallLines.push(" · " + wallSurfaceLabel("right", "Rechte Wand"));
-
- const floorLbl = floorTypeLabel(m.floor?.type);
-
- const lightsFront = mm.trussLightsFront ?? 0;
- const lightsBack = mm.trussLightsBack ?? 0;
- const lightsLeft = mm.trussLightsLeft ?? 0;
- const lightsRight = mm.trussLightsRight ?? 0;
- const wallBack = mm.wallLightsBack ?? 0;
- const wallLeft = mm.wallLightsLeft ?? 0;
- const wallRight = mm.wallLightsRight ?? 0;
-
- const bannerW = mm.trussBannerWidth ?? 0;
- const bannerH = mm.trussBannerHeight ?? 0;
- const bFront = mm.trussBannersFront ?? 0;
- const bBack = mm.trussBannersBack ?? 0;
- const bLeft = mm.trussBannersLeft ?? 0;
- const bRight = mm.trussBannersRight ?? 0;
-
- const lines = [
- "Neue Standanfrage über den 3D-Konfigurator:",
- "",
- "=== Standdaten ===",
- `Messe / Event: ${fair || "-"}`,
- `Fläche: ${config.width} x ${config.depth} m (${area} m²)`,
- `Standtyp: ${config.type}`,
- `Region: ${config.region}`,
- `Eilauftrag: ${config.rush ? "Ja" : "Nein"}`,
- "",
- "Module:",
- `- Boden: ${floorLbl}`,
- `- Doppelboden: ${
- (m.floor?.raised ?? m.raisedFloor) ? "Ja" : "Nein"
- }`,
- ...(wallLines.length ? ["- Wände:", ...wallLines] : []),
- `- Geschlossene Seiten: ${m.wallsClosedSides}`,
- `- Lagerraum: ${m.storageRoom ? "Ja" : "Nein"}${
- m.storageRoom ? ` (Tür: ${m.storageDoorSide ?? "front"})` : ""
- }`,
- `- LED-Rahmen: ${m.ledFrames} (Wand: ${m.ledWall ?? "back"})`,
- `- Counters: ${m.counters} (Position: ${
- m.countersWall ?? "front"
- }, Strom: ${m.countersWithPower ? "Ja" : "Nein"})`,
- `- Screens: ${m.screens} (Wand: ${m.screensWall ?? "back"})`,
- `- Truss: ${m.truss ? "Ja" : "Nein"}`,
- `- Truss-Lampen (Typ ${mm.trussLightType ?? "spot"}): Front ${lightsFront}, Back ${lightsBack}, Links ${lightsLeft}, Rechts ${lightsRight}`,
- `- Wandstrahler: Back ${wallBack}, Links ${wallLeft}, Rechts ${wallRight}`,
- `- Truss-Bannerrahmen (ca. ${bannerW || "?"} × ${
- bannerH || "?"
- } m): Front ${bFront}, Back ${bBack}, Links ${bLeft}, Rechts ${bRight}`,
- "",
- `Richtpreis (brutto / Richtwert): ${price.toLocaleString("de-DE")} €`,
- "",
- "=== Kontaktdaten Kunde ===",
- `Name: ${customerName || "-"}`,
- `Firma: ${company || "-"}`,
- `E-Mail: ${email || "-"}`,
- `Telefon: ${phone || "-"}`,
- ];
-
- const subject = encodeURIComponent(
- `Standanfrage Konfigurator – ${company || customerName || "Unbekannt"}`
- );
- const body = encodeURIComponent(lines.join("\n"));
-
- const mailto = `mailto:sunds-messebau@gmx.de?subject=${subject}&body=${body}`;
- window.location.href = mailto;
- };
-
- return (
-
-
-
-
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/lib/collision.ts b/ss-messebau-configurator/src/lib/collision.ts
new file mode 100644
index 0000000..1fa287a
--- /dev/null
+++ b/ss-messebau-configurator/src/lib/collision.ts
@@ -0,0 +1,149 @@
+import type { StandConfig } from "./pricing";
+
+export type Aabb = {
+ id: string;
+ label: string;
+ minX: number;
+ maxX: number;
+ minZ: number;
+ maxZ: number;
+};
+
+export const DEFAULT_CLEARANCE = 0.2;
+
+export const intersects = (a: Aabb, b: Aabb) =>
+ !(a.maxX <= b.minX || a.minX >= b.maxX || a.maxZ <= b.minZ || a.minZ >= b.maxZ);
+
+export const makeAabb = (
+ id: string,
+ label: string,
+ x: number,
+ z: number,
+ width: number,
+ depth: number,
+ clearance: number = DEFAULT_CLEARANCE
+): Aabb => {
+ const halfW = width / 2 + clearance;
+ const halfD = depth / 2 + clearance;
+ return {
+ id,
+ label,
+ minX: x - halfW,
+ maxX: x + halfW,
+ minZ: z - halfD,
+ maxZ: z + halfD,
+ };
+};
+
+export const findCollision = (
+ candidate: Aabb,
+ boxes: Aabb[],
+ ignored: Set = new Set()
+): Aabb | undefined => {
+ for (const box of boxes) {
+ if (ignored.has(box.id)) continue;
+ if (intersects(candidate, box)) return box;
+ }
+ return undefined;
+};
+
+export const findCollisionForMany = (
+ candidates: Aabb[],
+ boxes: Aabb[],
+ ignored: Set = new Set()
+): { collided: boolean; hit?: Aabb; candidate?: Aabb } => {
+ for (const candidate of candidates) {
+ const hit = findCollision(candidate, boxes, ignored);
+ if (hit) {
+ return { collided: true, hit, candidate };
+ }
+ }
+ return { collided: false };
+};
+
+export function buildSceneAabbs(
+ cfg: StandConfig,
+ clearance: number = DEFAULT_CLEARANCE
+): Aabb[] {
+ const boxes: Aabb[] = [];
+ const modules = cfg.modules as any;
+ const mAny = modules ?? {};
+
+ // Kabine
+ const cabin = mAny.cabin as
+ | (StandConfig["modules"]["cabin"] & { position?: { x?: number; z?: number } })
+ | undefined;
+ if (cabin && (cabin.enabled ?? mAny.storageRoom)) {
+ const x = cabin.position?.x ?? -cfg.width / 2 + (cabin.width ?? 1.5) / 2 + 0.25;
+ const z = cabin.position?.z ?? -cfg.depth / 2 + (cabin.depth ?? 1.5) / 2 + 0.25;
+ boxes.push(makeAabb("cabin", "Kabine", x, z, cabin.width ?? 1.5, cabin.depth ?? 1.5, clearance));
+ }
+
+ // Counters (detailliert)
+ const countersDetailed = (mAny.countersDetailed ?? []) as {
+ id: string;
+ variant?: "basic" | "premium" | "corner";
+ size?: { w?: number; d?: number };
+ position?: { x?: number; z?: number };
+ }[];
+ countersDetailed.forEach((ctr) => {
+ const variant = ctr.variant ?? (mAny.counterVariant ?? "basic");
+ const w = ctr.size?.w ?? (variant === "premium" ? 1.4 : 0.9);
+ const d = ctr.size?.d ?? (variant === "premium" ? 0.6 : 0.5);
+ const x = ctr.position?.x ?? 0;
+ const z = ctr.position?.z ?? 0;
+ boxes.push(makeAabb(`ctr-d-${ctr.id}`, "Counter", x, z, w, d, clearance));
+ });
+
+ // Screens (nur detailliert)
+ const detailedScreens = (mAny.detailedScreens ?? []) as {
+ id: string;
+ size?: { w?: number; h?: number; t?: number };
+ mount?: "wall" | "truss" | "floor";
+ wallSide?: "back" | "left" | "right";
+ position?: { x?: number; z?: number };
+ rotationY?: number;
+ }[];
+
+ detailedScreens.forEach((scr) => {
+ const w = scr.size?.w ?? 0.9;
+ const t = scr.size?.t ?? 0.02;
+ const mount = scr.mount ?? "wall";
+ const wallSide = scr.wallSide ?? "back";
+ const x = scr.position?.x ?? 0;
+ const z = scr.position?.z ?? 0;
+
+ // Wall-Mount => Breite folgt Wand, Tiefe minimal
+ if (mount === "wall") {
+ if (wallSide === "left" || wallSide === "right") {
+ boxes.push(makeAabb(`scr-d-${scr.id}`, "Screen", x, z, t || 0.05, w, clearance));
+ } else {
+ boxes.push(makeAabb(`scr-d-${scr.id}`, "Screen", x, z, w, t || 0.05, clearance));
+ }
+ return;
+ }
+
+ const depth = mount === "floor" ? t || 0.1 : w * 0.25;
+ boxes.push(makeAabb(`scr-d-${scr.id}`, "Screen", x, z, w, depth, clearance));
+ });
+
+ // Truss-Stützen (vier Eck-Pfosten)
+ if (mAny.truss) {
+ const columnSize = 0.12; // etwas größer als die optischen 8 cm
+ const offsetX = mAny.trussOffset?.x ?? 0;
+ const offsetZ = mAny.trussOffset?.z ?? 0;
+
+ const positions: [string, number, number][] = [
+ ["truss-col-front-left", -cfg.width / 2 + offsetX, cfg.depth / 2 + offsetZ],
+ ["truss-col-front-right", cfg.width / 2 + offsetX, cfg.depth / 2 + offsetZ],
+ ["truss-col-back-left", -cfg.width / 2 + offsetX, -cfg.depth / 2 + offsetZ],
+ ["truss-col-back-right", cfg.width / 2 + offsetX, -cfg.depth / 2 + offsetZ],
+ ];
+
+ positions.forEach(([id, x, z]) => {
+ boxes.push(makeAabb(id, "Truss-Stütze", x, z, columnSize, columnSize, clearance));
+ });
+ }
+
+ return boxes;
+}
diff --git a/ss-messebau-configurator/src/lib/playgrounds.ts b/ss-messebau-configurator/src/lib/playgrounds.ts
new file mode 100644
index 0000000..a8524ef
--- /dev/null
+++ b/ss-messebau-configurator/src/lib/playgrounds.ts
@@ -0,0 +1,86 @@
+import type { StandConfig } from "./pricing";
+
+/**
+ * Manuelle Prüfkonfiguration für Kollisionen.
+ * Mehrere Counters/Screens dicht beieinander + Kabine + Truss.
+ */
+export const collisionPlayground: StandConfig = {
+ width: 6,
+ depth: 4,
+ height: 2.5,
+ type: "corner",
+ region: "NRW",
+ rush: false,
+ modules: {
+ wallsClosedSides: 2,
+ storageRoom: true,
+ storageDoorSide: "left",
+ ledFrames: 1,
+ ledWall: "back",
+ counters: 0,
+ countersWithPower: true,
+ counterVariant: "premium",
+ countersDetailed: [
+ {
+ id: "ctr-demo-1",
+ variant: "premium",
+ withPower: true,
+ size: { w: 1.4, d: 0.6, h: 1.1 },
+ position: { x: -1.4, z: 1.2 },
+ },
+ {
+ id: "ctr-demo-2",
+ variant: "basic",
+ withPower: true,
+ size: { w: 0.9, d: 0.5, h: 1.1 },
+ position: { x: -0.1, z: 1.3 },
+ },
+ {
+ id: "ctr-demo-3",
+ variant: "corner",
+ withPower: false,
+ size: { w: 1.2, d: 0.9, h: 1.1 },
+ position: { x: 1.3, z: 0.9 },
+ },
+ ],
+ screens: 0,
+ detailedScreens: [
+ {
+ id: "scr-demo-back",
+ mount: "wall",
+ wallSide: "back",
+ size: { w: 1.2, h: 0.7, t: 0.06 },
+ position: { x: 0, z: -1.9 },
+ },
+ {
+ id: "scr-demo-floor",
+ mount: "floor",
+ size: { w: 1, h: 0.6, t: 0.12 },
+ position: { x: 1.2, z: -0.8 },
+ },
+ ],
+ truss: true,
+ trussHeight: 3.2,
+ trussOffset: { x: 0.4, z: 0.2 },
+ trussLightsFront: 2,
+ trussLightsLeft: 1,
+ trussLightsRight: 1,
+ trussLightType: "spot",
+ trussBannersFront: 1,
+ trussBannerWidth: 3,
+ trussBannerHeight: 1,
+ floor: {
+ type: "carpet",
+ raised: false,
+ },
+ collisionClearance: 0.25,
+ cabin: {
+ enabled: true,
+ width: 2,
+ depth: 1.6,
+ height: 2.5,
+ doorSide: "front",
+ position: { x: -1.6, z: -1.2 },
+ },
+ } as any,
+};
diff --git a/ss-messebau-configurator/src/lib/useIsMobile.ts b/ss-messebau-configurator/src/lib/useIsMobile.ts
new file mode 100644
index 0000000..b9800fb
--- /dev/null
+++ b/ss-messebau-configurator/src/lib/useIsMobile.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from "react";
+
+const MOBILE_BREAKPOINT = 1024;
+
+export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT): boolean {
+ const [isMobile, setIsMobile] = useState(() => {
+ if (typeof window === "undefined") return false;
+ return window.innerWidth < breakpoint;
+ });
+
+ useEffect(() => {
+ const handleResize = () => setIsMobile(window.innerWidth < breakpoint);
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, [breakpoint]);
+
+ return isMobile;
+}
+
+export default useIsMobile;
diff --git a/ss-messebau-configurator/src/scene/createStandScene.ts b/ss-messebau-configurator/src/scene/createStandScene.ts
new file mode 100644
index 0000000..c67faa4
--- /dev/null
+++ b/ss-messebau-configurator/src/scene/createStandScene.ts
@@ -0,0 +1,628 @@
+import * as THREE from "three";
+import type { StandConfig as PricingStandConfig, WallSide } from "../lib/pricing";
+
+export type StandConfig = PricingStandConfig;
+
+export type StandSceneObjects = {
+ basePlate: THREE.Mesh;
+ raisedFloor?: THREE.Mesh;
+ floor: THREE.Mesh;
+ walls: THREE.Mesh[];
+ cabin?: THREE.Group;
+ counters: THREE.Group[];
+ countersDetailed: { id: string; group: THREE.Group }[];
+ screensDetailed: { id: string; group: THREE.Object3D }[];
+ ledFrames: THREE.Mesh[];
+ ledWall?: THREE.Mesh;
+ truss?: THREE.Group;
+ trussLights: THREE.Object3D[];
+ trussBanners: THREE.Mesh[];
+ wallLights: THREE.PointLight[];
+};
+
+type CounterVariant = "basic" | "premium" | "corner";
+
+type DetailedCounter = {
+ id: string;
+ variant?: CounterVariant;
+ size?: { w: number; d: number; h?: number };
+ position: { x: number; z: number; y?: number };
+ rotationY?: number;
+};
+
+type DetailedScreen = {
+ id: string;
+ size?: { w: number; h: number; t?: number };
+ mount?: "wall" | "truss" | "floor";
+ wallSide?: WallSide;
+ heightFromFloor?: number;
+ position: { x: number; z: number };
+ rotationY?: number;
+};
+
+function makePlane({
+ size,
+ color,
+ rotation = new THREE.Euler(-Math.PI / 2, 0, 0),
+ position = new THREE.Vector3(),
+}: {
+ size: [number, number];
+ color: string;
+ rotation?: THREE.Euler;
+ position?: THREE.Vector3;
+}) {
+ const mesh = new THREE.Mesh(
+ new THREE.PlaneGeometry(size[0], size[1]),
+ new THREE.MeshStandardMaterial({ color })
+ );
+ mesh.rotation.copy(rotation);
+ mesh.position.copy(position);
+ mesh.receiveShadow = true;
+ return mesh;
+}
+
+function makeBox({
+ size,
+ material,
+ position = new THREE.Vector3(),
+}: {
+ size: [number, number, number];
+ material: THREE.MeshStandardMaterialParameters;
+ position?: THREE.Vector3;
+}) {
+ const mesh = new THREE.Mesh(
+ new THREE.BoxGeometry(size[0], size[1], size[2]),
+ new THREE.MeshStandardMaterial(material)
+ );
+ mesh.position.copy(position);
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ return mesh;
+}
+
+function buildCounter(variant: CounterVariant, dims: { w: number; d: number; h: number }) {
+ const group = new THREE.Group();
+ const { w, d, h } = dims;
+
+ if (variant === "basic") {
+ const mesh = makeBox({
+ size: [w, h, d],
+ material: { color: "#1d4ed8", roughness: 0.35, metalness: 0.45 },
+ position: new THREE.Vector3(0, h / 2, 0),
+ });
+ group.add(mesh);
+ return group;
+ }
+
+ if (variant === "premium") {
+ const base = makeBox({
+ size: [Math.max(w, 1.4), h, Math.max(d, 0.6)],
+ material: { color: "#0f172a", roughness: 0.4, metalness: 0.6 },
+ position: new THREE.Vector3(0, h / 2, 0),
+ });
+ const accent = makeBox({
+ size: [1.2, 0.5, 0.02],
+ material: { color: "#1d4ed8", roughness: 0.2, metalness: 0.7 },
+ position: new THREE.Vector3(0, h * 0.55, Math.max(d, 0.6) / 2 - 0.29),
+ });
+ const top = makeBox({
+ size: [Math.max(w, 1.45), 0.06, Math.max(d, 0.65)],
+ material: { color: "#e5e7eb", roughness: 0.2, metalness: 0.3 },
+ position: new THREE.Vector3(0, h + 0.02, 0),
+ });
+ group.add(base, accent, top);
+ return group;
+ }
+
+ const bodyA = makeBox({
+ size: [w, h, d],
+ material: { color: "#1e293b", roughness: 0.5, metalness: 0.35 },
+ position: new THREE.Vector3(-w / 2 + w * 0.5, h / 2, 0),
+ });
+ const bodyB = makeBox({
+ size: [d, h, w],
+ material: { color: "#1e293b", roughness: 0.5, metalness: 0.35 },
+ position: new THREE.Vector3(0, h / 2, -d / 2 + d * 0.5),
+ });
+ const topPlate = makeBox({
+ size: [1.2, 0.06, 1.2],
+ material: { color: "#e5e7eb", roughness: 0.3, metalness: 0.3 },
+ position: new THREE.Vector3(-0.2, h + 0.02, -0.2),
+ });
+ group.add(bodyA, bodyB, topPlate);
+ return group;
+}
+
+function buildScreen({ w, h, t }: { w: number; h: number; t: number }) {
+ return makeBox({
+ size: [w, h, t],
+ material: { color: "#020617", roughness: 0.2, metalness: 0.7 },
+ });
+}
+
+function wallMaterial(surface: string): THREE.MeshStandardMaterialParameters {
+ switch (surface) {
+ case "wood":
+ return { color: "#a16207", roughness: 0.5, metalness: 0.2 };
+ case "banner":
+ return { color: "#0ea5e9", roughness: 0.65, metalness: 0.1 };
+ case "seg":
+ return { color: "#f3f4f6", roughness: 0.85, metalness: 0.05 };
+ case "led":
+ return {
+ color: "#0f172a",
+ roughness: 0.35,
+ metalness: 0.15,
+ emissive: "#38bdf8",
+ emissiveIntensity: 0.9,
+ };
+ case "system":
+ default:
+ return { color: "#e5e7eb", roughness: 0.9, metalness: 0.05 };
+ }
+}
+
+function buildCabin({ width, depth, height }: { width: number; depth: number; height: number }) {
+ const cabin = new THREE.Group();
+ const body = makeBox({
+ size: [width, height, depth],
+ material: { color: "#d1d5db", roughness: 0.9, metalness: 0.05 },
+ position: new THREE.Vector3(0, height / 2, 0),
+ });
+ cabin.add(body);
+ return cabin;
+}
+
+export function createStandScene(
+ config: StandConfig
+): { scene: THREE.Scene; camera: THREE.PerspectiveCamera; objects: StandSceneObjects } {
+ const scene = new THREE.Scene();
+ const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
+
+ const { width, depth, height, modules } = config;
+
+ const objects: StandSceneObjects = {
+ basePlate: new THREE.Mesh(),
+ floor: new THREE.Mesh(),
+ walls: [],
+ counters: [],
+ countersDetailed: [],
+ screensDetailed: [],
+ ledFrames: [],
+ trussLights: [],
+ trussBanners: [],
+ wallLights: [],
+ };
+
+ const mAny = modules as any;
+ const wallsClosedSides: number = mAny.wallsClosedSides ?? 1;
+ const wallHeight = height;
+ const wallThickness = 0.06;
+ const panelGap = 0.01;
+ const floorConfig = modules.floor;
+ const isRaised = floorConfig?.raised ?? modules.raisedFloor ?? false;
+ const floorHeight = isRaised ? 0.08 : 0.025;
+
+ // base plate
+ objects.basePlate = makePlane({
+ size: [width + 0.4, depth + 0.4],
+ color: "#020617",
+ position: new THREE.Vector3(0, 0, 0),
+ });
+ objects.basePlate.castShadow = false;
+ objects.basePlate.receiveShadow = false;
+ scene.add(objects.basePlate);
+
+ // raised floor
+ if (isRaised) {
+ objects.raisedFloor = makeBox({
+ size: [width, floorHeight, depth],
+ material: { color: "#020617", roughness: 0.6, metalness: 0.2 },
+ position: new THREE.Vector3(0, floorHeight / 2, 0),
+ });
+ scene.add(objects.raisedFloor);
+ }
+
+ // floor plane
+ const floorMaterial = (floorConfig as any)?.material ?? "grey";
+ const floorMaterialMap: Record = {
+ grey: { color: "#e5e7eb", roughness: 0.9, metalness: 0.05 },
+ blue: { color: "#0ea5e9", roughness: 0.6, metalness: 0.2 },
+ dark: { color: "#0f172a", roughness: 0.6, metalness: 0.2 },
+ wood: { color: "#854d0e", roughness: 0.7, metalness: 0.15 },
+ };
+ const fm = floorMaterialMap[floorMaterial] ?? floorMaterialMap.grey;
+ objects.floor = makePlane({
+ size: [width, depth],
+ color: fm.color as string,
+ position: new THREE.Vector3(0, floorHeight + 0.001, 0),
+ });
+ (objects.floor.material as THREE.MeshStandardMaterial).roughness = fm.roughness ?? 0.8;
+ (objects.floor.material as THREE.MeshStandardMaterial).metalness = fm.metalness ?? 0.1;
+ scene.add(objects.floor);
+
+ // walls
+ const wallCenterY = floorHeight + wallHeight / 2;
+ const surfaces = mAny.wallSurfaces || {};
+
+ const backSurface = wallMaterial(surfaces.back ?? "system");
+ const leftSurface = wallMaterial(surfaces.left ?? "system");
+ const rightSurface = wallMaterial(surfaces.right ?? "system");
+
+ if (wallsClosedSides >= 1) {
+ const backWall = makeBox({
+ size: [width, wallHeight, 0.06],
+ material: backSurface,
+ position: new THREE.Vector3(0, wallCenterY, -depth / 2 + 0.06 / 2),
+ });
+ scene.add(backWall);
+ objects.walls.push(backWall);
+ }
+ if (wallsClosedSides >= 2) {
+ const leftWall = makeBox({
+ size: [0.06, wallHeight, depth],
+ material: leftSurface,
+ position: new THREE.Vector3(-width / 2 + 0.06 / 2, wallCenterY, 0),
+ });
+ scene.add(leftWall);
+ objects.walls.push(leftWall);
+ }
+ if (wallsClosedSides >= 3) {
+ const rightWall = makeBox({
+ size: [0.06, wallHeight, depth],
+ material: rightSurface,
+ position: new THREE.Vector3(width / 2 - 0.06 / 2, wallCenterY, 0),
+ });
+ scene.add(rightWall);
+ objects.walls.push(rightWall);
+ }
+
+ // cabin
+ const cabin = mAny.cabin;
+ if (cabin?.enabled) {
+ const cabinGroup = buildCabin({
+ width: cabin.width ?? 1.5,
+ depth: cabin.depth ?? 1.5,
+ height: cabin.height ?? 2.2,
+ });
+ const cabinPosX = cabin.position?.x ?? -width / 2 + (cabin.width ?? 1.5) / 2 + 0.25;
+ const cabinPosZ = cabin.position?.z ?? -depth / 2 + (cabin.depth ?? 1.5) / 2 + 0.25;
+ cabinGroup.position.set(cabinPosX, floorHeight, cabinPosZ);
+ scene.add(cabinGroup);
+ objects.cabin = cabinGroup;
+ }
+
+ // truss
+ if (mAny.truss) {
+ const defaultTrussHeight = floorHeight + wallHeight + 0.5;
+ const trussHeight = Math.max(
+ defaultTrussHeight,
+ typeof mAny.trussHeight === "number" ? mAny.trussHeight : defaultTrussHeight
+ );
+ const trussGroup = new THREE.Group();
+
+ const frameMaterial = new THREE.MeshStandardMaterial({
+ color: "#94a3b8",
+ roughness: 0.65,
+ metalness: 0.35,
+ });
+ const beamThickness = 0.08;
+ const spanX = width;
+ const spanZ = depth;
+
+ const topY = trussHeight;
+ const yBottom = floorHeight + wallHeight;
+
+ const beams: Array<[number, number, number, number, number, number]> = [
+ [0, topY, -spanZ / 2 + beamThickness / 2, spanX, beamThickness, beamThickness],
+ [0, topY, spanZ / 2 - beamThickness / 2, spanX, beamThickness, beamThickness],
+ [-spanX / 2 + beamThickness / 2, topY, 0, beamThickness, beamThickness, spanZ],
+ [spanX / 2 - beamThickness / 2, topY, 0, beamThickness, beamThickness, spanZ],
+ ];
+
+ beams.forEach(([x, y, z, w, h, d]) => {
+ const beam = makeBox({
+ size: [w, h, d],
+ material: frameMaterial,
+ position: new THREE.Vector3(x, y, z),
+ });
+ trussGroup.add(beam);
+ });
+
+ const posts: Array<[number, number, number]> = [
+ [-spanX / 2 + beamThickness / 2, yBottom, -spanZ / 2 + beamThickness / 2],
+ [spanX / 2 - beamThickness / 2, yBottom, -spanZ / 2 + beamThickness / 2],
+ [-spanX / 2 + beamThickness / 2, yBottom, spanZ / 2 - beamThickness / 2],
+ [spanX / 2 - beamThickness / 2, yBottom, spanZ / 2 - beamThickness / 2],
+ ];
+ posts.forEach(([x, _y, z]) => {
+ const post = makeBox({
+ size: [beamThickness, topY - yBottom, beamThickness],
+ material: frameMaterial,
+ position: new THREE.Vector3(x, (topY + yBottom) / 2, z),
+ });
+ trussGroup.add(post);
+ });
+
+ trussGroup.position.set(mAny.trussOffset?.x ?? 0, 0, mAny.trussOffset?.z ?? 0);
+ scene.add(trussGroup);
+ objects.truss = trussGroup;
+
+ const trussLightType: "spot" | "wash" = (mAny.trussLightType ?? "spot") as "spot" | "wash";
+ const lightCount = {
+ front: mAny.trussLightsFront ?? 0,
+ back: mAny.trussLightsBack ?? 0,
+ left: mAny.trussLightsLeft ?? 0,
+ right: mAny.trussLightsRight ?? 0,
+ };
+
+ const addTrussLight = (
+ key: string,
+ x: number,
+ z: number,
+ lx: number,
+ lz: number,
+ flipY = false
+ ) => {
+ const lightY = trussHeight - 0.1;
+ const group = new THREE.Group();
+ let lightMesh: THREE.Mesh;
+ if (trussLightType === "spot") {
+ lightMesh = new THREE.Mesh(
+ new THREE.ConeGeometry(0.07, 0.12, 10),
+ new THREE.MeshStandardMaterial({
+ color: "#facc15",
+ emissive: "#facc15",
+ emissiveIntensity: 1.2,
+ roughness: 0.4,
+ })
+ );
+ } else {
+ lightMesh = new THREE.Mesh(
+ new THREE.BoxGeometry(0.14, 0.08, 0.1),
+ new THREE.MeshStandardMaterial({
+ color: "#fde68a",
+ emissive: "#fbbf24",
+ emissiveIntensity: 0.9,
+ roughness: 0.35,
+ metalness: 0.4,
+ })
+ );
+ }
+ lightMesh.position.set(x, lightY, z);
+ if (flipY) lightMesh.rotation.y = Math.PI;
+ const point = new THREE.PointLight("#fef3c7", 1.1, 6, 2);
+ point.position.set(lx, lightY - 0.05, lz);
+ group.add(lightMesh, point);
+ group.name = key;
+ objects.trussLights.push(group);
+ trussGroup.add(group);
+ };
+
+ const lightSpacing = (span: number, count: number) => (count > 0 ? span / (count + 1) : 0);
+ const frontSpacing = lightSpacing(width, lightCount.front);
+ const backSpacing = lightSpacing(width, lightCount.back);
+ const leftSpacing = lightSpacing(depth, lightCount.left);
+ const rightSpacing = lightSpacing(depth, lightCount.right);
+
+ for (let i = 0; i < lightCount.front; i++) {
+ const x = -width / 2 + frontSpacing * (i + 1);
+ addTrussLight(`truss-front-${i}`, x, -depth / 2, x, -depth / 2 + 0.6, false);
+ }
+ for (let i = 0; i < lightCount.back; i++) {
+ const x = -width / 2 + backSpacing * (i + 1);
+ addTrussLight(`truss-back-${i}`, x, depth / 2, x, depth / 2 - 0.6, true);
+ }
+ for (let i = 0; i < lightCount.left; i++) {
+ const z = -depth / 2 + leftSpacing * (i + 1);
+ addTrussLight(`truss-left-${i}`, -width / 2, z, -width / 2 + 0.6, z, false);
+ }
+ for (let i = 0; i < lightCount.right; i++) {
+ const z = -depth / 2 + rightSpacing * (i + 1);
+ addTrussLight(`truss-right-${i}`, width / 2, z, width / 2 - 0.6, z, true);
+ }
+
+ const bannerWidth: number = mAny.trussBannerWidth ?? 3;
+ const bannerHeight: number = mAny.trussBannerHeight ?? 1;
+ const bannerThickness = 0.04;
+ const bannerMaterial = new THREE.MeshStandardMaterial({
+ color: "#e2e8f0",
+ roughness: 0.8,
+ metalness: 0.05,
+ });
+
+ const bannerCounts = {
+ front: mAny.trussBannersFront ?? 0,
+ back: mAny.trussBannersBack ?? 0,
+ left: mAny.trussBannersLeft ?? 0,
+ right: mAny.trussBannersRight ?? 0,
+ };
+
+ const addBanner = (key: string, position: THREE.Vector3, rotationY: number) => {
+ const mesh = makeBox({
+ size: [bannerWidth, bannerHeight, bannerThickness],
+ material: bannerMaterial,
+ position,
+ });
+ mesh.rotation.y = rotationY;
+ mesh.castShadow = false;
+ mesh.receiveShadow = false;
+ mesh.name = key;
+ objects.trussBanners.push(mesh);
+ trussGroup.add(mesh);
+ };
+
+ const bannerY = trussHeight - bannerHeight / 2;
+ const frontBannerSpacing = lightSpacing(width, bannerCounts.front);
+ for (let i = 0; i < bannerCounts.front; i++) {
+ const x = -width / 2 + frontBannerSpacing * (i + 1);
+ addBanner(`banner-front-${i}`, new THREE.Vector3(x, bannerY, -depth / 2 + bannerThickness / 2), 0);
+ }
+ for (let i = 0; i < bannerCounts.back; i++) {
+ const x = -width / 2 + frontBannerSpacing * (i + 1);
+ addBanner(`banner-back-${i}`, new THREE.Vector3(x, bannerY, depth / 2 - bannerThickness / 2), Math.PI);
+ }
+ const leftBannerSpacing = lightSpacing(depth, bannerCounts.left);
+ for (let i = 0; i < bannerCounts.left; i++) {
+ const z = -depth / 2 + leftBannerSpacing * (i + 1);
+ addBanner(
+ `banner-left-${i}`,
+ new THREE.Vector3(-width / 2 + bannerThickness / 2, bannerY, z),
+ Math.PI / 2
+ );
+ }
+ for (let i = 0; i < bannerCounts.right; i++) {
+ const z = -depth / 2 + leftBannerSpacing * (i + 1);
+ addBanner(
+ `banner-right-${i}`,
+ new THREE.Vector3(width / 2 - bannerThickness / 2, bannerY, z),
+ -Math.PI / 2
+ );
+ }
+ }
+
+ // wall lights
+ const addWallLights = (count: number, side: WallSide) => {
+ if (!count) return;
+ const lights: THREE.PointLight[] = [];
+ const spacing = (side === "back" ? width : depth) / (count + 1);
+ for (let i = 0; i < count; i++) {
+ const pos = new THREE.PointLight("#fef3c7", 0.8, 4, 2);
+ if (side === "back") {
+ pos.position.set(-width / 2 + spacing * (i + 1), floorHeight + wallHeight * 0.6, -depth / 2 + 0.05);
+ } else if (side === "left") {
+ pos.position.set(-width / 2 + 0.05, floorHeight + wallHeight * 0.6, -depth / 2 + spacing * (i + 1));
+ } else {
+ pos.position.set(width / 2 - 0.05, floorHeight + wallHeight * 0.6, -depth / 2 + spacing * (i + 1));
+ }
+ lights.push(pos);
+ scene.add(pos);
+ }
+ objects.wallLights.push(...lights);
+ };
+
+ addWallLights(mAny.wallLightsBack ?? 0, "back");
+ addWallLights(mAny.wallLightsLeft ?? 0, "left");
+ addWallLights(mAny.wallLightsRight ?? 0, "right");
+
+ // led frames
+ const ledFramesCount = mAny.ledFrames ?? 0;
+ if (ledFramesCount > 0) {
+ const ledSize = { w: 1, h: 2.2, t: 0.08 };
+ const gap = 0.2;
+ for (let i = 0; i < ledFramesCount; i++) {
+ const frame = makeBox({
+ size: [ledSize.w, ledSize.h, ledSize.t],
+ material: {
+ color: "#0f172a",
+ emissive: "#22d3ee",
+ emissiveIntensity: 1.1,
+ roughness: 0.35,
+ },
+ position: new THREE.Vector3(
+ -width / 2 + ledSize.w / 2 + gap + i * (ledSize.w + gap),
+ floorHeight + ledSize.h / 2,
+ -depth / 2 + ledSize.t / 2
+ ),
+ });
+ objects.ledFrames.push(frame);
+ scene.add(frame);
+ }
+ }
+
+ // led wall
+ if (mAny.ledWall) {
+ const led = makeBox({
+ size: [width, wallHeight * 0.6, 0.1],
+ material: { color: "#0f172a", emissive: "#38bdf8", emissiveIntensity: 0.8, roughness: 0.4 },
+ position: new THREE.Vector3(0, floorHeight + wallHeight * 0.3, -depth / 2 + 0.08),
+ });
+ objects.ledWall = led;
+ scene.add(led);
+ }
+
+ // counters quick placement
+ const counters = mAny.counters ?? 0;
+ const countersWall: "front" | "island" = (mAny.countersWall ?? "front") as any;
+ const counterVariant: CounterVariant = (mAny.counterVariant ?? "basic") as CounterVariant;
+ for (let i = 0; i < counters; i++) {
+ const ctr = buildCounter(counterVariant, { w: 0.9, d: 0.5, h: 1.1 });
+ const spacing = (countersWall === "front" ? width : depth) / (counters + 1);
+ if (countersWall === "front") {
+ ctr.position.set(-width / 2 + spacing * (i + 1), floorHeight, depth / 2 - 0.5);
+ } else {
+ ctr.position.set(0, floorHeight, -depth / 2 + spacing * (i + 1));
+ }
+ objects.counters.push(ctr);
+ scene.add(ctr);
+ }
+
+ // detailed counters
+ const countersDetailed = (mAny.countersDetailed ?? []) as DetailedCounter[];
+ countersDetailed.forEach((ctr) => {
+ const dims = {
+ w: ctr.size?.w ?? 0.9,
+ d: ctr.size?.d ?? 0.5,
+ h: ctr.size?.h ?? 1.1,
+ };
+ const group = buildCounter((ctr.variant ?? counterVariant) as CounterVariant, dims);
+ group.position.set(ctr.position.x ?? 0, (ctr.position.y ?? 0) + floorHeight, ctr.position.z ?? 0);
+ if (ctr.rotationY) group.rotation.y = ctr.rotationY;
+ objects.countersDetailed.push({ id: ctr.id, group });
+ scene.add(group);
+ });
+
+ // detailed screens
+ const screensDetailed = (mAny.detailedScreens ?? []) as DetailedScreen[];
+ const screensWallSide = (mAny.screensWall as WallSide) ?? "back";
+ const backWallFrontZ = -depth / 2 + wallThickness + panelGap;
+ const leftWallInnerX = -width / 2 + wallThickness + panelGap;
+ const rightWallInnerX = width / 2 - wallThickness - panelGap;
+
+ if (screensDetailed.length === 0 && (mAny.screens ?? 0) > 0) {
+ const total = mAny.screens as number;
+ for (let idx = 0; idx < total; idx++) {
+ let x = 0;
+ let z = 0;
+ const wall: WallSide = screensWallSide;
+ if (wall === "back") {
+ const spacing = width / (total + 1);
+ x = -width / 2 + spacing * (idx + 1);
+ z = backWallFrontZ;
+ } else if (wall === "left") {
+ const spacing = depth / (total + 1);
+ z = -depth / 2 + spacing * (idx + 1);
+ x = leftWallInnerX;
+ } else {
+ const spacing = depth / (total + 1);
+ z = -depth / 2 + spacing * (idx + 1);
+ x = rightWallInnerX;
+ }
+ screensDetailed.push({
+ id: `scr-${Date.now()}-${idx}`,
+ size: { w: 0.9, h: 0.55, t: 0.02 },
+ mount: "wall",
+ wallSide: wall,
+ heightFromFloor: floorHeight + 1.6,
+ position: { x, z },
+ rotationY: wall === "left" ? Math.PI / 2 : wall === "right" ? -Math.PI / 2 : 0,
+ });
+ }
+ }
+
+ screensDetailed.forEach((scr) => {
+ const size = {
+ w: scr.size?.w ?? 0.9,
+ h: scr.size?.h ?? 0.55,
+ t: scr.size?.t ?? 0.02,
+ };
+ const panel = buildScreen(size);
+ const y = scr.heightFromFloor ?? floorHeight + 1.6;
+ panel.position.set(scr.position.x ?? 0, y, scr.position.z ?? 0);
+ if (scr.rotationY) panel.rotation.y = scr.rotationY;
+ objects.screensDetailed.push({ id: scr.id, group: panel });
+ scene.add(panel);
+ });
+
+ return { scene, camera, objects };
+}
diff --git a/ss-messebau-configurator/src/store/configStore.ts b/ss-messebau-configurator/src/store/configStore.ts
index ec087e7..f95012d 100644
--- a/ss-messebau-configurator/src/store/configStore.ts
+++ b/ss-messebau-configurator/src/store/configStore.ts
@@ -39,7 +39,7 @@ type ConfigState = {
config: StandConfig;
price: number;
- /** Undo/Redo-Stacks (intern, nützlich z. B. für Buttons) */
+ /** Undo/Redo-Stacks (intern, nützlich z. B. für Buttons) */
history: StandConfig[];
future: StandConfig[];
historyLimit: number;
@@ -242,7 +242,7 @@ function normalizeConfig(cfg: StandConfig): StandConfig {
// feste Anzahl geschlossener Wände je Standtyp
const fixedWalls = wallFixedMap[cfg.type];
- let modules: StandModules = {
+ const modules: StandModules = {
...cfg.modules,
wallsClosedSides: fixedWalls,
};
@@ -276,6 +276,35 @@ function normalizeConfig(cfg: StandConfig): StandConfig {
modules.ledWall = fixWall(modules.ledWall);
modules.screensWall = fixWall(modules.screensWall);
+ // Wenn keine geeignete Wand existiert, wall-gebundene Module entfernen
+ if (!allowedWalls.length) {
+ modules.screens = 0;
+ modules.ledFrames = 0;
+ if (modules.detailedScreens) {
+ modules.detailedScreens = modules.detailedScreens.filter(
+ (scr) => (scr.mount ?? "wall") !== "wall"
+ );
+ }
+ }
+
+ // Fallback: falls keine gültige Wandseite, Zähler zurücksetzen
+ if (!modules.screensWall && (modules.screens ?? 0) > 0) {
+ modules.screens = 0;
+ }
+ if (!modules.ledWall && (modules.ledFrames ?? 0) > 0) {
+ modules.ledFrames = 0;
+ }
+
+ // Detaillierte Screens an vorhandene Wände anpassen
+ if (allowedWalls.length && modules.detailedScreens?.length) {
+ modules.detailedScreens = modules.detailedScreens.map((scr) => {
+ if ((scr.mount ?? "wall") !== "wall") return scr;
+ const side = scr.wallSide;
+ if (side && allowedWalls.includes(side)) return scr;
+ return { ...scr, wallSide: allowedWalls[0] };
+ });
+ }
+
// --- Boden-Defaults ---
if (!modules.floor) {
modules.floor = {
diff --git a/ss-messebau-configurator/src/styles.css b/ss-messebau-configurator/src/styles.css
index 3208de8..f8ad8c2 100644
--- a/ss-messebau-configurator/src/styles.css
+++ b/ss-messebau-configurator/src/styles.css
@@ -15,14 +15,198 @@ body,
color: #e5e7eb;
}
+.mobile-banner {
+ display: none;
+ padding: 8px 12px;
+ background: #ad1f34;
+ font-size: 12px;
+ text-align: center;
+ font-weight: 600;
+ color: white;
+}
+
+@media (max-width: 768px) {
+ .mobile-banner {
+ display: block;
+ }
+}
+
.app-root {
display: flex;
- height: 100vh;
- overflow: hidden;
+ flex-direction: column;
+ min-height: 100vh;
background: radial-gradient(circle at top left, #111827 0, #020617 55%);
color: #e5e7eb;
}
+.app-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 18px;
+ background: rgba(15, 23, 42, 0.95);
+ border-bottom: 1px solid #111827;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
+ position: sticky;
+ top: 0;
+ z-index: 2;
+}
+
+.app-header__title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+}
+
+.app-header__menu-btn {
+ padding: 8px 10px;
+ border-radius: 8px;
+ border: 1px solid #1f2937;
+ background: #020617;
+ color: #e5e7eb;
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.app-header__menu-btn:hover {
+ background: #0b1220;
+ border-color: #374151;
+}
+
+.app-header__menu-btn:active {
+ background: #111827;
+ transform: translateY(1px);
+}
+
+.app-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.app-canvas-area {
+ flex: 1;
+ position: relative;
+ min-height: 0;
+ width: 100%;
+ overflow: hidden;
+}
+
+.app-sidebar {
+ display: block;
+ height: 100%;
+}
+
+.mobile-fab {
+ position: fixed;
+ right: 16px;
+ bottom: 16px;
+ z-index: 25;
+ display: none;
+}
+
+.mobile-fab button {
+ width: 56px;
+ height: 56px;
+ border-radius: 999px;
+ border: none;
+ background: rgba(0, 0, 0, 0.6);
+ color: #f8fafc;
+ font-size: 22px;
+ font-weight: 700;
+ cursor: pointer;
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
+ transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
+}
+
+.mobile-fab button:active {
+ transform: translateY(2px) scale(0.98);
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.25);
+ background: rgba(0, 0, 0, 0.7);
+}
+
+.mobile-panel-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.4);
+ z-index: 40;
+ display: none;
+ align-items: stretch;
+ justify-content: center;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.25s ease;
+}
+
+.mobile-panel-backdrop.is-open {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.mobile-panel-content {
+ background: #ffffff;
+ color: #0f172a;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ transform: translateY(10px);
+ opacity: 0;
+ transition: opacity 0.25s ease, transform 0.25s ease;
+}
+
+.mobile-panel-backdrop.is-open .mobile-panel-content {
+ transform: translateY(0);
+ opacity: 1;
+}
+
+.mobile-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ min-height: 56px;
+ border-bottom: 1px solid #e5e7eb;
+ background: #ffffff;
+}
+
+.mobile-panel-header h2 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 700;
+}
+
+.mobile-panel-close {
+ border: none;
+ background: #0f172a;
+ color: #f8fafc;
+ border-radius: 999px;
+ padding: 10px 14px;
+ font-weight: 700;
+ cursor: pointer;
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
+ transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease;
+}
+
+.mobile-panel-close:hover {
+ background: #111827;
+}
+
+.mobile-panel-close:active {
+ transform: translateY(1px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+.mobile-panel-body {
+ flex: 1;
+ overflow-y: auto;
+ background: #f8fafc;
+ padding: 12px 14px 16px;
+}
+
/* SIDEBAR */
.sidebar {
@@ -35,7 +219,7 @@ body,
flex-direction: column;
gap: 12px;
box-shadow: 4px 0 25px rgba(0, 0, 0, 0.4);
- height: 100vh;
+ height: 100%;
overflow-y: auto;
}
@@ -282,23 +466,142 @@ body,
.main-viewport {
flex: 1;
position: relative;
- height: 100vh;
+ height: 100%;
}
.main-viewport canvas {
outline: none;
}
+.canvas-root {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
/* Responsive */
-@media (max-width: 900px) {
- .app-root {
+@media (min-width: 1024px) {
+ .app-main {
+ flex-direction: row;
+ }
+
+ .app-sidebar {
+ display: block;
+ }
+}
+
+@media (max-width: 1023px) {
+ .app-main {
flex-direction: column;
+ min-height: 100vh;
+ }
+
+ .app-header,
+ .mobile-banner {
+ display: none;
+ }
+
+ .app-sidebar {
+ display: none;
+ }
+
+ .app-canvas-area,
+ .main-viewport {
+ height: 100vh;
+ }
+
+ .mobile-fab {
+ display: block;
+ }
+
+ .mobile-panel-backdrop {
+ display: flex;
+ }
+}
+
+@media (max-width: 768px) {
+ .app-header {
+ padding: calc(12px + env(safe-area-inset-top, 0px)) 16px 12px;
+ gap: 12px;
+ }
+
+ .app-header__title {
+ font-size: 16px;
+ }
+
+ .app-header__menu-btn {
+ min-height: 44px;
+ padding: 10px 14px;
+ font-size: 15px;
+ }
+
+ .mobile-panel-body {
+ padding: 14px 16px 18px;
+ overflow-x: hidden;
}
.sidebar {
- width: 100%;
- box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
+ padding: 16px 14px;
+ gap: 14px;
+ }
+
+ .sidebar-section {
+ margin-bottom: 10px;
+ }
+
+ .sidebar label {
+ font-size: 14px;
+ margin-bottom: 10px;
+ gap: 6px;
+ }
+
+ .sidebar input,
+ .sidebar select,
+ .preset-btn,
+ .btn-primary,
+ .btn-secondary,
+ .mobile-panel-close,
+ .app-header__menu-btn,
+ .icon-btn {
+ min-height: 44px;
+ padding: 10px 14px;
+ font-size: 15px;
+ }
+
+ .sidebar input,
+ .sidebar select {
+ padding-left: 12px;
+ padding-right: 12px;
+ }
+
+ .form-grid {
+ gap: 10px;
+ }
+
+ .preset-row,
+ .sidebar-section-header,
+ .number-input-row {
+ gap: 8px;
+ }
+
+ .checkbox-row {
+ gap: 10px;
+ font-size: 14px;
+ }
+
+ .section-title {
+ font-size: 14px;
+ }
+
+ .sidebar small,
+ .section-sub,
+ .preset-btn small {
+ font-size: 12px;
+ }
+
+ .preset-btn strong {
+ font-size: 14px;
}
}