Files
DOSSIER-STANDALONE/src/state/useGripEditing.ts
T
karim ad16202c12 2D: Bezier-Hilfslinien beim Editieren + Kreis kann zur Ellipse werden
Bezier: Anker→Griff-Verbindungen werden beim Editieren (Selektion) gestrichelt
angezeigt, nicht nur während des Zeichnens — macht sichtbar, welcher Griff
welchen Kurvenabschnitt steuert (wie bei üblichen Vektor-Editoren).

Kreis: bekommt wie die Ellipse zwei Griffe (Ost/Nord). Zieht man einen davon
unabhängig, wird der Kreis zur Ellipse (rotation 0); nähern sich die
Halbachsen beim Ziehen wieder an, wird daraus wieder ein Kreis. Erstellen
bleibt unverändert ein normaler Kreis — der Formwandel passiert nur beim
Bearbeiten.
2026-08-22 01:54:27 +02:00

1095 lines
50 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.
// 2D-Griff-Editieren des aktuell einzeln selektierten Elements (Wand/2D-Element/
// Decke/Raum/Öffnung/Treppe): Eckpunkt- + Kanten-Griffe, deren Zieh-Handler
// (inkl. Ortho/Snap/koinzidente Nachbar-Ecken) sowie der Tab-Feld-Controller für
// präzise Länge/Winkel-Eingabe während eines Vertex-Drags.
//
// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { useRef, useState, type RefObject } from "react";
import { add, dot, leftNormal, normalize, scale, sub } from "../model/geometry";
import type { Ceiling, Drawing2D, EdgeGrip, Project, Room, Stair, Vec2 } from "../model/types";
import { drawingVertices } from "./projectSlice";
import type { SnapResult, SnapSettings, ToolDraft, ToolMods } from "../tools/types";
import { applyAngleConstraint, computeSnap } from "../tools/snapping";
import { useCommandTabShortcut } from "./useKeyboardShortcuts";
import type { CommandLineHandle } from "../ui/CommandLine";
import type { CommandEngine } from "../commands/engine";
export interface GripEditFieldsState {
id: string;
labelKey: string;
value: number | null;
locked: boolean;
active: boolean;
}
export function useGripEditing(opts: {
project: Project;
selectedDrawing: Drawing2D | null;
selectedCeiling: Ceiling | null;
selectedRoom: Room | null;
selectedOpeningId: string | null;
selectedStairId: string | null;
selectedRoomId: string | null;
selectedCeilingId: string | null;
selectedDrawingId: string | null;
selectedWallId: string | null;
selectedWallIds: string[];
selectedDrawingIds: string[];
levelId: string;
visibleCodes: Set<string>;
snap: SnapSettings;
setDraft: (draft: ToolDraft | null) => void;
snapFor: (raw: Vec2, pxPerMeter: number, mods: ToolMods) => SnapResult | null;
engineRef: RefObject<CommandEngine | null>;
commandLineRef: RefObject<CommandLineHandle | null>;
updateOpening: (id: string, patch: Record<string, unknown>) => void;
moveStairBy: (id: string, delta: Vec2) => void;
moveRoomGrip: (id: string, index: number, pt: Vec2) => void;
moveCeilingGrip: (id: string, index: number, pt: Vec2) => void;
moveGripOf: (drawingId: string | null, wallId: string | null, index: number, pt: Vec2) => void;
moveRoomEdge: (id: string, aIndex: number, bIndex: number, delta: Vec2) => void;
moveCeilingEdge: (id: string, aIndex: number, bIndex: number, delta: Vec2) => void;
moveEdgeOf: (
drawingId: string | null,
wallId: string | null,
aIndex: number,
bIndex: number,
delta: Vec2,
) => void;
moveRoomBy: (id: string, delta: Vec2) => void;
moveCeilingBy: (id: string, delta: Vec2) => void;
moveElementByOf: (drawingId: string | null, wallId: string | null, delta: Vec2) => void;
}) {
const {
project,
selectedDrawing,
selectedCeiling,
selectedRoom,
selectedOpeningId,
selectedStairId,
selectedRoomId,
selectedCeilingId,
selectedDrawingId,
selectedWallId,
selectedWallIds,
selectedDrawingIds,
levelId,
visibleCodes,
snap,
setDraft,
snapFor,
engineRef,
commandLineRef,
updateOpening,
moveStairBy,
moveRoomGrip,
moveCeilingGrip,
moveGripOf,
moveRoomEdge,
moveCeilingEdge,
moveEdgeOf,
moveRoomBy,
moveCeilingBy,
moveElementByOf,
} = opts;
// Bewegpunkt (Move-Handle) einer Treppe: liegt auf der Treppe (Mitte des
// ersten Laufs bei gerade/L, Zentrum bei Wendel) und verschiebt beim Ziehen
// die GANZE Treppe (moveStairBy mit dem Delta). Ein einzelner, gut sichtbarer
// Griff — analog zum Öffnungs-Mittelpunkt.
const stairMoveHandle = (st: Stair): Vec2 => {
if (st.shape === "spiral") return st.center ?? st.start;
const d = normalize(st.dir);
return add(st.start, scale(d, st.runLength / 2));
};
// Welchem Element + lokalem Vertex-Index ein Griff im flachen `grips`-Array
// gehört (Mehrfachauswahl, Nutzer-Report: Griffe verschwanden komplett,
// sobald 2 Elemente gewählt waren — die alte Logik kannte nur GENAU EIN
// selektiertes Element). Leer (`{}`) bei den exklusiven Einzel-Fällen
// (Öffnung/Treppe/Decke/Raum), die weiterhin nur einzeln editierbar sind.
interface GripOwner {
drawingId?: string;
wallId?: string;
localIndex: number;
}
const gripOwners: GripOwner[] = [];
// [ersterFlachIndex, letzterFlachIndex] DESSELBEN Elements — für die Anker-
// Auflösung (Shift-Ortho, Tab-Feld-Controller): der "Nachbar-Griff" muss aus
// demselben Element kommen, nicht irgendeinem anderen der Mehrfachauswahl.
const gripOwnerRange: [number, number][] = [];
// Bei Mehrfachauswahl fehlt `selectedDrawingIds` manchmal trotzdem (nur die
// Einzel-ID `selectedDrawingId` gesetzt, s. useSelectionState-Konvention) —
// derselbe Fallback wie an anderen Stellen im Projekt.
const effectiveDrawingIds =
selectedDrawingIds.length > 0
? selectedDrawingIds
: selectedDrawingId
? [selectedDrawingId]
: [];
const grips: Vec2[] = (() => {
// Öffnung (Fenster/Tür): EIN Mittelpunkt-Griff auf der Wandachse. Ziehen
// verschiebt die Öffnung entlang der Wirts-Wand (siehe applyGrip →
// `position`). Nur bei genau einer selektierten Öffnung (Auswahl exklusiv).
if (selectedOpeningId) {
const op = (project.openings ?? []).find((o) => o.id === selectedOpeningId);
const w = op && project.walls.find((x) => x.id === op.hostWallId);
if (op && w) {
const axis = normalize(sub(w.end, w.start));
const pt = add(w.start, scale(axis, op.position + op.width / 2));
gripOwners.push({ localIndex: 0 });
gripOwnerRange.push([0, 0]);
return [pt];
}
return [];
}
// Treppe: EIN Bewegpunkt (siehe stairMoveHandle); Ziehen verschiebt die
// ganze Treppe (applyGrip → moveStairBy). Auswahl exklusiv.
if (selectedStairId) {
const st = (project.stairs ?? []).find((s) => s.id === selectedStairId);
if (!st) return [];
gripOwners.push({ localIndex: 0 });
gripOwnerRange.push([0, 0]);
return [stairMoveHandle(st)];
}
// Decke: die Umriss-Vertices als Griffe (nur bei genau einer gewählten Decke,
// solange kein 2D-Element/Wand mitgewählt ist — kein Multi-Select für Decken).
if (selectedCeiling && effectiveDrawingIds.length === 0 && selectedWallIds.length === 0) {
const pts = [...selectedCeiling.outline];
pts.forEach((_, i) => { gripOwners.push({ localIndex: i }); gripOwnerRange.push([0, pts.length - 1]); });
return pts;
}
// Raum: die Umriss-Vertices als Griffe (nur bei genau einem gewählten Raum).
if (
selectedRoom &&
effectiveDrawingIds.length === 0 &&
selectedWallIds.length === 0 &&
!selectedCeiling
) {
const pts = [...selectedRoom.boundary];
pts.forEach((_, i) => { gripOwners.push({ localIndex: i }); gripOwnerRange.push([0, pts.length - 1]); });
return pts;
}
// Mehrfachauswahl (Regel- UND Standardfall): je gewähltem 2D-Element bzw.
// je gewählter Wand ein eigener, in sich zusammenhängender Vertex-Block.
// Wand UND 2D-Element schliessen sich weiterhin gegenseitig aus (wie schon
// bei der Auswahl selbst — s. die vielen `!selectedDrawingId`-Wächter, die
// dieses Modul vorher hatte).
const out: Vec2[] = [];
const pushGroup = (pts: Vec2[], owner: { drawingId?: string; wallId?: string }) => {
if (pts.length === 0) return;
const start = out.length;
pts.forEach((p, i) => {
out.push(p);
gripOwners.push({ ...owner, localIndex: i });
});
const end = out.length - 1;
for (let i = start; i <= end; i++) gripOwnerRange.push([start, end]);
};
for (const id of effectiveDrawingIds) {
const d = project.drawings2d.find((x) => x.id === id);
if (d) pushGroup(drawingVertices(d), { drawingId: id });
}
if (effectiveDrawingIds.length === 0) {
for (const id of selectedWallIds) {
const w = project.walls.find((x) => x.id === id);
if (w) pushGroup([w.start, w.end], { wallId: id });
}
}
return out;
})();
// Bezier-Hilfslinien (Anker → Kontrollpunkt-Griff), gestrichelt gerendert,
// damit sichtbar/nachvollziehbar ist, welcher Griff welchen Kurvenabschnitt
// steuert (Nutzer-Wunsch: „Hilfslinien sollten schon sichtbar sein um sie
// zu steuern" — wie bei Illustrator/Inkscape üblich). `pts` folgt dem Muster
// Anker·Griff·Griff·Anker·… (s. `Drawing2DGeom["bezier"]`); jeder Anker
// bekommt eine Linie zu seinem/seinen benachbarten Griff(en).
const gripHandleLines: { a: Vec2; b: Vec2 }[] = (() => {
const out: { a: Vec2; b: Vec2 }[] = [];
for (const id of effectiveDrawingIds) {
const d = project.drawings2d.find((x) => x.id === id);
const g = d?.geom;
if (!g || g.shape !== "bezier") continue;
for (let i = 0; i + 3 < g.pts.length; i += 3) {
out.push({ a: g.pts[i], b: g.pts[i + 1] }); // Anker → Griff 1
out.push({ a: g.pts[i + 3], b: g.pts[i + 2] }); // nächster Anker → Griff 2
}
}
return out;
})();
// Nachbar-Griff-Index INNERHALB desselben Elements (für Anker-Auflösung).
// null = kein Nachbar möglich (Einzelgriff-Element wie Kreis/Öffnung/Treppe).
const neighborGripIndex = (index: number): number | null => {
const range = gripOwnerRange[index];
if (!range) return null;
const [start, end] = range;
if (index > start) return index - 1;
if (end > start) return start + 1;
return null;
};
// Pro Seite JEDES gewählten Elements ein Griff: Kanten-Mittelpunkt + nach
// AUSSEN zeigende Einheitsnormale + die beiden lokalen Vertex-Indizes +
// `owner` (welchem Element diese Kante gehört — Mehrfachauswahl). Die
// Außenrichtung wird über das Vorzeichen des Skalarprodukts der Kanten-
// Normale mit (mid − centroid) gewählt (vom Polygon-Schwerpunkt weg).
// • rect: 4 Kanten (geschlossen).
// • polyline mit `closed`: Kanten zwischen aufeinanderfolgenden Vertices
// inkl. Schlusskante (letzter → erster).
// • Wand: eine Kante [0,1]; Außennormale = leftNormal der Achse. Ziehen
// verschiebt beide Enden (ganze Wand senkrecht).
// • offene Polylinie/Linie: je Segment ein Kanten-Griff. Ziehen verschiebt DAS
// Segment (seine beiden Endpunkte) um das volle Drag-Delta — die geteilten
// Ecken wandern mit, die Nachbar-Segmente dehnen sich nach (Parallel-
// Verschieben EINER Kante einer komplexen Polylinie, §Task B).
const edgeGrips: EdgeGrip[] = (() => {
// Geschlossener Ring aus Vertices → Kanten-Griffe mit Außennormale.
const ringEdges = (verts: Vec2[], owner: { drawingId?: string; wallId?: string }): EdgeGrip[] => {
const n = verts.length;
if (n < 3) return [];
// Schwerpunkt (Mittel der Eckpunkte) als Innenbezug.
let cx = 0, cy = 0;
for (const v of verts) { cx += v.x; cy += v.y; }
const centroid: Vec2 = { x: cx / n, y: cy / n };
const out: EdgeGrip[] = [];
for (let i = 0; i < n; i++) {
const a = verts[i];
const b = verts[(i + 1) % n];
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const edge = sub(b, a);
let nrm = normalize(leftNormal(edge));
// Nach außen orientieren: Normale soll von centroid → mid weisen.
const outward = sub(mid, centroid);
if (nrm.x * outward.x + nrm.y * outward.y < 0) nrm = scale(nrm, -1);
out.push({ mid, normal: nrm, aIndex: i, bIndex: (i + 1) % n, owner });
}
return out;
};
// Offene Kette (Linie/offene Polylinie) → ein Griff je Segment, OHNE
// Schlusskante. `normal` dient nur der Griff-Darstellung (leftNormal der
// Kante); die Bewegung ist frei (volles Delta), nicht auf die Normale
// beschränkt — daher `free: true`.
const openEdges = (verts: Vec2[], owner: { drawingId?: string; wallId?: string }): EdgeGrip[] => {
const n = verts.length;
if (n < 2) return [];
const out: EdgeGrip[] = [];
for (let i = 0; i < n - 1; i++) {
const a = verts[i];
const b = verts[i + 1];
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const nrm = normalize(leftNormal(sub(b, a)));
out.push({ mid, normal: nrm, aIndex: i, bIndex: i + 1, free: true, owner });
}
return out;
};
// Decke: geschlossener Ring aus dem Umriss (Auswahl exklusiv, wie bei grips).
if (selectedCeiling && effectiveDrawingIds.length === 0 && selectedWallIds.length === 0) {
return ringEdges(selectedCeiling.outline, {});
}
// Raum: geschlossener Ring aus dem Umriss.
if (
selectedRoom &&
effectiveDrawingIds.length === 0 &&
selectedWallIds.length === 0 &&
!selectedCeiling
) {
return ringEdges(selectedRoom.boundary, {});
}
const out: EdgeGrip[] = [];
for (const id of effectiveDrawingIds) {
const d = project.drawings2d.find((x) => x.id === id);
if (!d) continue;
const verts = drawingVertices(d);
const g = d.geom;
const owner = { drawingId: id };
if (g.shape === "rect" || g.shape === "image") out.push(...ringEdges(verts, owner));
else if (g.shape === "polyline" && g.closed) out.push(...ringEdges(verts, owner));
else if (g.shape === "polyline" && !g.closed) out.push(...openEdges(verts, owner));
else if (g.shape === "spline" && g.closed) out.push(...ringEdges(verts, owner));
else if (g.shape === "spline" && !g.closed) out.push(...openEdges(verts, owner));
else if (g.shape === "line") out.push(...openEdges(verts, owner));
}
if (effectiveDrawingIds.length === 0) {
for (const id of selectedWallIds) {
const w = project.walls.find((x) => x.id === id);
if (!w) continue;
const mid: Vec2 = { x: (w.start.x + w.end.x) / 2, y: (w.start.y + w.end.y) / 2 };
const nrm = normalize(leftNormal(sub(w.end, w.start)));
out.push({ mid, normal: nrm, aIndex: 0, bIndex: 1, owner: { wallId: id } });
}
}
return out;
})();
// Bereits angewandtes 2D-Delta seit Grab-Beginn eines Kanten-Drags, relativ
// zum ORIGINALEN Kanten-Mittelpunkt (`edge.mid` — die beim Grab erfasste
// Momentaufnahme, ändert sich während des Drags nicht). null = noch kein
// Move-Event verarbeitet (erstes Event verankert nur, kein Sprung). Ein
// ABSOLUTER Zielpunkt (aus Maus ODER gelocktem Feldwert) wird jeden Frame neu
// berechnet; angewandt wird nur die Differenz zum zuletzt angewandten Delta
// (moveEdgeOf/moveRoomEdge/moveCeilingEdge sind additiv). Dieselbe Formel
// trägt sowohl den geführten (Normalen-)Fall als auch den freien Fall — vorher
// zwei getrennte, rein inkrementelle Mechanismen ohne Bezug zum Ursprung, was
// eine getippte Ziel-Distanz (s. edgeEdit unten) unmöglich gemacht hätte.
const edgeAppliedDeltaRef = useRef<Vec2 | null>(null);
// Körper-Verschieben (2D): koinzidente Endpunkte ANDERER gewählter Elemente, die
// an einer Ecke des verschobenen Elements hängen, werden um dasselbe Delta
// mitgeführt (gemeinsame Ecke bleibt verbunden). Lazy beim ersten Move-Event
// bestimmt (Snapshot der Selektion), je Drag in onGripEnd zurückgesetzt. null =
// noch nicht bestimmt.
const moveCoincidentRef = useRef<
| { drawingId: string | null; wallId: string | null; index: number; pos: Vec2 }[]
| null
>(null);
// Eckpunkte eines selektierten Elements (Wand: start/end; 2D: drawingVertices).
const elementCorners = (drawingId: string | null, wallId: string | null): Vec2[] => {
if (drawingId) {
const d = project.drawings2d.find((x) => x.id === drawingId);
return d ? drawingVertices(d) : [];
}
if (wallId) {
const w = project.walls.find((x) => x.id === wallId);
return w ? [w.start, w.end] : [];
}
return [];
};
// Koinzidente Sibling-Vertices (unter den ÜBRIGEN gewählten Elementen) an JEDER
// Ecke des verschobenen Elements. Toleranz wie beim 3D-Drag (gemeinsame Ecke).
const COINCIDENT_TOL_2D = 0.05; // m
const findMoveCoincident2D = (
selfDrawingId: string | null,
selfWallId: string | null,
): { drawingId: string | null; wallId: string | null; index: number; pos: Vec2 }[] => {
const corners = elementCorners(selfDrawingId, selfWallId);
if (corners.length === 0) return [];
const near = (p: Vec2, q: Vec2) => Math.hypot(p.x - q.x, p.y - q.y) <= COINCIDENT_TOL_2D;
const seen = new Set<string>();
const out: { drawingId: string | null; wallId: string | null; index: number; pos: Vec2 }[] = [];
const add2 = (drawingId: string | null, wallId: string | null, index: number, pos: Vec2) => {
const key = `${drawingId ?? ""}|${wallId ?? ""}|${index}`;
if (seen.has(key)) return;
seen.add(key);
out.push({ drawingId, wallId, index, pos: { ...pos } });
};
for (const corner of corners) {
for (const id of selectedWallIds) {
if (id === selfWallId) continue;
const w = project.walls.find((x) => x.id === id);
if (!w) continue;
if (near(corner, w.start)) add2(null, id, 0, w.start);
if (near(corner, w.end)) add2(null, id, 1, w.end);
}
for (const id of selectedDrawingIds) {
if (id === selfDrawingId) continue;
const d = project.drawings2d.find((x) => x.id === id);
if (!d) continue;
drawingVertices(d).forEach((v, i) => {
if (near(corner, v)) add2(id, null, i, v);
});
}
}
return out;
};
// Editieren läuft AD-HOC (gripHandlers → moveGripOf), NICHT durch die Engine.
// Damit „Tab → Länge/Winkel" wie beim Zeichnen funktioniert, ohne die Engine
// umzubauen, hält App einen kleinen FELD-Controller, der NUR während eines
// Vertex-Griff-Drags aktiv ist: er belegt das Befehlsfeld (Prompt/Felder/
// Eingabe) und liefert gelockte Länge/Winkel relativ zum Anker (Nachbarecke).
// `onGripMove` liest diese Locks und zwingt den Zielpunkt darauf — so setzt ein
// getippter Wert den exakten Endpunkt. Foundation: heute 2D-Vertex; dieselbe
// Lock-Auflösung (polar um den Anker) ist auf 3D-Vertex/Körper erweiterbar.
const [gripEdit, setGripEdit] = useState<{
/** Fixer Bezug (Nachbarecke), relativ zu dem Länge/Winkel gelten. */
anchor: Vec2;
/** Aktives Tab-Feld (zyklt length ↔ angle). */
active: "length" | "angle";
/** Gelockte Werte (getippt). length in m, angle in Grad. */
locks: { length?: number; angle?: number };
} | null>(null);
// Live-Spiegel für Handler ohne Re-Render-Abhängigkeit (Drag liest synchron).
const gripEditRef = useRef<typeof gripEdit>(null);
gripEditRef.current = gripEdit;
// Letzter roher Cursor des laufenden Vertex-Drags (für die Lock-Auflösung beim
// Tippen, wenn gerade kein Move-Event kommt). Plus der gezogene Griff-Index.
const gripDragInfoRef = useRef<{ index: number; raw: Vec2 } | null>(null);
// Position des gezogenen Griffs VOR dem Drag (beim ersten Move-Event nach dem
// Grab verankert) — Bezugspunkt für die Flucht-Kandidaten unten (die
// Nachbarkanten müssen an ihrer URSPRÜNGLICHEN, nicht der schon verschobenen
// Position gemessen werden, sonst würde die Richtung während des Ziehens
// mitdriften).
const gripOriginRef = useRef<Vec2 | null>(null);
// Endpunkt aus Anker + (gelockten/Live-)Länge/Winkel — wie lineEndFromFields,
// hier für den Vertex-Drag. Ungelockte Größen folgen dem rohen Cursor `raw`.
const gripEditPoint = (
anchor: Vec2,
locks: { length?: number; angle?: number },
raw: Vec2,
): Vec2 => {
const DEG = Math.PI / 180;
const dx = raw.x - anchor.x;
const dy = raw.y - anchor.y;
const length = locks.length != null ? locks.length : Math.hypot(dx, dy);
const angleDeg = locks.angle != null ? locks.angle : (Math.atan2(dy, dx) * 180) / Math.PI;
const a = angleDeg * DEG;
return { x: anchor.x + Math.cos(a) * length, y: anchor.y + Math.sin(a) * length };
};
// Felder fürs Befehlsfeld während des Vertex-Drags (Live ODER gelockt).
const gripEditFields = (): GripEditFieldsState[] => {
const ge = gripEdit;
const info = gripDragInfoRef.current;
if (!ge || !info) return [];
const dx = info.raw.x - ge.anchor.x;
const dy = info.raw.y - ge.anchor.y;
const liveLen = Math.hypot(dx, dy);
const liveAng = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 360) + 360) % 360;
return [
{
id: "length",
labelKey: "cmd.field.length",
value: ge.locks.length ?? liveLen,
locked: ge.locks.length != null,
active: ge.active === "length",
},
{
id: "angle",
labelKey: "cmd.field.angle",
value: ge.locks.angle ?? liveAng,
locked: ge.locks.angle != null,
active: ge.active === "angle",
},
];
};
// Fixer Bezug (Anker) für Länge/Winkel eines gezogenen Punkt-Griffs. Normal
// die BENACHBARTE Ecke DESSELBEN Elements (Polylinien-Semantik) — bei Kreis/
// Bogen ist das falsch (deren Griffe liegen auf dem Ring, "Länge" muss der
// RADIUS sein, also relativ zum ZENTRUM). Ohne diesen Sonderfall blieb der
// Kreis-Radius-Griff sogar ganz ohne Anker (nur 1 Griff, kein Nachbar).
// Gemeinsam genutzt vom Tab-Feld-Controller UND dem Shift-Ortho-Bezug UND
// dem Cursor-HUD (Länge/Winkel-Anzeige neben der Maus).
const resolveGripAnchor = (index: number): Vec2 | null => {
// Bei Mehrfachauswahl gehört der gezogene Griff evtl. NICHT dem einzeln
// selektierten `selectedDrawing`, sondern einem anderen Element der
// Auswahl — das eigentliche Element über `gripOwners` auflösen.
const owner = gripOwners[index];
const ownerDrawing = owner?.drawingId
? project.drawings2d.find((d) => d.id === owner.drawingId)
: selectedDrawing;
const g = ownerDrawing?.geom;
if (g && (g.shape === "circle" || g.shape === "arc" || g.shape === "ellipse")) return g.center;
const refIndex = neighborGripIndex(index);
return refIndex != null ? grips[refIndex] : null;
};
// Die beiden (fixen) Polygon-Nachbarn eines gezogenen Punkt-Griffs — für die
// Flucht-Kandidaten unten. Anders als `neighborGripIndex` (EIN Nachbar,
// reicht für Länge/Winkel-Anker) werden hier BEIDE gebraucht: ein Vielecks-
// Eckpunkt hängt an ZWEI Kanten, jede liefert eine eigene mögliche Flucht-
// Richtung. Kennt geschlossene Ringe (rect/geschlossene Polylinie/Raum/
// Decke — Nachbar 0/n-1 wickelt um) vs. offene Ketten (offene Polylinie/
// Linie — Enden haben nur einen Nachbarn). Kreis/Bogen/Wand/Öffnung/Treppe
// haben keine Polygon-Nachbarn (kein Vielecks-Eckpunkt) → {null,null}.
const ringNeighborPts = (
pts: Vec2[],
i: number,
closed: boolean,
): { prev: Vec2 | null; next: Vec2 | null } => {
const n = pts.length;
const prevIdx = i > 0 ? i - 1 : closed ? n - 1 : null;
const nextIdx = i < n - 1 ? i + 1 : closed ? 0 : null;
return {
prev: prevIdx != null && prevIdx !== i ? pts[prevIdx] : null,
next: nextIdx != null && nextIdx !== i && nextIdx !== prevIdx ? pts[nextIdx] : null,
};
};
const resolvePolygonNeighbors = (index: number): { prev: Vec2 | null; next: Vec2 | null } => {
const owner = gripOwners[index];
const none = { prev: null, next: null };
if (!owner?.drawingId && !owner?.wallId) {
// Raum/Decke (exklusiv) — kein Owner, `localIndex` fehlt dann auch, also
// ist der flache `index` selbst der Ring-Index.
const pts = selectedCeiling
? selectedCeiling.outline
: selectedRoom
? selectedRoom.boundary
: null;
return pts ? ringNeighborPts(pts, owner?.localIndex ?? index, true) : none;
}
if (owner.wallId) return none; // Wand: nur 2 Punkte, kein Vielecks-Nachbar
const d = project.drawings2d.find((x) => x.id === owner.drawingId);
const g = d?.geom;
if (!g) return none;
if (g.shape === "polyline" || g.shape === "spline")
return ringNeighborPts(g.pts, owner.localIndex, !!g.closed);
if (g.shape === "rect" || g.shape === "image") {
const corners = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
return ringNeighborPts(corners, owner.localIndex, true);
}
if (g.shape === "line") return ringNeighborPts([g.a, g.b], owner.localIndex, false);
return none; // circle/arc: kein Vielecks-Eckpunkt
};
// Rohen Cursor `raw` auf die nächstliegende von mehreren Kandidaten-Achsen
// projizieren (jeweils Stützpunkt + Richtung) — "nächstliegend" = kleinster
// senkrechter Abstand von `raw` zur (unendlich verlängerten) Achse. Deckt
// Shift-Ortho (Welt-H/V) UND Flucht-Snap (Richtung anliegender, fixer
// Kanten) in einem einheitlichen Mechanismus ab.
const closestAxisPoint = (raw: Vec2, candidates: { point: Vec2; dir: Vec2 }[]): Vec2 | null => {
let best: Vec2 | null = null;
let bestDist = Infinity;
for (const c of candidates) {
const len = Math.hypot(c.dir.x, c.dir.y);
if (len < 1e-9) continue; // entartete Richtung (koinzidenter Nachbar) → überspringen
const u = { x: c.dir.x / len, y: c.dir.y / len };
const rel = sub(raw, c.point);
const t = rel.x * u.x + rel.y * u.y;
const proj = add(c.point, scale(u, t));
const dist = Math.hypot(raw.x - proj.x, raw.y - proj.y);
if (dist < bestDist) {
bestDist = dist;
best = proj;
}
}
return best;
};
// Stabiler Ref auf cycleGripEditField (der globale Tab-Handler hat leere Deps).
const cycleGripEditFieldRef = useRef<() => void>(() => {});
// Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle).
// • ÖFFNEN (noch kein gripEdit): friert BEIDE Werte an der aktuellen
// Position ein (`locks` sofort vollständig befüllt statt leer) — die
// Maus hat ab jetzt keinen Einfluss mehr, bis man explizit wieder in ein
// Feld tabbt (verhindert, dass ein Zittern der Hand beim Greifen zur
// Tastatur den Punkt noch verschiebt).
// • ZYKELN (gripEdit schon offen): wechselt zum anderen Feld und LÖST
// dessen Lock (frei für die Maus) — das Feld, das man verlässt, bleibt
// exakt auf seinem letzten Wert fixiert (getippt oder eingefroren).
// Nutzer-Wunsch: nach dem ersten getippten Wert soll das ANDERE Feld
// nicht sofort wieder der Maus folgen, sondern erst nach explizitem Tab
// dorthin — sonst „wird der Winkel nie richtig fix".
const cycleGripEditField = () => {
const info = gripDragInfoRef.current;
if (!info) return;
const anchor = resolveGripAnchor(info.index);
if (!anchor) return; // ohne Anker keine polare Eingabe möglich
setGripEdit((prev) => {
if (!prev) {
const frozen = grips[info.index];
const dx = frozen.x - anchor.x;
const dy = frozen.y - anchor.y;
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { anchor, active: "length", locks: { length: Math.hypot(dx, dy), angle } };
}
const active = prev.active === "length" ? "angle" : "length";
const locks = { ...prev.locks };
delete locks[active];
return { ...prev, active, locks };
});
};
cycleGripEditFieldRef.current = cycleGripEditField;
// Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken
// (das andere Feld bleibt unverändert fixiert, s. o.).
const submitGripEditValue = (value: number) => {
const ge = gripEditRef.current;
const info = gripDragInfoRef.current;
if (!ge || !info) return;
const locks = { ...ge.locks, [ge.active]: value };
const next = ge.active === "length" ? "angle" : "length";
setGripEdit({ ...ge, locks, active: next });
// Zielpunkt mit dem frischen Lock anwenden (live, ohne Maus-Bewegung).
const pt = gripEditPoint(ge.anchor, locks, info.raw);
applyGrip(info.index, pt);
};
// Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile, s.
// App.tsx) — beendet NICHT den ganzen Griff-Drag (der bleibt bewaffnet,
// Maus steuert wieder normal wie vor dem ersten Tab).
const closeGripEditField = () => {
setGripEdit(null);
};
// Dasselbe Feld-Controller-Muster wie oben (gripEdit), jetzt für einen Kanten-
// Griff (Dreieck-Anfasser, „Seite parallel verschieben"). Ein Feld genügt für
// geführte Kanten (rect/geschlossene Polylinie/Wand — Bewegung ist auf die
// Normale beschränkt, "Länge" ist der vorzeichenbehaftete Abstand); freie
// Kanten (offene Polylinie/Linie) bekommen wie ein Punkt-Griff Länge+Winkel.
const [edgeEdit, setEdgeEdit] = useState<{
/** Kanten-Mittelpunkt bei Grab-Beginn (fix für die ganze Dauer des Drags). */
anchor: Vec2;
active: "length" | "angle";
locks: { length?: number; angle?: number };
} | null>(null);
const edgeEditRef = useRef<typeof edgeEdit>(null);
edgeEditRef.current = edgeEdit;
// Letzter roher Cursor + der gezogene Kanten-Griff (Momentaufnahme vom Grab).
const edgeDragInfoRef = useRef<{ edge: EdgeGrip; raw: Vec2 } | null>(null);
// Zielpunkt aus Anker + (gelockten/Live-)Werten — bei freien Kanten wie ein
// Punkt-Griff (polar), bei geführten Kanten nur entlang der festen Normale.
const edgeEditPoint = (
edge: EdgeGrip,
anchor: Vec2,
locks: { length?: number; angle?: number },
raw: Vec2,
): Vec2 => {
if (edge.free) return gripEditPoint(anchor, locks, raw);
const live = dot(sub(raw, anchor), edge.normal);
const len = locks.length != null ? locks.length : live;
return add(anchor, scale(edge.normal, len));
};
// Felder fürs Befehlsfeld während des Kanten-Drags. Geführte Kanten zeigen
// NUR "Länge" (kein Winkel — der ist durch die feste Normale vorgegeben).
const edgeEditFields = (): GripEditFieldsState[] => {
const ee = edgeEdit;
const info = edgeDragInfoRef.current;
if (!ee || !info) return [];
const { edge, raw } = info;
if (!edge.free) {
const live = dot(sub(raw, ee.anchor), edge.normal);
return [
{
id: "length",
labelKey: "cmd.field.length",
value: ee.locks.length ?? live,
locked: ee.locks.length != null,
active: true,
},
];
}
const dx = raw.x - ee.anchor.x;
const dy = raw.y - ee.anchor.y;
const liveLen = Math.hypot(dx, dy);
const liveAng = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 360) + 360) % 360;
return [
{
id: "length",
labelKey: "cmd.field.length",
value: ee.locks.length ?? liveLen,
locked: ee.locks.length != null,
active: ee.active === "length",
},
{
id: "angle",
labelKey: "cmd.field.angle",
value: ee.locks.angle ?? liveAng,
locked: ee.locks.angle != null,
active: ee.active === "angle",
},
];
};
const cycleEdgeEditFieldRef = useRef<() => void>(() => {});
// Tab im Kanten-Drag: Feld-Controller öffnen/zyklen — dasselbe Muster wie
// cycleGripEditField oben (Öffnen friert BEIDE Werte ein, Zykeln löst NUR
// das neu aktive Feld). Geführte Kanten haben nur ein Feld ("Länge" =
// Abstand entlang der Normale) → Zykeln tut dort nichts, das eine Feld
// bleibt beim Öffnen ohnehin schon eingefroren/fixiert.
const cycleEdgeEditField = () => {
const info = edgeDragInfoRef.current;
if (!info) return;
const { edge, raw } = info;
setEdgeEdit((prev) => {
if (!prev) {
const anchor = edge.mid;
const frozen = edgeEditPoint(edge, anchor, {}, raw);
const dx = frozen.x - anchor.x;
const dy = frozen.y - anchor.y;
if (edge.free) {
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { anchor, active: "length", locks: { length: Math.hypot(dx, dy), angle } };
}
const offset = dx * edge.normal.x + dy * edge.normal.y;
return { anchor, active: "length", locks: { length: offset } };
}
if (!edge.free) return prev; // geführte Kante: nur ein Feld, nichts zu zykeln
const active = prev.active === "length" ? "angle" : "length";
const locks = { ...prev.locks };
delete locks[active];
return { ...prev, active, locks };
});
};
cycleEdgeEditFieldRef.current = cycleEdgeEditField;
// Globaler Tab-Handler (Feld-Zyklus/Zahlen-Tippen, Punkt- UND Kanten-Griff) —
// siehe state/useKeyboardShortcuts.ts.
useCommandTabShortcut(
engineRef,
commandLineRef,
gripDragInfoRef,
gripEditRef,
cycleGripEditFieldRef,
edgeDragInfoRef,
edgeEditRef,
cycleEdgeEditFieldRef,
);
// Getippte Zahl im Kanten-Drag: Feld locken, bei freien Kanten aufs nächste
// Feld vorrücken, Zielpunkt sofort mit dem gelockten Wert neu anwenden.
const submitEdgeEditValue = (value: number) => {
const ee = edgeEditRef.current;
const info = edgeDragInfoRef.current;
if (!ee || !info) return;
const { edge, raw } = info;
const locks = { ...ee.locks, [ee.active]: value };
const next = edge.free ? (ee.active === "length" ? "angle" : "length") : ee.active;
setEdgeEdit({ ...ee, locks, active: next });
const pt = edgeEditPoint(edge, ee.anchor, locks, raw);
applyEdgeToward(edge, ee.anchor, pt);
};
// Feld-Controller schließen (Escape MIT Fokus in der Befehlszeile) — beendet
// NICHT den Kanten-Drag (der bleibt bewaffnet, Maus steuert wieder normal).
const closeEdgeEditField = () => {
setEdgeEdit(null);
};
// Editier-Dispatch: bei gewählter Decke auf die Decken-Aktionen umleiten, sonst
// auf die Wand/2D-Aktionen. So teilen Plan- UND 3D-Griffe denselben Pfad.
const applyGrip = (index: number, pt: Vec2) => {
// Öffnung: den gezogenen Punkt auf die Wandachse projizieren → neue
// `position` (Abstand des ersten Pfostens vom Wand-Start), sodass die
// Öffnung entlang der Wand gleitet. Griff sitzt in der Öffnungsmitte, daher
// width/2 abziehen; auf [0, Wandlänge − Breite] klemmen (Öffnung bleibt drin).
if (selectedOpeningId) {
const op = (project.openings ?? []).find((o) => o.id === selectedOpeningId);
const w = op && project.walls.find((x) => x.id === op.hostWallId);
if (op && w) {
const d = sub(w.end, w.start);
const len = Math.hypot(d.x, d.y);
if (len > 1e-9) {
const axis = scale(d, 1 / len);
const t = dot(sub(pt, w.start), axis);
const pos = Math.max(0, Math.min(len - op.width, t - op.width / 2));
updateOpening(op.id, { position: pos });
}
}
return;
}
// Treppe: den Bewegpunkt verfolgen → ganze Treppe um das Delta (Ziel −
// aktueller Handle) verschieben. Handle wird identisch zu grips gebildet,
// sodass er nach dem Move exakt unter dem Cursor sitzt.
if (selectedStairId) {
const st = (project.stairs ?? []).find((s) => s.id === selectedStairId);
if (st) moveStairBy(st.id, sub(pt, stairMoveHandle(st)));
return;
}
if (selectedRoomId) moveRoomGrip(selectedRoomId, index, pt);
else if (selectedCeilingId) moveCeilingGrip(selectedCeilingId, index, pt);
else {
// Mehrfachauswahl: welchem Element dieser Griff gehört, kommt aus
// `gripOwners` (nicht mehr aus den singulären selectedDrawingId/-WallId).
const owner = gripOwners[index];
moveGripOf(owner?.drawingId ?? selectedDrawingId, owner?.wallId ?? selectedWallId, owner?.localIndex ?? index, pt);
}
};
const applyEdge = (edge: EdgeGrip, delta: Vec2) => {
if (selectedRoomId) moveRoomEdge(selectedRoomId, edge.aIndex, edge.bIndex, delta);
else if (selectedCeilingId) moveCeilingEdge(selectedCeilingId, edge.aIndex, edge.bIndex, delta);
else moveEdgeOf(edge.owner?.drawingId ?? selectedDrawingId, edge.owner?.wallId ?? selectedWallId, edge.aIndex, edge.bIndex, delta);
};
// Kante auf einen ABSOLUTEN Zielpunkt (relativ zum ORIGINALEN Anker) bewegen —
// moveEdge* ist additiv, daher nur die Differenz zum zuletzt angewandten Delta
// anwenden (s. edgeAppliedDeltaRef). Trägt sowohl Maus-geführte Bewegung als
// auch gelockte Feldwerte UND das Verwerfen (Ziel = Anker selbst → Delta 0,
// die Differenz zum vorherigen Delta macht die ganze Verschiebung rückgängig).
const applyEdgeToward = (edge: EdgeGrip, anchor: Vec2, pt: Vec2) => {
const desired = sub(pt, anchor);
const prev = edgeAppliedDeltaRef.current;
edgeAppliedDeltaRef.current = desired;
if (prev === null) return; // erster Event verankert nur (kein Sprung beim Greifen)
const inc = sub(desired, prev);
if (inc.x === 0 && inc.y === 0) return;
applyEdge(edge, inc);
};
// Maß-/Winkel-HUD am Cursor für einen gezogenen Punkt-Griff — dieselbe
// Darstellung wie beim Zeichnen (DraftHud, VW-Stil). Ohne Anker (Einzelgriff-
// Elemente wie Öffnung/Treppe) gibt es keine sinnvolle Länge/Winkel-Anzeige.
const hudFor = (index: number, pt: Vec2): NonNullable<ToolDraft["hud"]> | undefined => {
const anchor = resolveGripAnchor(index);
if (!anchor) return undefined;
const dx = pt.x - anchor.x;
const dy = pt.y - anchor.y;
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { at: pt, length: Math.hypot(dx, dy), angleDeg };
};
// Dasselbe für einen gezogenen Kanten-Griff: freie Kanten zeigen Länge+Winkel
// (wie ein Punkt-Griff), geführte Kanten nur EINEN Wert (Abstand entlang der
// Normale) — dafür reicht das HUD-Schema nicht (length+angleDeg gehören
// zusammen), daher als freier Text `Δ: …m`.
const edgeHudFor = (edge: EdgeGrip, anchor: Vec2, pt: Vec2): NonNullable<ToolDraft["hud"]> => {
const dx = pt.x - anchor.x;
const dy = pt.y - anchor.y;
if (edge.free) {
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { at: pt, length: Math.hypot(dx, dy), angleDeg };
}
const offset = dx * edge.normal.x + dy * edge.normal.y;
return { at: pt, text: `Δ: ${offset.toFixed(3)}m` };
};
// Aufräumen nach Abschluss/Verwerfen eines Griff- ODER Kanten-Drags (geteilt
// von onGripEnd/onGripCancel/onEdgeEnd/onEdgeCancel — nie beide gleichzeitig
// aktiv, ein gemeinsames Aufräumen ist daher unschädlich und einfacher).
const endEditing = () => {
setDraft(null);
edgeAppliedDeltaRef.current = null; // Kanten-Drag beendet → Anker zurücksetzen
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen
gripOriginRef.current = null; // Flucht-Bezugspunkt verwerfen
setGripEdit(null); // (c) Feld-Controller schließen
edgeDragInfoRef.current = null; // Kanten-Drag-Info verwerfen
setEdgeEdit(null); // Kanten-Feld-Controller schließen
};
const gripHandlers = {
onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
// Erster Move-Event nach dem Grab (gripDragInfoRef noch leer): die
// AKTUELLE — hier noch unveränderte — Position als Flucht-Bezugspunkt
// verankern (s. Kommentar an gripOriginRef).
if (gripDragInfoRef.current === null) gripOriginRef.current = grips[index];
// (c) Vertex-Drag mit aktivem Feld-Controller: `locks` ist ab dem
// ÖFFNEN (Tab) immer für BEIDE Felder befüllt (s. cycleGripEditField),
// die Maus wirkt also erst wieder, sobald ein Feld per Tab explizit
// gelöst wurde — kein separater „eingefroren"-Zustand mehr nötig.
const ge = gripEditRef.current;
gripDragInfoRef.current = { index, raw };
if (ge && (ge.locks.length != null || ge.locks.angle != null)) {
const pt = gripEditPoint(ge.anchor, ge.locks, raw);
applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
return;
}
// Mehrfachauswahl: das Element, dem DIESER Griff gehört (nicht zwingend
// das singuläre selectedDrawingId/selectedWallId) — von den eigenen
// Snap-Kandidaten ausschließen (sonst fängt der Punkt an sich selbst).
const owner = gripOwners[index];
const snapProject: Project = selectedRoomId
? { ...project, rooms: (project.rooms ?? []).filter((r) => r.id !== selectedRoomId) }
: selectedCeilingId
? { ...project, ceilings: (project.ceilings ?? []).filter((c) => c.id !== selectedCeilingId) }
: owner?.drawingId
? { ...project, drawings2d: project.drawings2d.filter((d) => d.id !== owner.drawingId) }
: owner?.wallId
? { ...project, walls: project.walls.filter((w) => w.id !== owner.wallId) }
: project;
// Bezugspunkt für Shift-Ortho: ein Nachbar-Griff DESSELBEN Elements
// (festes Ende), relativ zu dem die Bewegung auf H/V eingerastet wird.
const refIndex = neighborGripIndex(index);
const anchor = refIndex != null ? grips[refIndex] : null;
// Shift = Achsen-Lock: den gezogenen Punkt auf eine von mehreren
// Kandidaten-Achsen zwingen (welche gewinnt, s. closestAxisPoint) — Welt-
// H/V UND, bei einem Vielecks-Eckpunkt (zwei anliegende Kanten), zusätzlich
// die FLUCHT der beiden fixen Nachbarkanten in ihrer ursprünglichen
// Richtung. Nutzer-Wunsch: „wenn ein Punkt aus zwei Linien besteht, die
// nicht in 0/90° liegen, sollte Shift auch in DIESE Richtung snappen" —
// vorher war Shift stur auf Welt-H/V beschränkt, bei schrägen Anschlüssen
// also nutzlos für „entlang der bestehenden Kante weiterziehen". Der Lock
// dominiert (kein Snap), damit die Endkoordinate garantiert eine Achse
// trifft — identisch zum bisherigen Ortho-Verhalten.
if (mods.shift && anchor) {
const candidates: { point: Vec2; dir: Vec2 }[] = [
{ point: anchor, dir: { x: 1, y: 0 } },
{ point: anchor, dir: { x: 0, y: 1 } },
];
const origin = gripOriginRef.current;
if (origin) {
const { prev, next } = resolvePolygonNeighbors(index);
if (prev) candidates.push({ point: prev, dir: sub(origin, prev) });
if (next) candidates.push({ point: next, dir: sub(next, origin) });
}
const pt = closestAxisPoint(raw, candidates) ?? applyAngleConstraint(anchor, raw, 90);
applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: hudFor(index, pt) });
return;
}
// Snap gegen das Projekt OHNE das gerade gezogene Element (sonst fängt der
// Punkt an sich selbst); Bezug ist der feste Nachbar-Griff, nicht der
// laufende Werkzeug-Zustand (kein aktives Zeichenwerkzeug während des Drags).
const snapResult = computeSnap({
raw,
project: snapProject,
levelId,
visibleCodes,
settings: snap,
draftPoints: [],
lastPoint: anchor,
pxPerMeter,
shift: mods.shift,
ctrl: mods.ctrl,
});
const pt = snapResult?.point ?? raw;
applyGrip(index, pt);
setDraft({ preview: [], vertices: [], snap: snapResult, hud: hudFor(index, pt) });
},
onMoveBody: (
delta: Vec2,
target?: { wallId?: string; drawingId?: string },
mods?: ToolMods,
) => {
// Shift = Ortho: das Delta auf die dominante Achse (H/V) zwingen —
// dieselbe Regel wie beim freien Kanten-Zug (onEdgeMove) und beim
// Vertex-Griff (onGripMove). Bisher wirkte Shift beim Körper-Verschieben
// (Wand/2D-Element/Decke/Treppe/Raum als Ganzes) gar nicht (Nutzer-Report
// 2026-07-06) — `mods` kam von PlanView schlicht nicht bis hierher.
if (mods?.shift) {
if (Math.abs(delta.x) >= Math.abs(delta.y)) delta = { x: delta.x, y: 0 };
else delta = { x: 0, y: delta.y };
}
// Decke: ganzen Umriss verschieben (kein Multi-Koinzidenz-Handling nötig).
if (selectedCeilingId && !target) {
moveCeilingBy(selectedCeilingId, delta);
return;
}
// Treppe: ganzen Antritt (+ Zentrum) verschieben.
if (selectedStairId && !target) {
moveStairBy(selectedStairId, delta);
return;
}
// Raum: ganzen Umriss (+ Stempel-Anker) verschieben.
if (selectedRoomId && !target) {
moveRoomBy(selectedRoomId, delta);
return;
}
// Gegriffenes Element: bei Mehrfachauswahl das unter dem Cursor (target),
// sonst das einzeln selektierte. Wand UND 2D schließen sich aus.
const moveDrawingId = target ? target.drawingId ?? null : selectedDrawingId;
const moveWallId = target ? target.wallId ?? null : selectedWallId;
moveElementByOf(moveDrawingId, moveWallId, delta);
// Beim ersten Move-Event die Koinzidenz-Gruppe einmalig bestimmen (Snapshot
// VOR der ersten Mutation der Siblings). Danach inkrementell mitführen.
if (moveCoincidentRef.current === null) {
moveCoincidentRef.current = findMoveCoincident2D(moveDrawingId, moveWallId);
}
for (const c of moveCoincidentRef.current) {
c.pos = { x: c.pos.x + delta.x, y: c.pos.y + delta.y };
moveGripOf(c.drawingId, c.wallId, c.index, c.pos);
}
},
// Kanten-Griff ziehen. Zwei Verhalten (Zielpunkt jeweils ABSOLUT relativ
// zum ORIGINALEN Anker `edge.mid`, angewandt über applyEdgeToward — s. dort):
// • free (offene Polylinie/Linie, §Task B): das Segment folgt dem VOLLEN
// Cursor-Delta — beide Endpunkte wandern gleich, die geteilten Ecken ziehen
// die Nachbar-Segmente nach. Shift = Ortho (H/V) auf den Gesamt-Vektor.
// • sonst (rect/geschlossene Polylinie/Wand): NUR die Komponente senkrecht
// zur Kante (Seite verschiebt sich parallel nach außen/innen). Fängt an
// eingeschalteten Geometrie-Snaps (Endpunkt/Mittelpunkt/Schnittpunkt/…);
// ohne Geometrie-Snap wird der Offset wie bisher aufs Raster gerundet.
onEdgeMove: (edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
edgeDragInfoRef.current = { edge, raw };
const anchor = edge.mid; // Momentaufnahme vom Grab, ändert sich während des Drags nicht
const ee = edgeEditRef.current;
// Feld-Controller mit Lock aktiv: Zielpunkt aus Anker + gelocktem Wert.
// `locks` ist ab dem Öffnen (Tab) immer befüllt (s. cycleEdgeEditField),
// die Maus wirkt also erst wieder, sobald ein Feld per Tab gelöst wurde.
if (ee && (ee.locks.length != null || ee.locks.angle != null)) {
const pt = edgeEditPoint(edge, anchor, ee.locks, raw);
applyEdgeToward(edge, anchor, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
return;
}
if (edge.free) {
// Keine Geometrie-Snap-Projektion (keine feste Kanten-Normale, an die
// sich ein gefangener Punkt anlehnen könnte) — der Cursor bewegt den
// Zielpunkt direkt, nur Ortho/Raster wirken.
let pt = raw;
if (mods.shift) {
const dx = raw.x - anchor.x;
const dy = raw.y - anchor.y;
pt = Math.abs(dx) >= Math.abs(dy) ? { x: raw.x, y: anchor.y } : { x: anchor.x, y: raw.y };
}
const gs = snap.gridSize;
if (!mods.ctrl && snap.enabled && snap.grid && gs > 0) {
const dx = Math.round((pt.x - anchor.x) / gs) * gs;
const dy = Math.round((pt.y - anchor.y) / gs) * gs;
pt = { x: anchor.x + dx, y: anchor.y + dy };
}
applyEdgeToward(edge, anchor, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
return;
}
// Cursor zuerst durch die Snap-Engine schicken (Ctrl unterdrückt Snap
// wie überall sonst, siehe computeSnap). Ein GEOMETRIE-Snap (Endpunkt/
// Mittelpunkt/Schnittpunkt/…, nicht Raster/Ortho) liefert den gefangenen
// Punkt, der dann auf die Außennormale projiziert wird — so rastet die
// Kante an reale Geometrie statt nur ans Raster.
const snapResult = snapFor(raw, pxPerMeter, mods);
const isGeometrySnap = !!snapResult && snapResult.kind !== "grid" && snapResult.kind !== "ortho";
const cursor = isGeometrySnap ? snapResult!.point : raw;
// Senkrechte Projektion von (Cursor − Anker) auf die Außennormale.
const rel = sub(cursor, anchor);
let offset = rel.x * edge.normal.x + rel.y * edge.normal.y;
// Raster-Schritt (Snap) nur, wenn kein Geometrie-Snap gefangen wurde —
// sonst gewinnt der gefangene Punkt.
const step = snap.gridSize;
if (!isGeometrySnap && !mods.ctrl && snap.enabled && snap.grid && step > 0) {
offset = Math.round(offset / step) * step;
}
const pt = add(anchor, scale(edge.normal, offset));
applyEdgeToward(edge, anchor, pt);
setDraft({
preview: [],
vertices: [],
snap: isGeometrySnap ? snapResult : null,
hud: edgeHudFor(edge, anchor, pt),
});
},
onGripEnd: () => {
endEditing();
},
// Bewaffneten Punkt-Griff verwerfen (Escape): exakt auf `origin`
// zurücksetzen — ohne Snap/Ortho-Neuauswertung, ein reiner Revert — und
// danach wie onGripEnd aufräumen.
onGripCancel: (index: number, origin: Vec2) => {
applyGrip(index, origin);
endEditing();
},
onEdgeEnd: () => {
endEditing();
},
// Bewaffneten Kanten-Griff verwerfen (Escape): Ziel = Anker selbst (Delta
// 0) — applyEdgeToward macht dadurch die komplette bisherige Verschiebung
// in einem Schritt rückgängig (negatives Delta zum zuletzt angewandten).
onEdgeCancel: (edge: EdgeGrip, anchor: Vec2) => {
applyEdgeToward(edge, anchor, anchor);
endEditing();
},
};
return {
grips,
gripHandleLines,
edgeGrips,
gripHandlers,
gripEdit,
setGripEdit,
gripEditFields,
cycleGripEditField,
submitGripEditValue,
closeGripEditField,
edgeEdit,
setEdgeEdit,
edgeEditFields,
cycleEdgeEditField,
submitEdgeEditValue,
closeEdgeEditField,
// Für useToolNumberShortcuts: solange ein Punkt- ODER Kanten-Griff
// bewaffnet ist, dürfen Zifferntasten NICHT als Werkzeug-Wechsel
// interpretiert werden (auch nicht, wenn das Befehlsfeld fokussiert und
// leer ist — die Zeichen-Engine selbst läuft ja nicht, daher greift
// dieser Wächter sonst nicht).
gripDragInfoRef,
edgeDragInfoRef,
};
}