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

32 KiB
Raw Permalink Blame History

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 (Wand-/Tür-Modell), resources-graphics.md (Stil-Auflösung), plans-output.md (Papier-Maßstab, mm-Strichstärken), 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:

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

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)

/** 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:

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

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

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

/** 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:

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:

// 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:

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 useEffects).

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.