305 lines
7.3 KiB
Markdown
305 lines
7.3 KiB
Markdown
# 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*
|