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..13387ca 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,13 @@ import { } from "@react-three/drei"; import * as THREE from "three"; import { useConfigStore } from "../store/configStore"; +import { + buildSceneAabbs, + DEFAULT_CLEARANCE, + findCollisionForMany, + makeAabb, +} from "../lib/collision"; +import { clampDimension, COUNTER_SIZE_LIMITS, SCREEN_SIZE_LIMITS } from "../config/sizeLimits"; type WallSide = "back" | "left" | "right"; type CounterVariant = "basic" | "premium" | "corner"; @@ -108,6 +117,26 @@ function clampXZ( }; } +function clampCounterScale( + scale: THREE.Vector3, + base: { w: number; d: number; h: number } +): THREE.Vector3 { + const nextW = clampDimension(base.w * scale.x, COUNTER_SIZE_LIMITS.min.w, COUNTER_SIZE_LIMITS.max.w); + const nextD = clampDimension(base.d * scale.z, COUNTER_SIZE_LIMITS.min.d, COUNTER_SIZE_LIMITS.max.d); + const nextH = clampDimension(base.h * scale.y, COUNTER_SIZE_LIMITS.min.h, COUNTER_SIZE_LIMITS.max.h); + return new THREE.Vector3(nextW / base.w, nextH / base.h, nextD / base.d); +} + +function clampScreenScale( + scale: THREE.Vector3, + base: { w: number; h: number; t: number } +): THREE.Vector3 { + const nextW = clampDimension(base.w * scale.x, SCREEN_SIZE_LIMITS.min.w, SCREEN_SIZE_LIMITS.max.w); + const nextH = clampDimension(base.h * scale.y, SCREEN_SIZE_LIMITS.min.h, SCREEN_SIZE_LIMITS.max.h); + const nextT = clampDimension(base.t * scale.z, SCREEN_SIZE_LIMITS.min.t, SCREEN_SIZE_LIMITS.max.t); + return new THREE.Vector3(nextW / base.w, nextH / base.h, nextT / base.t); +} + /** Geometrien */ function CounterBlock({ variant, @@ -181,6 +210,7 @@ function Transformable({ snap, children, onChange, + onScaleChange, onDragStart, onDragEnd, }: { @@ -189,17 +219,38 @@ function Transformable({ snap: boolean; children: ReactNode; onChange?: (pos: THREE.Vector3) => void; + onScaleChange?: (scale: THREE.Vector3, prevScale: THREE.Vector3) => THREE.Vector3; onDragStart?: () => void; onDragEnd?: () => void; }) { const tcRef = useRef(null); const groupRef = useRef(null!); + const lastScaleRef = useRef(new THREE.Vector3(1, 1, 1)); + + useEffect(() => { + if (groupRef.current) { + lastScaleRef.current.copy(groupRef.current.scale); + } + }, []); useEffect(() => { const tc = tcRef.current; if (!tc) return; - const handleChange = () => onChange?.(groupRef.current.position); + const handleChange = () => { + const group = groupRef.current; + if (!group) return; + + if (onScaleChange && !lastScaleRef.current.equals(group.scale)) { + const guarded = onScaleChange(group.scale.clone(), lastScaleRef.current.clone()); + group.scale.copy(guarded); + lastScaleRef.current.copy(guarded); + } else { + lastScaleRef.current.copy(group.scale); + } + + onChange?.(group.position); + }; const handleMouseDown = () => onDragStart?.(); const handleMouseUp = () => onDragEnd?.(); const handleDraggingChanged = (e: any) => { @@ -218,7 +269,7 @@ function Transformable({ tc.removeEventListener("mouseUp", handleMouseUp); tc.removeEventListener("dragging-changed", handleDraggingChanged); }; - }, [onChange, onDragEnd, onDragStart]); + }, [onChange, onDragEnd, onDragStart, onScaleChange]); if (!enabled) { return {children}; @@ -287,6 +338,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 +491,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; @@ -609,6 +746,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 +821,34 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has("cabin") && ( + <> + + + + + +
+ Belegt – bitte verschieben +
+ + + )} )} @@ -682,9 +857,12 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { {countersDetailed.length > 0 ? countersDetailed.map((ctr) => { const variant: CounterVariant = 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 h = ctr.size?.h ?? 1.1; + const rawW = ctr.size?.w ?? (variant === "premium" ? 1.4 : 0.9); + const rawD = ctr.size?.d ?? (variant === "premium" ? 0.6 : 0.5); + const rawH = ctr.size?.h ?? 1.1; + const w = clampDimension(rawW, COUNTER_SIZE_LIMITS.min.w, COUNTER_SIZE_LIMITS.max.w); + const d = clampDimension(rawD, COUNTER_SIZE_LIMITS.min.d, COUNTER_SIZE_LIMITS.max.d); + const h = clampDimension(rawH, COUNTER_SIZE_LIMITS.min.h, COUNTER_SIZE_LIMITS.max.h); const px = ctr.position?.x ?? 0; const pz = ctr.position?.z ?? 0; const key = `ctr-d-${ctr.id}`; @@ -696,10 +874,21 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { enabled={editMode && selected} mode={transformMode} snap={snapOn} + onScaleChange={(scale) => clampCounterScale(scale, { w, d, h })} onDragStart={disableOrbit} onDragEnd={enableOrbit} onChange={(pos) => { const c = clampXZ(pos.x, pos.z, width, depth, w / 2, d / 2); + const candidate = makeAabb(key, "Counter", c.x, c.z, w, d, collisionClearance); + const collision = ensureNoCollision(key, [candidate]); + + if (collision.collided) { + const fallback = getFallbackPosition(key, { x: px, z: pz }); + pos.set(fallback.x, pos.y, fallback.z); + return; + } + + rememberValidPosition(key, { x: c.x, z: c.z }); pos.set(c.x, pos.y, c.z); const next = countersDetailed.map((c0) => c0.id === ctr.id ? { ...c0, position: { ...c0.position, x: c.x, z: c.z } } : c0 @@ -734,6 +923,30 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has(key) && ( + <> + + + + + +
+ Kollision erkannt +
+ + + )} ); @@ -819,9 +1032,12 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { {/* Screens – Detailed bevorzugt, sonst Legacy */} {screensDetailed.length > 0 ? screensDetailed.map((scr) => { - const w = scr.size?.w ?? 0.9; - const h = scr.size?.h ?? 0.55; - const t = scr.size?.t ?? 0.02; + const rawW = scr.size?.w ?? 0.9; + const rawH = scr.size?.h ?? 0.55; + const rawT = scr.size?.t ?? 0.02; + const w = clampDimension(rawW, SCREEN_SIZE_LIMITS.min.w, SCREEN_SIZE_LIMITS.max.w); + const h = clampDimension(rawH, SCREEN_SIZE_LIMITS.min.h, SCREEN_SIZE_LIMITS.max.h); + const t = clampDimension(rawT, SCREEN_SIZE_LIMITS.min.t, SCREEN_SIZE_LIMITS.max.t); const mount = scr.mount ?? "wall"; const y = (scr.heightFromFloor ?? (floorHeight + 1.6)) - floorHeight; // lokaler Offset const key = `scr-d-${scr.id}`; @@ -863,10 +1079,39 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { enabled={editMode && selected} mode={transformMode} snap={snapOn} + onScaleChange={(scale) => clampScreenScale(scale, { w, h, t })} onDragStart={disableOrbit} onDragEnd={enableOrbit} onChange={(pos) => { const c = clampXZ(pos.x, pos.z, width, depth, w / 2, t / 2); + let candidateW = w; + let candidateD = t; + if (mount === "wall") { + if (scr.wallSide === "left" || scr.wallSide === "right") { + candidateW = t; + candidateD = w; + } + } else if (mount === "floor") { + candidateD = t; + } + const candidate = makeAabb( + key, + "Screen", + c.x, + c.z, + candidateW, + candidateD, + collisionClearance + ); + const collision = ensureNoCollision(key, [candidate]); + + if (collision.collided) { + const fallback = getFallbackPosition(key, { x: px, z: pz }); + pos.set(fallback.x, pos.y, fallback.z); + return; + } + + rememberValidPosition(key, { x: c.x, z: c.z }); pos.set(c.x, pos.y, c.z); const next = screensDetailed.map((s0) => s0.id === scr.id @@ -891,6 +1136,22 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { )} + {collidingKeys.has(key) && ( + +
+ Screen kollidiert +
+ + )} ); @@ -1038,6 +1299,61 @@ function StandMesh({ orbitRef }: { orbitRef: MutableRefObject }) { onDragEnd={enableOrbit} onChange={(pos) => { const c = clampXZ(pos.x, pos.z, width, depth, 0.4, 0.4); + const columnSize = 0.12; + const candidates = [ + makeAabb( + "truss-col-front-left", + "Truss-Stütze", + -width / 2 + c.x, + depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + makeAabb( + "truss-col-front-right", + "Truss-Stütze", + width / 2 + c.x, + depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + makeAabb( + "truss-col-back-left", + "Truss-Stütze", + -width / 2 + c.x, + -depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + makeAabb( + "truss-col-back-right", + "Truss-Stütze", + width / 2 + c.x, + -depth / 2 + c.z, + columnSize, + columnSize, + collisionClearance + ), + ]; + + const ignoreSelf = [ + "truss-col-front-left", + "truss-col-front-right", + "truss-col-back-left", + "truss-col-back-right", + ]; + + const collision = ensureNoCollision("truss", candidates, ignoreSelf); + if (collision.collided) { + const fallback = getFallbackPosition("truss", { x: trussOffsetX, z: trussOffsetZ }); + pos.set(fallback.x, pos.y, fallback.z); + return; + } + + rememberValidPosition("truss", { x: c.x, z: c.z }); pos.set(c.x, pos.y, c.z); setConfig({ modules: { trussOffset: { x: c.x, z: c.z } } as any }); }} @@ -1056,6 +1372,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..6f696f4 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) => @@ -314,6 +315,31 @@ export default function SidebarControls() { 8×5 · Kopfstand Premium +
+ +
+ + +
+
+ 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 */} diff --git a/ss-messebau-configurator/src/config/sizeLimits.ts b/ss-messebau-configurator/src/config/sizeLimits.ts new file mode 100644 index 0000000..214634d --- /dev/null +++ b/ss-messebau-configurator/src/config/sizeLimits.ts @@ -0,0 +1,13 @@ +export const COUNTER_SIZE_LIMITS = { + min: { w: 0.6, d: 0.4, h: 0.8 }, + max: { w: 3, d: 1.4, h: 1.6 }, +}; + +export const SCREEN_SIZE_LIMITS = { + min: { w: 0.4, h: 0.3, t: 0.01 }, + max: { w: 4, h: 3, t: 0.25 }, +}; + +export function clampDimension(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} 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, +};