Compare commits

..

7 Commits

Author SHA1 Message Date
mistangl
36e2d81843 erste Funktionierende Fassung 2026-04-16 16:31:23 +02:00
mistangl
f75eaa724c npm Version war veraltet 2026-04-16 16:30:36 +02:00
mistangl
5b38af78f0 erste Fassung von Client und Server rein 2026-04-16 13:34:05 +02:00
mistangl
f44949f0af erste Studie zur Verwendung von tldraw 2026-04-13 14:45:27 +02:00
mistangl
c0d37da24c erstes Konzept bei Absprache mit Simon und Yuelin erstellt 2026-04-13 13:45:33 +02:00
mistangl
2281866eb2 icons bunt dazu 2026-04-13 12:32:41 +02:00
mistangl
3e33e11be6 ppt von hand ergänzt 2026-04-13 12:14:20 +02:00
29 changed files with 6148 additions and 21 deletions

4
.gitignore vendored
View File

@@ -87,3 +87,7 @@ ipython_config.py
/work /work
/log /log
/results /results
# Node.js (client)
node_modules/
client/dist/

74
bin/client.bat Normal file
View File

@@ -0,0 +1,74 @@
@echo off
REM ================================================================
REM PLANTPLAN - Client (Vite Dev-Server) verwalten
REM Verwendung: client.bat [start|stop|reload|status]
REM ================================================================
call "%~dp0setenv.bat"
if "%1"=="" goto usage
if /i "%1"=="start" goto start
if /i "%1"=="stop" goto stop
if /i "%1"=="reload" goto reload
if /i "%1"=="status" goto status
goto usage
:start
echo Starting PlantPlan Client (Vite) ...
REM Pruefen ob Client bereits laeuft
tasklist /FI "WINDOWTITLE eq PlantPlan-Client" 2>nul | find /i "cmd.exe" >nul
if not errorlevel 1 (
echo Client laeuft bereits!
goto end
)
REM Pruefen ob node_modules existiert
if not exist "%PV_CLIENT%\node_modules" (
echo FEHLER: node_modules nicht gefunden! Bitte zuerst install_npm.bat ausfuehren.
goto end
)
start "PlantPlan-Client" cmd /k "cd /d "%PV_CLIENT%" && npm run dev"
echo Client gestartet auf %PV_CLIENT_URL%
goto end
:stop
echo Stopping PlantPlan Client ...
taskkill /FI "WINDOWTITLE eq PlantPlan-Client" /T /F >nul 2>&1
if errorlevel 1 (
echo Client war nicht gestartet.
) else (
echo Client gestoppt.
)
goto end
:reload
echo Reloading PlantPlan Client ...
call :stop
timeout /t 2 /nobreak >nul
call :start
goto end
:status
tasklist /FI "WINDOWTITLE eq PlantPlan-Client" 2>nul | find /i "cmd.exe" >nul
if not errorlevel 1 (
echo Client: LAEUFT
) else (
echo Client: GESTOPPT
)
goto end
:usage
echo.
echo Verwendung: client.bat [start^|stop^|reload^|status]
echo.
echo start - Client starten (Vite Dev-Server)
echo stop - Client stoppen
echo reload - Client neu starten
echo status - Pruefen ob Client laeuft
echo.
:end

18
bin/install_npm.bat Normal file
View File

@@ -0,0 +1,18 @@
@echo off
REM ================================================================
REM PLANTPLAN - Node.js Packages fuer Client installieren
REM ================================================================
REM Fuehrt npm install im client/ Verzeichnis aus
REM ================================================================
call "%~dp0setenv.bat"
if not exist "%PV_CLIENT%\node_modules" (
echo Installiere Node.js Packages fuer Client...
cd /d "%PV_CLIENT%"
npm install
echo Erfolgreich.
) else (
echo Node.js Packages bereits installiert!
echo Zum Aktualisieren: cd client ^& npm install
)

19
bin/install_npm.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# ================================================================
# PLANTPLAN - Node.js Packages fuer Client installieren
# ================================================================
# Fuehrt npm install im client/ Verzeichnis aus
# ================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/setenv.sh"
if [ ! -d "$PV_CLIENT/node_modules" ]; then
echo "Installiere Node.js Packages fuer Client..."
cd "$PV_CLIENT"
npm install
echo "Erfolgreich."
else
echo "Node.js Packages bereits installiert!"
echo "Zum Aktualisieren: cd client && npm install"
fi

74
bin/server.bat Normal file
View File

@@ -0,0 +1,74 @@
@echo off
REM ================================================================
REM PLANTPLAN - Server (FastAPI/Uvicorn) verwalten
REM Verwendung: server.bat [start|stop|reload|status]
REM ================================================================
call "%~dp0setenv.bat"
if "%1"=="" goto usage
if /i "%1"=="start" goto start
if /i "%1"=="stop" goto stop
if /i "%1"=="reload" goto reload
if /i "%1"=="status" goto status
goto usage
:start
echo Starting PlantPlan Server (uvicorn) ...
REM Pruefen ob Server bereits laeuft
tasklist /FI "WINDOWTITLE eq PlantPlan-Server" 2>nul | find /i "cmd.exe" >nul
if not errorlevel 1 (
echo Server laeuft bereits!
goto end
)
REM Pruefen ob venv existiert
if not exist "%PROJECT%\.venv\Scripts\activate.bat" (
echo FEHLER: .venv nicht gefunden! Bitte zuerst install_py.bat ausfuehren.
goto end
)
start "PlantPlan-Server" cmd /k "cd /d "%PV_SERVER%" && "%PROJECT%\.venv\Scripts\activate.bat" && uvicorn main:app --reload --host %PV_SERVER_HOST% --port %PV_SERVER_PORT%"
echo Server gestartet auf %PV_SERVER_URL%
goto end
:stop
echo Stopping PlantPlan Server ...
taskkill /FI "WINDOWTITLE eq PlantPlan-Server" /T /F >nul 2>&1
if errorlevel 1 (
echo Server war nicht gestartet.
) else (
echo Server gestoppt.
)
goto end
:reload
echo Reloading PlantPlan Server ...
call :stop
timeout /t 2 /nobreak >nul
call :start
goto end
:status
tasklist /FI "WINDOWTITLE eq PlantPlan-Server" 2>nul | find /i "cmd.exe" >nul
if not errorlevel 1 (
echo Server: LAEUFT
) else (
echo Server: GESTOPPT
)
goto end
:usage
echo.
echo Verwendung: server.bat [start^|stop^|reload^|status]
echo.
echo start - Server starten (uvicorn mit --reload)
echo stop - Server stoppen
echo reload - Server neu starten
echo status - Pruefen ob Server laeuft
echo.
:end

View File

@@ -20,6 +20,15 @@ set "PV_LOG=%PROJECT%\log"
set "PV_TESTS=%PROJECT%\tests" set "PV_TESTS=%PROJECT%\tests"
set "PV_RESULTS=%PROJECT%\results" set "PV_RESULTS=%PROJECT%\results"
set "PV_EXAMPLES=%PROJECT%\examples" set "PV_EXAMPLES=%PROJECT%\examples"
set "PV_CLIENT=%PROJECT%\client"
set "PV_SERVER=%PROJECT%\server"
REM Netzwerk-Konfiguration
set "PV_SERVER_HOST=127.0.0.1"
set "PV_SERVER_PORT=8000"
set "PV_CLIENT_PORT=5173"
set "PV_SERVER_URL=http://%PV_SERVER_HOST%:%PV_SERVER_PORT%"
set "PV_CLIENT_URL=http://localhost:%PV_CLIENT_PORT%"
REM Python-Pfad erweitern (nur wenn noch nicht vorhanden) REM Python-Pfad erweitern (nur wenn noch nicht vorhanden)
echo %PYTHONPATH% | find /i "%PV_LIB%" >nul echo %PYTHONPATH% | find /i "%PV_LIB%" >nul
@@ -35,21 +44,27 @@ if not exist "%PV_DATA%" mkdir "%PV_DATA%"
if not exist "%PV_LOG%" mkdir "%PV_LOG%" if not exist "%PV_LOG%" mkdir "%PV_LOG%"
if not exist "%PV_RESULTS%" mkdir "%PV_RESULTS%" if not exist "%PV_RESULTS%" mkdir "%PV_RESULTS%"
if not exist "%PV_EXAMPLES%" mkdir "%PV_EXAMPLES%" if not exist "%PV_EXAMPLES%" mkdir "%PV_EXAMPLES%"
if not exist "%PV_CLIENT%" mkdir "%PV_CLIENT%"
if not exist "%PV_SERVER%" mkdir "%PV_SERVER%"
REM Umgebungsvariablen anzeigen REM Umgebungsvariablen anzeigen
echo. REM echo.
echo ================================================================ REM echo ================================================================
echo PROJECT = %PROJECT% REM echo PROJECT = %PROJECT%
echo PV_BIN = %PV_BIN% REM echo PV_BIN = %PV_BIN%
echo PV_CFG = %PV_CFG% REM echo PV_CFG = %PV_CFG%
echo PV_LIB = %PV_LIB% REM echo PV_LIB = %PV_LIB%
echo PV_DATA = %PV_DATA% REM echo PV_DATA = %PV_DATA%
echo PV_RESULTS = %PV_RESULTS% REM echo PV_RESULTS = %PV_RESULTS%
echo PV_LOG = %PV_LOG% REM echo PV_LOG = %PV_LOG%
echo PV_EXAMPLES = %PV_EXAMPLES% REM echo PV_EXAMPLES = %PV_EXAMPLES%
echo PYTHONPATH = %PYTHONPATH% REM echo PV_CLIENT = %PV_CLIENT%
echo ================================================================ REM echo PV_SERVER = %PV_SERVER%
echo. REM echo PV_SERVER_URL = %PV_SERVER_URL%
REM echo PV_CLIENT_URL = %PV_CLIENT_URL%
REM echo PYTHONPATH = %PYTHONPATH%
REM echo ================================================================
REM echo.
REM Optionally keep window open REM Optionally keep window open
if "%1"=="--keep-open" pause if "%1"=="--keep-open" pause

View File

@@ -19,8 +19,17 @@ export PV_CFG="$PROJECT/cfg"
export PV_LOG="$PROJECT/log" export PV_LOG="$PROJECT/log"
export PV_TESTS="$PROJECT/tests" export PV_TESTS="$PROJECT/tests"
export PV_RESULTS="$PROJECT/results" export PV_RESULTS="$PROJECT/results"
export PV_CLIENT="$PROJECT/client"
export PV_SERVER="$PROJECT/server"
export PV_EXAMPLES="$PROJECT/examples" export PV_EXAMPLES="$PROJECT/examples"
# Netzwerk-Konfiguration
export PV_SERVER_HOST="127.0.0.1"
export PV_SERVER_PORT="8000"
export PV_CLIENT_PORT="5173"
export PV_SERVER_URL="http://$PV_SERVER_HOST:$PV_SERVER_PORT"
export PV_CLIENT_URL="http://localhost:$PV_CLIENT_PORT"
# Python-Pfad erweitern (nur wenn noch nicht vorhanden) # Python-Pfad erweitern (nur wenn noch nicht vorhanden)
if [[ ":$PYTHONPATH:" != *":$PV_LIB:"* ]]; then if [[ ":$PYTHONPATH:" != *":$PV_LIB:"* ]]; then
export PYTHONPATH="$PV_LIB:$PYTHONPATH" export PYTHONPATH="$PV_LIB:$PYTHONPATH"
@@ -40,7 +49,11 @@ echo "PV_LIB = $PV_LIB"
echo "PV_DATA = $PV_DATA" echo "PV_DATA = $PV_DATA"
echo "PV_RESULTS = $PV_RESULTS" echo "PV_RESULTS = $PV_RESULTS"
echo "PV_LOG = $PV_LOG" echo "PV_LOG = $PV_LOG"
echo "PV_CLIENT = $PV_CLIENT"
echo "PV_SERVER = $PV_SERVER"
echo "PV_EXAMPLES = $PV_EXAMPLES" echo "PV_EXAMPLES = $PV_EXAMPLES"
echo "PV_SERVER_URL = $PV_SERVER_URL"
echo "PV_CLIENT_URL = $PV_CLIENT_URL"
echo "PYTHONPATH = $PYTHONPATH" echo "PYTHONPATH = $PYTHONPATH"
echo "================================================================" echo "================================================================"
echo "" echo ""

21
bin/start.bat Normal file
View File

@@ -0,0 +1,21 @@
@echo off
REM ================================================================
REM PLANTPLAN - Server und Client gemeinsam starten
REM ================================================================
call "%~dp0setenv.bat"
echo ================================================================
echo PlantPlan - Starte Server und Client ...
echo ================================================================
echo.
call "%~dp0server.bat" start
call "%~dp0client.bat" start
echo.
echo ================================================================
echo Server: %PV_SERVER_URL%
echo Client: %PV_CLIENT_URL%
echo ================================================================

17
bin/stop.bat Normal file
View File

@@ -0,0 +1,17 @@
@echo off
REM ================================================================
REM PLANTPLAN - Server und Client gemeinsam stoppen
REM ================================================================
echo ================================================================
echo PlantPlan - Stoppe Server und Client ...
echo ================================================================
echo.
call "%~dp0server.bat" stop
call "%~dp0client.bat" stop
echo.
echo Alle Prozesse gestoppt.
echo ================================================================

12
client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlantPlan</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4653
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
client/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "plantplan-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tldraw": "^4.5.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

18
client/src/App.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import './shapes/shape-types' // Type-Augmentation für Custom Shapes
import { PufferShapeUtil } from './shapes/PufferShapeUtil'
import { KreiselShapeUtil } from './shapes/KreiselShapeUtil'
import { ShapePanel } from './components/ShapePanel'
const customShapeUtils = [PufferShapeUtil, KreiselShapeUtil]
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw shapeUtils={customShapeUtils}>
<ShapePanel />
</Tldraw>
</div>
)
}

View File

@@ -0,0 +1,131 @@
import { useEditor } from 'tldraw'
import { useCallback } from 'react'
import { PUFFER_CONFIG, KREISEL_CONFIG } from '../shapes/shape-config'
/** Nächste freie Nummer für einen Shape-Typ ermitteln. */
function nextNumber(editor: ReturnType<typeof useEditor>, type: string): number {
const existing = editor.getCurrentPageShapes().filter((s) => s.type === type)
// Höchste bestehende Nummer finden
let max = 0
for (const s of existing) {
const lbl = (s.props as { label?: string }).label ?? ''
const m = lbl.match(/_(\d+)$/)
if (m) max = Math.max(max, parseInt(m[1], 10))
}
return max + 1
}
/**
* Panel links oben: Buttons zum Erstellen von Symbolen + Sync zum Server.
* Wird als Kind von <Tldraw> gerendert, daher ist useEditor() verfügbar.
*/
export function ShapePanel() {
const editor = useEditor()
const createPuffer = useCallback(() => {
const nr = nextNumber(editor, 'puffer')
const center = editor.getViewportScreenCenter()
const point = editor.screenToPage(center)
editor.createShape({
type: 'puffer',
x: point.x - PUFFER_CONFIG.defaultW / 2,
y: point.y - PUFFER_CONFIG.defaultH / 2,
props: {
w: PUFFER_CONFIG.defaultW,
h: PUFFER_CONFIG.defaultH,
label: `Puffer_${nr}`,
},
})
}, [editor])
const createKreisel = useCallback(() => {
const nr = nextNumber(editor, 'kreisel')
const center = editor.getViewportScreenCenter()
const point = editor.screenToPage(center)
const totalW = 2 * KREISEL_CONFIG.defaultRadius + KREISEL_CONFIG.defaultAbstand
const totalH = 2 * KREISEL_CONFIG.defaultRadius
editor.createShape({
type: 'kreisel',
x: point.x - totalW / 2,
y: point.y - totalH / 2,
props: {
abstand: KREISEL_CONFIG.defaultAbstand,
radius: KREISEL_CONFIG.defaultRadius,
label: `Kreisel_${nr}`,
},
})
}, [editor])
const syncToServer = useCallback(async () => {
const allShapes = editor.getCurrentPageShapes()
const puffer = allShapes
.filter((s) => s.type === 'puffer')
.map((s) => {
const p = s.props as { w: number; h: number }
return { id: s.id, x: s.x, y: s.y, w: p.w, h: p.h }
})
const kreisel = allShapes
.filter((s) => s.type === 'kreisel')
.map((s) => {
const p = s.props as { abstand: number; radius: number }
return { id: s.id, x: s.x, y: s.y, abstand: p.abstand, radius: p.radius }
})
try {
const res = await fetch('/api/shapes/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ puffer, kreisel }),
})
const data = await res.json()
console.log('Server response:', data)
alert(`Sync OK ${data.received.puffer_count} Puffer, ${data.received.kreisel_count} Kreisel`)
} catch (err) {
console.error('Sync error:', err)
alert('Sync fehlgeschlagen siehe Konsole')
}
}, [editor])
return (
<div style={panelStyle}>
<div style={{ fontWeight: 'bold', fontSize: 13, marginBottom: 4 }}>
Symbole
</div>
<button onClick={createPuffer} style={btnStyle}>
Puffer
</button>
<button onClick={createKreisel} style={btnStyle}>
Kreisel
</button>
<hr style={{ width: '100%', margin: '4px 0' }} />
<button onClick={syncToServer} style={{ ...btnStyle, background: '#e0f0ff' }}>
Sync Server
</button>
</div>
)
}
const panelStyle: React.CSSProperties = {
position: 'absolute',
top: 60,
left: 10,
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
gap: 6,
background: 'white',
padding: 10,
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}
const btnStyle: React.CSSProperties = {
padding: '6px 12px',
cursor: 'pointer',
border: '1px solid #ccc',
borderRadius: 4,
background: '#f8f8f8',
fontSize: 13,
}

9
client/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,142 @@
import {
ShapeUtil,
TLHandle,
TLShapePartial,
Rectangle2d,
HTMLContainer,
IndexKey,
} from 'tldraw'
import { KreiselShape, kreiselShapeProps } from './shape-types'
import { KREISEL_CONFIG as CFG } from './shape-config'
/**
* Kreisel: Zwei Kreise (links/rechts) mit tangentialen Verbindungslinien.
* Handles im Zentrum beider Kreise zum Ändern des Abstands.
*/
export class KreiselShapeUtil extends ShapeUtil<KreiselShape> {
static override type = 'kreisel' as const
static override props = kreiselShapeProps
getDefaultProps(): KreiselShape['props'] {
return { abstand: CFG.defaultAbstand, radius: CFG.defaultRadius, label: 'Kreisel' }
}
getGeometry(shape: KreiselShape) {
const { abstand, radius } = shape.props
return new Rectangle2d({
width: 2 * radius + abstand,
height: 2 * radius,
isFilled: false,
})
}
override canResize() {
return false
}
override onRotate(initial: KreiselShape, current: KreiselShape): TLShapePartial<KreiselShape> {
const HALF_PI = Math.PI / 2
const snapped = Math.round(current.rotation / HALF_PI) * HALF_PI
return { id: current.id, type: 'kreisel', x: initial.x, y: initial.y, rotation: snapped }
}
override getHandles(shape: KreiselShape): TLHandle[] {
const { abstand, radius } = shape.props
return [
{
id: 'left',
type: 'vertex',
x: radius,
y: radius,
index: 'a1' as IndexKey,
canSnap: true,
},
{
id: 'right',
type: 'vertex',
x: radius + abstand,
y: radius,
index: 'a2' as IndexKey,
canSnap: true,
},
]
}
override onHandleDrag(
shape: KreiselShape,
{ handle }: { handle: TLHandle }
): TLShapePartial<KreiselShape> | void {
const r = shape.props.radius
if (handle.id === 'right') {
const newAbstand = Math.min(CFG.maxAbstand, Math.max(CFG.minAbstand, handle.x - r))
return {
id: shape.id,
type: 'kreisel',
props: { ...shape.props, abstand: newAbstand },
}
}
if (handle.id === 'left') {
const oldRightX = r + shape.props.abstand
const raw = oldRightX - handle.x
const newAbstand = Math.min(CFG.maxAbstand, Math.max(CFG.minAbstand, raw))
const dx = shape.props.abstand - newAbstand
return {
id: shape.id,
type: 'kreisel',
x: shape.x + dx,
props: { ...shape.props, abstand: newAbstand },
}
}
}
component(shape: KreiselShape) {
const { abstand, radius: r } = shape.props
const totalW = 2 * r + abstand
const totalH = 2 * r
return (
<HTMLContainer>
<svg
width={totalW}
height={totalH}
style={{ overflow: 'visible' }}
>
{/* Linker Kreis */}
<circle cx={r} cy={r} r={r} fill="none" stroke="black" strokeWidth={2} />
{/* Rechter Kreis */}
<circle cx={r + abstand} cy={r} r={r} fill="none" stroke="black" strokeWidth={2} />
{/* Obere Tangentiallinie */}
<line x1={r} y1={0} x2={r + abstand} y2={0} stroke="black" strokeWidth={2} />
{/* Untere Tangentiallinie */}
<line x1={r} y1={totalH} x2={r + abstand} y2={totalH} stroke="black" strokeWidth={2} />
{/* Label */}
<text
x={totalW / 2}
y={r}
textAnchor="middle"
dominantBaseline="central"
fontSize={36}
fill="#666"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{shape.props.label}
</text>
</svg>
</HTMLContainer>
)
}
indicator(shape: KreiselShape) {
const { abstand, radius: r } = shape.props
return (
<rect
x={0}
y={0}
width={2 * r + abstand}
height={2 * r}
/>
)
}
}

View File

@@ -0,0 +1,127 @@
import {
ShapeUtil,
TLHandle,
TLShapePartial,
Rectangle2d,
HTMLContainer,
IndexKey,
} from 'tldraw'
import { PufferShape, pufferShapeProps } from './shape-types'
import { PUFFER_CONFIG as CFG } from './shape-config'
/** Puffer: Rechteck mit Handles oben-mittig (Höhe) und rechts-mittig (Breite) */
export class PufferShapeUtil extends ShapeUtil<PufferShape> {
static override type = 'puffer' as const
static override props = pufferShapeProps
getDefaultProps(): PufferShape['props'] {
return { w: CFG.defaultW, h: CFG.defaultH, label: 'Puffer' }
}
getGeometry(shape: PufferShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: false,
})
}
override canResize() {
return false
}
override onRotate(initial: PufferShape, current: PufferShape): TLShapePartial<PufferShape> {
const HALF_PI = Math.PI / 2
const snapped = Math.round(current.rotation / HALF_PI) * HALF_PI
return { id: current.id, type: 'puffer', x: initial.x, y: initial.y, rotation: snapped }
}
override getHandles(shape: PufferShape): TLHandle[] {
return [
{
id: 'top',
type: 'vertex',
x: shape.props.w / 2,
y: 0,
index: 'a1' as IndexKey,
canSnap: true,
},
{
id: 'right',
type: 'vertex',
x: shape.props.w,
y: shape.props.h / 2,
index: 'a2' as IndexKey,
canSnap: true,
},
]
}
override onHandleDrag(
shape: PufferShape,
{ handle }: { handle: TLHandle }
): TLShapePartial<PufferShape> | void {
if (handle.id === 'top') {
const newH = Math.min(CFG.maxH, Math.max(CFG.minH, shape.props.h - handle.y))
return {
id: shape.id,
type: 'puffer',
y: shape.y + (shape.props.h - newH),
props: { ...shape.props, h: newH },
}
}
if (handle.id === 'right') {
const newW = Math.min(CFG.maxW, Math.max(CFG.minW, handle.x))
return {
id: shape.id,
type: 'puffer',
props: { ...shape.props, w: newW },
}
}
}
component(shape: PufferShape) {
const { w, h } = shape.props
return (
<HTMLContainer>
<svg
width={w}
height={h}
style={{ overflow: 'visible' }}
>
<rect
x={0}
y={0}
width={w}
height={h}
fill="none"
stroke="#2563eb"
strokeWidth={2}
/>
<text
x={w / 2}
y={h / 2}
textAnchor="middle"
dominantBaseline="central"
fontSize={36}
fill="#666"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{shape.props.label}
</text>
</svg>
</HTMLContainer>
)
}
indicator(shape: PufferShape) {
return (
<rect
x={0}
y={0}
width={shape.props.w}
height={shape.props.h}
/>
)
}
}

View File

@@ -0,0 +1,26 @@
/**
* Zentrale Konfiguration für alle Custom Shapes.
* Defaults, Minima und Maxima an einer Stelle gepflegt.
*/
export const PUFFER_CONFIG = {
// Defaults
defaultW: 120,
defaultH: 60,
// Minima
minW: 20,
minH: 20,
// Maxima
maxW: 500,
maxH: 300,
}
export const KREISEL_CONFIG = {
// Defaults
defaultAbstand: 200,
defaultRadius: 80,
// Minima
minAbstand: 20,
// Maxima
maxAbstand: 4000,
}

View File

@@ -0,0 +1,39 @@
import { T, TLBaseShape } from 'tldraw'
// --- Puffer ---
export type PufferShapeProps = {
w: number
h: number
label: string
}
export type PufferShape = TLBaseShape<'puffer', PufferShapeProps>
export const pufferShapeProps = {
w: T.number,
h: T.number,
label: T.string,
}
// --- Kreisel ---
export type KreiselShapeProps = {
abstand: number
radius: number
label: string
}
export type KreiselShape = TLBaseShape<'kreisel', KreiselShapeProps>
export const kreiselShapeProps = {
abstand: T.number,
radius: T.number,
label: T.string,
}
// --- tldraw Type-Augmentation: Custom Shapes registrieren ---
declare module '@tldraw/tlschema' {
interface TLGlobalShapePropsMap {
puffer: PufferShapeProps
kreisel: KreiselShapeProps
}
}

17
client/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

16
client/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})

View File

@@ -20,8 +20,8 @@ style: |
top: 0; left: 0; right: 0; top: 0; left: 0; right: 0;
margin: 0; margin: 0;
padding: 8px 30px; padding: 8px 30px;
font-size: 1.0em; font-size: 1.2em;
background-color: #44546A; background-color: #274E93;
color: white; color: white;
} }
footer { footer {
@@ -43,7 +43,7 @@ style: |
section.cover h1, section.cover::after { display: none; } section.cover h1, section.cover::after { display: none; }
.cover-wrap { display: flex; width: 100%; height: 100%; } .cover-wrap { display: flex; width: 100%; height: 100%; }
.cover-left { .cover-left {
flex: 0 0 63%; flex: 0 0 60%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -52,21 +52,21 @@ style: |
background: #ffffff; background: #ffffff;
} }
.cover-left img { .cover-left img {
width: 400px; width: 550px;
margin-bottom: 14px; margin-bottom: 14px;
} }
.cover-subtitle { .cover-subtitle {
align-self: flex-start; align-self: flex-start;
margin-left: 23%; margin-left: 23%;
color: #737373; color: #737373;
font-size: 0.5em; font-size: 1.0em;
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
.cover-right { flex: 0 0 37%; position: relative; overflow: hidden; } .cover-right { flex: 0 0 40%; position: relative; overflow: hidden; }
.cover-right img { .cover-right img {
width: 150%; height: 150%; width: 150%; height: 150%;
object-fit: cover; object-fit: cover;
object-position: right center; object-position: 75% center;
display: block; display: block;
} }
.cover-right::after { .cover-right::after {
@@ -81,7 +81,7 @@ style: |
<!-- _class: cover --> <!-- _class: cover -->
<div class="cover-wrap"> <div class="cover-wrap">
<div class="cover-left"> <div class="cover-left">
<img src="img/logo-mista.svg" alt="Mista GmbH Logo"> <img src="img/logo-mista.png" alt="Mista GmbH Logo">
<p class="cover-subtitle">Digitalisierung &amp; Engineering</p> <p class="cover-subtitle">Digitalisierung &amp; Engineering</p>
</div> </div>
<div class="cover-right"> <div class="cover-right">

BIN
doc/Cockburn.pptx Normal file

Binary file not shown.

152
doc/architektur.md Normal file
View File

@@ -0,0 +1,152 @@
# PlantPlan Architektur
**Datum:** 16. April 2026
---
## 1. Überblick
PlantPlan ist eine hybride Webanwendung für technische Gleisplan-Skizzen. Das iPad dient als Eingabegerät (PWA im Safari), ein Python-Backend übernimmt Bibliotheksverwaltung, Normalisierung und Export.
```
┌─────────────────────────┐ ┌─────────────────────────┐
│ client/ │ │ server/ │
│ (tldraw + React + Vite) │ HTTP │ (FastAPI + Python) │
│ │────────▶│ │
│ - Canvas & Zeichnung │ JSON │ - Symbolbibliothek │
│ - Custom Shapes │◀────────│ - Normalisierung │
│ - Snap-to-Grid │ │ - JSON/SVG Export │
└─────────────────────────┘ └─────────────────────────┘
```
---
## 2. Projektstruktur
```
plantplan/
├── bin/ Skripte (setenv, install, activate)
├── cfg/ Konfigurationsdateien
├── client/ Frontend wird auf Server deployed
│ ├── src/
│ │ ├── main.tsx React Entry Point
│ │ └── App.tsx tldraw Canvas
│ ├── index.html
│ ├── package.json
│ ├── tsconfig.json
│ └── vite.config.ts Dev-Proxy /api → localhost:8000
├── server/ Backend wird auf Server kopiert
│ ├── catalog/
│ │ └── symbols.json Symbolbibliothek
│ ├── main.py FastAPI Anwendung
│ └── requirements.txt Python-Abhängigkeiten
├── doc/ Dokumentation
├── lib/ Python-Bibliotheken (eigene Module)
├── tests/ Tests
└── examples/ Beispieldaten
```
---
## 3. Client (Frontend)
| Aspekt | Detail |
|---|---|
| Framework | React 18 + TypeScript |
| Canvas | tldraw v2 |
| Bundler | Vite |
| Dev-Server | `localhost:5173` |
| Build-Output | `client/dist/` (statische Dateien) |
Der Vite Dev-Server leitet `/api/*`-Requests per Proxy an das lokale Backend weiter. Im Produktivbetrieb liefert nginx die statischen Dateien aus und proxied API-Calls.
---
## 4. Server (Backend)
| Aspekt | Detail |
|---|---|
| Framework | FastAPI |
| ASGI-Server | Uvicorn |
| Python | >= 3.11 |
| Dev-Server | `localhost:8000` |
### API-Endpunkte
| Methode | Pfad | Beschreibung |
|---|---|---|
| GET | `/symbols` | Symbolbibliothek als JSON ausliefern |
| POST | `/normalize` | Koordinaten auf Grid snappen |
| POST | `/export` | Finales JSON/SVG-Projektfile erzeugen |
### Datenmodell (Request)
```json
{
"shapes": [
{
"id": "shape:abc",
"type": "weiche",
"x": 100,
"y": 200,
"props": { "radius": 300, "direction": "left" }
}
],
"grid_size": 10
}
```
---
## 5. Lokale Entwicklung
Beide Komponenten laufen parallel auf einer Maschine:
```
Terminal 1 (Server):
cd server
pip install -r requirements.txt
uvicorn main:app --reload
Terminal 2 (Client):
cd client
npm install
npm run dev
```
Kein externer Server nötig. Der Vite-Proxy sorgt dafür, dass Frontend und Backend nahtlos kommunizieren.
---
## 6. Deployment (Hetzner)
Beim Deployment werden zwei Teile auf den Server kopiert:
| Was | Wohin | Wie |
|---|---|---|
| `client/dist/` (nach `npm run build`) | nginx Document Root | Statische Dateien |
| `server/` | Python-Umgebung | uvicorn hinter nginx reverse proxy |
```
nginx
├── / → client/dist/ (statische Dateien)
└── /api/* → proxy_pass http://127.0.0.1:8000
```
---
## 7. Umgebungsvariablen (setenv.bat / setenv.sh)
| Variable | Pfad |
|---|---|
| `PROJECT` | Projekt-Root |
| `PV_CLIENT` | `client/` |
| `PV_SERVER` | `server/` |
| `PV_BIN` | `bin/` |
| `PV_LIB` | `lib/` |
| `PV_CFG` | `cfg/` |
| `PV_DATA` | `data/` |
| `PV_LOG` | `log/` |
| `PV_RESULTS` | `results/` |
| `PV_EXAMPLES` | `examples/` |
| `PV_TESTS` | `tests/` |

40
doc/konzept.md Normal file
View File

@@ -0,0 +1,40 @@
# Story
Grobauslegung einer Schönenberger ILS Anlage mit Anbindung an SIVAS
# Stakeholder
## Geschäftsführung und Vertrieb
- Preis (Genauigkeit durch Koppelung an Sivas)
- Schnelligkeit um ein Angebot
- Vorstellung des Konzeptes, besser als eine Bleistiftskizze
- Möglichkeit zur Weitergabe
- Digitale Datenverwaltung
## Kunde
- Konzept
- Budget Preis
- Visualierung in 2D, professioneller als Handskizze
# Standardablauf
1. Prozessablauf wird erfragt, bzw. festgelegt (Eingang, Ausgang, Zwischenstationen)
2. Erfassung der Materialflussdaten wenn vorhanden
- Art der Betriebsmittel (minitrolley, single carrier, trolley -> durchschnittliche Teiledicke)
- Stückzahlen / Durchsatz an einzelnen Zwischenstationen
3. Skizzierung von Grundelementen der Anlage
- Kreisel
- Pufferbereiche (Modifikation der Kapazität, Strecke/Fläche aus der Kappa, oder umgekehrt)
- Arbeitsplätze
- Schnittstelle zu anderen Systemen, Be- und Entladung
- Sorter (Art aus Durchsatz: Matrix, Tuple, Sequenzierkreisel)
- Anbaugruppen (RFID Leser, )
4. Verbindung der Elemente wenn nötig
- Verbindungsstrecken
- Förderer
5. Export einer csv Zur Preisbestimmung in Sivas (oder direkte Abfrage über Serverprozess)
6. Rahmenangaben für die Kalkulation von Programmierung, Reisekosten, Montagestundenplan
7. Bestimmung eines Endpreises aus Sivas Abfrage und Rahmenangaben
# Ausnahmen

304
doc/tldraw_technologie.md Normal file
View File

@@ -0,0 +1,304 @@
# Technologie-Zusammenfassung: iPad Skizzen-Tool mit Symbolbibliothek
**Datum:** 09. April 2026
**Thema:** Hybride iPad-Anwendung für technische Skizzen (Schienen/Weichen/Kreisel) mit Snap-to-Grid, Symbolbibliothek und JSON/SVG-Export
---
## 1. Anforderungen
- iPad als Eingabegerät (Bleistift-Look)
- ~10 Grundsymbole (Weichen, Kreisel, etc.) aus Katalog
- Snap-to-Grid
- Output: **JSON + SVG**
- Symbole parametrisch veränderbar (z.B. Kreiselab stand)
---
## 2. Gewählte Architektur: Option C (Hybrid)
```
iPad (PWA/Safari) Hetzner Server
───────────────── ──────────────
tldraw (Canvas) FastAPI (Python)
rough.js (Bleistift) │
Custom Shapes ├── GET /symbols → Bibliothek ausliefern
│ ├── POST /normalize → Skizze → SVG
│ JSON (Shapes + Meta) └── POST /export → finales JSON/SVG
└──────────────────────────────────────►
```
**Warum Hybrid:**
- iPad nur für Eingabe + lokales Snap/Rendering (kein App Store, kein Swift)
- Server für Bibliotheksverwaltung, Normalisierung und Export (Python/FastAPI)
- PWA läuft direkt im Safari, gehostet auf Hetzner via nginx
---
## 3. Kerntechnologien
| Schicht | Technologie | Zweck |
|---|---|---|
| iPad-Frontend | **tldraw** (PWA) | Canvas, Snap-to-Grid, SVG-Export |
| Bleistift-Look | **rough.js** | Skizzenhafter Renderstil |
| Symbolbibliothek | **JSON + SVG-Pfade** | Katalog mit Metadaten |
| Backend | **FastAPI (Python)** | Bibliothek, Normalisierung, Export |
| Output | **SVG + JSON** | Weiterverarbeitung / CAD |
---
## 4. Symbolbibliothek-Format (JSON)
```json
{
"symbols": [
{
"id": "weiche_links_r300",
"label": "Weiche links R300",
"type": "weiche",
"radius": 300,
"direction": "left",
"svg_path": "M 0,0 L 100,0 Q 150,0 200,50",
"snap_points": [[0,0], [100,0], [200,50]],
"ports": ["in", "out_gerade", "out_abzweig"]
},
{
"id": "kreisel_standard",
"label": "Kreisel",
"type": "kreisel",
"geometry": {
"circle_top_r": 40,
"circle_bottom_r": 40,
"straight_length": 80
},
"svg_path": "...",
"snap_points": [[0,0], [0,160]]
}
]
}
```
---
## 5. Metadaten an tldraw-Shapes anhängen
### Option A: `meta`-Feld (schnell, für Prototypen)
```typescript
editor.createShape({
type: 'geo',
x: 100,
y: 200,
props: {
geo: 'rectangle',
w: 80,
h: 40,
},
meta: {
symbol_id: 'weiche_links_r300',
symbol_type: 'weiche',
radius: 300,
direction: 'left',
ports: ['in', 'out_gerade', 'out_abzweig'],
catalog_version: '1.2'
}
})
```
### Option B: Custom Shape Type (produktiv, empfohlen)
```typescript
const WeicheShapeUtil = defineShape({
type: 'weiche',
props: {
radius: T.number,
direction: T.string,
ports: T.arrayOf(T.string),
svg_path: T.string,
},
component(shape) {
return (
<SVGContainer>
<path d={shape.props.svg_path} stroke="black" fill="none" />
</SVGContainer>
)
},
getBounds(shape) { /* ... */ }
})
```
**JSON-Export einer Custom Shape:**
```json
{
"id": "shape:abc123",
"type": "weiche",
"x": 100,
"y": 200,
"props": {
"radius": 300,
"direction": "left",
"ports": ["in", "out_gerade", "out_abzweig"],
"svg_path": "M 0,0 L 100,0 Q 150,0 200,50"
}
}
```
---
## 6. Parametrische Shapes: Kreisel-Beispiel
Ziel: Nutzer kann nur den **Abstand der zwei Kreise** verändern, alles andere ist gesperrt.
### 6.1 Shape-Definition mit eingeschränkten Props
```typescript
const KreiselShapeUtil = defineShape({
type: 'kreisel',
props: {
abstand: T.number, // ← einziger veränderbarer Parameter
radius: T.number, // fest (aus Katalog)
},
```
### 6.2 Custom Handle (nur vertikale Bewegung)
```typescript
getHandles(shape) {
return {
mitte: {
id: 'mitte',
type: 'vertex',
x: 0,
y: shape.props.abstand / 2, // Handle mittig zwischen den Kreisen
canBind: false,
}
}
},
onHandleDrag(shape, { handle, delta }) {
return {
props: {
// Nur abstand ändern, x-Bewegung ignorieren, Minimum erzwingen
abstand: Math.max(20, shape.props.abstand + delta.y * 2)
}
}
},
```
### 6.3 Resize einschränken (nur Höhe)
```typescript
onResize(shape, info) {
return {
props: {
abstand: Math.max(20, info.newPoint.y * 2)
}
}
},
getResizeSnapGeometry(shape) {
return { points: [[0, 0], [0, shape.props.abstand]] }
},
```
### 6.4 SVG-Rendering (reagiert auf `abstand`)
```typescript
component(shape) {
const { abstand, radius } = shape.props
const r = radius
return (
<SVGContainer>
{/* Oberer Kreis */}
<circle cx={0} cy={0} r={r} stroke="black" fill="none" />
{/* Unterer Kreis position hängt von abstand ab */}
<circle cx={0} cy={abstand} r={r} stroke="black" fill="none" />
{/* Zwei vertikale Verbindungslinien */}
<line x1={-r} y1={0} x2={-r} y2={abstand} stroke="black" />
<line x1={ r} y1={0} x2={ r} y2={abstand} stroke="black" />
</SVGContainer>
)
}
```
### 6.5 Freiheitsgrade im Überblick
| Aktion | Erlaubt |
|---|---|
| Kreisel verschieben | ✓ |
| Abstand der Kreise ändern | ✓ (Handle mittig) |
| Breite / Radius ändern | ✗ gesperrt |
| Drehen | konfigurierbar |
| Im JSON-Export | `"abstand": 120` |
---
## 7. FastAPI-Backend (3 Endpunkte)
```python
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
# 1. Symbolbibliothek ausliefern
@app.get("/symbols")
async def get_symbols():
with open("catalog/symbols.json") as f:
return json.load(f)
# 2. Koordinaten normalisieren / bereinigen
class SketchPayload(BaseModel):
shapes: list[dict]
grid_size: int = 10
@app.post("/normalize")
async def normalize(payload: SketchPayload):
# Snap-Koordinaten auf Grid runden, SVG-Pfade normalisieren
...
return {"svg": normalized_svg, "shapes": cleaned_shapes}
# 3. Finales Projektfile exportieren
@app.post("/export")
async def export(payload: SketchPayload):
return {
"json": build_project_json(payload.shapes),
"svg": build_final_svg(payload.shapes)
}
```
---
## 8. Umsetzungsplan (Phasen)
| Phase | Inhalt | Aufwand |
|---|---|---|
| 1 | tldraw als PWA auf Hetzner (nginx + Docker) | 1 Tag |
| 2 | Symbolbibliothek JSON + SVG-Pfade für 10 Symbole | 12 Tage |
| 3 | Custom Shape Types in tldraw registrieren | 1 Tag |
| 4 | FastAPI-Backend mit 3 Endpunkten | 1 Tag |
| 5 | Parametrische Handles (Kreisel etc.) | 1 Tag |
| 6 | Export → JSON + SVG finalisieren | 1 Tag |
**Geschätzter Gesamtaufwand Prototyp: ~1 Woche**
---
## 9. Entscheidungsmatrix: `meta` vs. Custom Shape
| Kriterium | `meta`-Feld | Custom Shape Type |
|---|---|---|
| Implementierungsaufwand | minimal | mittel |
| Eigenes SVG-Rendering | ✗ | ✓ |
| Typvalidierung der Props | ✗ | ✓ |
| Parametrische Handles | ✗ | ✓ |
| Im JSON-Export enthalten | ✓ | ✓ |
| Empfehlung | Prototyp Phase 1 | Produktiv ab Phase 3 |
---
*Erstellt mit Claude (Anthropic) Technologiegespräch vom 09.04.2026*

View File

@@ -0,0 +1,26 @@
{
"symbols": [
{
"id": "weiche_links_r300",
"label": "Weiche links R300",
"type": "weiche",
"radius": 300,
"direction": "left",
"svg_path": "M 0,0 L 100,0 Q 150,0 200,50",
"snap_points": [[0,0], [100,0], [200,50]],
"ports": ["in", "out_gerade", "out_abzweig"]
},
{
"id": "kreisel_standard",
"label": "Kreisel",
"type": "kreisel",
"geometry": {
"circle_top_r": 40,
"circle_bottom_r": 40,
"straight_length": 80
},
"svg_path": "M -40,0 A 40,40 0 1,1 40,0 A 40,40 0 1,1 -40,0 M -40,160 A 40,40 0 1,1 40,160 A 40,40 0 1,1 -40,160 M -40,0 L -40,160 M 40,0 L 40,160",
"snap_points": [[0,0], [0,160]]
}
]
}

134
server/main.py Normal file
View File

@@ -0,0 +1,134 @@
"""
PlantPlan FastAPI Backend
- Symbolbibliothek ausliefern
- Koordinaten normalisieren
- JSON/SVG Export
- Shape-Änderungen empfangen (Puffer + Kreisel)
"""
import json
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = FastAPI(title="PlantPlan API")
# CORS: Erlaubt Zugriff vom lokalen Frontend Dev-Server
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_methods=["*"],
allow_headers=["*"],
)
CATALOG_DIR = Path(__file__).parent / "catalog"
# ---------------------------------------------------------------------------
# Pydantic Models
# ---------------------------------------------------------------------------
class SketchPayload(BaseModel):
shapes: list[dict]
grid_size: int = 10
class PufferUpdate(BaseModel):
"""Puffer-Symbol: Rechteck mit Breite und Höhe."""
id: str
x: float
y: float
w: float # Breite
h: float # Höhe
class KreiselUpdate(BaseModel):
"""Kreisel-Symbol: Zwei Kreise mit Abstand und Radius."""
id: str
x: float
y: float
abstand: float # Abstand zwischen den Kreismittelpunkten
radius: float # Radius beider Kreise
class ShapeUpdatePayload(BaseModel):
"""Payload für Shape-Änderungen vom Client."""
puffer: list[PufferUpdate] = []
kreisel: list[KreiselUpdate] = []
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@app.get("/symbols")
async def get_symbols():
"""Symbolbibliothek als JSON ausliefern."""
catalog_file = CATALOG_DIR / "symbols.json"
with open(catalog_file, encoding="utf-8") as f:
return json.load(f)
@app.post("/normalize")
async def normalize(payload: SketchPayload):
"""Koordinaten auf Grid snappen und SVG-Pfade normalisieren."""
cleaned = []
for shape in payload.shapes:
s = dict(shape)
if "x" in s:
s["x"] = round(s["x"] / payload.grid_size) * payload.grid_size
if "y" in s:
s["y"] = round(s["y"] / payload.grid_size) * payload.grid_size
cleaned.append(s)
return {"shapes": cleaned}
@app.post("/export")
async def export_sketch(payload: SketchPayload):
"""Finales Projektfile als JSON exportieren."""
return {
"json": {
"version": "1.0",
"shapes": payload.shapes,
}
}
@app.post("/shapes/update")
async def update_shapes(payload: ShapeUpdatePayload):
"""
Shape-Änderungen vom Client empfangen.
Gibt für jeden Puffer Breite/Höhe und für jeden Kreisel
Abstand/Radius + berechnete Gesamtlänge zurück.
"""
result = {
"received": {
"puffer_count": len(payload.puffer),
"kreisel_count": len(payload.kreisel),
},
"puffer": [],
"kreisel": [],
}
for p in payload.puffer:
result["puffer"].append({
"id": p.id,
"x": p.x,
"y": p.y,
"breite": p.w,
"hoehe": p.h,
})
for k in payload.kreisel:
result["kreisel"].append({
"id": k.id,
"x": k.x,
"y": k.y,
"abstand": k.abstand,
"radius": k.radius,
"gesamtlaenge": 2 * k.radius + k.abstand,
})
return result

3
server/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
pydantic>=2.0.0