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/src/components/Configurator3D.tsx b/ss-messebau-configurator/src/components/Configurator3D.tsx index 5105b20..2b3ba7a 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, @@ -19,6 +21,12 @@ import { } from "@react-three/drei"; import * as THREE from "three"; import { useConfigStore } from "../store/configStore"; +import { + buildSceneAabbs, + DEFAULT_CLEARANCE, + findCollisionForMany, + makeAabb, +} from "../lib/collision"; type WallSide = "back" | "left" | "right"; type CounterVariant = "basic" | "premium" | "corner"; @@ -179,6 +187,8 @@ function Transformable({ enabled, mode, snap, + rotationAxes = ["y"], + rotationSnapDeg = 5, children, onChange, onDragStart, @@ -187,8 +197,10 @@ function Transformable({ enabled: boolean; mode: "translate" | "rotate" | "scale"; snap: boolean; + rotationAxes?: Array<"x" | "y" | "z">; + rotationSnapDeg?: number; children: ReactNode; - onChange?: (pos: THREE.Vector3) => void; + onChange?: (pos: THREE.Vector3, rot: THREE.Euler) => void; onDragStart?: () => void; onDragEnd?: () => void; }) { @@ -199,7 +211,7 @@ function Transformable({ const tc = tcRef.current; if (!tc) return; - const handleChange = () => onChange?.(groupRef.current.position); + const handleChange = () => onChange?.(groupRef.current.position, groupRef.current.rotation); const handleMouseDown = () => onDragStart?.(); const handleMouseUp = () => onDragEnd?.(); const handleDraggingChanged = (e: any) => { @@ -223,16 +235,22 @@ function Transformable({ if (!enabled) { return {children}; } + + const showX = mode === "rotate" ? rotationAxes.includes("x") : true; + const showY = mode === "rotate" ? rotationAxes.includes("y") : false; + const showZ = mode === "rotate" ? rotationAxes.includes("z") : true; + const rotationSnap = THREE.MathUtils.degToRad(rotationSnapDeg); return ( {children} @@ -287,6 +305,12 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const wallLightsLeft: number = mAny.wallLightsLeft ?? 0; const wallLightsRight: number = mAny.wallLightsRight ?? 0; + // Kollisionsabstand (konfigurierbar über modules.collisionClearance) + const collisionClearance: number = Math.max( + 0, + typeof mAny.collisionClearance === "number" ? mAny.collisionClearance : DEFAULT_CLEARANCE + ); + // Banner / Truss const bannersFront: number = mAny.trussBannersFront ?? 0; const bannersBack: number = mAny.trussBannersBack ?? 0; @@ -434,6 +458,86 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const countersDetailed = (mAny.countersDetailed ?? []) as DetailedCounter[]; const screensDetailed = (mAny.detailedScreens ?? []) as DetailedScreen[]; + const sceneAabbs = useMemo( + () => buildSceneAabbs(config, collisionClearance), + [config, collisionClearance] + ); + + const [collidingKeys, setCollidingKeys] = useState>(new Set()); + const [lastValidPositions, setLastValidPositions] = useState< + Record + >({}); + + const rememberValidPosition = useCallback((key: string, pos: { x: number; z: number }) => { + setLastValidPositions((prev) => ({ ...prev, [key]: pos })); + }, []); + + const getFallbackPosition = useCallback( + (key: string, fallback: { x: number; z: number }) => lastValidPositions[key] ?? fallback, + [lastValidPositions] + ); + + const setCollisionState = useCallback((key: string, collided: boolean) => { + setCollidingKeys((prev) => { + const next = new Set(prev); + if (collided) next.add(key); + else next.delete(key); + return next; + }); + }, []); + + const ensureNoCollision = useCallback( + (key: string, boxes: ReturnType[], ignoreIds: string[] = []) => { + const ignored = new Set([key, ...ignoreIds]); + const collision = findCollisionForMany(boxes, sceneAabbs, ignored); + if (collision.collided) { + setCollisionState(key, true); + return collision; + } + setCollisionState(key, false); + return collision; + }, + [sceneAabbs, setCollisionState] + ); + + // initial gültige Positionen merken (Rollback bei Kollision) + useEffect(() => { + const next: Record = {}; + + countersDetailed.forEach((ctr) => { + next[`ctr-d-${ctr.id}`] = { + x: ctr.position?.x ?? 0, + z: ctr.position?.z ?? 0, + }; + }); + + screensDetailed.forEach((scr) => { + next[`scr-d-${scr.id}`] = { + x: scr.position?.x ?? 0, + z: scr.position?.z ?? 0, + }; + }); + + if (cabinEnabled) { + next.cabin = { x: cabinPosX, z: cabinPosZ }; + } + + if (trussEnabled) { + next.truss = { x: trussOffsetX, z: trussOffsetZ }; + } + + setLastValidPositions(next); + }, [ + cabinEnabled, + cabinPosX, + cabinPosZ, + countersDetailed, + screensDetailed, + trussEnabled, + trussOffsetX, + trussOffsetZ, + ]); + // ---- Legacy → Detailed Konverter (per Doppelklick) const convertLegacyCountersToDetailed = () => { if ((counters ?? 0) <= 0) return; @@ -605,10 +709,21 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { enabled={editMode && isSelected("cabin")} mode={transformMode} snap={snapOn} + rotationAxes={[]} onDragStart={disableOrbit} 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 +789,34 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has("cabin") && ( + <> + + + + + +
+ Belegt – bitte verschieben +
+ + + )} )} @@ -696,10 +839,32 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { enabled={editMode && selected} mode={transformMode} snap={snapOn} + rotationAxes={["y"]} + rotationSnapDeg={10} onDragStart={disableOrbit} onDragEnd={enableOrbit} - onChange={(pos) => { + onChange={(pos, rot) => { + if (transformMode === "rotate") { + const rotY = THREE.MathUtils.euclideanModulo(rot?.y ?? 0, Math.PI * 2); + pos.set(px, pos.y, pz); + const next = countersDetailed.map((c0) => + c0.id === ctr.id ? { ...c0, rotationY: rotY } : c0 + ); + setConfig({ modules: { countersDetailed: next } as any }); + return; + } + const c = clampXZ(pos.x, pos.z, width, depth, w / 2, d / 2); + const candidate = makeAabb(key, "Counter", c.x, c.z, w, d, collisionClearance); + const collision = ensureNoCollision(key, [candidate]); + + if (collision.collided) { + const fallback = getFallbackPosition(key, { x: px, z: pz }); + pos.set(fallback.x, pos.y, fallback.z); + return; + } + + rememberValidPosition(key, { x: c.x, z: c.z }); pos.set(c.x, pos.y, c.z); const next = countersDetailed.map((c0) => c0.id === ctr.id ? { ...c0, position: { ...c0.position, x: c.x, z: c.z } } : c0 @@ -734,6 +899,30 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has(key) && ( + <> + + + + + +
+ Kollision erkannt +
+ + + )} ); @@ -826,6 +1015,7 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { const y = (scr.heightFromFloor ?? (floorHeight + 1.6)) - floorHeight; // lokaler Offset const key = `scr-d-${scr.id}`; const selected = isSelected(key); + const rotatable = mount === "floor" || mount === "truss"; // Position & Rotation let px = scr.position?.x ?? 0; @@ -863,10 +1053,50 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { enabled={editMode && selected} mode={transformMode} snap={snapOn} + rotationAxes={rotatable ? ["y"] : []} + rotationSnapDeg={10} onDragStart={disableOrbit} onDragEnd={enableOrbit} - onChange={(pos) => { + onChange={(pos, rot) => { + if (transformMode === "rotate" && rotatable) { + const rotYNext = THREE.MathUtils.euclideanModulo(rot?.y ?? 0, Math.PI * 2); + pos.set(px, pos.y, pz); + const next = screensDetailed.map((s0) => + s0.id === scr.id ? { ...s0, rotationY: rotYNext } : s0 + ); + setConfig({ modules: { detailedScreens: next } as any }); + return; + } + const c = clampXZ(pos.x, pos.z, width, depth, w / 2, t / 2); + let candidateW = w; + let candidateD = t; + if (mount === "wall") { + if (scr.wallSide === "left" || scr.wallSide === "right") { + candidateW = t; + candidateD = w; + } + } else if (mount === "floor") { + candidateD = t; + } + const candidate = makeAabb( + key, + "Screen", + c.x, + c.z, + candidateW, + candidateD, + collisionClearance + ); + const collision = ensureNoCollision(key, [candidate]); + + if (collision.collided) { + const fallback = getFallbackPosition(key, { x: px, z: pz }); + pos.set(fallback.x, pos.y, fallback.z); + return; + } + + rememberValidPosition(key, { x: c.x, z: c.z }); pos.set(c.x, pos.y, c.z); const next = screensDetailed.map((s0) => s0.id === scr.id @@ -891,6 +1121,22 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has(key) && ( + +
+ Screen kollidiert +
+ + )} ); @@ -1034,10 +1280,66 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { enabled={editMode && isSelected("truss")} mode={transformMode} snap={snapOn} + rotationAxes={[]} onDragStart={disableOrbit} onDragEnd={enableOrbit} onChange={(pos) => { const c = clampXZ(pos.x, pos.z, width, depth, 0.4, 0.4); + const columnSize = 0.12; + const candidates = [ + makeAabb( + "truss-col-front-left", + "Truss-Stütze", + -width / 2 + c.x, + depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + makeAabb( + "truss-col-front-right", + "Truss-Stütze", + width / 2 + c.x, + depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + makeAabb( + "truss-col-back-left", + "Truss-Stütze", + -width / 2 + c.x, + -depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + makeAabb( + "truss-col-back-right", + "Truss-Stütze", + width / 2 + c.x, + -depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + ]; + + const ignoreSelf = [ + "truss-col-front-left", + "truss-col-front-right", + "truss-col-back-left", + "truss-col-back-right", + ]; + + const collision = ensureNoCollision("truss", candidates, ignoreSelf); + if (collision.collided) { + const fallback = getFallbackPosition("truss", { x: trussOffsetX, z: trussOffsetZ }); + pos.set(fallback.x, pos.y, fallback.z); + return; + } + + rememberValidPosition("truss", { x: c.x, z: c.z }); pos.set(c.x, pos.y, c.z); setConfig({ modules: { trussOffset: { x: c.x, z: c.z } } as any }); }} @@ -1056,6 +1358,22 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has("truss") && ( + +
+ Truss kollidiert +
+ + )} diff --git a/ss-messebau-configurator/src/components/SidebarControls.tsx b/ss-messebau-configurator/src/components/SidebarControls.tsx index 37d874a..1bba02f 100644 --- a/ss-messebau-configurator/src/components/SidebarControls.tsx +++ b/ss-messebau-configurator/src/components/SidebarControls.tsx @@ -2,6 +2,7 @@ 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"; @@ -14,7 +15,7 @@ const wallFixedMap = { } as const; export default function SidebarControls() { - const { config, price, setConfig, applyPreset } = useConfigStore(); + const { config, price, setConfig, applyPreset, replaceConfig } = useConfigStore(); // Helper: DeepPartial-Patch für modules (typsicher) const patchModules = (mods: DeepPartial) => @@ -60,6 +61,29 @@ export default function SidebarControls() { }); }; + const totalModulesSelected = () => { + const mm = config.modules as any; + const lights = + (mm.trussLightsFront ?? 0) + + (mm.trussLightsBack ?? 0) + + (mm.trussLightsLeft ?? 0) + + (mm.trussLightsRight ?? 0); + + const banners = + (mm.trussBannersFront ?? 0) + + (mm.trussBannersBack ?? 0) + + (mm.trussBannersLeft ?? 0) + + (mm.trussBannersRight ?? 0); + + return ( + (config.modules.counters ?? 0) + + (config.modules.screens ?? 0) + + (config.modules.ledFrames ?? 0) + + lights + + banners + ); + }; + const stepModule = ( field: "ledFrames" | "counters" | "screens", delta: number, @@ -87,6 +111,31 @@ export default function SidebarControls() { } }; + const applyGuidedDefaults = () => { + const mm = config.modules as any; + + patchModules({ + floor: { + ...(config.modules.floor ?? {}), + type: config.modules.floor?.type ?? "carpet", + raised: floorRaised, + }, + counters: Math.max(config.modules.counters ?? 0, 1), + counterVariant: config.modules.counterVariant ?? "premium", + countersWall: config.modules.countersWall ?? "front", + countersWithPower: true, + screens: Math.max(config.modules.screens ?? 0, 1), + screensWall: config.modules.screensWall ?? "back", + truss: config.modules.truss ?? true, + trussLightsFront: mm.trussLightsFront ?? 2, + trussLightsLeft: mm.trussLightsLeft ?? 0, + trussLightsRight: mm.trussLightsRight ?? 0, + trussLightsBack: mm.trussLightsBack ?? 0, + trussBannersFront: mm.trussBannersFront ?? 1, + wallLightsBack: mm.wallLightsBack ?? 2, + } as any); + }; + const copyConfigToClipboard = () => { const area = config.width * config.depth; const m = config.modules; @@ -272,6 +321,32 @@ export default function SidebarControls() { window.location.href = mailto; }; + const moduleCount = totalModulesSelected(); + const guidedSteps = [ + { + title: "Fläche & Typ wählen", + desc: "Breite/Tiefe setzen und Standtyp bestimmen.", + done: Boolean(config.width && config.depth && config.type), + }, + { + title: "Wände & Boden veredeln", + desc: "Oberflächen, Lagerraum oder Raised Floor festlegen.", + done: + Boolean(config.modules.floor?.type) && + config.modules.wallsClosedSides === fixedWalls, + }, + { + title: "Module & Varianten platzieren", + desc: "Tresen, Screens, Truss, Bannerrahmen und Licht auswählen.", + done: moduleCount > 0, + }, + { + title: "Preis prüfen & exportieren", + desc: "Live-Richtpreis kontrollieren, kopieren oder per Mail senden.", + done: price > 0 && Boolean(customerName || company || email || phone), + }, + ]; + return (