# 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 `|b−a|` 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 (``, ``). 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 `|a1−a0|` 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("select"); const [activeCategoryCode, setActiveCategoryCode] = useState(/* erste Wand-Kat */); const [activeWallTypeId, setActiveWallTypeId] = useState(project.wallTypes[0].id); const [activeLineStyleId, setActiveLineStyleId] = useState(project.lineStyles[0].id); const [snap, setSnap] = useState(DEFAULT_SNAP); const toolStateRef = useRef(getTool(activeTool).init()); const [draft, setDraft] = useState(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.