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.
32 KiB
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.
PlanViewrechnet 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.
PlanViewbesitzt 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 Werkzeugpointerdown/move/up; dasselect-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 aufselect. - 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 |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). Indrawing-Ebenen ist das Wand-Werkzeug deaktiviert (nur 2D-Werkzeuge); siehe §6. - Die Wicklung wird NICHT erzwungen —
leftNormal-Konvention (CONVENTIONS.md) undcomputeJoinsarbeiten 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→ ⟂-Glyphgrid→ feiner Punktortho/extension→ zusätzlich eine gestrichelte Hilfslinie vonrefA(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 (
activeLevelIdinApp.tsx). Wände nur aufkind === "floor". 2D-Primitive (Drawing2D) auf jeder Ebene, also auch aufkind === "drawing"(freie 2D-Zeichnung).
6.2 Kategorie (categoryCode)
- Es gibt eine aktive Kategorie als View-State (
activeCategoryCodein 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 vonactiveCategoryCodegespeist 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 wiegeneratePlanes heute für Wände viacategoryLwMapnutzt.
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 ingeneratePlan). - 2D-Primitive referenzieren optional einen
LineStyleaus 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 |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 inApp.tsx(floor-${Date.now()}` usw.). Wand-Präfix „W", 2D-Präfix „dr2d". - Alle Mutationen laufen über
setProjectimmutabel (CONVENTIONS.md / App-Konvention). Dercommit(project)eines Werkzeugs ist eine reine FunktionProject → Project; App ruftsetProject(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).
ToolId,Tool,ToolContext,ToolPointer,ToolDraft,ToolResult,SnapResult,SnapSettings,Drawing2D(+Project.drawings2d) als Typen.PlanView:toScreen/viewToModel/pxPerMeterals Callbacks nach außen; Pointer-Routing füractiveTool !== "select"; Draft-/Marker-Overlay-Rendering; Crosshair-Cursor.ToolController+ App-State (activeTool,activeCategoryCode,activeWallTypeId,snap) + Keyboard (Esc/Enter/Backspace).- WallTool voll funktionsfähig (Polylinie → Wände, Live-Band-Vorschau, HUD,
Commit via
appendWalls). Verschneidung kommt automatisch auscomputeJoins. - LineTool als erstes 2D-Werkzeug;
generatePlan.addDrawing2Dfürline;Drawing2D-Löschung beim Geschoss-Löschen. - Snapping Stufe 1: endpoint + grid + ortho (Shift), mit Bildschirm-Markern.
- TopBar-Werkzeuggruppe (select/wall/line) + WallType-/Kategorie-Auswahl; i18n-Keys; Statusleiste zeigt aktives Werkzeug + Kategorie.
- Verifizieren:
npx tsc -b,npm run build, Screenshot viascripts/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.
Primitiveumcircle(+arclargeArc) undtexterweitern; 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 viaformatM; 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.