Files
karim ca859c4aa4 Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge
Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
2026-06-30 20:52:27 +02:00

761 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Aktive Zeichen- und Bearbeitungs-Werkzeuge
Status: Entwurf. Dieses Dokument spezifiziert das **Tool-System** für das aktive
Erzeugen von Modell-Elementen durch Zeichnen im Grundriss: Wände (Achs-Polylinie
`Wall` eines `WallType`) sowie reine 2D-Geometrie (Linie, Polylinie, Rechteck,
Kreis, Bogen, Text). Es definiert die Werkzeug-Zustandsmaschine, die Live-Vorschau
(Rubber-Band), das **Snapping** mit Bildschirm-Markern, die Ebenen-/Kategorie-/
Stil-Zuordnung neuer Elemente und das neue Element `Drawing2D` samt Ableitung in
`generatePlan`.
Bezugsdokumente: [elements.md](elements.md) (Wand-/Tür-Modell),
[resources-graphics.md](resources-graphics.md) (Stil-Auflösung),
[plans-output.md](plans-output.md) (Papier-Maßstab, mm-Strichstärken),
[context-menu.md](context-menu.md) (Maus-Schema).
## 0. Architektur-Prinzip (Bezug zum Repo)
Die App folgt der Regel **ein semantisches Modell ist die einzige Wahrheit; jede
Ansicht ist abgeleitet** (CONVENTIONS.md, `App.tsx`). Werkzeuge greifen darum NUR über
`setProject` immutabel auf das `Project`-Modell zu; sie schreiben NIE Geometrie
direkt in den Plan. Der `PlanView` bleibt eine reine Darstellungs-/Eingabe-
Schicht. Das Tool-System setzt genau an der bestehenden Naht in `PlanView` an:
- **Modell↔Screen.** `PlanView` rechnet bereits Cursor-Pixel → viewBox-Einheiten
(`clientToView`) → Modell-Meter (`viewToModel`). Diese Umrechnung ist die
Grundlage; Werkzeuge arbeiten ausschließlich in **Modell-Metern** (CONVENTIONS.md:
intern alles in Metern). Für Snap-Marker brauchen Werkzeuge zusätzlich die
Rückrichtung Modell → viewBox (`toScreen`, existiert bereits) bzw. Modell →
Client-Pixel.
- **Pointer-Handling.** `PlanView` besitzt heute drei Gesten an der linken Taste/
Mitte/rechts: Auswahl/Marquee, Pan, Kontextmenü. Das Tool-System schiebt sich
VOR diese Logik: ist ein aktives Zeichenwerkzeug gewählt (≠ `select`), übernimmt
das Werkzeug `pointerdown/move/up`; das `select`-Werkzeug delegiert an die heute
schon vorhandene Auswahl-/Marquee-Logik (kein Verhaltensbruch).
- **Pan/Zoom bleiben immer aktiv.** Mittlere Maustaste (Pan) und Mausrad (Zoom)
laufen unverändert weiter, auch während ein Zeichenwerkzeug aktiv ist — sonst
kann man beim Zeichnen nicht navigieren.
## 1. Datenfluss-Überblick
```
TopBar (Werkzeugleiste) --activeTool--> App-State
┌──── activeTool, wallTypeId, defaultCategoryCode ────┐
▼ ▼
PlanView ── pointerdown/move/up (Modellpunkt) ──> ToolController
▲ │
Snap-Marker + Rubber-Band-Overlay <── DraftState (Vorschau) ──┘
│ │
└──────────────── commit ──> onToolCommit(Element) ──> setProject
```
`activeTool` und die Werkzeug-Parameter (aktiver `WallType`, Default-Kategorie)
liegen als **View-State** in `App.tsx` — wie `viewType`, `detail`, `selectedWallIds`
bereits dort liegen. Der `ToolController` ist **frameworkfrei** (reines TS, kein
React-State pro Mausbewegung — analog zu `drag`/`marquee` als `useRef` in
`PlanView`), damit die Live-Vorschau ohne Re-Render des ganzen Baums läuft. Nur
beim **Commit** wird `setProject` (Re-Render) ausgelöst.
## 2. Koordinaten & Hilfsfunktionen
`PlanView` exportiert künftig zwei reine Konverter (heute intern vorhanden),
plus die effektive Pixel-pro-Meter-Skala für die Snap-Toleranz:
```ts
// PlanView-intern bereits da; wird als stabile Callbacks nach außen gereicht.
type ToModel = (clientX: number, clientY: number) => Vec2; // Pixel → Meter
type ToClient = (m: Vec2) => { x: number; y: number }; // Meter → Pixel
type PxPerMeter = () => number; // aktuelle meet-Skala * PX_PER_M (Snap-Toleranz)
```
`PxPerMeter` ergibt sich aus `meetScale(view) * PX_PER_M` (beides in `PlanView`
vorhanden). Snap-Toleranzen werden in **Bildschirm-Pixeln** definiert (z. B. 10 px)
und über `pxPerMeter` in Meter umgerechnet — so ist der Fangradius zoom-unabhängig
konstant am Bildschirm.
## 3. Tool-System
### 3.1 Werkzeug-Identität und Registry
```ts
export type ToolId =
| "select" // Default: Auswahl/Marquee (heutiges Verhalten)
| "wall" // Wand-Achs-Polylinie → Wall je Segment
| "line" // einzelne 2D-Strecke
| "polyline" // offene 2D-Polylinie
| "rect" // 2D-Rechteck (zwei Ecken)
| "circle" // 2D-Kreis (Zentrum + Radius)
| "arc" // 2D-Bogen (3-Punkt oder Zentrum-Start-Ende)
| "text"; // 2D-Textmarke
/** Live-Kontext, den ein Werkzeug bei jedem Schritt erhält. */
export interface ToolContext {
project: Project;
/** Aktives Geschoss/Zeichnungsebene (Ziel der neuen Elemente). */
level: DrawingLevel;
/** Default-Kategorie-Code für neue Elemente (siehe §6). */
defaultCategoryCode: string;
/** Aktiver Wandtyp für das Wand-Werkzeug. */
activeWallTypeId: string;
/** Aktiver Linienstil-Code für 2D-Primitive (Line Manager). */
activeLineStyleId: string;
/** Snapping-Einstellungen (an/aus je Typ, ortho, grid). */
snap: SnapSettings;
/** Pixel pro Meter (für Snap-Toleranz in Metern). */
pxPerMeter: number;
}
/** Ein an einer Modellposition ausgelöstes Pointer-Ereignis. */
export interface ToolPointer {
/** Roher Modellpunkt (vor Snapping), in Metern. */
raw: Vec2;
/** Gesnappter Punkt + Marker-Info (siehe §5). null = kein Snap. */
snap: SnapResult | null;
/** Effektiver Punkt = snap?.point ?? raw. */
point: Vec2;
/** Modifikatoren (Shift = Ortho erzwingen, Ctrl = Snap aus, Alt = …). */
shift: boolean;
ctrl: boolean;
alt: boolean;
button: number; // 0 links, 2 rechts
}
/** Was ein Werkzeug-Schritt nach außen meldet. */
export interface ToolResult {
/** Neuer Vorschau-Zustand (Rubber-Band-Geometrie); null = nichts zu zeigen. */
draft: ToolDraft | null;
/** Bei Abschluss: Mutation, die App über setProject anwendet. */
commit?: (p: Project) => Project;
/** true → Werkzeug ist fertig und kehrt in seinen Ruhezustand zurück. */
done?: boolean;
}
/** Die Werkzeug-Schnittstelle (reine Funktionen über einen internen State). */
export interface Tool {
id: ToolId;
/** UI-Label-Key (i18n), z. B. "tool.wall". */
labelKey: string;
/** Material-Symbol-Name für die Werkzeugleiste. */
icon: string;
/** Statuszeilen-Hinweis-Key je Phase (z. B. "tool.wall.firstPoint"). */
hintKey: (state: ToolState) => string;
/** Initialer Ruhezustand. */
init(): ToolState;
/** Klick/Tap (pointerdown→up ohne Drag, bzw. „setze Punkt"). */
onClick(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
/** Bewegung (Hover/Drag): nur Vorschau, nie Commit. */
onMove(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
/** Doppelklick/Enter: mehrteilige Werkzeuge abschließen (z. B. Polylinie). */
onCommitGesture(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
/** Esc: aktuellen Entwurf verwerfen, zurück in den Ruhezustand. */
onCancel(state: ToolState): [ToolState, ToolResult];
/** Backspace: letzten gesetzten Punkt zurücknehmen (mehrteilig). */
onUndoPoint?(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
}
```
`ToolState` ist je Werkzeug ein Discriminated Union (Beispiel Wand in §4). Der
`ToolController` hält genau eine aktive `Tool`-Instanz + deren `ToolState` in
einem `useRef` und ist die einzige Stelle, die diese Methoden aufruft.
### 3.2 Vorschau-Geometrie (Rubber-Band)
```ts
/** Darstellbare Vorschau — dieselben Primitive wie der Plan, plus Marker. */
export interface ToolDraft {
/** Vorschau-Primitive (gestrichelt/halbtransparent gezeichnet). */
preview: Primitive[];
/** Bereits gesetzte „feste" Stützpunkte (kleine Quadrate). */
vertices: Vec2[];
/** Optionaler Maß-/Winkel-Text am Cursor (z. B. "3.20 m, 90°"). */
hud?: { at: Vec2; text: string };
}
```
Wichtig: Die Vorschau benutzt **dieselben `Primitive`-Typen** wie `generatePlan`
(`polygon | line | arc`). Damit kann der Vorschau-Layer mit derselben
`PrimitiveShape`-Renderlogik gezeichnet werden (DRY) — nur mit einer
Vorschau-CSS-Klasse (gestrichelt, Akzentfarbe). Für die Wand-Vorschau kann das
Werkzeug sogar `generatePlan` auf einem **temporären Projekt** (Original + die in
Bau befindliche Wand) aufrufen, um echte gehrte Poché live zu zeigen; in der
ersten Phase reicht eine einfache Bandvorschau (`wallCorners`).
### 3.3 Zustandsmaschine (allgemein)
Jedes Werkzeug ist eine kleine Maschine über `pointerdown → move → up`. Da
`PlanView` Pointer-Capture nutzt, kommen `move`/`up` zuverlässig an. Generisches
Muster:
```
ruht ──pointerdown──> (Werkzeug setzt 1. Punkt / startet Drag)
▲ │
│ ├──move──> Vorschau (rubber-band), kein Commit
│ │
│ (mehrteilig) pointerdown──> Punkt anhängen, Vorschau weiter
│ │
└──Esc/Cancel─────────────┤
Doppelklick/Enter/letzter Punkt ──> commit(project) ──> ruht
```
- **Klick-vs-Drag.** Wie heute in `PlanView` (`MARQUEE_THRESHOLD_PX`): unter der
Schwelle ist es ein „Punkt setzen" (Klick), darüber ein Drag. Rechteck/Kreis/
Linie unterstützen BEIDE Bedienarten: Zwei-Klick (Punkt, Punkt) ODER Drücken-
Ziehen-Loslassen. Polyline/Wall sind reine Klickfolgen mit Abschluss per
Doppelklick/Enter.
- **Esc** verwirft den Entwurf (`onCancel`) und bleibt im selben Werkzeug.
Zweites Esc (im Ruhezustand) schaltet zurück auf `select`.
- **Rechtsklick** während eines aktiven Entwurfs = „abschließen/abbrechen"
(CAD-üblich), KEIN Kontextmenü; im Ruhezustand öffnet Rechtsklick wie bisher
das Plan-Kontextmenü.
### 3.4 Einbettung in PlanView (Pointer-Routing)
`PlanView` bekommt zwei neue Props:
```ts
interface PlanViewProps {
// … bisherige Props …
/** Aktives Werkzeug; "select" = bisheriges Verhalten. */
activeTool?: ToolId;
/**
* Werkzeug-Treiber. PlanView ruft diese Callbacks mit fertig gesnappten
* Modellpunkten auf und rendert den zurückgegebenen Draft als Overlay.
*/
toolHandlers?: {
onToolDown(p: ToolPointer): void;
onToolMove(p: ToolPointer): void;
onToolUp(p: ToolPointer): void;
onToolDoubleClick(): void;
/** liefert die zu zeichnende Vorschau (von App/Controller gehalten). */
draft: ToolDraft | null;
};
}
```
Routing in `onPointerDown` (Ergänzung der bestehenden Methode):
```
onPointerDown(e):
if e.button === 1: → bestehender Pan (unverändert)
if e.button === 0:
if activeTool === "select": → bestehende Auswahl-/Marquee-Geste
else:
setPointerCapture
p = makeToolPointer(e) // raw → snap → point (§5)
toolHandlers.onToolDown(p)
if e.button === 2 (rechts):
if activeTool !== "select" && entwurf aktiv: toolHandlers.onToolUp({button:2,…}) // abschließen
else: bestehendes Kontextmenü
```
`onPointerMove`/`onPointerUp` analog: bei aktivem Zeichenwerkzeug an
`onToolMove`/`onToolUp` routen statt an Pan/Marquee. Der Cursor wird auf
`crosshair` gesetzt. `makeToolPointer` führt das Snapping aus (§5) und liefert den
fertigen `ToolPointer`.
Die **Snap-Marker** und der **Draft** werden als zusätzliche SVG-Gruppe NACH den
Plan-Primitiven, aber vor der Auswahl-Hervorhebung gerendert (immer obenauf,
`pointerEvents="none"`). Marker werden in viewBox-Einheiten über `toScreen`
positioniert (existiert bereits).
## 4. Werkzeug: Wand (Wall)
Das Wand-Werkzeug zeichnet eine **Achs-Polylinie**; jedes Segment wird zu einem
eigenständigen `Wall`-Element des aktiven `WallType` auf dem aktiven Geschoss.
Aufeinanderfolgende Segmente teilen sich einen Knoten → die bestehende
`computeJoins`-Verschneidung (in `generatePlan`) erzeugt automatisch saubere
Gehrungen an den Ecken. Kein zusätzlicher Join-Code nötig.
### 4.1 Zustand
```ts
type WallToolState =
| { phase: "idle" }
| {
phase: "drawing";
/** Bisher gesetzte Achs-Knoten (in Metern). */
points: Vec2[];
/** Aktuelle Cursor-Position (gesnappt) für die Rubber-Band-Vorschau. */
cursor: Vec2 | null;
};
```
### 4.2 Pseudocode
```
WallTool.onClick(state, p, ctx):
if state.phase === "idle":
return [{phase:"drawing", points:[p.point], cursor:p.point}, {draft: draftFor([p.point], p.point, ctx)}]
else: // weiteren Knoten anhängen
pts = [...state.points, p.point]
# Ortho/Snap haben p.point bereits ausgerichtet (§5).
return [{phase:"drawing", points: pts, cursor: p.point}, {draft: draftFor(pts, p.point, ctx)}]
WallTool.onMove(state, p, ctx):
if state.phase !== "drawing": return [state, {draft:null}]
return [{...state, cursor:p.point}, {draft: draftFor(state.points, p.point, ctx)}]
WallTool.onCommitGesture(state, ctx): // Doppelklick / Enter / Rechtsklick
if state.phase !== "drawing" || state.points.length < 2:
return [{phase:"idle"}, {draft:null, done:true}]
pts = state.points
return [{phase:"idle"}, {
draft: null, done: true,
commit: (proj) => appendWalls(proj, pts, ctx)
}]
WallTool.onCancel(state):
return [{phase:"idle"}, {draft:null, done:true}]
WallTool.onUndoPoint(state):
if state.phase==="drawing" && state.points.length>1:
return [{...state, points: state.points.slice(0,-1)}, {draft: …}]
return [{phase:"idle"}, {draft:null}]
```
`draftFor` baut die Vorschau: feste Segmente zwischen `points` + ein „lebendes"
Segment `points[last] → cursor`. Pro Segment werden die vier Band-Eckpunkte über
`wallCorners(a, b, thickness)` (vorhanden) berechnet und als Vorschau-`polygon`
gezeichnet; zusätzlich ein HUD mit Länge `|ba|` und Winkel. `thickness =
wallTypeThickness(getWallType(...))`.
### 4.3 Commit ins Modell
```
appendWalls(project, pts, ctx):
newWalls = []
for i in 0 .. pts.length-2:
a = pts[i]; b = pts[i+1]
if |b-a| < EPS: continue // Null-Segmente überspringen
newWalls.push({
id: uniqueId("W"), // siehe §8 (ID-Vergabe)
type: "wall",
floorId: ctx.level.id, // aktives Geschoss
categoryCode: ctx.defaultCategoryCode, // §6
start: a, end: b,
wallTypeId: ctx.activeWallTypeId,
height: ctx.level.floorHeight ?? 2.6, // Geschosshöhe als Default
})
return { ...project, walls: [...project.walls, ...newWalls] }
```
Hinweise:
- **Höhe** erbt die lichte Geschosshöhe (`DrawingLevel.floorHeight`), Fallback 2.6 m.
- **Geschossbindung**: Das Wand-Werkzeug ist nur aktiv, wenn `level.kind === "floor"`
(sonst gibt es keine Wände). In `drawing`-Ebenen ist das Wand-Werkzeug
deaktiviert (nur 2D-Werkzeuge); siehe §6.
- Die Wicklung wird NICHT erzwungen — `leftNormal`-Konvention (CONVENTIONS.md) und
`computeJoins` arbeiten richtungsunabhängig pro Segment.
## 5. Snapping
Snapping läuft in `makeToolPointer` (PlanView) BEVOR der Punkt an das Werkzeug
geht. Es prüft mehrere Snap-Quellen, wählt die nächstgelegene innerhalb der
Toleranz und liefert sowohl den gefangenen Punkt als auch eine **Marker-Art** für
die Bildschirmdarstellung.
### 5.1 Typen
```ts
export type SnapKind =
| "endpoint" // Wand-Achsenende, Polylinien-Knoten, Primitiv-Endpunkt
| "midpoint" // Mitte einer Strecke/Wandachse
| "intersection" // Schnittpunkt zweier Achsen/Linien
| "center" // Kreis-/Bogenzentrum
| "quadrant" // Kreis-Quadrantenpunkte (0/90/180/270°)
| "onEdge" // nächster Punkt AUF einer Wandachse/Linie (Lot)
| "grid" // Rasterpunkt
| "ortho" // orthogonal/winkelrastriert zum vorigen Punkt
| "extension"; // Verlängerung einer Achse (gestrichelte Hilfslinie)
export interface SnapResult {
point: Vec2; // gefangener Punkt (Meter)
kind: SnapKind;
/** Quell-Element (für Marker/Hilfslinien), optional. */
refA?: Vec2;
refB?: Vec2;
/** Bildschirm-Distanz Cursor→Snap (px) — für die Auswahl des Besten. */
distPx: number;
}
export interface SnapSettings {
enabled: boolean; // Master-Schalter (Ctrl invertiert temporär)
endpoint: boolean;
midpoint: boolean;
intersection: boolean;
center: boolean;
onEdge: boolean;
grid: boolean;
gridSize: number; // Rasterweite in Metern, z. B. 0.10
ortho: boolean; // Shift erzwingt zusätzlich
angleStep: number; // Winkelraster in Grad (z. B. 45)
tolerancePx: number; // Fangradius am Bildschirm, z. B. 10
}
```
### 5.2 Snap-Kandidaten sammeln
Quellen pro Geschoss (gefiltert auf sichtbare Kategorien, wie der Plan):
| Snap | Quelle |
|------|--------|
| endpoint | `wall.start`, `wall.end` aller sichtbaren Wände; Knoten bereits gesetzter Draft-Punkte; `Drawing2D`-Vertices |
| midpoint | Mitte jeder Wandachse und jedes 2D-Segments |
| intersection | paarweise `lineIntersect` der Wandachsen (nur Paare, deren Boxen sich am Cursor nähern) |
| center/quadrant | Kreise/Bögen aus `Drawing2D` |
| onEdge | Lotfußpunkt des Cursors auf jede nahe Wandachse/2D-Linie |
| grid | Rundung des Cursors auf `gridSize` |
| ortho | Ausrichtung relativ zum letzten Draft-Punkt (§5.4) |
Performance: Kandidaten werden je `move` neu erzeugt, aber **früh nach
Bildschirm-Distanz gefiltert** (nur Punkte innerhalb ~`2·tolerancePx`). Bei
großen Modellen kann eine grobe Bounding-Box-Vorauswahl je Wand vorgeschaltet
werden; in den ersten Phasen genügt lineares Scannen (Wandzahl ist klein).
### 5.3 Auswahl-Pseudocode
```
computeSnap(rawModel, ctx, draftPoints, lastPoint):
if ctrl(): return null # Snap temporär aus
s = ctx.snap
tolM = s.tolerancePx / ctx.pxPerMeter # px-Toleranz → Meter
cands: SnapResult[] = []
if s.endpoint: cands += endpoints(...) filtered to within tolM
if s.midpoint: cands += midpoints(...)
if s.intersection: cands += intersections(...)
if s.center: cands += centers/quadrants(...)
if s.onEdge: cands += perpendicularFeet(...) # niedrigere Priorität
# Punkt-Snaps haben Vorrang vor Linien-/Raster-Snaps:
pick = argmin(cands, by distPx within tolM, tie-break by priority)
if pick: rawModel = pick.point
# Ortho/Winkelraster wirkt RELATIV zum letzten Punkt und ÜBERLAGERT:
if (s.ortho || shift()) && lastPoint:
rawModel = applyAngleConstraint(lastPoint, rawModel, s.angleStep)
# Wenn dabei auch ein Punkt-Snap nahe der Ortho-Linie liegt → bevorzugen.
if !pick && s.grid:
g = snapToGrid(rawModel, s.gridSize)
if dist(g, rawModel) within tolM: return {point:g, kind:"grid", …}
return pick ?? null
```
Prioritätsreihenfolge bei gleichem Abstand: `endpoint > intersection > midpoint >
center/quadrant > onEdge > grid`. Ortho/Winkelraster ist eine **Projektion**, kein
Punkt-Kandidat: es verschiebt den (ggf. schon gesnappten) Punkt auf die nächste
erlaubte Richtung vom letzten Knoten.
### 5.4 Ortho / Winkelraster
```
applyAngleConstraint(from, to, stepDeg):
d = to - from
ang = atan2(d.y, d.x)
k = round(ang / rad(stepDeg)) * rad(stepDeg)
len = |d|
return from + (cos(k), sin(k)) * len
```
Mit `stepDeg = 90` ist das klassisches Ortho (H/V); `45` erlaubt Diagonalen.
`Shift` erzwingt Ortho temporär unabhängig von der Einstellung.
### 5.5 Bildschirm-Marker
Pro aktivem Snap zeichnet `PlanView` ein Marker-Glyph an `toScreen(snap.point)`
(`pointerEvents="none"`, eigene CSS-Klassen, papierkonstante Größe via
non-scaling):
- `endpoint` → kleines Quadrat ▫
- `midpoint` → Dreieck
- `intersection` → ✕
- `center` → ○, `quadrant` → ◇
- `onEdge` → ⟂-Glyph
- `grid` → feiner Punkt
- `ortho`/`extension` → zusätzlich eine **gestrichelte Hilfslinie** von `refA`
(Bezugspunkt) zum Cursor
Marker erscheinen NUR während ein Zeichenwerkzeug aktiv ist. i18n-Tooltips/Status
(„Endpunkt", „Mittelpunkt", …) über `t('snap.endpoint')` etc.
## 6. Ebene, Kategorie und Stil neuer Elemente
Neue Elemente brauchen eine **Zeichnungsebene** (DrawingLevel) und eine
**Kategorie** (LayerCategory `code`) sowie — bei 2D-Primitiven — einen Stift/
Schraffur-Stil.
### 6.1 Zeichnungsebene (Ziel)
- Ziel ist **immer das aktive Geschoss/die aktive Zeichnungsebene** (`activeLevelId`
in `App.tsx`). Wände nur auf `kind === "floor"`. 2D-Primitive (`Drawing2D`) auf
jeder Ebene, also auch auf `kind === "drawing"` (freie 2D-Zeichnung).
### 6.2 Kategorie (categoryCode)
- Es gibt eine **aktive Kategorie** als View-State (`activeCategoryCode` in App,
neu). Default beim Start: der Code der gewählten Wand-Kategorie (im Sample „20"
Wände), bzw. die erste sichtbare Kategorie. Die Statusleiste zeigt heute schon
die „aktive Ebene" (`activeLayerName`); diese wird künftig von `activeCategoryCode`
gespeist statt nur aus der Auswahl abgeleitet.
- Neue Wände: `categoryCode = activeCategoryCode` (z. B. „20").
- Neue 2D-Primitive: ebenfalls `activeCategoryCode`. Sinnvoll ist eine eigene
2D-/Hilfslinien-Kategorie (z. B. „90 Zeichnung"); diese wird über die
Kategorie-Auswahl in der Statusleiste/Werkzeugleiste gesetzt.
- Die Kategorie liefert Farbe + Strichstärke (`LayerCategory.color`, `.lw`), genau
wie `generatePlan` es heute für Wände via `categoryLwMap` nutzt.
### 6.3 Stift/Schraffur
- **Wände** erhalten KEINEN eigenen Stift — ihr Erscheinungsbild kommt aus dem
`WallType` (Component → Hatch → LineStyle) und der Kategorie-`lw` (bestehender
Pfad in `generatePlan`).
- **2D-Primitive** referenzieren optional einen `LineStyle` aus dem Line Manager
(`activeLineStyleId`). Ohne expliziten Stil erben sie Farbe/Strichstärke aus der
Kategorie (`color`, `lw`). Flächige 2D-Primitive (geschlossenes Rechteck/Kreis/
Polyline) können optional eine Schraffur (`hatchId`) tragen.
## 7. Speicherung der 2D-Primitive: `Drawing2D`
2D-Geometrie wird als neues Modell-Element `Drawing2D` gespeichert — analog zu
`Wall`/`Door` ein semantisches Element, das beim Rendern abgeleitet wird (KEINE
vorab erzeugten Primitive im Modell). Damit bleibt die Architektur „Modell →
abgeleitete Ansicht" intakt.
### 7.1 Typ
```ts
/** Geometrie-Form eines 2D-Zeichenelements. */
export type Drawing2DGeom =
| { shape: "line"; a: Vec2; b: Vec2 }
| { shape: "polyline"; pts: Vec2[]; closed: boolean }
| { shape: "rect"; min: Vec2; max: Vec2 } // achsparallel
| { shape: "circle"; center: Vec2; r: number }
| {
shape: "arc";
center: Vec2;
r: number;
/** Start-/Endwinkel in Radiant (math. Konvention, CCW positiv). */
a0: number;
a1: number;
}
| { shape: "text"; at: Vec2; text: string; height: number; angle: number };
/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */
export interface Drawing2D {
id: string;
type: "drawing2d";
/** Zeichnungsebene (Geschoss ODER freie 2D-Ebene). */
levelId: string;
/** Grafik-Kategorie (Ebene) — liefert Farbe/Strichstärke als Default. */
categoryCode: string;
geom: Drawing2DGeom;
/** Optionaler Linienstil (Line Manager); sonst Kategorie-Default. */
lineStyleId?: string;
/** Optionale Schraffur für geschlossene Formen (Hatch Manager). */
hatchId?: string;
/** Optionale explizite Strichfarbe; sonst Kategorie-Farbe. */
color?: string;
}
```
Ergänzung am `Project`:
```ts
export interface Project {
// … bisher …
drawings2d: Drawing2D[]; // NEU
}
export type Element = Wall | Door | Drawing2D; // erweitert
```
`sampleProject` bekommt ein leeres `drawings2d: []`. Lösch-/Referenz-Regeln:
beim Löschen einer Zeichnungsebene werden auch deren `Drawing2D` entfernt (analog
zur bestehenden Wand-/Tür-Bereinigung in `deleteLevel`).
### 7.2 Ableitung in `generatePlan`
`generatePlan` rendert künftig zusätzlich die `Drawing2D` des Geschosses (gefiltert
wie Wände auf sichtbare Kategorien + `categoryDisplay`). Neue Funktion
`addDrawing2D(out, project, d, greyed, lwMm)`:
```
addDrawing2D(out, project, d):
color = d.color ?? categoryColor(d.categoryCode)
weight = lineStyle(d.lineStyleId)?.weight ?? categoryLw(d.categoryCode)
dash = lineStyle(d.lineStyleId)?.dash ?? null
switch d.geom.shape:
"line": out.push({kind:"line", a, b, cls:"draw2d", weightMm:weight, dash})
"polyline": for each segment → line-Primitive (closed → Schluss-Segment)
"rect": vier Kanten als line-Primitive (oder polygon, falls hatchId)
"circle": → als zwei 180°-Bögen (arc-Primitive) ODER neues Primitiv (s. u.)
"arc": → arc-Primitive (center/from/to/r aus a0,a1)
"text": → neues text-Primitiv (s. u.)
```
Dabei wird, wo möglich, der **vorhandene** `Primitive`-Vorrat (`line`, `arc`,
`polygon`) wiederverwendet — die Strichstärke kommt in mm Papier (wie der Rest des
Plans), Farbe über eine CSS-Klasse oder ein neues optionales `color`-Feld am
`line`-Primitive.
Zwei `Primitive`-Erweiterungen sind nötig:
```ts
// kreisförmige Vollkurve (Kreis) — sonst muss man sie in zwei Bögen zerlegen:
| { kind: "circle"; center: Vec2; r: number; cls: string; weightMm: number;
dash?: number[] | null; fill?: string; greyed?: boolean }
// Textmarke:
| { kind: "text"; at: Vec2; text: string; heightMm: number; angle: number;
cls: string; color?: string; greyed?: boolean }
```
`PlanView.renderPrimitive` bekommt entsprechende `case`-Zweige (`<circle>`,
`<text>`). Text wird in **Papier-Millimetern** dimensioniert (Höhe → `mmToPx`,
non-scaling), damit die Schrifthöhe beim Zoomen papierkonstant bleibt (analog zu
Strichstärken in `plans-output.md`).
Das `arc`-Primitiv zeichnet heute nur Kurzbögen (≤180°, `large-arc=0`). Für
beliebige 2D-Bögen wird es um ein `largeArc`-Flag erweitert (aus `|a1a0|`
berechnet); abwärtskompatibel (Default 0).
## 8. ID-Vergabe & Immutabilität
- Neue IDs über einen kleinen Helfer `uniqueId(prefix)` (z. B.
`\`${prefix}-${Date.now()}-${counter++}\``), konsistent mit der bestehenden
Praxis in `App.tsx` (`floor-${Date.now()}` usw.). Wand-Präfix „W", 2D-Präfix
„dr2d".
- Alle Mutationen laufen über `setProject` immutabel (CONVENTIONS.md / App-Konvention).
Der `commit(project)` eines Werkzeugs ist eine reine Funktion `Project →
Project`; App ruft `setProject(prev => result.commit(prev))`.
## 9. App- und PlanView-Verdrahtung (konkret)
Neuer View-State in `App.tsx`:
```ts
const [activeTool, setActiveTool] = useState<ToolId>("select");
const [activeCategoryCode, setActiveCategoryCode] = useState<string>(/* erste Wand-Kat */);
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(project.wallTypes[0].id);
const [activeLineStyleId, setActiveLineStyleId] = useState<string>(project.lineStyles[0].id);
const [snap, setSnap] = useState<SnapSettings>(DEFAULT_SNAP);
const toolStateRef = useRef<ToolState>(getTool(activeTool).init());
const [draft, setDraft] = useState<ToolDraft | null>(null);
```
Der `ToolController` ist eine kleine Hook/Klasse, die `toolStateRef` hält und die
`PlanView.toolHandlers` implementiert:
```
onToolDown(p): [st, res] = tool.onClick(toolStateRef.current, p, ctx)
toolStateRef.current = st; setDraft(res.draft)
if res.commit: setProject(res.commit)
if res.done: toolStateRef.current = tool.init()
onToolMove(p): [st, res] = tool.onMove(...); toolStateRef.current=st; setDraft(res.draft)
onToolDoubleClick(): [st,res]=tool.onCommitGesture(...); apply commit/done; setDraft(null)
```
Keyboard (global, nur wenn ein Zeichenwerkzeug aktiv ist):
`Esc → onCancel`, `Enter → onCommitGesture`, `Backspace → onUndoPoint`. Beim
Wechsel von `activeLevelId`/`viewType` wird der laufende Entwurf verworfen (analog
zur bestehenden Auswahl-Bereinigung in den `useEffect`s).
`ctx` (ToolContext) wird in App via `useMemo` aus Project + aktiven Selektionen
gebaut und an PlanView/Controller gereicht.
### 9.1 Werkzeugleiste (TopBar)
Eine neue Werkzeug-Gruppe in der `TopBar` (links, vor den Ansichts-Toggles), als
i18n-beschriftete Icon-Buttons (`t('tool.select')`, `t('tool.wall')`, …). Aktiv-
Zustand hervorgehoben. Daneben: Auswahl des aktiven `WallType` (für Wand) und der
aktiven Kategorie/des Linienstils (Dropdowns), sowie Snap-Toggles (kleines
Snap-Menü mit Checkboxen je `SnapKind`, Grid-Größe, Winkelraster). Wand-/2D-
Werkzeuge werden je nach `level.kind` aktiviert/deaktiviert (Tooltip nennt den
Grund — wie die bestehenden disabled-Menüpunkte in `App.tsx`).
### 9.2 i18n-Keys (neu, Auszug)
```
tool.select / tool.wall / tool.line / tool.polyline / tool.rect /
tool.circle / tool.arc / tool.text
tool.wall.firstPoint / tool.wall.nextPoint / tool.wall.finish
snap.endpoint / snap.midpoint / snap.intersection / snap.center /
snap.quadrant / snap.onEdge / snap.grid / snap.ortho
snap.settings / snap.gridSize / snap.angleStep
status.activeWallType / status.activeCategory / status.activeTool
```
Alle sichtbaren Strings über `t(...)` (CONVENTIONS.md). Identifier bleiben englisch.
## 10. Übrige Werkzeuge (Kurzspezifikation)
| Werkzeug | Eingabe | Zustand | Commit |
|----------|---------|---------|--------|
| **Line** | 2 Punkte (Klick-Klick oder Drag) | `{a?}` | `Drawing2D{shape:"line"}` |
| **Polyline** | n Punkte, Abschluss Doppelklick/Enter; `closed` per „C" oder Klick auf Start | `{pts}` | `Drawing2D{shape:"polyline"}` |
| **Rectangle** | 2 Ecken (Drag oder Klick-Klick) | `{p0?}` | `Drawing2D{shape:"rect"}` (min/max sortiert) |
| **Circle** | Zentrum + Radius-Punkt | `{center?}` | `Drawing2D{shape:"circle"}` |
| **Arc** | 3 Punkte (Start, durch, Ende) ODER Zentrum-Start-Ende (Modus-Toggle) | `{p0?,p1?}` | `Drawing2D{shape:"arc"}` (a0/a1 aus Punkten) |
| **Text** | 1 Punkt → Inline-Eingabefeld (wie `InlineEditor` in App) | `{at?}` | `Drawing2D{shape:"text"}` |
Alle nutzen dasselbe `Tool`-Interface, dasselbe Snapping und denselben Draft-/
Commit-Pfad. Text öffnet beim Setzen des Ankerpunkts ein kleines Overlay-Eingabe-
feld (an `toClient(at)` positioniert) und committet bei Enter/Blur.
3-Punkt-Bogen → Zentrum: Umkreismittelpunkt der drei Punkte (Schnitt der
Mittelsenkrechten via `lineIntersect`), `r`, `a0/a1` aus Start-/Endwinkel; Drehsinn
aus dem mittleren Punkt.
## 11. Phasenplan
**Phase 1 — Gerüst + Select + Wall + Line (MVP).**
1. `ToolId`, `Tool`, `ToolContext`, `ToolPointer`, `ToolDraft`, `ToolResult`,
`SnapResult`, `SnapSettings`, `Drawing2D`(+`Project.drawings2d`) als Typen.
2. `PlanView`: `toScreen`/`viewToModel`/`pxPerMeter` als Callbacks nach außen;
Pointer-Routing für `activeTool !== "select"`; Draft-/Marker-Overlay-Rendering;
Crosshair-Cursor.
3. `ToolController` + App-State (`activeTool`, `activeCategoryCode`,
`activeWallTypeId`, `snap`) + Keyboard (Esc/Enter/Backspace).
4. **WallTool** voll funktionsfähig (Polylinie → Wände, Live-Band-Vorschau, HUD,
Commit via `appendWalls`). Verschneidung kommt automatisch aus `computeJoins`.
5. **LineTool** als erstes 2D-Werkzeug; `generatePlan.addDrawing2D` für `line`;
`Drawing2D`-Löschung beim Geschoss-Löschen.
6. **Snapping Stufe 1**: endpoint + grid + ortho (Shift), mit Bildschirm-Markern.
7. TopBar-Werkzeuggruppe (select/wall/line) + WallType-/Kategorie-Auswahl;
i18n-Keys; Statusleiste zeigt aktives Werkzeug + Kategorie.
8. Verifizieren: `npx tsc -b`, `npm run build`, Screenshot via `scripts/probe.mjs`
(Wand zeichnen, Gehrung prüfen).
**Phase 2 — Snapping vervollständigen + 2D-Grundformen.**
- Snap: midpoint, intersection, onEdge (Lot), extension-Hilfslinien, Winkelraster
(45°), Snap-Einstellungsmenü in der TopBar.
- Werkzeuge: Polyline, Rectangle (inkl. optionaler Schraffur für geschlossene
Formen). `Primitive`-Erweiterung nur für tatsächlich gebrauchte Formen.
**Phase 3 — Kurven + Text.**
- `Primitive` um `circle` (+ `arc` `largeArc`) und `text` erweitern; PlanView-
Renderzweige; Text papierkonstant.
- Werkzeuge: Circle, Arc (3-Punkt), Text (Inline-Eingabe). Snap: center/quadrant.
**Phase 4 — Bearbeitung (Folge-Doku).**
- Grips/Editieren bestehender Elemente (Wand-Enden ziehen, 2D-Vertices verschieben),
Verschieben/Kopieren/Rotieren der Auswahl, numerische Direkteingabe von
Länge/Winkel im HUD. Baut auf demselben Snapping + Draft-Pfad auf. (Eigenes
Design-Dokument; hier nur als Ausblick.)
## 12. Architektur-Garantien (Checkliste)
- Modell bleibt einzige Wahrheit; Werkzeuge schreiben nur `Project`, nie Plan-
Primitive. Ansichten (Plan/3D) leiten ab.
- Alle Bezeichner englisch; alle UI-Texte über `t(...)`; Einheiten in Metern,
Anzeige via `formatM`; Strichstärken/Texthöhen in mm Papier (non-scaling).
- Native-App-Verhalten: kein Browser-Kontextmenü während des Zeichnens; keine
Textauswahl (außer Text-Eingabefeld); Pan/Zoom immer verfügbar.
- DRY: Vorschau nutzt dieselben `Primitive` + Renderlogik wie der Plan; Snapping
und Commit-Pfad sind werkzeugübergreifend geteilt.
</content>
</invoke>