Files
DOSSIER-STANDALONE/src/plan/PlanView.tsx
T
karim cdfef949bf 2D: Enter in der Befehlszeile bestätigt Punkt-Griff jetzt auch ohne neuen Wert
Nutzer-Report: Enter sollte den Punkt gemäss Maus-/Feld-Stand platzieren
("die Maus steuert bis man Werte eingibt, dann übernimmt es im Feld den
aktuellen Wert, man kann eigene Werte per Tab eingeben") -- aber Enter
tat nach dem Tippen eines Werts (oder auch ganz ohne Tippen) nichts.

Ursache: die Befehlszeile fokussiert sich selbst, sobald man Tab drückt
(Feld-Controller öffnen). Der GLOBALE Enter/Escape-Tastatur-Handler aus
dem vorigen Commit überspringt bewusst fokussierte Eingabefelder (damit
er sich nicht mit der Befehlszeilen-eigenen Enter-Behandlung beisst) --
aber die Befehlszeile selbst kannte für die Griff-Bearbeitung nur den
Fall "Text eingetippt, als Zahl geparst" (submitGripEditValue). Bei
LEERER Eingabe (Enter ohne neuen Wert -- der Normalfall, wenn man nur
bestätigen will) geschah dadurch schlicht nichts.

Fix: PlanViewHandle bekommt eine neue Methode confirmGripEdit() (liest
den bewaffneten Griff aus der lokalen gripDrag-Ref, ruft
gripHandlers.onGripEnd() -- dieselbe Aktion wie ein bestätigender Klick
auf der Zeichenfläche). Die Befehlszeilen-Eingabe in App.tsx ruft sie
jetzt bei leerem/nicht parsbarem Text während der Griff-Bearbeitung auf.

Da das Imperativ-Handle mit leeren Deps memoisiert ist (stabil für die
Oberleiste), braucht es einen frischen gripHandlersRef-Spiegel statt die
gripHandlers-Prop direkt zu schließen (sonst stale closure vom ersten
Render).
2026-08-21 23:38:05 +02:00

2664 lines
110 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Rendert einen Plan als SVG. Vektor → unendlich scharf & druckbar.
//
// Schraffuren werden aus der je Polygon aufgelösten HatchStyle-Ressource
// erzeugt (Muster, Maßstab, Winkel, Farbe, Linienstärke). Pro schraffiertem
// Polygon wird ein eigenes <pattern> in <defs> definiert, damit Maßstab/Winkel/
// Farbe variieren können.
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import type { CSSProperties } from "react";
import type { HatchRender, Plan, Primitive } from "./generatePlan";
import type { EdgeGrip, Vec2 } from "../model/types";
import type { ToolHandlers, ToolId, ToolMods } from "../tools/types";
import type { RichTextDoc } from "../text/richText";
import { docFromText } from "../text/richText";
import { RichTextEditor } from "../text/RichTextEditor";
import { useGlPlanRenderer } from "./useGlPlanRenderer";
import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
import { DEFAULT_MARQUEE_COLOR, DEFAULT_SNAP_COLOR } from "../theme/accents";
import {
type ViewBox,
PX_PER_M,
PAD,
toScreen,
clamp,
meetScale,
scaleFromView,
viewForScale,
fitBoxFor,
pointInPolygon,
pointSegDistPx,
marqueeHit,
marqueeHitDrawings,
} from "./planView/geometry";
import {
polyUsesPattern,
HatchPattern,
buildDrawingRuns,
DrawingRunShape,
PrimitiveShape,
} from "./planView/primitives";
import { DraftOverlay } from "./planView/overlays";
import { PlanRulers } from "./planView/PlanRulers";
/**
* Feld-Status der Befehlszeile fürs Cursor-HUD (Tab-Feld-Zyklus §2.7): Werte je
* Feld (gelockt oder live), aktives Tab-Ziel und der gerade getippte Text —
* das HUD spiegelt die Eingabe LIVE am Cursor (VW-Verhalten).
*/
export interface HudFieldsState {
fields: { id: string; value: number | null; locked: boolean; active: boolean }[];
typed: string;
}
// Zoom-Grenzen relativ zur eingepassten Default-Ansicht. Kleinere viewBox-
// Breite = stärkerer Zoom. ZOOM_MAX → max. Hineinzoomen, ZOOM_MIN → Herauszoomen.
// Grosszügig bemessen (CAD-Anspruch: vom Milimeter-Detail bis zum Siedlungs-
// Kontext) statt der früheren 250×-Spanne (50/0.2), die für Detailarbeit an
// einem Gebäude deutlich zu eng war.
const ZOOM_MAX = 20000; // bis 20 000× hinein (Sub-Millimeter-Details)
const ZOOM_MIN = 0.005; // bis 200× heraus (Siedlungs-/Site-Kontext)
const WHEEL_STEP = 0.0015; // Empfindlichkeit des Mausrads
// Schwelle (Bildschirm-Pixel), ab der ein Links-Ziehen als Marquee gilt; darunter
// bleibt es ein Klick (Einzelwahl). Verhindert versehentliche Mini-Rechtecke.
const MARQUEE_THRESHOLD_PX = 4;
// Trefferradius (Bildschirm-Pixel) für das Greifen eines Editier-Griffs.
const GRIP_HIT_PX = 9;
// Toleranz (Bildschirm-Pixel) für die Linien-Auswahl von 2D-Zeichenelementen.
const LINE_PICK_PX = 6;
/** Ein Polygon mit Schraffur + die ihm zugewiesene <pattern>-ID. */
interface HatchedPoly {
patternId: string;
hatch: HatchRender;
}
/**
* Imperatives Steuerhandle der Plan-Ansicht für die Oberleiste (Zoom-Buttons).
* Die Ansicht behält Pan/Zoom intern; das Handle löst nur die fest definierten
* Sprünge aus, ohne den sonstigen Zustand zu berühren.
*/
export interface PlanViewHandle {
/** Auf die eingepasste Standard-Ansicht zurücksetzen (Einpassen). */
fit: () => void;
/**
* Auf die aktuelle Auswahl einpassen (lokal hervorgehobenes Polygon); gibt es
* keine Auswahl, verhält es sich wie „Einpassen".
*/
fitSelection: () => void;
/**
* Den Zoom so setzen, dass das Modell im PAPIER-Massstab 1:N abgebildet wird
* (siehe docs/design/plans-output.md §3). Hält die aktuelle Bildmitte. Wird
* von der Oberleiste benutzt (Massstab-Preset wählen, „100 %").
*/
applyScale: (n: number) => void;
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz der Command-Engine). */
pxPerMeter: () => number;
/** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */
viewCenterModel: () => Vec2;
/**
* Auf eine beliebige Punktmenge (Modell-Meter) einpassen, z. B. die Bounding-
* Box neu importierter Kontext-Objekte (Auto-Zoom nach Geo-/DXF-Import).
* Leere Punktmenge → No-op (kein Fit, kein Crash).
*/
fitBounds: (pts: Vec2[]) => void;
/**
* Einen bewaffneten Punkt-Griff (s. gripDrag) an seiner AKTUELLEN Position
* abschließen — dieselbe Aktion wie ein bestätigender Klick/Enter auf der
* Zeichenfläche, aber von der Befehlszeile aus angestoßen (Enter dort MIT
* leerem Eingabetext: kein neuer Wert zu locken, nur „fertig, so lassen").
* No-op ohne bewaffneten Griff.
*/
confirmGripEdit: () => void;
}
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
export interface PlanSelection {
/** Klickposition in Modell-Metern. */
model: Vec2;
/** Index des getroffenen Polygon-Primitivs, oder `null` (Leerraum). */
hitIndex: number | null;
/**
* ID der getroffenen Wand (aus dem Polygon-Primitiv), oder `null`, wenn der
* Klick keine Wand traf (Leerraum bzw. ein Polygon ohne Wandbezug). Damit
* setzt der Aufrufer (App) die Auswahl auf genau diese Wand (bzw. leert sie).
*/
wallId: string | null;
/**
* ID des getroffenen 2D-Zeichenelements (Drawing2D), wenn der Klick nahe an
* dessen Linien lag und KEINE Wand getroffen wurde; sonst `null`.
*/
drawingId: string | null;
/**
* ID der getroffenen Decke (aus dem Polygon-Primitiv), wenn der Klick eine
* Decken-Fläche traf und KEINE Wand/kein 2D-Element; sonst `null`.
*/
ceilingId: string | null;
/**
* ID der getroffenen Öffnung (Fenster/Tür), wenn der Klick nahe an ihrem Symbol
* (Türblatt/Schwenkbogen/Fenster-Glaslinie/-Rahmen) lag und KEINE Wand/kein
* 2D-Element/keine Decke getroffen wurde; sonst `null`.
*/
openingId: string | null;
/**
* ID der getroffenen Treppe (aus einem Tritt-/Podest-Polygon), wenn der Klick
* eine Treppe traf und KEINE Wand/kein 2D-Element/keine Decke/keine Öffnung;
* sonst `null`.
*/
stairId: string | null;
/**
* ID des getroffenen Raums (aus einer Raum-Fläche), wenn der Klick einen Raum
* traf und KEIN Element höherer Priorität (Wand/2D/Decke/Öffnung/Treppe);
* sonst `null`.
*/
roomId: string | null;
/**
* ID des getroffenen extrudierten Körpers (truck-Integration, Footprint-
* Umriss), wenn der Klick ihn traf und KEIN Element höherer Priorität
* (Wand/2D/Decke/Öffnung/Treppe); sonst `null`.
*/
extrudedSolidId: string | null;
/**
* ID der getroffenen Stütze (Column, Grundriss-Querschnitt), wenn der Klick sie
* traf und KEIN Element höherer Priorität (Öffnung/Wand); sonst `null`.
*/
columnId: string | null;
/**
* ID des getroffenen Dachs (Traufe-Fläche), wenn der Klick es traf und KEIN
* Element höherer Priorität (alle anderen gehen vor); sonst `null`.
*/
roofId: string | null;
/**
* ID der getroffenen Schnitt-/Ansichtslinie (DrawingLevel), wenn der Klick ihr
* dünnes Pick-Band traf; sonst `null`. Höchste Priorität (das schmale Band liegt
* über allem — ein Klick genau darauf meint die Linie).
*/
sectionLineId: string | null;
/**
* ID des getroffenen Kontext-Objekts (importiertes Gebäude/Terrain), wenn der
* Klick sein Grundriss-Pick-Band traf und KEIN Element höherer Priorität
* (alle anderen gehen vor, analog `roofId`); sonst `null`.
*/
contextObjectId: string | null;
/**
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
* ersetzen. Bei Leerraum-Klick mit Shift bleibt die Auswahl erhalten.
*/
shift: boolean;
}
/**
* Ergebnis eines Auswahl-Rechtecks (Marquee). `wallIds` ist die nach der
* CAD-Konvention bestimmte Menge getroffener Wände (siehe `crossing`). Der
* Aufrufer (App) ersetzt die aktuelle Auswahl durch diese Menge.
*/
export interface MarqueeSelection {
wallIds: string[];
/** Getroffene 2D-Zeichenelemente (gleiche window/crossing-Konvention). */
drawingIds: string[];
/**
* Zieh-Richtung: `false` = links→rechts (positiv Δx) → nur VOLLSTÄNDIG
* eingeschlossene Wände; `true` = rechts→links (negativ Δx) → auch berührte/
* kreuzende Wände (CAD-Konvention: „crossing window").
*/
crossing: boolean;
}
export interface PlanViewProps {
plan: Plan;
/**
* Stabiler Schlüssel des Plan-Kontexts (z. B. Geschoss-/Ebenen-ID). Der
* sichtbare Ausschnitt wird NUR auf „eingepasst" zurückgesetzt, wenn sich
* dieser Schlüssel ändert (Plan-/Geschoss-Wechsel) — NICHT bei jeder Modell-/
* Bounds-Änderung durch Editieren. So „wandert" die Ansicht beim Verschieben/
* Löschen nicht mehr mit; sie ändert sich nur durch manuelles Pan/Zoom/
* Einpassen. Fehlt der Schlüssel, bleibt der Ausschnitt nach dem ersten
* Einpassen stehen (kein Auto-Reset).
*/
resetKey?: string;
/**
* Meldet die Cursor-Position in Modell-Koordinaten (Meter) nach oben, bzw.
* `null`, wenn der Zeiger die Fläche verlässt. Optional — ohne Callback
* verhält sich die Ansicht wie zuvor.
*/
onCursor?: (model: Vec2 | null) => void;
/**
* Meldet den LIVE Papier-Massstab als Nenner N (1:N), berechnet aus dem
* aktuellen Ausschnitt + Canvas-Größe + dpi (docs/design/plans-output.md §3).
* Aktualisiert sich beim Wheel-Zoom/Pan/Resize. Optional.
*/
onScale?: (n: number) => void;
/**
* Links-Klick (Auswahl): meldet die getroffene Wand (bzw. `hitIndex: null`
* bei Klick in den Leerraum → Auswahl löschen). Optional. Die Ansicht
* markiert das getroffene Polygon zusätzlich selbst (lokale Hervorhebung).
*/
onSelect?: (selection: PlanSelection) => void;
/**
* Alt+Klick (Schere/Segment löschen): meldet das nahe getroffene Drawing2D plus
* den Modellpunkt des Klicks. Der Aufrufer (App) ermittelt daraus die nächst-
* gelegene Kante und entfernt sie. Optional. Nur ausgelöst, wenn ein 2D-Element
* unter dem Cursor liegt (sonst no-op).
*/
onSegmentCut?: (drawingId: string, model: Vec2) => void;
/**
* Aktuell ausgewählte Wand-IDs (kontrolliert von App). Die Ansicht markiert
* ALLE Polygone JEDER dieser Wände mit einer Akzentkontur — unabhängig davon,
* welches Band der Klick traf. Leeres Array/undefined = keine Wand-Auswahl.
*/
selectedWallIds?: string[];
/**
* Aktuell ausgewählte Decken-IDs (kontrolliert von App). Die Ansicht markiert
* ALLE Polygone JEDER dieser Decken mit einer Akzentkontur.
*/
selectedCeilingIds?: string[];
/**
* Aktuell ausgewählte Öffnungs-IDs (Fenster/Tür; kontrolliert von App). Die
* Ansicht markiert alle Symbol-Primitive JEDER dieser Öffnungen.
*/
selectedOpeningIds?: string[];
/**
* Aktuell ausgewählte Treppen-IDs (kontrolliert von App). Die Ansicht markiert
* alle Tritt-/Symbol-Primitive JEDER dieser Treppen mit einer Akzentkontur.
*/
selectedStairIds?: string[];
/**
* Aktuell ausgewählte Raum-IDs (kontrolliert von App). Die Ansicht markiert die
* Fläche JEDES dieser Räume mit einer Akzentkontur.
*/
selectedRoomIds?: string[];
/** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */
selectedRoofIds?: string[];
/**
* Aktuell ausgewählte Kontext-Objekt-IDs (importiertes Gebäude/Terrain; von
* App). Die Ansicht markiert das Grundriss-Pick-Band JEDES dieser Objekte mit
* einer Akzentkontur.
*/
selectedContextObjectIds?: string[];
/**
* Aktuell ausgewählte Schnitt-/Ansichtslinien-IDs (DrawingLevel; von App). Die
* Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur.
*/
selectedSectionLineIds?: string[];
/**
* Aktuell ausgewählte IDs extrudierter Körper (truck-Integration; kontrolliert
* von App). Die Ansicht markiert den Footprint-Umriss JEDES dieser Körper mit
* einer Akzentkontur.
*/
selectedExtrudedSolidIds?: string[];
/**
* Aktuell ausgewählte Stützen-IDs (Tragwerk; kontrolliert von App). Die Ansicht
* markiert den Querschnitt-Umriss JEDER dieser Stützen mit einer Akzentkontur.
*/
selectedColumnIds?: string[];
/**
* Der Stempel-Griff des (einzeln) gewählten Raums: sein Anker (Meter) + ID.
* PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick.
* `null` = kein einzelner Raum gewählt.
*/
roomStamp?: { roomId: string; anchor: Vec2 } | null;
/** Der Stempel-Griff wurde verschoben (inkrementelles Modell-Delta). */
onStampMove?: (roomId: string, delta: Vec2) => void;
/** Doppelklick auf einen Raum-Stempel: Editor öffnen. */
onStampEdit?: (roomId: string) => void;
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
onEditDrawingText?: (drawingId: string) => void;
/**
* Drawing2D-ID, deren Inhalt gerade inline auf der Zeichenfläche editiert
* wird (frisch über `text`/`textbox` erzeugt ODER per Doppelklick auf ein
* bestehendes Element geöffnet) — `null`/undefined = kein Inline-Editor
* offen. PlanView rendert dafür einen `RichTextEditor`-Overlay direkt über
* dem Element (kein Dialog), positioniert/skaliert über den aktuellen
* Ausschnitt.
*/
editTextId?: string | null;
/**
* Mindesthöhe (Meter) des Inline-Editors, wenn `editTextId` aus einem
* frisch aufgezogenen Textspalten-RAHMEN kommt (s. textbox.ts). `null`/
* undefined = keine Mindesthöhe, der Inhalt bestimmt die Höhe allein.
*/
editTextMinHeightM?: number | null;
/** Jede Änderung im Inline-Text-Editor (live, pro Tastendruck). */
onCommitTextEdit?: (doc: RichTextDoc) => void;
/** Inline-Text-Editor schliessen (Backdrop-Klick/Escape). */
onCloseTextEdit?: () => void;
/** Doppelklick auf eine Schnittlinie: in deren 2D-Schnittansicht springen
* (Zeichnungsebene aktivieren). */
onOpenSectionLevel?: (levelId: string) => void;
/**
* Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach
* der CAD-Konvention (siehe {@link MarqueeSelection}). Optional.
*/
onMarquee?: (selection: MarqueeSelection) => void;
/**
* Rechts-Klick: öffnet ein Kontextmenü an der Cursorposition. Erhält die
* Viewport-Pixel (clientX/Y) plus den Modellpunkt. Optional. Das Standard-
* Browser-Kontextmenü wird immer unterdrückt.
*/
onContextMenu?: (info: { clientX: number; clientY: number; model: Vec2 }) => void;
/**
* Aktives Zeichenwerkzeug. `"select"`/undefined = bisheriges Verhalten
* (Auswahl/Marquee). Bei einem aktiven Zeichenwerkzeug übernimmt das Werkzeug
* die linke Maustaste (Punkte setzen) statt der Auswahl; Pan/Zoom bleiben.
*/
activeTool?: ToolId;
/**
* Übernimmt ein Zeichenwerkzeug ODER eine Transformation die linke Maustaste?
* Wird von App berechnet (Zeichenwerkzeug aktiv ODER laufende Transformation).
* Fehlt der Wert, wird aus `activeTool` abgeleitet (Rückwärtskompatibilität).
*/
toolInputActive?: boolean;
/**
* Werkzeug-Treiber: PlanView meldet rohe Modellpunkte + Pixel/Meter hierher
* und zeichnet den zurückgegebenen `draft` als Overlay (Vorschau + Snap-Marker).
*/
toolHandlers?: ToolHandlers;
/** Lineale (oben + links, VW-Stil) einblenden. Default an. */
showRulers?: boolean;
/**
* Feld-Status der Befehlszeile fürs Cursor-HUD (Tab-Feld-Zyklus §2.7):
* gelockte/live Werte je Feld, aktives Tab-Ziel und der gerade getippte
* Text. Das HUD spiegelt damit die Eingabe LIVE am Cursor (VW-Verhalten:
* tippen → Wert erscheint oben im Kästchen UND unten im Befehl); ohne
* Felder fällt es auf die draft.hud-Livewerte zurück.
*/
hudFields?: HudFieldsState | null;
/**
* Editier-Griffe (Grips) des aktuell EINZELN selektierten Elements, in Modell-
* Metern (Wand-Enden, 2D-Vertices/Ecken). Werden als ziehbare Quadrate
* gezeichnet; das Ziehen meldet sich über {@link gripHandlers}. Nur im
* Auswahl-Werkzeug sichtbar/aktiv.
*/
grips?: Vec2[];
/**
* Kanten-/Seiten-Griffe (Edge Grips) des selektierten Elements: je Seite ein
* dreieckiger Anfasser am Kanten-Mittelpunkt, dessen Spitze nach AUSSEN zeigt.
* Ziehen meldet sich über {@link GripHandlers.onEdgeMove}. Nur sichtbar/aktiv,
* wenn auch die Eckpunkt-Griffe aktiv sind.
*/
edgeGrips?: EdgeGrip[];
/**
* „Display"-Modus: alle Plan-Linien als konstante Haarlinie (1 px) zeichnen,
* statt der echten mm-Strichstärken (Print). Reine Darstellung.
*/
hairline?: boolean;
/** ID des selektierten 2D-Elements (für „Körper greifen → verschieben"). */
selectedDrawingId?: string | null;
/** Alle selektierten 2D-Elemente (Mehrfach-/Marquee-Auswahl) — für Hervorhebung. */
selectedDrawingIds?: string[];
/** Griff-Zieh-Treiber: Live-Bewegung (gesnappt) + Abschluss. */
gripHandlers?: GripHandlers;
/**
* Farbe des Auswahlrahmens (Marquee-Ziehauswahl). Einstellbar im
* Einstellungs-Fenster (State in ViewSlice); Default = Akzent „Yuyake".
*/
marqueeColor?: string;
/**
* Farbe der Snap-/Endpunkt-Hervorhebung (Werkzeug-Overlay). Einstellbar im
* Einstellungs-Fenster (State in ViewSlice); Default = Akzent „Sora".
*/
snapColor?: string;
}
/** Treiber für das Ziehen von Editier-Griffen (von App bereitgestellt). */
export interface GripHandlers {
onGripMove(index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods): void;
/**
* Ganzes Element parallel verschieben (inkrementelles Delta in Metern).
* `target` benennt das gegriffene Element (Wand-ID ODER Drawing-ID) — wichtig
* bei MEHRFACHAUSWAHL, wo das unter dem Cursor gegriffene Element verschoben
* wird und koinzidente Enden der übrigen gewählten Elemente mitwandern. Fehlt
* `target`, gilt das einzeln selektierte Element (Rückwärtskompatibilität).
*/
onMoveBody(
delta: Vec2,
target?: { wallId?: string; drawingId?: string },
mods?: ToolMods,
): void;
/**
* Eine SEITE (Kante) ziehen: verschiebt beide Vertices der Kante senkrecht
* (nur die Normal-Komponente von `raw` wirkt; siehe App). `raw` ist der rohe
* Modellpunkt unter dem Cursor.
*/
onEdgeMove(edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods): void;
onGripEnd(): void;
/**
* Bewaffneten Punkt-Griff verwerfen (Escape): setzt den Punkt exakt auf
* `origin` zurück (ohne Snap/Ortho-Neuauswertung) und räumt danach wie
* {@link onGripEnd} auf.
*/
onGripCancel(index: number, origin: Vec2): void;
}
export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
function PlanView(
{
plan,
resetKey,
onCursor,
onScale,
onSelect,
onMarquee,
onSegmentCut,
onContextMenu,
selectedWallIds,
selectedCeilingIds,
selectedOpeningIds,
selectedStairIds,
selectedRoomIds,
selectedRoofIds,
selectedContextObjectIds,
selectedSectionLineIds,
selectedExtrudedSolidIds,
selectedColumnIds,
roomStamp,
onStampMove,
onStampEdit,
onEditDrawingText,
editTextId,
editTextMinHeightM,
onCommitTextEdit,
onCloseTextEdit,
onOpenSectionLevel,
activeTool,
toolInputActive,
toolHandlers,
showRulers = true,
hudFields,
grips,
edgeGrips,
hairline,
selectedDrawingId,
selectedDrawingIds,
gripHandlers,
marqueeColor = DEFAULT_MARQUEE_COLOR,
snapColor = DEFAULT_SNAP_COLOR,
},
ref,
) {
// Linke Maustaste liegt beim Werkzeug/der Transformation, wenn App das meldet
// (toolInputActive) bzw. — fallback — ein Zeichenwerkzeug aktiv ist.
const inputActive = toolInputActive ?? (!!activeTool && activeTool !== "select");
const toolActive = inputActive && !!toolHandlers;
// Editier-Griffe sind nur im Auswahl-Werkzeug aktiv.
const gripsActive = !toolActive && !!grips && grips.length > 0 && !!gripHandlers;
// Kanten-Griffe nur aktiv, wenn auch die Eckpunkt-Griffe aktiv sind.
const edgeGripsActive = gripsActive && !!edgeGrips && edgeGrips.length > 0;
// Eingepasste Standard-Ansicht aus den AKTUELLEN Bounds, über die fixe
// toScreen-Abbildung. Reset-/Einpassen-Ziel + Bezug für die Zoom-Grenzen; wird
// NICHT automatisch angewandt (nur bei resetKey-Wechsel und „Einpassen").
const defaultView = useMemo<ViewBox>(() => {
const { minX, minY, maxX, maxY } = plan.bounds;
return {
x: minX * PX_PER_M - PAD,
y: -maxY * PX_PER_M - PAD,
w: (maxX - minX) * PX_PER_M + PAD * 2,
h: (maxY - minY) * PX_PER_M + PAD * 2,
};
}, [plan]);
// Aktueller Ausschnitt; treibt die viewBox des SVG. Initial = eingepasst.
const [view, setView] = useState<ViewBox>(defaultView);
// Ausschnitt NUR bei Plan-/Geschoss-Wechsel (resetKey) auf „eingepasst"
// zurücksetzen — NICHT bei jeder Bounds-Änderung durch Editieren (sonst
// „wandert" die Ansicht beim Verschieben/Löschen mit). `defaultView` ist beim
// resetKey-Wechsel bereits frisch (neues Geschoss), daher direkt anwendbar.
useEffect(() => {
setView(defaultView);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetKey]);
const svgRef = useRef<SVGSVGElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Engine-Auswahl. STANDARD-Pfad: WebGL2 (useGlPlanRenderer); der SVG-Renderer
// bleibt automatischer Fallback (nur falls WebGL2/Shader nicht verfügbar), `?gl=0`
// erzwingt reines SVG. INTEGRATIONS-SPIKE: Mit `?engine=wasm` UND vorhandenem
// WebGPU (navigator.gpu) übernimmt statt WebGL2 der native render2d-Renderer als
// WASM/WebGPU-Viewport (useWasmPlanRenderer) — dieselbe Szene, dieselbe ViewBox,
// dasselbe SVG-Overlay (Text/Griffe/Auswahl) darüber. Fehlt WebGPU trotz Flag:
// stiller Fallback auf WebGL2 (+ Warnung).
const engineParam =
typeof window === "undefined"
? null
: new URLSearchParams(window.location.search).get("engine");
const hasWebGpu = typeof navigator !== "undefined" && "gpu" in navigator;
const wantWasm = engineParam === "wasm" && hasWebGpu;
useEffect(() => {
if (engineParam === "wasm" && !hasWebGpu) {
console.warn("engine=wasm angefragt, aber navigator.gpu fehlt → WebGL2-Fallback");
}
}, [engineParam, hasWebGpu]);
// WebGL2 nur, wenn NICHT der WASM-Pfad aktiv ist (und `?gl!=0`).
const wantGl =
!wantWasm &&
(typeof window === "undefined"
? true
: new URLSearchParams(window.location.search).get("gl") !== "0");
// Beide Hooks unbedingt aufrufen (Hook-Regeln); je nach Flag ist genau einer aktiv.
const glHook = useGlPlanRenderer(canvasRef, wantGl);
const wasmHook = useWasmPlanRenderer(canvasRef, wantWasm);
const activeEngine = wantWasm ? wasmHook : glHook;
const { render: renderGl, updateGeometry, ready: glReady } = activeEngine;
// „GPU-Canvas montieren?" — beide GPU-Pfade brauchen das Canvas (an der ABSICHT
// montiert, nicht am ready, sonst könnte die Engine mangels Canvas nie starten).
const wantGpu = wantGl || wantWasm;
// Georeferenzierte Hintergrund-Rasterbilder (Luftbild) vorhanden? Der GPU-
// Canvas löscht das Blatt UNDURCHSICHTIG (clearColor …,1) und würde ein
// dahinterliegendes Bild verdecken. Daher bei aktivem Luftbild bewusst den
// vollen SVG-Pfad nutzen — dort liegt das Bild als eigene Ebene ZWISCHEN
// Blatt-Hintergrund und Geometrie (sichtbar, ohne den GL-Renderer zu ändern).
const hasBgImages = (plan.backgroundImages?.length ?? 0) > 0;
// Eingebettete Bild-Zeichenelemente (Drawing2D `{shape:"image"}`, Foto/Logo/
// PDF-Seite) kann das GPU-Backend ebenfalls nicht texturieren (kein Bild-
// Primitiv in render2d, s. toRenderScene.ts `RImage`-Kommentar) — dieselbe
// SVG-Fallback-Regel wie bei georeferenzierten Luftbildern.
const hasDrawingImages = plan.primitives.some((p) => p.kind === "drawingImage");
// Engine WIRKLICH aktiv? Steuert das Umschalten SVG↔GPU: bei false rendert der
// volle SVG-Pfad (Fallback), bei true übernimmt die GPU-Ebene + Text-Overlay.
const useGpuRenderer = wantGpu && glReady && !hasBgImages && !hasDrawingImages;
// Stabiler Papier-Massstab-Nenner N (1:N) als REFERENZ für die Print-Strich-
// breiten. BEWUSST NICHT der live aus dem Zoom abgeleitete N (der sich beim
// Rad-Zoom ändert): Print-Strichbreiten sind echte Papier-mm, die beim Zoomen
// MIT der Geometrie skalieren (reinzoomen = dicker). Damit das funktioniert,
// muss die mm→Welt-Umrechnung an einem beim Rad-Zoom KONSTANTEN N hängen; er
// ändert sich nur, wenn die Oberleiste explizit einen Massstab setzt
// (applyScale) bzw. beim ersten Messen/Einpassen. So gilt: 1:10→1:100 = ×10
// dicker (halber Sprung des Nenners → 10× mehr Welt-Meter je Papier-mm).
const [paperScale, setPaperScale] = useState<number | null>(null);
const paperScaleRef = useRef(paperScale);
paperScaleRef.current = paperScale;
// Frische Werte in Refs spiegeln, damit das (stabile) imperative Handle und
// der Resize-Beobachter stets den aktuellen Stand lesen (Handle hat leere Deps).
const viewRef = useRef(view);
viewRef.current = view;
const defaultViewRef = useRef(defaultView);
defaultViewRef.current = defaultView;
const toScreenRef = useRef(toScreen);
toScreenRef.current = toScreen;
const planRef = useRef(plan);
planRef.current = plan;
// Zuletzt tessellierter LOD-Bucket (quantisierter Zoom). Nur bei Bucket-Wechsel
// wird die Bogen-Geometrie neu aufgebaut — sonst bliebe Pan/Zoom reines
// Matrix-Update. −Infinity = „noch nie tesselliert".
const lodBucketRef = useRef<number>(-Infinity);
// Papier-Massstab-Nenner (1:N) für die GL-Strichbreiten — ECHTE mm im Massstab
// (0.35 mm bei 1:100). Bevorzugt der stabile `paperScale` (wie beim SVG-Print);
// solange der noch nicht gemessen ist, der live aus dem Ausschnitt abgeleitete N.
// Im Haarlinien-Modus (Display, wie `textScaleForGl` daneben) NICHT den
// stabilen `paperScale`: der bliebe beim Rad-Zoom konstant, während die GPU-
// Strichbreite (`mm_to_device_px` = N/1000·PX_PER_M·meet) über `meet` trotzdem
// mit dem Zoom wächst — genau der Haarlinien-Bug (Finding 1). Der LIVE aus dem
// Ausschnitt abgeleitete `scaleFromView` ist hingegen exakt der reziproke Wert
// von `meet` (siehe dessen Formel), sodass N·meet — und damit die GL-Strich-
// breite in Geräte-px — unabhängig vom Zoom konstant bleibt: dieselbe Wirkung
// wie `non-scaling-stroke` im SVG-Pfad, nur über den Massstab-Nenner erzielt.
const paperScaleForGl = (v: ViewBox): number =>
hairline
? scaleFromView(v, svgRef.current) ?? 100
: paperScaleRef.current ?? scaleFromView(v, svgRef.current) ?? 100;
// Referenz-Nenner (1:N) NUR für die Text-Schriftgröße der Engine — spiegelt
// EXAKT die SVG-Formel (case "text" unten, gleiche `print`-Bedingung): im
// normalen Anzeigemodus fest auf die Referenzskala 1:100 (Raumstempel bleibt
// lesbar, unabhängig vom zufälligen Einpass-Massstab der Ansicht), erst im
// Druck-Modus der echte gemessene `paperScale`. BEWUSST NICHT `paperScaleForGl`
// (das dort verwendete live abgeleitete N wäre für Text willkürlich/unlesbar
// und würde vom SVG-Referenzpfad abweichen — siehe Doku in `gpu::Renderer`).
const textScaleForGl = (): number => {
const print = !hairline && paperScale != null && paperScale > 0;
return print ? paperScale! : 100;
};
// Bildschirm-Massstab (Geräte-px je Modell-Meter) beim aktuellen Ausschnitt:
// meet-Skala (CSS-px je viewBox-Einheit) · PX_PER_M (viewBox-Einheiten je Meter)
// · dpr (Geräte-px je CSS-px). Steuert die adaptive Bogen-Tessellierung.
const pxPerMeterForView = (v: ViewBox): number => {
const meet = meetScale(v, svgRef.current);
if (!meet || meet <= 0) return 0;
return meet * PX_PER_M * (window.devicePixelRatio || 1);
};
// LOD-Bucket = quantisierter Zoom (log2 des Bildschirm-Massstabs, auf Ganzzahl
// gerundet → Schritte von ×2). Nur ein Bucket-Wechsel löst eine Neu-Tessellierung
// aus; innerhalb eines Buckets bleibt Pan/Zoom reines Matrix-Uniform-Update.
const lodBucketForView = (v: ViewBox): number => {
const s = pxPerMeterForView(v);
return s > 0 ? Math.round(Math.log2(s)) : -Infinity;
};
// GL-Geometrie beim Plan-Wechsel neu tessellieren + sofort neu zeichnen. Der
// aktuelle LOD-Bucket wird mitgeschrieben, damit der Pan/Zoom-Effekt weiss, ab
// wann sich der Zoom weit genug bewegt hat, um Bögen neu aufzulösen.
useEffect(() => {
if (!useGpuRenderer) return;
const v = viewRef.current;
updateGeometry(plan, pxPerMeterForView(v));
lodBucketRef.current = lodBucketForView(v);
renderGl(v, paperScaleForGl(v), textScaleForGl());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useGpuRenderer, plan, updateGeometry, renderGl]);
// GL bei jeder Ausschnittsänderung (Pan/Zoom) neu zeichnen. Normalfall: NUR
// Matrix-Uniform (keine Re-Tessellierung → flüssig). Ausnahme: hat der Zoom
// einen neuen LOD-Bucket erreicht, werden die Bögen einmalig mit dem neuen
// Bildschirm-Massstab neu tesselliert (rund beim Hineinzoomen, billig weit weg).
// Kostennotiz: `updateGeometry` baut die GESAMTE Linien-/Füll-Geometrie neu auf,
// nicht nur die Bögen — Bucket-Wechsel sind aber selten (Zoom-Faktor ×2).
useEffect(() => {
if (!useGpuRenderer) return;
const bucket = lodBucketForView(view);
if (bucket !== lodBucketRef.current) {
lodBucketRef.current = bucket;
updateGeometry(plan, pxPerMeterForView(view));
}
renderGl(view, paperScaleForGl(view), textScaleForGl());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useGpuRenderer, renderGl, view, paperScale]);
// Ausstehende imperative Pan-Aufräumung beim Unmount.
useEffect(() => {
return () => {
if (panRaf.current !== null) cancelAnimationFrame(panRaf.current);
};
}, []);
// LIVE Papier-Massstab (1:N) nach oben melden. Berechnung aus der effektiven
// meet-Skala (Bildschirm-px je viewBox-Einheit) → px je Meter → mm je Meter,
// verglichen mit der Papier-Abbildung. Aktualisiert bei Ausschnitts-/Größen-
// änderung; ein ResizeObserver fängt reine Größenänderungen ab.
const reportScale = () => {
if (!onScale) return;
const n = scaleFromView(viewRef.current, svgRef.current);
if (n != null && isFinite(n)) onScale(n);
};
useEffect(() => {
reportScale();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onScale, view, plan]);
useEffect(() => {
const el = svgRef.current;
if (!el) return;
const ro = new ResizeObserver(() => reportScale());
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onScale]);
// Referenz-Papier-Massstab initialisieren, sobald das SVG messbar ist (bzw. der
// Plan/das Geschoss wechselt): aus dem AKTUELLEN Ausschnitt (der beim Wechsel
// eingepasst wird). Danach bleibt er beim Rad-Zoom stehen; applyScale setzt ihn
// explizit neu. Ohne Messung (SSR/erste Runde) bleibt er null → Fallback im
// Renderer auf live-N, damit Print nie „unsichtbar" wird.
useEffect(() => {
if (paperScaleRef.current != null) return;
const n = scaleFromView(viewRef.current, svgRef.current);
if (n != null && isFinite(n) && n > 0) setPaperScale(n);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
// Beim Plan-/Geschoss-Wechsel den Referenz-Massstab an den (frisch eingepassten)
// Ausschnitt angleichen.
useEffect(() => {
const n = scaleFromView(defaultViewRef.current, svgRef.current);
if (n != null && isFinite(n) && n > 0) setPaperScale(n);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetKey]);
// Frischer Spiegel der gripHandlers-Prop für das (unten stabil mit leeren
// Deps memoisierte) Imperativ-Handle — direkt referenzieren würde die Prop
// vom ERSTEN Render einfrieren (stale closure, s. Kommentar dort).
const gripHandlersRef = useRef(gripHandlers);
gripHandlersRef.current = gripHandlers;
// Imperatives Steuerhandle für die Oberleiste (Einpassen / Auswahl / Massstab).
// Stabil (leere Deps); liest den frischen Ausschnitt + die Auswahl aus Refs.
useImperativeHandle(
ref,
() => ({
fit: () => setView(defaultViewRef.current),
confirmGripEdit: () => {
// Von der Befehlszeile aus angestoßen (Enter mit leerem Eingabetext,
// s. App.tsx) — dieselbe Aktion wie ein bestätigender Klick auf der
// Zeichenfläche (siehe onPointerDown-Kopf unten), nur eben nicht über
// ein Pointer-Event.
if (gripDrag.current) {
gripDrag.current = null;
gripHandlersRef.current?.onGripEnd();
}
},
fitSelection: () => {
const sel = selectedRef.current;
const prim = sel !== null ? planRef.current.primitives[sel] : undefined;
if (prim && prim.kind === "polygon") {
setView(fitBoxFor(prim.pts, toScreenRef.current));
} else {
setView(defaultViewRef.current);
}
},
applyScale: (n: number) => {
const target = viewForScale(n, viewRef.current, svgRef.current);
if (target) setView(target);
// Diesen Massstab als stabile Referenz für die Print-Strichbreiten merken:
// ab jetzt skalieren die Papier-mm-Linien um genau dieses N (bis der
// nächste Massstab gewählt wird); Rad-Zoom lässt ihn unberührt.
if (n > 0) setPaperScale(n);
},
pxPerMeter: () => currentPxPerMeter(),
viewCenterModel: () => {
// Mitte der frischen viewBox (aus Ref) → Modell (inverse toScreen).
const v = viewRef.current;
return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M };
},
fitBounds: (pts) => {
if (pts.length === 0) return; // leerer Import → kein Fit, kein Crash
setView(fitBoxFor(pts, toScreenRef.current));
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// Transiente Pan-Zieh-Daten (Mitteltaste; kein State → kein Re-Render je
// Bewegung).
const drag = useRef<{
pointerId: number;
startClientX: number;
startClientY: number;
startView: ViewBox;
} | null>(null);
const [dragging, setDragging] = useState(false);
// Transiente Marquee-Zieh-Daten (linke Taste). Startpunkt in viewBox-Einheiten
// + die Bildschirm-Pixel (für die Klick-vs-Drag-Schwelle).
const marquee = useRef<{
pointerId: number;
startClientX: number;
startClientY: number;
startView: Vec2; // Startpunkt in viewBox-Einheiten
moved: boolean; // Schwelle überschritten → echtes Rechteck (kein Klick)
} | null>(null);
// Aktuelles Auswahl-Rechteck in viewBox-Einheiten (für die sichtbare Kontur);
// null = kein Marquee aktiv.
const [marqueeRect, setMarqueeRect] = useState<{
x: number;
y: number;
w: number;
h: number;
crossing: boolean;
} | null>(null);
// Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je
// Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove.
// Anders als bei Kante/Körper braucht das Loslassen der Maus die Bearbeitung
// NICHT mehr zu beenden: der Griff bleibt „bewaffnet" (kein Pointer-Capture),
// folgt dem Cursor beim Schweben weiter — wie beim Zeichnen (Klick → Punkt
// setzen, Maus bewegt sich frei, Wert tippbar, nächster Klick/Enter bestätigt,
// Escape verwirft zur ursprünglichen Position `origin` zurück).
const gripDrag = useRef<{ pointerId: number; index: number; origin: Vec2 } | null>(
null,
);
// Transiente Kanten-Griff-Zieh-Daten (linke Taste auf einem Dreieck-Anfasser).
// Hält den gegriffenen EdgeGrip; die Mutation läuft live über onEdgeMove.
const edgeDrag = useRef<{ pointerId: number; edge: EdgeGrip } | null>(null);
// Transiente „Körper verschieben"-Daten: letzter Modellpunkt, gegen den das
// inkrementelle Delta gebildet wird (parallel-Verschiebung des Elements).
const moveDrag = useRef<{
pointerId: number;
last: Vec2;
target: { wallId?: string; drawingId?: string };
} | null>(null);
// Transiente Stempel-Griff-Zieh-Daten: letzter Modellpunkt, gegen den das
// inkrementelle Delta gebildet wird (verschiebt NUR den Raum-Stempel-Anker).
const stampDrag = useRef<{ pointerId: number; roomId: string; last: Vec2 } | null>(
null,
);
// Letzter Links-Klick (Zeit + Bildschirmposition) für die manuelle Doppelklick-
// Erkennung auf Raum-Stempel (Pointer-Capture unterdrückt natives dblclick).
const lastClickRef = useRef<{ t: number; x: number; y: number } | null>(null);
// Tastatur-Abschluss eines bewaffneten Punkt-Griffs (s. gripDrag oben): Escape
// verwirft (zurück auf `origin`), Enter bestätigt die aktuelle Position — beide
// NUR wenn kein Eingabefeld fokussiert ist (die Befehlszeile behandelt ihr
// eigenes Enter für getippte Länge/Winkel-Werte selbst, s. App.tsx).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const gd = gripDrag.current;
if (!gd || !gripHandlers) return;
if (e.key !== "Escape" && e.key !== "Enter") return;
const el = e.target as HTMLElement | null;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) {
return;
}
e.preventDefault();
gripDrag.current = null;
if (e.key === "Escape") gripHandlers.onGripCancel(gd.index, gd.origin);
else gripHandlers.onGripEnd();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [gripHandlers]);
/** Bildschirm-px-Distanz Cursor → Stempel-Griff; null = kein einzelner Raum. */
const stampHandleAt = (clientX: number, clientY: number): string | null => {
if (!roomStamp) return null;
const s = vbToClient(toScreen(roomStamp.anchor).x, toScreen(roomStamp.anchor).y);
const d = Math.hypot(clientX - s.x, clientY - s.y);
return d <= GRIP_HIT_PX ? roomStamp.roomId : null;
};
// Lokale Auswahl: Index des hervorgehobenen Polygon-Primitivs (oder null).
// Reine Darstellung; die eigentliche Wand-Auswahl meldet onSelect nach oben.
// Beim Plan-Wechsel zurücksetzen (Indizes sind nicht mehr gültig).
const [selected, setSelected] = useState<number | null>(null);
const selectedRef = useRef(selected);
selectedRef.current = selected;
useEffect(() => {
setSelected(null);
}, [plan]);
// Verhältnis viewBox-Einheiten ↔ Bildschirm-Pixel. preserveAspectRatio
// "xMidYMid meet" passt die viewBox einheitlich (kleinerer Faktor) ein und
// zentriert sie; daraus ergibt sich die effektive Skala + der Versatz.
const measure = (vb: ViewBox) => {
const el = svgRef.current;
const rect = el?.getBoundingClientRect();
const pxW = rect?.width ?? vb.w;
const pxH = rect?.height ?? vb.h;
// Bildschirm-Pixel je viewBox-Einheit (meet = min beider Achsen).
const scale = Math.min(pxW / vb.w, pxH / vb.h);
const drawnW = vb.w * scale;
const drawnH = vb.h * scale;
// Letterbox-Versatz durch die Zentrierung.
const offX = (pxW - drawnW) / 2;
const offY = (pxH - drawnH) / 2;
return { rect, scale, offX, offY };
};
// Cursor-Position (clientX/Y) → viewBox-Koordinaten des aktuellen Ausschnitts.
const clientToView = (clientX: number, clientY: number, vb: ViewBox): Vec2 => {
const { rect, scale, offX, offY } = measure(vb);
const left = rect?.left ?? 0;
const top = rect?.top ?? 0;
return {
x: vb.x + (clientX - left - offX) / scale,
y: vb.y + (clientY - top - offY) / scale,
};
};
// viewBox-Einheiten → Modell-Koordinaten in Metern (Inverse des fixen
// toScreen): x/PX_PER_M, Plan-Y zeigt nach oben → −y/PX_PER_M.
const viewToModel = (vbX: number, vbY: number): Vec2 => ({
x: vbX / PX_PER_M,
y: -vbY / PX_PER_M,
});
// Roher Modellpunkt (Meter) unter dem Cursor — für die Zeichenwerkzeuge.
const rawModelAt = (clientX: number, clientY: number): Vec2 => {
const v = clientToView(clientX, clientY, view);
return viewToModel(v.x, v.y);
};
// viewBox-Koordinaten → Client-Pixel (Inverse von clientToView), für die
// Griff-Trefferprüfung in Bildschirm-Pixeln.
const vbToClient = (vbX: number, vbY: number): { x: number; y: number } => {
const { rect, scale, offX, offY } = measure(view);
const left = rect?.left ?? 0;
const top = rect?.top ?? 0;
return { x: left + offX + (vbX - view.x) * scale, y: top + offY + (vbY - view.y) * scale };
};
// Index des Griffs unter dem Cursor (Bildschirm-Pixel) oder null.
const gripAt = (clientX: number, clientY: number): number | null => {
if (!grips) return null;
for (let i = 0; i < grips.length; i++) {
const vb = toScreen(grips[i]);
const c = vbToClient(vb.x, vb.y);
if (Math.hypot(c.x - clientX, c.y - clientY) <= GRIP_HIT_PX) return i;
}
return null;
};
// Bildschirm-Versatz (viewBox-Einheiten) eines Kanten-Anfassers entlang seiner
// Normale: das Dreieck sitzt etwas AUSSERHALB der Kante. Y wird gespiegelt
// (toScreen spiegelt Y), damit „außen" am Bildschirm auch wirklich außen ist.
// Dreieck-Höhe (nach außen) + halbe Basisbreite in Bildschirm-Pixeln. Die Basis
// sitzt AUF der Linie (am Kanten-Mittelpunkt), die Spitze zeigt nach außen.
// Flacher, breiter Pfeil (Apex ~124°, also flacher als 120°) statt spitzem
// Dreieck: breitere Basis, geringere Höhe.
const EDGE_GRIP_H_PX = 8;
const EDGE_GRIP_HALF_PX = 15;
const screenNormal = (n: Vec2): Vec2 => ({ x: n.x, y: -n.y });
// Index des Kanten-Griffs unter dem Cursor (Bildschirm-Pixel) oder null. Die
// Trefferfläche liegt um die Dreiecks-Mitte (etwas außerhalb der Linie).
const edgeGripAt = (clientX: number, clientY: number): EdgeGrip | null => {
if (!edgeGrips) return null;
const s = meetScale(view, svgRef.current) ?? 1;
const midOutVb = (EDGE_GRIP_H_PX * 0.45) / s; // Mitte des Dreiecks
for (const e of edgeGrips) {
const m = toScreen(e.mid);
const sn = screenNormal(e.normal);
const cVb = { x: m.x + sn.x * midOutVb, y: m.y + sn.y * midOutVb };
const c = vbToClient(cVb.x, cVb.y);
if (Math.hypot(c.x - clientX, c.y - clientY) <= GRIP_HIT_PX) return e;
}
return null;
};
// Aktuelle Bildschirm-Pixel pro Meter (für die zoom-unabhängige Snap-Toleranz).
const currentPxPerMeter = (): number =>
(meetScale(view, svgRef.current) ?? 1) * PX_PER_M;
// Modifikatoren eines Pointer-Events (Shift=Ortho, Ctrl/Cmd=Snap aus).
const toolMods = (e: React.PointerEvent | React.MouseEvent) => ({
shift: e.shiftKey,
ctrl: e.ctrlKey || e.metaKey,
alt: e.altKey,
});
// Cursor in Modell-Metern nach oben melden (für die Status-Leiste) und
// lokal für die Lineal-Marker halten (App re-rendert bei jedem Move ohnehin
// über onCursor → X/Y-Anzeige; das lokale State-Update kostet nichts extra).
const [rulerCursor, setRulerCursor] = useState<Vec2 | null>(null);
const reportCursor = (clientX: number, clientY: number) => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
setRulerCursor(m);
if (!onCursor) return;
onCursor(m);
};
// Trefferprüfung: liefert den Index des obersten Polygon-Primitivs unter dem
// Cursor (Modellpunkt in einem Schicht-Polygon) oder null. In umgekehrter
// Reihenfolge testen, damit das zuletzt gezeichnete (oberste) zuerst trifft.
const hitTest = (clientX: number, clientY: number): number | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
const prims = plan.primitives;
for (let i = prims.length - 1; i >= 0; i--) {
const p = prims[i];
if (p.kind === "polygon" && pointInPolygon(m, p.pts)) return i;
}
return null;
};
// Abstand (Bildschirm-Pixel) eines Klicks vom Ring eines Kreis/Bogen-2D-
// Elements — bei gefüllten Kreisen zählt auch INNERHALB als Treffer (d=0).
// Bögen zusätzlich auf ihre Winkel-Spanne begrenzt (CCW, Modell-Radiant, s.
// toRenderScene.ts für dieselbe Normalisierung).
const distToDrawingRing = (
clientX: number,
clientY: number,
p: Extract<Primitive, { kind: "drawingCircle" | "drawingArc" }>,
): number => {
const m = rawModelAt(clientX, clientY);
const distFromCenter = Math.hypot(m.x - p.center.x, m.y - p.center.y);
if (p.kind === "drawingArc") {
const TAU = Math.PI * 2;
const sweep = (((p.a1 - p.a0) % TAU) + TAU) % TAU || TAU;
const ang = Math.atan2(m.y - p.center.y, m.x - p.center.x);
const rel = (((ang - p.a0) % TAU) + TAU) % TAU;
// Kleiner Winkel-Toleranzsaum (in Bogenmass, radiusabhängig), damit die
// Enden eines kurzen Bogens nicht unerreichbar schmal werden.
const angTolRad = distFromCenter > 1e-6 ? LINE_PICK_PX / (currentPxPerMeter() * Math.max(distFromCenter, 0.05)) : Infinity;
if (rel > sweep + angTolRad && TAU - rel > angTolRad) return Infinity;
}
const filled = p.kind === "drawingCircle" && p.fill !== "none";
const dMeters = filled ? Math.max(0, distFromCenter - p.r) : Math.abs(distFromCenter - p.r);
return dMeters * currentPxPerMeter();
};
// Nächstgelegenes 2D-Zeichenelement (Drawing2D) zum Cursor per Linien-Nähe in
// Bildschirm-Pixeln (zoom-unabhängige Toleranz). Liefert die drawingId oder null.
const pickDrawing = (clientX: number, clientY: number): string | null => {
let bestId: string | null = null;
let bestDist = LINE_PICK_PX;
for (const p of plan.primitives) {
if (p.kind === "line" && p.drawingId) {
const a = vbToClient(toScreen(p.a).x, toScreen(p.a).y);
const b = vbToClient(toScreen(p.b).x, toScreen(p.b).y);
const d = pointSegDistPx({ x: clientX, y: clientY }, a, b);
if (d < bestDist) {
bestDist = d;
bestId = p.drawingId;
}
} else if ((p.kind === "drawingCircle" || p.kind === "drawingArc") && p.drawingId) {
const d = distToDrawingRing(clientX, clientY, p);
if (d < bestDist) {
bestDist = d;
bestId = p.drawingId;
}
}
}
if (bestId) return bestId;
// Kein Linien-/Kreis-/Bogen-Treffer: Text- dann Bild-Elemente prüfen (eigene Bbox-Logik).
return pickDrawingText(clientX, clientY) ?? pickDrawingImage(clientX, clientY);
};
// Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
// Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen);
// Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null.
const pickDrawingText = (clientX: number, clientY: number): string | null => {
const m = rawModelAt(clientX, clientY);
for (const p of plan.primitives) {
if (p.kind !== "drawingText" || !p.drawingId) continue;
const singleW = Math.max(1, p.text.length) * p.heightM * 0.6;
// Textspalte: Breite = wrapWidth, Höhe = geschätzte Zeilenzahl · Zeilenhöhe.
const w = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth : singleW;
const lines =
p.wrapWidth && p.wrapWidth > 0 ? Math.max(1, Math.ceil(singleW / p.wrapWidth)) : 1;
const pad = p.heightM * 0.6;
const minX = p.at.x - pad;
const maxX = p.at.x + w + pad;
// Baseline am Anker; Text läuft nach unten (Zeilen) und etwas nach oben (Oberlängen).
const minY = p.at.y - lines * p.heightM * 1.25 - pad;
const maxY = p.at.y + p.heightM * 0.4 + pad;
if (m.x >= minX && m.x <= maxX && m.y >= minY && m.y <= maxY) {
return p.drawingId;
}
}
return null;
};
// Trifft der Klick ein Bild-2D-Element (kind "drawingImage")? Reine
// Bounding-Box-Prüfung (achsparallel, keine Rotation). Liefert die drawingId
// oder null.
const pickDrawingImage = (clientX: number, clientY: number): string | null => {
const m = rawModelAt(clientX, clientY);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind !== "drawingImage" || !p.drawingId) continue;
if (m.x >= p.min.x && m.x <= p.max.x && m.y >= p.min.y && m.y <= p.max.y) {
return p.drawingId;
}
}
return null;
};
// Nächstgelegene Öffnung (Fenster/Tür) zum Cursor: Linien-/Bogen-Primitive mit
// `openingId` per Bildschirm-Nähe, plus getroffene Fenster-Rahmen (Polygon mit
// openingId, per Punkt-in-Polygon). Liefert die openingId oder null.
const pickOpening = (clientX: number, clientY: number): string | null => {
let bestId: string | null = null;
let bestDist = LINE_PICK_PX;
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (const p of plan.primitives) {
if (p.kind === "line" && p.openingId) {
const a = vbToClient(toScreen(p.a).x, toScreen(p.a).y);
const b = vbToClient(toScreen(p.b).x, toScreen(p.b).y);
const d = pointSegDistPx({ x: clientX, y: clientY }, a, b);
if (d < bestDist) {
bestDist = d;
bestId = p.openingId;
}
} else if (p.kind === "arc" && p.openingId) {
// Bogen: über from→to als Sehne annähern (ausreichend für die Auswahl).
const a = vbToClient(toScreen(p.from).x, toScreen(p.from).y);
const b = vbToClient(toScreen(p.to).x, toScreen(p.to).y);
const d = pointSegDistPx({ x: clientX, y: clientY }, a, b);
if (d < bestDist) {
bestDist = d;
bestId = p.openingId;
}
} else if (p.kind === "polygon" && p.openingId && pointInPolygon(m, p.pts)) {
return p.openingId;
}
}
return bestId;
};
// Nächstgelegene Treppe zum Cursor: getroffenes Tritt-/Podest-Polygon (per
// Punkt-in-Polygon), sonst eine nahe Symbollinie (Lauflinie/Pfeil) mit stairId.
const pickStair = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (const p of plan.primitives) {
if (p.kind === "polygon" && p.stairId && pointInPolygon(m, p.pts)) {
return p.stairId;
}
}
let bestId: string | null = null;
let bestDist = LINE_PICK_PX;
for (const p of plan.primitives) {
if (p.kind === "line" && p.stairId) {
const a = vbToClient(toScreen(p.a).x, toScreen(p.a).y);
const b = vbToClient(toScreen(p.b).x, toScreen(p.b).y);
const d = pointSegDistPx({ x: clientX, y: clientY }, a, b);
if (d < bestDist) {
bestDist = d;
bestId = p.stairId;
}
}
}
return bestId;
};
// Getroffener Raum: die Raum-Fläche (Polygon mit `roomId`) per Punkt-in-Polygon.
// Bei überlappenden Räumen gewinnt der zuletzt gezeichnete (oberste).
const pickRoom = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.roomId && pointInPolygon(m, p.pts)) {
return p.roomId;
}
}
return null;
};
// Getroffenes Dach: das unsichtbare Traufe-Pick-Polygon per Punkt-in-Polygon
// (die sichtbaren Dachlinien allein wären schwer exakt zu treffen).
const pickRoof = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.roofId && pointInPolygon(m, p.pts)) {
return p.roofId;
}
}
return null;
};
// Getroffene Schnitt-/Ansichtslinie: ihr schmales, unsichtbares Pick-Band
// (Polygon mit sectionLineId) per Punkt-in-Polygon.
const pickSectionLine = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.sectionLineId && pointInPolygon(m, p.pts)) {
return p.sectionLineId;
}
}
return null;
};
// Getroffenes Kontext-Objekt (importiertes Gebäude/Terrain): sein unsichtbares
// Grundriss-Pick-Band (Bounding-Box) per Punkt-in-Polygon — macht es wie eine
// Wand irgendwo innerhalb seiner Fläche anklickbar, nicht nur exakt auf der
// dünnen Umrisslinie.
const pickContextObject = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.contextObjectId && pointInPolygon(m, p.pts)) {
return p.contextObjectId;
}
}
return null;
};
// Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
const pickExtrudedSolid = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.extrudedSolidId && pointInPolygon(m, p.pts)) {
return p.extrudedSolidId;
}
}
return null;
};
// Getroffene Stütze (Tragwerk): ihr Querschnitt ist eine gefüllte Poché
// (Polygon mit columnId) — per Punkt-in-Polygon als FLÄCHE anklickbar.
const pickColumn = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.columnId && pointInPolygon(m, p.pts)) {
return p.columnId;
}
}
return null;
};
// Liegt der Cursor auf dem KÖRPER eines GEWÄHLTEN Elements (Wand-Poché bzw.
// 2D-Linie)? Liefert das gegriffene Element (Wand-ID ODER Drawing-ID) für das
// Parallel-Verschieben — oder null. Bei MEHRFACHAUSWAHL wird das Element UNTER
// dem Cursor gegriffen; die übrigen gewählten Enden wandern (App) koinzident mit.
const grabbedBody = (
clientX: number,
clientY: number,
): { wallId?: string; drawingId?: string } | null => {
const selWalls = new Set(selectedWallIds ?? []);
if (selWalls.size) {
const hi = hitTest(clientX, clientY);
if (hi !== null) {
const p = plan.primitives[hi];
if (p.kind === "polygon" && p.wallId && selWalls.has(p.wallId)) {
return { wallId: p.wallId };
}
}
}
const selDraws = selectedDrawingIds && selectedDrawingIds.length
? new Set(selectedDrawingIds)
: selectedDrawingId
? new Set([selectedDrawingId])
: null;
if (selDraws) {
const id = pickDrawing(clientX, clientY);
if (id && selDraws.has(id)) return { drawingId: id };
}
return null;
};
// Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan),
// LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom.
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
// Ein Punkt-Griff ist bereits „bewaffnet" (Maus wurde losgelassen, der
// Punkt folgt seither dem Cursor) — JEDER weitere Linksklick bestätigt nur
// die aktuelle Position, statt irgendeine andere Aktion (Doppelklick,
// Schere, neue Auswahl, …) an derselben Stelle auszulösen.
if (e.button === 0 && gripDrag.current && gripHandlers) {
e.preventDefault();
gripDrag.current = null;
gripHandlers.onGripEnd();
return;
}
// Doppelklick (zweiter Klick der Sequenz) auf einen Raum-Stempel/-Fläche →
// Stempel-Editor öffnen. Manuell über Zeit/Abstand erkannt, weil PlanView
// Pointer-Capture nutzt und der Browser dann kein natives `dblclick`
// synthetisiert (bzw. `detail` nicht zuverlässig füllt).
if (e.button === 0 && !toolActive && onStampEdit) {
const now = Date.now();
const prev = lastClickRef.current;
const isDbl =
prev &&
now - prev.t < 400 &&
Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < 6;
lastClickRef.current = { t: now, x: e.clientX, y: e.clientY };
if (isDbl) {
const rid =
stampHandleAt(e.clientX, e.clientY) ?? pickRoom(e.clientX, e.clientY);
if (rid) {
e.preventDefault();
lastClickRef.current = null;
onStampEdit(rid);
return;
}
}
}
if (e.button === 1) {
// Mittlere Taste: Ausschnitt zum Ziehen einfrieren (Pan).
e.preventDefault(); // verhindert Auto-Scroll-Modus im Browser.
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = {
pointerId: e.pointerId,
startClientX: e.clientX,
startClientY: e.clientY,
startView: view,
};
setDragging(true);
return;
}
if (e.button === 0 && e.altKey && onSegmentCut) {
// Alt+Klick = Schere: getroffenes 2D-Element bestimmen und Segment-Löschen
// an App melden (App ermittelt die nächstgelegene Kante aus dem Modellpunkt).
const id = pickDrawing(e.clientX, e.clientY);
if (id) {
e.preventDefault();
const v = clientToView(e.clientX, e.clientY, view);
onSegmentCut(id, viewToModel(v.x, v.y));
return;
}
// Kein 2D-Element getroffen → wie ein normaler Klick weiterbehandeln.
}
if (e.button === 0 && toolActive) {
// Aktives Zeichenwerkzeug: linke Taste setzt Punkte (kein Marquee). Capture,
// damit move/up zuverlässig ankommen; sofort eine Vorschau anstoßen.
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
toolHandlers!.onToolMove(
rawModelAt(e.clientX, e.clientY),
currentPxPerMeter(),
toolMods(e),
);
return;
}
if (e.button === 0 && !toolActive && roomStamp && onStampMove) {
// Linke Taste auf dem Stempel-Griff: NUR den Stempel-Anker verschieben.
const rid = stampHandleAt(e.clientX, e.clientY);
if (rid) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
stampDrag.current = {
pointerId: e.pointerId,
roomId: rid,
last: rawModelAt(e.clientX, e.clientY),
};
return;
}
}
if (e.button === 0 && gripsActive) {
// Linke Taste auf einem Editier-Griff: Griff ziehen (Endpunkt verschieben).
const gi = gripAt(e.clientX, e.clientY);
if (gi !== null) {
e.preventDefault();
// KEIN Pointer-Capture: der Griff bleibt nach dem Loslassen bewaffnet
// und folgt via normalem Hover-onPointerMove weiter dem Cursor (s.
// Kommentar an der gripDrag-Deklaration).
gripDrag.current = { pointerId: e.pointerId, index: gi, origin: grips![gi] };
return;
}
// Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen.
if (edgeGripsActive) {
const eg = edgeGripAt(e.clientX, e.clientY);
if (eg) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
edgeDrag.current = { pointerId: e.pointerId, edge: eg };
return;
}
}
// Sonst: auf dem Körper eines gewählten Elements? → Parallel-Verschieben.
const body = grabbedBody(e.clientX, e.clientY);
if (body) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
moveDrag.current = { pointerId: e.pointerId, last: rawModelAt(e.clientX, e.clientY), target: body };
return;
}
}
// Körper-Verschieben auch bei MEHRFACHAUSWAHL (dann sind die einzel-Element-
// Griffe inaktiv): Links-Drag auf einem gewählten Körper verschiebt es; die
// koinzidenten Enden der übrigen gewählten Elemente wandern mit (App).
if (e.button === 0 && !toolActive && !!gripHandlers && !gripsActive) {
const body = grabbedBody(e.clientX, e.clientY);
if (body) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
moveDrag.current = { pointerId: e.pointerId, last: rawModelAt(e.clientX, e.clientY), target: body };
return;
}
}
if (e.button === 0) {
// Linke Taste: Auswahl-Geste beginnen. Ob daraus ein Klick (eine Wand
// wählen) oder ein Marquee (Mengenauswahl) wird, entscheidet sich erst
// beim Loslassen anhand der zurückgelegten Strecke (Schwelle in
// onPointerMove). Startpunkt in viewBox-Einheiten festhalten.
e.currentTarget.setPointerCapture(e.pointerId);
const start = clientToView(e.clientX, e.clientY, view);
marquee.current = {
pointerId: e.pointerId,
startClientX: e.clientX,
startClientY: e.clientY,
startView: start,
moved: false,
};
return;
}
// Rechte Taste (button 2): Kontextmenü läuft über onContextMenu.
};
// Perf: modell-mutierende Drags (Griff/Kante/Körper/Stempel) auf EIN Update je
// Animationsframe drosseln. Schnelles Ziehen feuert pointermove schneller, als
// der Plan neu gebaut/gezeichnet werden kann — ungebremst staut sich ein
// Rückstand ("kommt nicht hinterher"). Wir merken nur die NEUESTE Position und
// verarbeiten sie einmal pro Frame.
const dragMovePending = useRef<{
cx: number;
cy: number;
shift: boolean;
ctrl: boolean;
alt: boolean;
pointerId: number;
} | null>(null);
const dragMoveRaf = useRef<number | null>(null);
// Imperativer GL-Pan/Zoom: während der Geste NICHT setView pro Frame (das würde
// die ganze PlanView neu rendern). GL-Matrix wird pro Frame aktualisiert; das
// SVG-Overlay bekommt statt eines viewBox-Neusatzes (→ Cairo-Re-Raster in
// WebKitGTK, teuer!) einen GPU-komponierten CSS-`transform`. React-State (view)
// + echtes viewBox werden erst beim Loslassen abgeglichen → in WebKit KEIN
// SVG-Re-Raster je Frame mehr.
const panPending = useRef<ViewBox | null>(null);
const panStartView = useRef<ViewBox | null>(null); // viewBox bei Gestenbeginn (V0)
const panRaf = useRef<number | null>(null);
/**
* CSS-transform, der das SVG (fest gezeichnet mit viewBox V0) so verschiebt/
* skaliert, dass es exakt den Ziel-Ausschnitt V zeigt — deckungsgleich mit der
* GL-Ebene (beide nutzen xMidYMid meet in dieselbe Elementbox).
*/
const applySvgTransform = (v0: ViewBox, v: ViewBox) => {
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const m = Math.min(rect.width / v0.w, rect.height / v0.h); // meet-Skala bei V0
const A = v0.w / v.w; // Zoomfaktor (uniform)
const lbX = (rect.width - v0.w * m) / 2; // Letterbox-Offset (0 bei passendem Aspekt)
const lbY = (rect.height - v0.h * m) / 2;
const cx = m * A * (v0.x - v.x) + (1 - A) * lbX;
const cy = m * A * (v0.y - v.y) + (1 - A) * lbY;
svg.style.transformOrigin = "0 0";
svg.style.transform = `translate(${cx}px, ${cy}px) scale(${A})`;
};
const flushPan = () => {
panRaf.current = null;
const v = panPending.current;
const v0 = panStartView.current;
if (!v || !v0) return;
viewRef.current = v; // abgeleitete Reads (Snap-Toleranz etc.) frisch halten
applySvgTransform(v0, v); // SVG per GPU-Composit (kein Re-Raster)
renderGl(v, paperScaleForGl(v), textScaleForGl()); // GL exakt auf V (Strich in echten Papier-mm)
};
/** Pan-/Zoom-Ziel imperativ anwenden (rAF-gedrosselt), ohne React-Render. */
const enqueuePan = (v: ViewBox) => {
if (!panStartView.current) panStartView.current = { ...viewRef.current };
panPending.current = v;
if (panRaf.current === null) panRaf.current = requestAnimationFrame(flushPan);
};
/** Anhängigen imperativen Pan in React-State übernehmen (+ Transform lösen). */
const commitPan = () => {
if (panRaf.current !== null) {
cancelAnimationFrame(panRaf.current);
panRaf.current = null;
}
if (panPending.current) {
// SVG-Transform lösen; setView setzt das echte viewBox (ein einziger Re-Raster).
if (svgRef.current) svgRef.current.style.transform = "";
setView(panPending.current);
panPending.current = null;
}
panStartView.current = null;
};
const flushDragMove = () => {
dragMoveRaf.current = null;
const p = dragMovePending.current;
dragMovePending.current = null;
if (!p) return;
const mods = { shift: p.shift, ctrl: p.ctrl, alt: p.alt };
const raw = rawModelAt(p.cx, p.cy);
const ed = edgeDrag.current;
if (ed && ed.pointerId === p.pointerId) {
gripHandlers!.onEdgeMove(ed.edge, raw, currentPxPerMeter(), mods);
return;
}
const gd = gripDrag.current;
if (gd && gd.pointerId === p.pointerId) {
gripHandlers!.onGripMove(gd.index, raw, currentPxPerMeter(), mods);
return;
}
const sd = stampDrag.current;
if (sd && sd.pointerId === p.pointerId) {
const delta = { x: raw.x - sd.last.x, y: raw.y - sd.last.y };
if ((delta.x !== 0 || delta.y !== 0) && onStampMove) {
onStampMove(sd.roomId, delta);
sd.last = raw;
}
return;
}
const md = moveDrag.current;
if (md && md.pointerId === p.pointerId) {
const delta = { x: raw.x - md.last.x, y: raw.y - md.last.y };
if (delta.x !== 0 || delta.y !== 0) {
gripHandlers!.onMoveBody(delta, md.target, mods);
md.last = raw;
}
return;
}
};
const enqueueDragMove = (e: React.PointerEvent<SVGSVGElement>) => {
dragMovePending.current = {
cx: e.clientX,
cy: e.clientY,
shift: e.shiftKey,
ctrl: e.ctrlKey || e.metaKey,
alt: e.altKey,
pointerId: e.pointerId,
};
if (dragMoveRaf.current === null) {
dragMoveRaf.current = requestAnimationFrame(flushDragMove);
}
};
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
// Immer die Cursor-Position melden (auch ohne Ziehen), für die Statusleiste.
reportCursor(e.clientX, e.clientY);
// Modell-mutierende Drags (Kante/Griff/Stempel/Körper): auf rAF drosseln —
// ein Update je Frame mit der jeweils neuesten Position (siehe flushDragMove).
if (
(edgeDrag.current && edgeDrag.current.pointerId === e.pointerId) ||
(gripDrag.current && gripDrag.current.pointerId === e.pointerId) ||
(stampDrag.current && stampDrag.current.pointerId === e.pointerId) ||
(moveDrag.current && moveDrag.current.pointerId === e.pointerId)
) {
enqueueDragMove(e);
return;
}
// Aktives Zeichenwerkzeug: Hover/Drag liefert die Live-Vorschau + Snap-Marker.
// Mittlere Taste (Pan) bleibt davon unberührt (eigener Zweig unten).
if (toolActive && drag.current === null) {
toolHandlers!.onToolMove(
rawModelAt(e.clientX, e.clientY),
currentPxPerMeter(),
toolMods(e),
);
return;
}
// Marquee (linke Taste) aufziehen: Rechteck in viewBox-Einheiten vom
// Startpunkt zum aktuellen Cursor. Erst ab einer kleinen Pixel-Schwelle gilt
// es als Drag (sonst bleibt es ein Klick → Einzelwahl beim Loslassen).
const mq = marquee.current;
if (mq && mq.pointerId === e.pointerId) {
const dxPx = e.clientX - mq.startClientX;
const dyPx = e.clientY - mq.startClientY;
if (!mq.moved && Math.hypot(dxPx, dyPx) < MARQUEE_THRESHOLD_PX) return;
mq.moved = true;
const cur = clientToView(e.clientX, e.clientY, view);
// crossing-Richtung aus dem Vorzeichen von Δx am Bildschirm (rechts→links
// = negativ = berührend). Bildschirm-x und viewBox-x sind gleichläufig.
const crossing = dxPx < 0;
setMarqueeRect({
x: Math.min(mq.startView.x, cur.x),
y: Math.min(mq.startView.y, cur.y),
w: Math.abs(cur.x - mq.startView.x),
h: Math.abs(cur.y - mq.startView.y),
crossing,
});
return;
}
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
const { scale } = measure(d.startView);
// Bildschirm-Delta → viewBox-Einheiten; Inhalt folgt dem Cursor, also
// verschiebt sich der Ausschnitt entgegengesetzt.
const dx = (e.clientX - d.startClientX) / scale;
const dy = (e.clientY - d.startClientY) / scale;
const next: ViewBox = {
x: d.startView.x - dx,
y: d.startView.y - dy,
w: d.startView.w,
h: d.startView.h,
};
// GL-Pfad: imperativ (rAF, kein React-Render pro Frame). SVG-Pfad: wie bisher
// über setView (das SVG muss über React neu rendern).
if (useGpuRenderer) enqueuePan(next);
else setView(next);
};
const endDrag = (e: React.PointerEvent<SVGSVGElement>) => {
// Perf: einen anhängigen (rAF-gedrosselten) Drag mit der FINALEN Position
// sofort anwenden, damit das Objekt exakt an der Loslass-Stelle landet, bevor
// committet wird.
if (dragMoveRaf.current !== null) {
cancelAnimationFrame(dragMoveRaf.current);
dragMoveRaf.current = null;
}
if (
(edgeDrag.current && edgeDrag.current.pointerId === e.pointerId) ||
(gripDrag.current && gripDrag.current.pointerId === e.pointerId) ||
(stampDrag.current && stampDrag.current.pointerId === e.pointerId) ||
(moveDrag.current && moveDrag.current.pointerId === e.pointerId)
) {
dragMovePending.current = {
cx: e.clientX,
cy: e.clientY,
shift: e.shiftKey,
ctrl: e.ctrlKey || e.metaKey,
alt: e.altKey,
pointerId: e.pointerId,
};
flushDragMove();
}
// Stempel-Griff loslassen: Ziehen abschließen.
const sd = stampDrag.current;
if (sd && sd.pointerId === e.pointerId) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
stampDrag.current = null;
return;
}
// Kanten-Griff loslassen: Ziehen abschließen.
const ed = edgeDrag.current;
if (ed && ed.pointerId === e.pointerId) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
edgeDrag.current = null;
gripHandlers!.onGripEnd();
return;
}
// Editier-Griff: Maus loslassen beendet die Bearbeitung NICHT mehr — kein
// Pointer-Capture gehalten, der Griff bleibt bewaffnet und folgt weiter dem
// Cursor. Abschluss erst per erneutem Klick (oben in onPointerDown) oder
// Enter, Verwerfen per Escape (beides im Tastatur-Effekt unten).
if (gripDrag.current && gripDrag.current.pointerId === e.pointerId) {
return;
}
// Körper-Verschieben loslassen.
const md = moveDrag.current;
if (md && md.pointerId === e.pointerId) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
moveDrag.current = null;
gripHandlers!.onGripEnd();
return;
}
// Aktives Zeichenwerkzeug: linke Taste loslassen = Punkt setzen (Klick).
if (toolActive && e.button === 0) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
toolHandlers!.onToolClick(
rawModelAt(e.clientX, e.clientY),
currentPxPerMeter(),
toolMods(e),
);
return;
}
// Marquee/Klick (linke Taste) abschließen.
const mq = marquee.current;
if (mq && mq.pointerId === e.pointerId) {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
marquee.current = null;
if (mq.moved) {
// Echtes Auswahl-Rechteck: Wände nach CAD-Konvention bestimmen und nach
// oben melden. Das Rechteck stand zuletzt in marqueeRect (viewBox-Raum)
// → in Modell-Raum umrechnen und gegen die Wand-Polygone testen.
const cur = clientToView(e.clientX, e.clientY, view);
const crossing = e.clientX - mq.startClientX < 0;
const a = viewToModel(mq.startView.x, mq.startView.y);
const b = viewToModel(cur.x, cur.y);
const rect = {
minX: Math.min(a.x, b.x),
minY: Math.min(a.y, b.y),
maxX: Math.max(a.x, b.x),
maxY: Math.max(a.y, b.y),
};
if (onMarquee) {
onMarquee({
wallIds: marqueeHit(plan.primitives, rect, crossing),
drawingIds: marqueeHitDrawings(plan.primitives, rect, crossing),
crossing,
});
}
// Die lokale Einzel-Hervorhebung räumen (jetzt gilt die Mengenauswahl).
setSelected(null);
setMarqueeRect(null);
return;
}
// Kein Drag → Klick: Wand unter dem Cursor wählen bzw. Auswahl löschen.
setMarqueeRect(null);
const hitIndex = hitTest(e.clientX, e.clientY);
setSelected(hitIndex);
if (onSelect) {
const v = clientToView(e.clientX, e.clientY, view);
const hit = hitIndex !== null ? plan.primitives[hitIndex] : undefined;
// Öffnung (Fenster/Tür) zuerst: ihr Symbol sitzt in der ausgesparten Lücke
// bzw. schlägt über die Wand — ein Klick nahe am Symbol wählt die Öffnung.
// Schnitt-/Ansichtslinie zuerst: ihr schmales Band liegt über allem, ein
// Klick genau darauf meint die Linie (nicht die darunterliegende Wand) —
// dann sofort melden (kein anderes Element konkurriert).
const sectionLineId = pickSectionLine(e.clientX, e.clientY);
if (sectionLineId) {
onSelect({
model: viewToModel(v.x, v.y),
hitIndex,
wallId: null,
drawingId: null,
ceilingId: null,
openingId: null,
stairId: null,
roomId: null,
extrudedSolidId: null,
columnId: null,
roofId: null,
sectionLineId,
contextObjectId: null,
shift: e.shiftKey,
});
return;
}
const openingId = pickOpening(e.clientX, e.clientY);
const wallId =
openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null;
// Stütze (Tragwerk): ihre Querschnitt-Poché liegt ÜBER der Wand-Poché —
// ein direkter Klick auf sie trifft zuerst die Stütze (hit.columnId), sonst
// die Fläche per Punkt-in-Polygon. Priorität direkt nach Öffnung/Wand.
const polyColumnId =
hit && hit.kind === "polygon" ? hit.columnId ?? null : null;
const columnId =
openingId || wallId
? null
: polyColumnId ?? pickColumn(e.clientX, e.clientY);
// Traf der Klick KEINE Wand/Öffnung/Stütze: zuerst eine getroffene gefüllte
// 2D-Fläche (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe.
const polyDrawingId =
hit && hit.kind === "polygon" ? hit.drawingId ?? null : null;
const drawingId =
openingId || wallId || columnId
? null
: polyDrawingId ?? pickDrawing(e.clientX, e.clientY);
// Extrudierter Körper (truck-Integration): sein Footprint-Umriss ersetzt
// die frühere Drawing2D-Fläche, also gleiche Priorität wie drawingId.
const polyExtrudedSolidId =
hit && hit.kind === "polygon" ? hit.extrudedSolidId ?? null : null;
const extrudedSolidId =
openingId || wallId || columnId || drawingId
? null
: polyExtrudedSolidId ?? pickExtrudedSolid(e.clientX, e.clientY);
// Decke nur, wenn weder Öffnung/Wand noch 2D-Element getroffen wurde. Da die
// Decken-Polygone UNTER der Wand-Poché liegen, trifft ein direkter Klick
// auf die Wand zuerst die Wand; freie Deckenfläche trifft die Decke.
const hitCeilingId =
hit && hit.kind === "polygon" ? hit.ceilingId ?? null : null;
const ceilingId =
openingId || wallId || columnId || drawingId || extrudedSolidId ? null : hitCeilingId;
// Treppe nur, wenn nichts anderes getroffen wurde (Tritte liegen frei).
const stairId =
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId
? null
: pickStair(e.clientX, e.clientY);
// Raum als vorletzte Priorität: die Raum-Fläche liegt UNTER allem anderen.
const roomId =
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId
? null
: pickRoom(e.clientX, e.clientY);
// Dach als LETZTE Priorität: die Traufe-Fläche deckt den ganzen Grundriss
// ab, daher erst wählen, wenn wirklich nichts anderes getroffen wurde.
const roofId =
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId
? null
: pickRoof(e.clientX, e.clientY);
// Kontext-Objekt (Gebäude/Terrain) als ALLERLETZTE Priorität — seine
// Bbox-Pick-Fläche kann grosszügig überlappen, daher erst wenn wirklich
// nichts anderes getroffen wurde.
const contextObjectId =
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId || roofId
? null
: pickContextObject(e.clientX, e.clientY);
onSelect({
model: viewToModel(v.x, v.y),
hitIndex,
wallId,
drawingId,
ceilingId,
openingId,
stairId,
roomId,
extrudedSolidId,
columnId,
roofId,
sectionLineId: null,
contextObjectId,
shift: e.shiftKey,
});
}
return;
}
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
// Imperativen GL-Pan in React-State übernehmen (Ausschnitt final festschreiben).
commitPan();
drag.current = null;
setDragging(false);
};
/** Rechnet aus einem Ausschnitt + Rad-Delta den neuen (geklemmten) Ausschnitt. */
const zoomedView = (prev: ViewBox, clientX: number, clientY: number, deltaY: number): ViewBox => {
// Punkt unter dem Cursor (bleibt nach dem Zoom ortsfest).
const anchor = clientToView(clientX, clientY, prev);
// Hochrollen (deltaY < 0) → hinein → kleinere viewBox.
let factor = Math.exp(deltaY * WHEEL_STEP);
// Zoom relativ zur Default-Breite begrenzen.
const minW = defaultView.w / ZOOM_MAX;
const maxW = defaultView.w / ZOOM_MIN;
const targetW = clamp(prev.w * factor, minW, maxW);
// Tatsächlich angewandter Faktor nach Klemmung (hält Seitenverhältnis).
factor = targetW / prev.w;
return {
x: anchor.x - (anchor.x - prev.x) * factor,
y: anchor.y - (anchor.y - prev.y) * factor,
w: prev.w * factor,
h: prev.h * factor,
};
};
const onWheel = (e: React.WheelEvent<SVGSVGElement>) => {
e.preventDefault();
// Zoom läuft IMMER über setView (React) — auch im GL-Pfad. Grund: der Zoom
// ändert den Massstab, und bildschirmkonstante SVG-Elemente (Auswahlrahmen,
// Griffe, Snap-Marker) müssen dabei NEU (in korrekter Grösse) gerendert
// werden. Ein imperativer CSS-scale würde sie fälschlich mitskalieren. In
// Chromium ist setView pro Rad-Schritt problemlos flüssig; der GL-Effekt auf
// `view` zeichnet die Ebene nur per Matrix-Uniform neu (keine Re-Tessellierung).
setView((prev) => zoomedView(prev, e.clientX, e.clientY, e.deltaY));
};
// Doppelklick → bei aktivem Werkzeug den mehrteiligen Entwurf abschließen,
// sonst wieder auf die eingepasste Standard-Ansicht.
const onDoubleClick = (e: React.MouseEvent<SVGSVGElement>) => {
if (toolActive) {
toolHandlers!.onToolCommit();
return;
}
// Doppelklick auf ein Text-2D-Element → Inhalt bearbeiten (Vorrang vor Raum).
if (onEditDrawingText) {
const tid = pickDrawingText(e.clientX, e.clientY);
if (tid) {
onEditDrawingText(tid);
return;
}
}
// Doppelklick auf einen Raum (Stempel-Griff ODER Fläche) → Stempel-Editor.
if (onStampEdit) {
const rid =
stampHandleAt(e.clientX, e.clientY) ?? pickRoom(e.clientX, e.clientY);
if (rid) {
onStampEdit(rid);
return;
}
}
// Doppelklick auf eine Schnittlinie → in deren 2D-Schnittansicht springen.
if (onOpenSectionLevel) {
const sid = pickSectionLine(e.clientX, e.clientY);
if (sid) {
onOpenSectionLevel(sid);
return;
}
}
setView(defaultView);
};
// Rechtsklick: Browser-Menü unterdrücken. Bei aktivem Werkzeug mit laufendem
// Entwurf = abschließen (CAD-üblich, KEIN Kontextmenü); sonst Kontextmenü.
const onContextMenuHandler = (e: React.MouseEvent<SVGSVGElement>) => {
e.preventDefault();
// Bei aktivem Werkzeug/Befehl ist Rechtsklick = Enter (abschließen/wiederholen,
// §2.3) — KEIN Kontextmenü, auch ohne laufenden Entwurf.
if (toolActive) {
toolHandlers!.onToolCommit();
return;
}
if (!onContextMenu) return;
const v = clientToView(e.clientX, e.clientY, view);
onContextMenu({
clientX: e.clientX,
clientY: e.clientY,
model: viewToModel(v.x, v.y),
});
};
// Hervorhebungs-Konturen der Auswahl. Vorrang hat die wand-bezogene Auswahl
// (selectedWallIds, von App kontrolliert): dann werden ALLE Bänder JEDER
// gewählten Wand umrandet. Ohne Wand-Auswahl greift die lokale Klick-
// Hervorhebung (ein einzelnes Polygon per hitIndex), z. B. für Polygone ohne
// Wandbezug.
const highlightPolys = useMemo(() => {
const ids = selectedWallIds && selectedWallIds.length ? new Set(selectedWallIds) : null;
if (ids) {
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId != null && ids.has(p.wallId),
);
}
if (selected !== null) {
const p = plan.primitives[selected];
if (p && p.kind === "polygon") return [p];
}
return [];
}, [plan, selectedWallIds, selected]);
// Hervorhebung der selektierten 2D-Zeichenelemente (Mehrfach/Marquee): alle
// Linien-/Polygon-Primitive, deren drawingId in der Auswahl liegt, werden mit
// Akzentkontur überzeichnet (sichtbares Feedback auch ohne Griffe).
const highlightDrawPrims = useMemo(() => {
const ids =
selectedDrawingIds && selectedDrawingIds.length
? new Set(selectedDrawingIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "line" | "polygon" }> =>
(p.kind === "line" || p.kind === "polygon") &&
p.drawingId != null &&
ids.has(p.drawingId),
);
}, [plan, selectedDrawingIds]);
// Hervorhebung der selektierten Decken: alle Polygone JEDER gewählten Decke
// (Fläche + Umriss) mit Akzentkontur überzeichnen — analog zur Wand-Auswahl.
const highlightCeilingPolys = useMemo(() => {
const ids =
selectedCeilingIds && selectedCeilingIds.length
? new Set(selectedCeilingIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.ceilingId != null && ids.has(p.ceilingId),
);
}, [plan, selectedCeilingIds]);
// Hervorhebung der selektierten Öffnungen: alle Symbol-Primitive (Linien/Bögen/
// Rahmen) mit der gewählten openingId mit Akzentkontur überzeichnen.
const highlightOpeningPrims = useMemo(() => {
const ids =
selectedOpeningIds && selectedOpeningIds.length
? new Set(selectedOpeningIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "line" | "arc" | "polygon" }> =>
(p.kind === "line" || p.kind === "arc" || p.kind === "polygon") &&
p.openingId != null &&
ids.has(p.openingId),
);
}, [plan, selectedOpeningIds]);
// Hervorhebung der selektierten Treppen: alle Tritt-/Symbol-Primitive (Polygone
// + Linien) mit der gewählten stairId mit Akzentkontur überzeichnen.
const highlightStairPrims = useMemo(() => {
const ids =
selectedStairIds && selectedStairIds.length ? new Set(selectedStairIds) : null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "line" | "polygon" }> =>
(p.kind === "line" || p.kind === "polygon") &&
p.stairId != null &&
ids.has(p.stairId),
);
}, [plan, selectedStairIds]);
// Hervorhebung der selektierten Räume: die Fläche JEDES gewählten Raums (Polygon
// mit roomId) mit Akzentkontur überzeichnen — analog zur Decken-Auswahl.
const highlightRoomPolys = useMemo(() => {
const ids =
selectedRoomIds && selectedRoomIds.length ? new Set(selectedRoomIds) : null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.roomId != null && ids.has(p.roomId),
);
}, [plan, selectedRoomIds]);
// Hervorhebung der selektierten Dächer: das (unsichtbare) Traufe-Pick-Polygon
// mit roofId als Akzentkontur überzeichnen — analog zur Raum-/Decken-Auswahl.
const highlightRoofPolys = useMemo(() => {
const ids =
selectedRoofIds && selectedRoofIds.length ? new Set(selectedRoofIds) : null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.roofId != null && ids.has(p.roofId),
);
}, [plan, selectedRoofIds]);
// Hervorhebung der selektierten Kontext-Objekte (Gebäude/Terrain): das
// (unsichtbare) Bbox-Pick-Band mit contextObjectId als Akzentkontur
// überzeichnen — analog zur Dach-/Raum-Auswahl.
const highlightContextObjectPolys = useMemo(() => {
const ids =
selectedContextObjectIds && selectedContextObjectIds.length
? new Set(selectedContextObjectIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.contextObjectId != null && ids.has(p.contextObjectId),
);
}, [plan, selectedContextObjectIds]);
// Hervorhebung der selektierten Schnitt-/Ansichtslinie: das (unsichtbare)
// Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen.
const highlightSectionLinePolys = useMemo(() => {
const ids =
selectedSectionLineIds && selectedSectionLineIds.length
? new Set(selectedSectionLineIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.sectionLineId != null && ids.has(p.sectionLineId),
);
}, [plan, selectedSectionLineIds]);
// Hervorhebung der selektierten extrudierten Körper (truck-Integration): der
// Footprint-Umriss (Polygon mit extrudedSolidId) mit Akzentkontur überzeichnen
// — analog zur Raum-Auswahl.
const highlightExtrudedSolidPolys = useMemo(() => {
const ids =
selectedExtrudedSolidIds && selectedExtrudedSolidIds.length
? new Set(selectedExtrudedSolidIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.extrudedSolidId != null && ids.has(p.extrudedSolidId),
);
}, [plan, selectedExtrudedSolidIds]);
// Hervorhebung der selektierten Stützen (Tragwerk): der Querschnitt-Umriss
// (Polygon mit columnId) mit Akzentkontur überzeichnen — analog Extrusion/Raum.
const highlightColumnPolys = useMemo(() => {
const ids =
selectedColumnIds && selectedColumnIds.length ? new Set(selectedColumnIds) : null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.columnId != null && ids.has(p.columnId),
);
}, [plan, selectedColumnIds]);
// Zusammenhängende 2D-Zeichen-Linien (gleiche drawingId + Stil) zu Zügen
// bündeln, damit sie als EIN <polyline>/<polygon> gezeichnet werden können —
// so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die
// Kanten eines Elements aufeinanderfolgend + End-an-Start verkettet ein, daher
// genügt greedy-Anhängen benachbarter Segmente.
const drawingRuns = useMemo(() => buildDrawingRuns(plan.primitives), [plan]);
// Pro Polygon mit Linien-/Kreuz-Schraffur ein eigenes <pattern> sammeln.
// Vollfüllung ("solid") und "none" brauchen kein Muster.
const hatched = useMemo(() => {
const list: HatchedPoly[] = [];
plan.primitives.forEach((p, i) => {
if (p.kind === "polygon" && polyUsesPattern(p.hatch)) {
list.push({ patternId: `hatch-${i}`, hatch: p.hatch });
}
});
return list;
}, [plan]);
// Perf: die Primitiv-Elemente hängen NUR am Plan (+ Haarlinie/Massstab), NICHT
// an `view`. Ohne Memo würde jedes Schwenken/Zoomen (das nur die viewBox ändert)
// den gesamten Element-Baum neu erzeugen und React ihn komplett abgleichen —
// auf WebKitGTK (Tauri-Desktop) der spürbare Ruckler. Memoisiert bleibt der Baum
// referenzstabil, Pan/Zoom aktualisiert nur noch das viewBox-Attribut.
const primitiveEls = useMemo(
() =>
plan.primitives.map((p, i) =>
// Bei aktivem GL zeichnet die WebGL-Ebene Poché/Linien/Bögen; das SVG wird
// zur reinen Text-Overlay-Ebene (scharfe Schrift, DOM-Hit-Test) → hier nur
// noch Text-Primitive. Bei aktiver WASM-Engine rastert render2d den
// Raum-Stempel selbst (glyphon) — das SVG darf ihn NICHT zusätzlich
// zeichnen (sonst doppelter Text), also entfällt dort auch das Textoverlay.
// Sonst (reines SVG): alles außer den gebündelten 2D-Zeichenlinien.
// `drawingText` (importierter Einzeltext) rendert IMMER im SVG — der GL-
// wie der WASM-Renderer zeichnet ihn nicht. Der Raum-Stempel (`text`)
// bleibt: unter WASM rastert ihn render2d selbst (sonst doppelt).
(useGpuRenderer
? wantWasm
? p.kind !== "drawingText"
: p.kind !== "text" && p.kind !== "drawingText"
: p.kind === "line" && p.drawingId)
? null : (
<PrimitiveShape
key={i}
p={p}
index={i}
toScreen={toScreen}
hairline={!!hairline}
paperScale={paperScale}
/>
),
),
// eslint-disable-next-line react-hooks/exhaustive-deps -- toScreen ist modulweit konstant
[plan, hairline, paperScale, useGpuRenderer, wantWasm],
);
const drawingRunEls = useMemo(
() =>
// Bei aktivem GL übernimmt die WebGL-Ebene die 2D-Zeichenlinien.
useGpuRenderer
? []
: drawingRuns.map((run, i) => (
<DrawingRunShape
key={`run-${i}`}
run={run}
toScreen={toScreen}
hairline={!!hairline}
paperScale={paperScale}
/>
)),
// eslint-disable-next-line react-hooks/exhaustive-deps -- toScreen ist modulweit konstant
[drawingRuns, hairline, paperScale, useGpuRenderer],
);
return (
// Flex-Passthrough (wie das frühere .plan-svg direkt): flex:1 + min-height:0,
// damit die Fläche in WebKitGTK/Tauri NICHT auf Null-Höhe kollabiert (was
// height:100% dort täte). display:flex, damit das innere SVG (flex:1) füllt.
<div
style={{
position: "relative",
flex: 1,
minHeight: 0,
width: "100%",
display: "flex",
}}
>
{/* WebGL2-GPU-Ebene: liegt HINTER dem SVG (z-index 0). Zeichnet die schwere
Geometrie (Poché/Linien/Bögen); pointer-events aus, damit Klicks das SVG
darüber erreichen. An `wantGpu` (Absicht) montiert — NICHT an `useGpuRenderer`
(=wantGpu&&glReady), sonst könnte GL nie initialisieren (Canvas fehlt →
glReady bleibt false → Deadlock). Bei WebGL-Fehler bleibt es leer hinten
und der volle SVG-Pfad übernimmt sichtbar. NICHT montiert, wenn ein
Luftbild aktiv ist — dann rendert der SVG-Pfad alles inkl. Bild-Ebene. */}
{wantGpu && !hasBgImages && (
<canvas
ref={canvasRef}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
zIndex: 0,
pointerEvents: "none",
}}
/>
)}
{/* SVG-Ebene: bei GL transparent DARÜBER (Text/Griffe/Snap/Vorschau, scharf +
DOM-Hit-Test); ohne GL der vollständige Renderer (Fallback). */}
<svg
ref={svgRef}
className="plan-svg"
viewBox={`${view.x} ${view.y} ${view.w} ${view.h}`}
preserveAspectRatio="xMidYMid meet"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); }}
onWheel={onWheel}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenuHandler}
// Maus-Schema: MITTE schwenkt (grabbing), sonst Standard-Cursor (LINKS
// wählt aus). touchAction:none, damit das Schwenken nicht scrollt.
style={{
position: useGpuRenderer ? "absolute" : undefined,
inset: useGpuRenderer ? 0 : undefined,
zIndex: useGpuRenderer ? 1 : undefined,
width: useGpuRenderer ? "100%" : undefined,
height: useGpuRenderer ? "100%" : undefined,
// Bei GL transparent, damit die WebGL-Ebene dahinter durchscheint.
background: useGpuRenderer ? "transparent" : undefined,
cursor: dragging
? "grabbing"
: toolActive || marqueeRect
? "crosshair"
: "default",
touchAction: "none",
}}
>
<defs>
{hatched.map((h) => (
<HatchPattern
key={h.patternId}
id={h.patternId}
hatch={h.hatch}
hairline={!!hairline}
paperScale={paperScale}
/>
))}
</defs>
{/* Zeichenblatt-Hintergrund: deckt den aktuellen Ausschnitt (viewBox)
vollständig — unabhängig von den Modell-Bounds (fixer Ursprung). Bei
aktivem GL löscht die WebGL-Ebene das Blatt → hier transparent halten
(bleibt aber Klick-/Pan-Trefferfläche für Leerraum). */}
<rect
x={view.x}
y={view.y}
width={view.w}
height={view.h}
className={useGpuRenderer ? undefined : "plan-bg"}
fill={useGpuRenderer ? "transparent" : undefined}
/>
{/* Georeferenzierte Luftbilder ZWISCHEN Blatt-Hintergrund und Geometrie:
Modell-Meter × PX_PER_M mit negierter y-Achse (s. toScreen) → Bild-Norden
(max.y) oben bei y=−max.y·PX; das Bild zeichnet natürlich von oben (kein
Flip). Nur im SVG-Pfad relevant (bei Luftbild ist GPU deaktiviert). */}
{hasBgImages && plan.backgroundImages!.map((img) => {
const w = (img.max.x - img.min.x) * PX_PER_M;
const h = (img.max.y - img.min.y) * PX_PER_M;
if (w <= 0 || h <= 0) return null;
return (
<image
key={img.id}
href={img.src}
x={img.min.x * PX_PER_M}
y={-img.max.y * PX_PER_M}
width={w}
height={h}
opacity={img.opacity}
preserveAspectRatio="none"
pointerEvents="none"
/>
);
})}
{/* 2D-Zeichen-Linien mit drawingId werden gemergt (drawingRuns) als EIN Zug
gezeichnet → gehrte Ecken statt Butt-Cap-Stufe; im Memo übersprungen. */}
{primitiveEls}
{/* Zusammenhängende 2D-Zeichen-Linienzüge als EIN <polyline>/<polygon>:
CAD-typische Gehrung an den Ecken (stroke-linejoin), keine Stufe bei
dicken Linien. Nach der Haupt-Ebene gezeichnet (2D liegt ohnehin oben). */}
{drawingRunEls}
{/* Auswahl-Hervorhebung: alle Bänder der gewählten Wand (bzw. das lokal
getroffene Polygon) mit Akzentkontur überzeichnen. */}
{highlightPolys.map((poly, i) => (
<polygon
key={`sel-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Decken (Akzentkontur). */}
{highlightCeilingPolys.map((poly, i) => (
<polygon
key={`selc-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Räume (Akzentkontur der Fläche). */}
{highlightRoomPolys.map((poly, i) => (
<polygon
key={`selrm-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Dächer (Traufe-Umriss). */}
{highlightRoofPolys.map((poly, i) => (
<polygon
key={`selrf-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Kontext-Objekte (Bbox-Pick-Band). */}
{highlightContextObjectPolys.map((poly, i) => (
<polygon
key={`selctx-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Schnitt-/Ansichtslinie (Pick-Band). */}
{highlightSectionLinePolys.map((poly, i) => (
<polygon
key={`selsl-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten extrudierten Körper (truck-Integration). */}
{highlightExtrudedSolidPolys.map((poly, i) => (
<polygon
key={`selex-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Stützen (Tragwerk). */}
{highlightColumnPolys.map((poly, i) => (
<polygon
key={`selcol-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Hervorhebung der selektierten Öffnungen (Fenster/Tür-Symbole). */}
{highlightOpeningPrims.map((p, i) =>
p.kind === "line" ? (
<line
key={`selo-${i}`}
className="plan-sel-draw"
x1={toScreen(p.a).x}
y1={toScreen(p.a).y}
x2={toScreen(p.b).x}
y2={toScreen(p.b).y}
pointerEvents="none"
/>
) : p.kind === "arc" ? (
<line
key={`selo-${i}`}
className="plan-sel-draw"
x1={toScreen(p.from).x}
y1={toScreen(p.from).y}
x2={toScreen(p.to).x}
y2={toScreen(p.to).y}
pointerEvents="none"
/>
) : (
<polygon
key={`selo-${i}`}
className="plan-sel-draw"
points={p.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
),
)}
{/* Hervorhebung der selektierten Treppen (Tritte + Symbollinien). */}
{highlightStairPrims.map((p, i) =>
p.kind === "line" ? (
<line
key={`selst-${i}`}
className="plan-sel-draw"
x1={toScreen(p.a).x}
y1={toScreen(p.a).y}
x2={toScreen(p.b).x}
y2={toScreen(p.b).y}
pointerEvents="none"
/>
) : (
<polygon
key={`selst-${i}`}
className="plan-selected"
points={p.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
),
)}
{/* Hervorhebung der selektierten 2D-Zeichenelemente (Akzentkontur). */}
{highlightDrawPrims.map((p, i) =>
p.kind === "line" ? (
<line
key={`seld-${i}`}
className="plan-sel-draw"
x1={toScreen(p.a).x}
y1={toScreen(p.a).y}
x2={toScreen(p.b).x}
y2={toScreen(p.b).y}
pointerEvents="none"
/>
) : (
<polygon
key={`seld-${i}`}
className="plan-sel-draw"
points={p.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
),
)}
{/* Auswahl-Rechteck (Marquee). Gestrichelt; die crossing-Variante (rechts→
links, berührend) hebt sich farblich/strichlich ab. Das Rechteck steht
bereits in viewBox-Einheiten → direkt setzbar. */}
{marqueeRect && (
<rect
className={
marqueeRect.crossing
? "plan-marquee plan-marquee-crossing"
: "plan-marquee"
}
x={marqueeRect.x}
y={marqueeRect.y}
width={marqueeRect.w}
height={marqueeRect.h}
style={{ stroke: marqueeColor, fill: marqueeColor }}
pointerEvents="none"
/>
)}
{/* Editier-Griffe des selektierten Elements (Wand-Enden / 2D-Vertices):
ziehbare Quadrate. Bildschirmkonstante Größe über die meet-Skala. */}
{gripsActive &&
grips!.map((g, i) => {
const s = toScreen(g);
const size = 6 / (meetScale(view, svgRef.current) ?? 1);
return (
<rect
key={`grip-${i}`}
className="plan-grip"
x={s.x - size}
y={s.y - size}
width={size * 2}
height={size * 2}
pointerEvents="none"
/>
);
})}
{/* Raum-Stempel-Griff: eine ziehbare Raute am Anker des gewählten Raums.
Verschiebt NUR den Stempel (nicht die Kontur); Doppelklick öffnet den
Editor. Bildschirmkonstante Größe. */}
{roomStamp &&
(() => {
const s = toScreen(roomStamp.anchor);
const size = 5 / (meetScale(view, svgRef.current) ?? 1);
const pts = [
`${s.x},${s.y - size}`,
`${s.x + size},${s.y}`,
`${s.x},${s.y + size}`,
`${s.x - size},${s.y}`,
].join(" ");
return (
<polygon
className="plan-grip plan-stamp-grip"
points={pts}
pointerEvents="none"
/>
);
})()}
{/* Kanten-/Seiten-Griffe: je Seite ein dreieckiger Anfasser, leicht nach
AUSSEN entlang der (bildschirm-transformierten) Normale versetzt, mit
der Spitze nach außen. Y wird gespiegelt (toScreen spiegelt Y), damit
das Dreieck wirklich nach außen zeigt. Größe bildschirmkonstant. */}
{edgeGripsActive &&
edgeGrips!.map((eg, i) => {
const s = meetScale(view, svgRef.current) ?? 1;
const m = toScreen(eg.mid);
// Normale in den Bildschirmraum (Y spiegeln); auf Bildschirm-px-Größen.
const sn = screenNormal(eg.normal);
const hVb = EDGE_GRIP_H_PX / s; // Dreieckshöhe (nach außen)
const halfVb = EDGE_GRIP_HALF_PX / s; // halbe Basisbreite
// Tangente = 90°-Drehung der (Bildschirm-)Normale, für die Basisbreite.
const tx = -sn.y;
const ty = sn.x;
// Basis SITZT AUF DER LINIE (Mittelpunkt m), Spitze zeigt nach außen.
const tipX = m.x + sn.x * hVb;
const tipY = m.y + sn.y * hVb;
const b1x = m.x + tx * halfVb;
const b1y = m.y + ty * halfVb;
const b2x = m.x - tx * halfVb;
const b2y = m.y - ty * halfVb;
return (
<polygon
key={`edge-grip-${i}`}
className="plan-edge-grip"
points={`${tipX},${tipY} ${b1x},${b1y} ${b2x},${b2y}`}
pointerEvents="none"
/>
);
})}
{/* Werkzeug-Overlay: Live-Vorschau (Rubber-Band) + gesetzte Stützpunkte +
Snap-Marker + HUD. Immer obenauf, pointerEvents none. Glyph-/Schrift-
Größen werden über die aktuelle meet-Skala bildschirmkonstant gehalten. */}
{toolHandlers?.draft && (
<DraftOverlay
draft={toolHandlers.draft}
toScreen={toScreen}
vbPerPx={1 / (meetScale(view, svgRef.current) ?? 1)}
snapColor={snapColor}
hudFields={hudFields ?? null}
/>
)}
</svg>
{/* Inline-Text-Editor DIREKT auf der Zeichenfläche (kein Dialog, Nutzer-
Feedback) — für ein frisch über `text`/`textbox` erzeugtes Element
(sofort nach dem Platzieren, s. App.tsx host.focusDrawing) ODER ein
per Doppelklick geöffnetes bestehendes. `RichTextEditor` bringt seine
eigene Mini-Toolbar mit (erscheint darüber, wie im Layout-Editor).
`at` ist die Baseline-links der ersten Zeile (s. primitives.tsx
"drawingText") — der Editor-Rahmen sitzt daher ~eine Zeilenhöhe
darüber. OHNE `wrapWidth` (Text-Werkzeug) bleibt die Breite frei
(kein Wortumbruch, nur Enter erzeugt neue Zeilen); MIT `wrapWidth`
(Textspalte) bricht der Editor live um wie ein InDesign-Textrahmen —
`position:"fixed"` mit echten Client-Pixeln (vbToClient), damit die
Positionierung unabhängig vom umgebenden Layout stimmt. */}
{editTextId &&
(() => {
const prim = plan.primitives.find(
(p): p is Extract<Primitive, { kind: "drawingText" }> =>
p.kind === "drawingText" && p.drawingId === editTextId,
);
if (!prim) return null;
const doc = prim.doc ?? docFromText(prim.text, prim.marks ?? {});
// WYSIWYG: Position, Breite, Feldhöhe UND Schriftgrösse folgen alle dem
// echten Zoom (currentPxPerMeter) — die Box soll exakt so aussehen wie
// der spätere gerenderte Text, in genau der Grösse des gezeichneten
// Feldes (Nutzer-Wunsch). Die Schriftgrösse geht NICHT über
// transform:scale() auf die ganze Box (das skalierte vorher die
// Toolbar mit hoch und sprengte sie beim Reinzoomen), sondern NUR auf
// die Textfläche via CSS-Variable (--rt-font-size/--rt-min-height,
// s. styles.css `.planview-inline-text .rt-surface`) — die Toolbar
// bleibt dadurch immer normal gross und bedienbar.
const pxPerM = currentPxPerMeter();
const topAnchor = toScreen({ x: prim.at.x, y: prim.at.y + prim.heightM });
const screen = vbToClient(topAnchor.x, topAnchor.y);
const hasWrap = !!prim.wrapWidth;
const widthPx = hasWrap ? prim.wrapWidth! * pxPerM : undefined;
const minWidthPx = hasWrap ? undefined : 160;
const fontSizePx = Math.max(9, prim.heightM * pxPerM);
// Aufgezogener Textspalten-Rahmen (textbox.ts): dessen Höhe als
// Mindesthöhe der Textfläche — wächst bei mehr Text darüber hinaus.
const minHeightPx = editTextMinHeightM ? editTextMinHeightM * pxPerM : undefined;
const cssVars = {
"--rt-font-size": `${fontSizePx}px`,
...(minHeightPx ? { "--rt-min-height": `${minHeightPx}px` } : {}),
} as CSSProperties;
return (
<>
<div
className="planview-inline-backdrop"
onPointerDown={() => onCloseTextEdit?.()}
/>
<div
className={"planview-inline-text" + (hasWrap ? "" : " planview-inline-text-free")}
style={{
left: screen.x,
top: screen.y,
width: widthPx,
minWidth: minWidthPx,
...cssVars,
}}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
onCloseTextEdit?.();
}
}}
>
<RichTextEditor value={doc} onChange={(d) => onCommitTextEdit?.(d)} autoFocus />
</div>
</>
);
})()}
{/* Lineale (oben + links, VW-Stil): Modell-Meter mit Ticks, Beschriftung
und Cursor-Marker — screen-fix über den sichtbaren Ausschnitt gelegt.
BEWUSST ein eigenes Sibling-SVG (nicht mehr Kind von `svgRef`): beim
imperativen GL-Pan (`applySvgTransform`, s.o.) bekommt NUR `svgRef`
während der Geste einen CSS-transform verpasst — läge das Lineal
darin, würde es sichtbar mit dem Inhalt mitwandern statt am Rand
fixiert zu bleiben. Als eigenständiges Element bleibt es während der
Geste stehen (aktualisiert erst beim Loslassen, wenn `view` neu
gesetzt wird) — exakt das gewünschte „statische" Verhalten. */}
{showRulers && (() => {
const m = measure(view);
if (!m.rect) return null;
const scale = m.scale || 1;
const vb = {
x0: view.x - m.offX / scale,
y0: view.y - m.offY / scale,
w: (m.rect.width || 1) / scale,
h: (m.rect.height || 1) / scale,
};
return (
<svg
className="plan-ruler-overlay"
viewBox={`${vb.x0} ${vb.y0} ${vb.w} ${vb.h}`}
preserveAspectRatio="none"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
zIndex: 2,
pointerEvents: "none",
}}
>
<PlanRulers
vb={vb}
vbPerPx={1 / scale}
pxPerMeter={scale * PX_PER_M}
cursor={rulerCursor}
/>
</svg>
);
})()}
</div>
);
});