Compare commits
7 Commits
b7ac119b05
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36e2d81843 | ||
|
|
f75eaa724c | ||
|
|
5b38af78f0 | ||
|
|
f44949f0af | ||
|
|
c0d37da24c | ||
|
|
2281866eb2 | ||
|
|
3e33e11be6 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
74
bin/client.bat
Normal 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
18
bin/install_npm.bat
Normal 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
19
bin/install_npm.sh
Normal 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
74
bin/server.bat
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
21
bin/start.bat
Normal 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
17
bin/stop.bat
Normal 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
12
client/index.html
Normal 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
4653
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
client/package.json
Normal file
23
client/package.json
Normal 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
18
client/src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
client/src/components/ShapePanel.tsx
Normal file
131
client/src/components/ShapePanel.tsx
Normal 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
9
client/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
142
client/src/shapes/KreiselShapeUtil.tsx
Normal file
142
client/src/shapes/KreiselShapeUtil.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
127
client/src/shapes/PufferShapeUtil.tsx
Normal file
127
client/src/shapes/PufferShapeUtil.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
client/src/shapes/shape-config.ts
Normal file
26
client/src/shapes/shape-config.ts
Normal 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,
|
||||||
|
}
|
||||||
39
client/src/shapes/shape-types.ts
Normal file
39
client/src/shapes/shape-types.ts
Normal 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
17
client/tsconfig.json
Normal 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
16
client/vite.config.ts
Normal 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/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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 & Engineering</p>
|
<p class="cover-subtitle">Digitalisierung & Engineering</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cover-right">
|
<div class="cover-right">
|
||||||
|
|||||||
BIN
doc/Cockburn.pptx
Normal file
BIN
doc/Cockburn.pptx
Normal file
Binary file not shown.
152
doc/architektur.md
Normal file
152
doc/architektur.md
Normal 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
40
doc/konzept.md
Normal 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
304
doc/tldraw_technologie.md
Normal 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 | 1–2 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*
|
||||||
26
server/catalog/symbols.json
Normal file
26
server/catalog/symbols.json
Normal 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
134
server/main.py
Normal 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
3
server/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.34.0
|
||||||
|
pydantic>=2.0.0
|
||||||
Reference in New Issue
Block a user