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.
This commit is contained in:
@@ -0,0 +1,760 @@
|
||||
# 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 (`<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 in `App.tsx` (`floor-${Date.now()}` usw.). Wand-Präfix „W", 2D-Präfix
|
||||
„dr2d".
|
||||
- Alle Mutationen laufen über `setProject` immutabel (CONVENTIONS.md / App-Konvention).
|
||||
Der `commit(project)` eines Werkzeugs ist eine reine Funktion `Project →
|
||||
Project`; App ruft `setProject(prev => result.commit(prev))`.
|
||||
|
||||
## 9. App- und PlanView-Verdrahtung (konkret)
|
||||
|
||||
Neuer View-State in `App.tsx`:
|
||||
|
||||
```ts
|
||||
const [activeTool, setActiveTool] = useState<ToolId>("select");
|
||||
const [activeCategoryCode, setActiveCategoryCode] = useState<string>(/* erste Wand-Kat */);
|
||||
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(project.wallTypes[0].id);
|
||||
const [activeLineStyleId, setActiveLineStyleId] = useState<string>(project.lineStyles[0].id);
|
||||
const [snap, setSnap] = useState<SnapSettings>(DEFAULT_SNAP);
|
||||
const toolStateRef = useRef<ToolState>(getTool(activeTool).init());
|
||||
const [draft, setDraft] = useState<ToolDraft | null>(null);
|
||||
```
|
||||
|
||||
Der `ToolController` ist eine kleine Hook/Klasse, die `toolStateRef` hält und die
|
||||
`PlanView.toolHandlers` implementiert:
|
||||
|
||||
```
|
||||
onToolDown(p): [st, res] = tool.onClick(toolStateRef.current, p, ctx)
|
||||
toolStateRef.current = st; setDraft(res.draft)
|
||||
if res.commit: setProject(res.commit)
|
||||
if res.done: toolStateRef.current = tool.init()
|
||||
onToolMove(p): [st, res] = tool.onMove(...); toolStateRef.current=st; setDraft(res.draft)
|
||||
onToolDoubleClick(): [st,res]=tool.onCommitGesture(...); apply commit/done; setDraft(null)
|
||||
```
|
||||
|
||||
Keyboard (global, nur wenn ein Zeichenwerkzeug aktiv ist):
|
||||
`Esc → onCancel`, `Enter → onCommitGesture`, `Backspace → onUndoPoint`. Beim
|
||||
Wechsel von `activeLevelId`/`viewType` wird der laufende Entwurf verworfen (analog
|
||||
zur bestehenden Auswahl-Bereinigung in den `useEffect`s).
|
||||
|
||||
`ctx` (ToolContext) wird in App via `useMemo` aus Project + aktiven Selektionen
|
||||
gebaut und an PlanView/Controller gereicht.
|
||||
|
||||
### 9.1 Werkzeugleiste (TopBar)
|
||||
|
||||
Eine neue Werkzeug-Gruppe in der `TopBar` (links, vor den Ansichts-Toggles), als
|
||||
i18n-beschriftete Icon-Buttons (`t('tool.select')`, `t('tool.wall')`, …). Aktiv-
|
||||
Zustand hervorgehoben. Daneben: Auswahl des aktiven `WallType` (für Wand) und der
|
||||
aktiven Kategorie/des Linienstils (Dropdowns), sowie Snap-Toggles (kleines
|
||||
Snap-Menü mit Checkboxen je `SnapKind`, Grid-Größe, Winkelraster). Wand-/2D-
|
||||
Werkzeuge werden je nach `level.kind` aktiviert/deaktiviert (Tooltip nennt den
|
||||
Grund — wie die bestehenden disabled-Menüpunkte in `App.tsx`).
|
||||
|
||||
### 9.2 i18n-Keys (neu, Auszug)
|
||||
|
||||
```
|
||||
tool.select / tool.wall / tool.line / tool.polyline / tool.rect /
|
||||
tool.circle / tool.arc / tool.text
|
||||
tool.wall.firstPoint / tool.wall.nextPoint / tool.wall.finish
|
||||
snap.endpoint / snap.midpoint / snap.intersection / snap.center /
|
||||
snap.quadrant / snap.onEdge / snap.grid / snap.ortho
|
||||
snap.settings / snap.gridSize / snap.angleStep
|
||||
status.activeWallType / status.activeCategory / status.activeTool
|
||||
```
|
||||
|
||||
Alle sichtbaren Strings über `t(...)` (CONVENTIONS.md). Identifier bleiben englisch.
|
||||
|
||||
## 10. Übrige Werkzeuge (Kurzspezifikation)
|
||||
|
||||
| Werkzeug | Eingabe | Zustand | Commit |
|
||||
|----------|---------|---------|--------|
|
||||
| **Line** | 2 Punkte (Klick-Klick oder Drag) | `{a?}` | `Drawing2D{shape:"line"}` |
|
||||
| **Polyline** | n Punkte, Abschluss Doppelklick/Enter; `closed` per „C" oder Klick auf Start | `{pts}` | `Drawing2D{shape:"polyline"}` |
|
||||
| **Rectangle** | 2 Ecken (Drag oder Klick-Klick) | `{p0?}` | `Drawing2D{shape:"rect"}` (min/max sortiert) |
|
||||
| **Circle** | Zentrum + Radius-Punkt | `{center?}` | `Drawing2D{shape:"circle"}` |
|
||||
| **Arc** | 3 Punkte (Start, durch, Ende) ODER Zentrum-Start-Ende (Modus-Toggle) | `{p0?,p1?}` | `Drawing2D{shape:"arc"}` (a0/a1 aus Punkten) |
|
||||
| **Text** | 1 Punkt → Inline-Eingabefeld (wie `InlineEditor` in App) | `{at?}` | `Drawing2D{shape:"text"}` |
|
||||
|
||||
Alle nutzen dasselbe `Tool`-Interface, dasselbe Snapping und denselben Draft-/
|
||||
Commit-Pfad. Text öffnet beim Setzen des Ankerpunkts ein kleines Overlay-Eingabe-
|
||||
feld (an `toClient(at)` positioniert) und committet bei Enter/Blur.
|
||||
|
||||
3-Punkt-Bogen → Zentrum: Umkreismittelpunkt der drei Punkte (Schnitt der
|
||||
Mittelsenkrechten via `lineIntersect`), `r`, `a0/a1` aus Start-/Endwinkel; Drehsinn
|
||||
aus dem mittleren Punkt.
|
||||
|
||||
## 11. Phasenplan
|
||||
|
||||
**Phase 1 — Gerüst + Select + Wall + Line (MVP).**
|
||||
1. `ToolId`, `Tool`, `ToolContext`, `ToolPointer`, `ToolDraft`, `ToolResult`,
|
||||
`SnapResult`, `SnapSettings`, `Drawing2D`(+`Project.drawings2d`) als Typen.
|
||||
2. `PlanView`: `toScreen`/`viewToModel`/`pxPerMeter` als Callbacks nach außen;
|
||||
Pointer-Routing für `activeTool !== "select"`; Draft-/Marker-Overlay-Rendering;
|
||||
Crosshair-Cursor.
|
||||
3. `ToolController` + App-State (`activeTool`, `activeCategoryCode`,
|
||||
`activeWallTypeId`, `snap`) + Keyboard (Esc/Enter/Backspace).
|
||||
4. **WallTool** voll funktionsfähig (Polylinie → Wände, Live-Band-Vorschau, HUD,
|
||||
Commit via `appendWalls`). Verschneidung kommt automatisch aus `computeJoins`.
|
||||
5. **LineTool** als erstes 2D-Werkzeug; `generatePlan.addDrawing2D` für `line`;
|
||||
`Drawing2D`-Löschung beim Geschoss-Löschen.
|
||||
6. **Snapping Stufe 1**: endpoint + grid + ortho (Shift), mit Bildschirm-Markern.
|
||||
7. TopBar-Werkzeuggruppe (select/wall/line) + WallType-/Kategorie-Auswahl;
|
||||
i18n-Keys; Statusleiste zeigt aktives Werkzeug + Kategorie.
|
||||
8. Verifizieren: `npx tsc -b`, `npm run build`, Screenshot via `scripts/probe.mjs`
|
||||
(Wand zeichnen, Gehrung prüfen).
|
||||
|
||||
**Phase 2 — Snapping vervollständigen + 2D-Grundformen.**
|
||||
- Snap: midpoint, intersection, onEdge (Lot), extension-Hilfslinien, Winkelraster
|
||||
(45°), Snap-Einstellungsmenü in der TopBar.
|
||||
- Werkzeuge: Polyline, Rectangle (inkl. optionaler Schraffur für geschlossene
|
||||
Formen). `Primitive`-Erweiterung nur für tatsächlich gebrauchte Formen.
|
||||
|
||||
**Phase 3 — Kurven + Text.**
|
||||
- `Primitive` um `circle` (+ `arc` `largeArc`) und `text` erweitern; PlanView-
|
||||
Renderzweige; Text papierkonstant.
|
||||
- Werkzeuge: Circle, Arc (3-Punkt), Text (Inline-Eingabe). Snap: center/quadrant.
|
||||
|
||||
**Phase 4 — Bearbeitung (Folge-Doku).**
|
||||
- Grips/Editieren bestehender Elemente (Wand-Enden ziehen, 2D-Vertices verschieben),
|
||||
Verschieben/Kopieren/Rotieren der Auswahl, numerische Direkteingabe von
|
||||
Länge/Winkel im HUD. Baut auf demselben Snapping + Draft-Pfad auf. (Eigenes
|
||||
Design-Dokument; hier nur als Ausblick.)
|
||||
|
||||
## 12. Architektur-Garantien (Checkliste)
|
||||
|
||||
- Modell bleibt einzige Wahrheit; Werkzeuge schreiben nur `Project`, nie Plan-
|
||||
Primitive. Ansichten (Plan/3D) leiten ab.
|
||||
- Alle Bezeichner englisch; alle UI-Texte über `t(...)`; Einheiten in Metern,
|
||||
Anzeige via `formatM`; Strichstärken/Texthöhen in mm Papier (non-scaling).
|
||||
- Native-App-Verhalten: kein Browser-Kontextmenü während des Zeichnens; keine
|
||||
Textauswahl (außer Text-Eingabefeld); Pan/Zoom immer verfügbar.
|
||||
- DRY: Vorschau nutzt dieselben `Primitive` + Renderlogik wie der Plan; Snapping
|
||||
und Commit-Pfad sind werkzeugübergreifend geteilt.
|
||||
</content>
|
||||
</invoke>
|
||||
Reference in New Issue
Block a user