erste Funktionierende Fassung

This commit is contained in:
mistangl
2026-04-16 16:31:23 +02:00
parent f75eaa724c
commit 36e2d81843
9 changed files with 5214 additions and 20 deletions

View File

@@ -48,23 +48,23 @@ if not exist "%PV_CLIENT%" mkdir "%PV_CLIENT%"
if not exist "%PV_SERVER%" mkdir "%PV_SERVER%"
REM Umgebungsvariablen anzeigen
echo.
echo ================================================================
echo PROJECT = %PROJECT%
echo PV_BIN = %PV_BIN%
echo PV_CFG = %PV_CFG%
echo PV_LIB = %PV_LIB%
echo PV_DATA = %PV_DATA%
echo PV_RESULTS = %PV_RESULTS%
echo PV_LOG = %PV_LOG%
echo PV_EXAMPLES = %PV_EXAMPLES%
echo PV_CLIENT = %PV_CLIENT%
echo PV_SERVER = %PV_SERVER%
echo PV_SERVER_URL = %PV_SERVER_URL%
echo PV_CLIENT_URL = %PV_CLIENT_URL%
echo PYTHONPATH = %PYTHONPATH%
echo ================================================================
echo.
REM echo.
REM echo ================================================================
REM echo PROJECT = %PROJECT%
REM echo PV_BIN = %PV_BIN%
REM echo PV_CFG = %PV_CFG%
REM echo PV_LIB = %PV_LIB%
REM echo PV_DATA = %PV_DATA%
REM echo PV_RESULTS = %PV_RESULTS%
REM echo PV_LOG = %PV_LOG%
REM echo PV_EXAMPLES = %PV_EXAMPLES%
REM echo PV_CLIENT = %PV_CLIENT%
REM echo PV_SERVER = %PV_SERVER%
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
if "%1"=="--keep-open" pause

4653
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +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 />
<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,
}

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
}
}

View File

@@ -3,6 +3,7 @@ PlantPlan FastAPI Backend
- Symbolbibliothek ausliefern
- Koordinaten normalisieren
- JSON/SVG Export
- Shape-Änderungen empfangen (Puffer + Kreisel)
"""
import json
@@ -24,14 +25,42 @@ app.add_middleware(
CATALOG_DIR = Path(__file__).parent / "catalog"
# --- Models ---
# ---------------------------------------------------------------------------
# Pydantic Models
# ---------------------------------------------------------------------------
class SketchPayload(BaseModel):
shapes: list[dict]
grid_size: int = 10
# --- Endpoints ---
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():
@@ -64,3 +93,42 @@ async def export_sketch(payload: SketchPayload):
"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