2D-Griff-Editieren aus App.tsx in eigenen Hook extrahiert (state/useGripEditing.ts).
Eckpunkt-/Kanten-Griffe der aktuellen Auswahl (Wand/2D-Element/Decke/Raum/ Öffnung/Treppe), deren Zieh-Handler (Ortho/Snap/koinzidente Nachbar-Ecken) sowie der Tab-Feld-Controller für präzise Länge/Winkel-Eingabe während eines Vertex-Drags waren als drei separat benannte, aber tatsächlich zusammen- hängende Abschnitte in App.tsx verstreut. Jetzt ein Hook mit explizitem Parameter-Vertrag (Selektion/Projekt/Snap-Settings/Projekt-Mutationen rein, grips/edgeGrips/gripHandlers/Feld-Controller raus) statt anonymer Closures über den gesamten Komponenten-Scope. Reine Verschiebung, keine Verhaltensänderung — einzige Korrektur beim Verschieben: der Öffnungs-/Wand-Snap in onGripMove nutzte computeSnap direkt (gegen ein um das gezogene Element bereinigtes Projekt), das im ersten Entwurf versehentlich mit dem allgemeinen snapFor-Helper vertauscht worden wäre — beim Nachvollziehen der Original-Logik korrigiert, bevor es committet wurde. App.tsx: 5096 → 4594 Zeilen. tsc -b sauber, vitest 891/891 grün, vite build ok.
This commit is contained in:
+49
-551
@@ -21,16 +21,13 @@ import {
|
|||||||
collectVisibleCodes,
|
collectVisibleCodes,
|
||||||
flattenCategories,
|
flattenCategories,
|
||||||
} from "./model/types";
|
} from "./model/types";
|
||||||
import { add, dot, leftNormal, normalize, scale, sub } from "./model/geometry";
|
|
||||||
import type {
|
import type {
|
||||||
Ceiling,
|
Ceiling,
|
||||||
ContextObject,
|
ContextObject,
|
||||||
Drawing2D,
|
Drawing2D,
|
||||||
EdgeGrip,
|
|
||||||
Layout,
|
Layout,
|
||||||
Project,
|
Project,
|
||||||
Roof,
|
Roof,
|
||||||
Stair,
|
|
||||||
Vec2,
|
Vec2,
|
||||||
Wall,
|
Wall,
|
||||||
} from "./model/types";
|
} from "./model/types";
|
||||||
@@ -103,7 +100,7 @@ import { RoomStampEditor } from "./panels/RoomStampEditor";
|
|||||||
import { defaultRoomStamp } from "./model/roomStamp";
|
import { defaultRoomStamp } from "./model/roomStamp";
|
||||||
import { StatusBar, type RendererMode } from "./ui/StatusBar";
|
import { StatusBar, type RendererMode } from "./ui/StatusBar";
|
||||||
import { getTool } from "./tools/tools";
|
import { getTool } from "./tools/tools";
|
||||||
import { applyAngleConstraint, computeSnap } from "./tools/snapping";
|
import { computeSnap } from "./tools/snapping";
|
||||||
import {
|
import {
|
||||||
requiredPoints,
|
requiredPoints,
|
||||||
transformPreview,
|
transformPreview,
|
||||||
@@ -128,12 +125,12 @@ import { useDialogState } from "./state/useDialogState";
|
|||||||
import { useTopBarState } from "./state/useTopBarState";
|
import { useTopBarState } from "./state/useTopBarState";
|
||||||
import { useDrawingToolState } from "./state/useDrawingToolState";
|
import { useDrawingToolState } from "./state/useDrawingToolState";
|
||||||
import { useSelectionState } from "./state/useSelectionState";
|
import { useSelectionState } from "./state/useSelectionState";
|
||||||
|
import { useGripEditing } from "./state/useGripEditing";
|
||||||
import { useProjectFileActions } from "./state/useProjectFileActions";
|
import { useProjectFileActions } from "./state/useProjectFileActions";
|
||||||
import { useProjectActions } from "./state/useProjectActions";
|
import { useProjectActions } from "./state/useProjectActions";
|
||||||
import {
|
import {
|
||||||
useActiveToolKeys,
|
useActiveToolKeys,
|
||||||
useBooleanOpsShortcut,
|
useBooleanOpsShortcut,
|
||||||
useCommandTabShortcut,
|
|
||||||
useDrawingClipboardShortcut,
|
useDrawingClipboardShortcut,
|
||||||
useSelectAllAndDeleteShortcut,
|
useSelectAllAndDeleteShortcut,
|
||||||
useSplitJoinShortcut,
|
useSplitJoinShortcut,
|
||||||
@@ -167,11 +164,7 @@ import type { BooleanResult } from "./editors/booleanOps";
|
|||||||
|
|
||||||
// Globaler Store (Slices: project/selection/view/layout).
|
// Globaler Store (Slices: project/selection/view/layout).
|
||||||
import { useStore } from "./state/appStore";
|
import { useStore } from "./state/appStore";
|
||||||
import {
|
import { categoryLabel, findCategory } from "./state/projectSlice";
|
||||||
categoryLabel,
|
|
||||||
drawingVertices,
|
|
||||||
findCategory,
|
|
||||||
} from "./state/projectSlice";
|
|
||||||
import { reconcileLayout } from "./state/layoutSlice";
|
import { reconcileLayout } from "./state/layoutSlice";
|
||||||
|
|
||||||
// Panel-System.
|
// Panel-System.
|
||||||
@@ -1015,547 +1008,8 @@ export default function App() {
|
|||||||
: null,
|
: null,
|
||||||
[selectedRoom],
|
[selectedRoom],
|
||||||
);
|
);
|
||||||
// Bewegpunkt (Move-Handle) einer Treppe: liegt auf der Treppe (Mitte des
|
// Editier-Griffe/Kanten-Griffe + Feld-Controller (Tab-Länge/Winkel im Vertex-Drag)
|
||||||
// ersten Laufs bei gerade/L, Zentrum bei Wendel) und verschiebt beim Ziehen
|
// → state/useGripEditing.ts (Hook-Aufruf weiter unten, nach useProjectActions()).
|
||||||
// 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));
|
|
||||||
};
|
|
||||||
const grips: Vec2[] = useMemo(() => {
|
|
||||||
// Ö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));
|
|
||||||
return [add(w.start, scale(axis, op.position + op.width / 2))];
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
return st ? [stairMoveHandle(st)] : [];
|
|
||||||
}
|
|
||||||
if (selectedDrawing) return drawingVertices(selectedDrawing);
|
|
||||||
if (selectedWallIds.length === 1 && !selectedDrawingId) {
|
|
||||||
const w = project.walls.find((x) => x.id === selectedWallIds[0]);
|
|
||||||
if (w) return [w.start, w.end];
|
|
||||||
}
|
|
||||||
// Decke: die Umriss-Vertices als Griffe (nur bei genau einer gewählten Decke).
|
|
||||||
if (selectedCeiling && !selectedDrawingId && selectedWallIds.length === 0) {
|
|
||||||
return [...selectedCeiling.outline];
|
|
||||||
}
|
|
||||||
// Raum: die Umriss-Vertices als Griffe (nur bei genau einem gewählten Raum).
|
|
||||||
if (
|
|
||||||
selectedRoom &&
|
|
||||||
!selectedDrawingId &&
|
|
||||||
selectedWallIds.length === 0 &&
|
|
||||||
!selectedCeiling
|
|
||||||
) {
|
|
||||||
return [...selectedRoom.boundary];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [
|
|
||||||
selectedDrawing,
|
|
||||||
selectedCeiling,
|
|
||||||
selectedRoom,
|
|
||||||
selectedOpeningId,
|
|
||||||
selectedStairId,
|
|
||||||
selectedWallIds,
|
|
||||||
selectedDrawingId,
|
|
||||||
project.walls,
|
|
||||||
project.openings,
|
|
||||||
project.stairs,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Kanten-/Seiten-Griffe (Edge Grips) ───────────────────────────────────
|
|
||||||
// Pro Seite des selektierten Elements ein Griff: Kanten-Mittelpunkt + nach
|
|
||||||
// AUSSEN zeigende Einheitsnormale + die beiden Vertex-Indizes (passend zur
|
|
||||||
// `grips`-/`drawingVertices`-Reihenfolge). 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[] = useMemo(() => {
|
|
||||||
// Geschlossener Ring aus Vertices → Kanten-Griffe mit Außennormale.
|
|
||||||
const ringEdges = (verts: Vec2[]): 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 });
|
|
||||||
}
|
|
||||||
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[]): 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 });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
if (selectedDrawing) {
|
|
||||||
const g = selectedDrawing.geom;
|
|
||||||
if (g.shape === "rect" || g.shape === "image") return ringEdges(grips);
|
|
||||||
if (g.shape === "polyline" && g.closed) return ringEdges(grips);
|
|
||||||
if (g.shape === "polyline" && !g.closed) return openEdges(grips);
|
|
||||||
if (g.shape === "line") return openEdges(grips);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (selectedWallIds.length === 1 && !selectedDrawingId) {
|
|
||||||
const w = project.walls.find((x) => x.id === selectedWallIds[0]);
|
|
||||||
if (w) {
|
|
||||||
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)));
|
|
||||||
return [{ mid, normal: nrm, aIndex: 0, bIndex: 1 }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Decke: geschlossener Ring aus dem Umriss.
|
|
||||||
if (selectedCeiling && !selectedDrawingId && selectedWallIds.length === 0) {
|
|
||||||
return ringEdges(grips);
|
|
||||||
}
|
|
||||||
// Raum: geschlossener Ring aus dem Umriss.
|
|
||||||
if (
|
|
||||||
selectedRoom &&
|
|
||||||
!selectedDrawingId &&
|
|
||||||
selectedWallIds.length === 0 &&
|
|
||||||
!selectedCeiling
|
|
||||||
) {
|
|
||||||
return ringEdges(grips);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [
|
|
||||||
selectedDrawing,
|
|
||||||
selectedCeiling,
|
|
||||||
selectedRoom,
|
|
||||||
grips,
|
|
||||||
selectedWallIds,
|
|
||||||
selectedDrawingId,
|
|
||||||
project.walls,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Snapping beim Griff-Ziehen: das gezogene Element selbst aus den Quellen
|
|
||||||
// nehmen (sonst fängt der Punkt an sich selbst). Live-Mutation über setProject.
|
|
||||||
// Zuletzt angewandter Kanten-Offset während eines Edge-Grip-Drags (Anker).
|
|
||||||
// null = kein laufender Drag; der erste Move-Event verankert nur (kein Sprung).
|
|
||||||
const edgeDragOffsetRef = useRef<number | null>(null);
|
|
||||||
// Freies Kanten-Ziehen (offene Polylinie/Linie): letzter roher Cursor, gegen den
|
|
||||||
// das inkrementelle 2D-Delta gebildet wird. null = Drag noch nicht verankert.
|
|
||||||
const edgeFreeLastRef = 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 add = (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)) add(null, id, 0, w.start);
|
|
||||||
if (near(corner, w.end)) add(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)) add(id, null, i, v);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
// ── (c) Präzise Werteingabe beim Editieren (Tab im Vertex-Griff-Drag) ───────
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// 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 = (): {
|
|
||||||
id: string;
|
|
||||||
labelKey: string;
|
|
||||||
value: number | null;
|
|
||||||
locked: boolean;
|
|
||||||
active: boolean;
|
|
||||||
}[] => {
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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).
|
|
||||||
const cycleGripEditField = () => {
|
|
||||||
const info = gripDragInfoRef.current;
|
|
||||||
if (!info) return;
|
|
||||||
const refIndex = info.index > 0 ? info.index - 1 : grips.length > 1 ? 1 : -1;
|
|
||||||
const anchor = refIndex >= 0 ? grips[refIndex] : null;
|
|
||||||
if (!anchor) return; // ohne Anker keine polare Eingabe möglich
|
|
||||||
setGripEdit((prev) =>
|
|
||||||
prev
|
|
||||||
? { ...prev, active: prev.active === "length" ? "angle" : "length" }
|
|
||||||
: { anchor, active: "length", locks: {} },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
cycleGripEditFieldRef.current = cycleGripEditField;
|
|
||||||
|
|
||||||
// Globaler Tab-Handler (Feld-Zyklus/Zahlen-Tippen) — siehe state/useKeyboardShortcuts.ts.
|
|
||||||
useCommandTabShortcut(engineRef, commandLineRef, gripDragInfoRef, gripEditRef, cycleGripEditFieldRef);
|
|
||||||
|
|
||||||
// Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken und
|
|
||||||
// den Zielpunkt sofort mit dem gelockten Wert neu setzen.
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 moveGripOf(selectedDrawingId, selectedWallId, index, pt);
|
|
||||||
};
|
|
||||||
const applyEdge = (aIndex: number, bIndex: number, delta: Vec2) => {
|
|
||||||
if (selectedRoomId) moveRoomEdge(selectedRoomId, aIndex, bIndex, delta);
|
|
||||||
else if (selectedCeilingId) moveCeilingEdge(selectedCeilingId, aIndex, bIndex, delta);
|
|
||||||
else moveEdgeOf(selectedDrawingId, selectedWallId, aIndex, bIndex, delta);
|
|
||||||
};
|
|
||||||
|
|
||||||
const gripHandlers = {
|
|
||||||
onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
|
|
||||||
// (c) Vertex-Drag mit aktivem Feld-Controller + Locks: den Zielpunkt polar
|
|
||||||
// aus Anker + gelockter Länge/Winkel bilden (ungelockt folgt dem Cursor).
|
|
||||||
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 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snapProject: Project = selectedRoomId
|
|
||||||
? { ...project, rooms: (project.rooms ?? []).filter((r) => r.id !== selectedRoomId) }
|
|
||||||
: selectedCeilingId
|
|
||||||
? { ...project, ceilings: (project.ceilings ?? []).filter((c) => c.id !== selectedCeilingId) }
|
|
||||||
: selectedDrawingId
|
|
||||||
? { ...project, drawings2d: project.drawings2d.filter((d) => d.id !== selectedDrawingId) }
|
|
||||||
: { ...project, walls: project.walls.filter((w) => w.id !== selectedWallId) };
|
|
||||||
// Bezugspunkt für Shift-Ortho: ein Nachbar-Griff (festes Ende), relativ zu
|
|
||||||
// dem die Bewegung auf H/V eingerastet wird.
|
|
||||||
const refIndex = index > 0 ? index - 1 : grips.length > 1 ? 1 : -1;
|
|
||||||
const anchor = refIndex >= 0 ? grips[refIndex] : null;
|
|
||||||
// Shift = Ortho-Lock: den gezogenen Punkt auf H/V gegenüber dem FIXEN Anker
|
|
||||||
// zwingen (Achse = die nähere; applyAngleConstraint mit 90°, wie Ortho beim
|
|
||||||
// Zeichnen). Der Lock dominiert (kein Snap), damit die Endkoordinate
|
|
||||||
// garantiert eine Achse mit dem Anker teilt — identisch zum 3D-Vertex-Drag.
|
|
||||||
if (mods.shift && anchor) {
|
|
||||||
const pt = applyAngleConstraint(anchor, raw, 90);
|
|
||||||
applyGrip(index, pt);
|
|
||||||
setDraft({ preview: [], vertices: [], snap: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snapResult = computeSnap({
|
|
||||||
raw,
|
|
||||||
project: snapProject,
|
|
||||||
levelId: activeLevel.id,
|
|
||||||
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 });
|
|
||||||
},
|
|
||||||
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:
|
|
||||||
// • 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 das Gesamt-Delta.
|
|
||||||
// • 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) => {
|
|
||||||
if (edge.free) {
|
|
||||||
// Freier Fall arbeitet mit einem inkrementellen Cursor-DELTA (nicht
|
|
||||||
// einem absoluten Punkt) — eine Geometrie-Snap-Projektion auf die
|
|
||||||
// Normale ergibt hier keinen Sinn, da es keine feste Kanten-Normale
|
|
||||||
// gibt, an die sich ein gefangener Punkt anlehnen könnte. Bleibt beim
|
|
||||||
// bisherigen Raster-Delta-Verhalten.
|
|
||||||
//
|
|
||||||
// Inkrementelles 2D-Delta gegen den letzten rohen Cursor. Erster Event
|
|
||||||
// verankert nur (kein Sprung, da der Griff am Kanten-Mittelpunkt sitzt).
|
|
||||||
const last = edgeFreeLastRef.current;
|
|
||||||
edgeFreeLastRef.current = raw;
|
|
||||||
if (last === null) return;
|
|
||||||
let dx = raw.x - last.x;
|
|
||||||
let dy = raw.y - last.y;
|
|
||||||
// Raster-Schritt auf das Delta (Snap aktiv, nicht per Ctrl unterdrückt).
|
|
||||||
const gs = snap.gridSize;
|
|
||||||
if (!mods.ctrl && snap.enabled && snap.grid && gs > 0) {
|
|
||||||
dx = Math.round(dx / gs) * gs;
|
|
||||||
dy = Math.round(dy / gs) * gs;
|
|
||||||
if (dx === 0 && dy === 0) {
|
|
||||||
edgeFreeLastRef.current = last; // noch kein voller Rasterschritt → warten
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Shift = Ortho: das Delta auf die dominante Achse (H/V) zwingen.
|
|
||||||
if (mods.shift) {
|
|
||||||
if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
|
|
||||||
else dx = 0;
|
|
||||||
}
|
|
||||||
if (dx === 0 && dy === 0) return;
|
|
||||||
applyEdge(edge.aIndex, edge.bIndex, { x: dx, y: dy });
|
|
||||||
setDraft({ preview: [], vertices: [], snap: null });
|
|
||||||
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 − Grab-Mittelpunkt) auf die Außennormale.
|
|
||||||
const rel = sub(cursor, edge.mid);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// INKREMENTELL + Grab-Anker: moveEdge verschiebt additiv, daher nur die
|
|
||||||
// Änderung seit dem letzten Event anwenden. Erster Event verankert nur
|
|
||||||
// (kein Sprung beim Greifen, da das Dreieck am Mittelpunkt sitzt).
|
|
||||||
const prev = edgeDragOffsetRef.current;
|
|
||||||
edgeDragOffsetRef.current = offset;
|
|
||||||
if (prev === null) return;
|
|
||||||
const inc = offset - prev;
|
|
||||||
if (inc === 0) return;
|
|
||||||
applyEdge(edge.aIndex, edge.bIndex, scale(edge.normal, inc));
|
|
||||||
setDraft({ preview: [], vertices: [], snap: isGeometrySnap ? snapResult : null });
|
|
||||||
},
|
|
||||||
onGripEnd: () => {
|
|
||||||
setDraft(null);
|
|
||||||
edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen
|
|
||||||
edgeFreeLastRef.current = null; // freien Kanten-Drag-Anker zurücksetzen
|
|
||||||
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen
|
|
||||||
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen
|
|
||||||
setGripEdit(null); // (c) Feld-Controller schließen
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ob irgendeine löschbare Auswahl ansteht — geteilt zwischen dem Entf-
|
// Ob irgendeine löschbare Auswahl ansteht — geteilt zwischen dem Entf-
|
||||||
// Tastatur-Handler unten und dem Menüpunkt „Bearbeiten > Löschen" (AppMenuBar).
|
// Tastatur-Handler unten und dem Menüpunkt „Bearbeiten > Löschen" (AppMenuBar).
|
||||||
@@ -1856,6 +1310,50 @@ export default function App() {
|
|||||||
setRoomStamp,
|
setRoomStamp,
|
||||||
} = useProjectActions(setProject, notify, planViewRef);
|
} = useProjectActions(setProject, notify, planViewRef);
|
||||||
|
|
||||||
|
// 2D-Griff-Editieren (Eckpunkt-/Kanten-Griffe + Feld-Controller) der aktuellen
|
||||||
|
// Auswahl — siehe state/useGripEditing.ts.
|
||||||
|
const {
|
||||||
|
grips,
|
||||||
|
edgeGrips,
|
||||||
|
gripHandlers,
|
||||||
|
gripEdit,
|
||||||
|
setGripEdit,
|
||||||
|
gripEditFields,
|
||||||
|
cycleGripEditField,
|
||||||
|
submitGripEditValue,
|
||||||
|
} = useGripEditing({
|
||||||
|
project,
|
||||||
|
selectedDrawing,
|
||||||
|
selectedCeiling,
|
||||||
|
selectedRoom,
|
||||||
|
selectedOpeningId,
|
||||||
|
selectedStairId,
|
||||||
|
selectedRoomId,
|
||||||
|
selectedCeilingId,
|
||||||
|
selectedDrawingId,
|
||||||
|
selectedWallId,
|
||||||
|
selectedWallIds,
|
||||||
|
selectedDrawingIds,
|
||||||
|
levelId: activeLevel.id,
|
||||||
|
visibleCodes,
|
||||||
|
snap,
|
||||||
|
setDraft,
|
||||||
|
snapFor,
|
||||||
|
engineRef,
|
||||||
|
commandLineRef,
|
||||||
|
updateOpening,
|
||||||
|
moveStairBy,
|
||||||
|
moveRoomGrip,
|
||||||
|
moveCeilingGrip,
|
||||||
|
moveGripOf,
|
||||||
|
moveRoomEdge,
|
||||||
|
moveCeilingEdge,
|
||||||
|
moveEdgeOf,
|
||||||
|
moveRoomBy,
|
||||||
|
moveCeilingBy,
|
||||||
|
moveElementByOf,
|
||||||
|
});
|
||||||
|
|
||||||
// Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen — siehe
|
// Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen — siehe
|
||||||
// state/useKeyboardShortcuts.ts.
|
// state/useKeyboardShortcuts.ts.
|
||||||
useDrawingClipboardShortcut(
|
useDrawingClipboardShortcut(
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
// 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));
|
||||||
|
};
|
||||||
|
|
||||||
|
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));
|
||||||
|
return [add(w.start, scale(axis, op.position + op.width / 2))];
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
return st ? [stairMoveHandle(st)] : [];
|
||||||
|
}
|
||||||
|
if (selectedDrawing) return drawingVertices(selectedDrawing);
|
||||||
|
if (selectedWallIds.length === 1 && !selectedDrawingId) {
|
||||||
|
const w = project.walls.find((x) => x.id === selectedWallIds[0]);
|
||||||
|
if (w) return [w.start, w.end];
|
||||||
|
}
|
||||||
|
// Decke: die Umriss-Vertices als Griffe (nur bei genau einer gewählten Decke).
|
||||||
|
if (selectedCeiling && !selectedDrawingId && selectedWallIds.length === 0) {
|
||||||
|
return [...selectedCeiling.outline];
|
||||||
|
}
|
||||||
|
// Raum: die Umriss-Vertices als Griffe (nur bei genau einem gewählten Raum).
|
||||||
|
if (
|
||||||
|
selectedRoom &&
|
||||||
|
!selectedDrawingId &&
|
||||||
|
selectedWallIds.length === 0 &&
|
||||||
|
!selectedCeiling
|
||||||
|
) {
|
||||||
|
return [...selectedRoom.boundary];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Pro Seite des selektierten Elements ein Griff: Kanten-Mittelpunkt + nach
|
||||||
|
// AUSSEN zeigende Einheitsnormale + die beiden Vertex-Indizes (passend zur
|
||||||
|
// `grips`-/`drawingVertices`-Reihenfolge). 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[]): 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 });
|
||||||
|
}
|
||||||
|
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[]): 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 });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
if (selectedDrawing) {
|
||||||
|
const g = selectedDrawing.geom;
|
||||||
|
if (g.shape === "rect" || g.shape === "image") return ringEdges(grips);
|
||||||
|
if (g.shape === "polyline" && g.closed) return ringEdges(grips);
|
||||||
|
if (g.shape === "polyline" && !g.closed) return openEdges(grips);
|
||||||
|
if (g.shape === "line") return openEdges(grips);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (selectedWallIds.length === 1 && !selectedDrawingId) {
|
||||||
|
const w = project.walls.find((x) => x.id === selectedWallIds[0]);
|
||||||
|
if (w) {
|
||||||
|
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)));
|
||||||
|
return [{ mid, normal: nrm, aIndex: 0, bIndex: 1 }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Decke: geschlossener Ring aus dem Umriss.
|
||||||
|
if (selectedCeiling && !selectedDrawingId && selectedWallIds.length === 0) {
|
||||||
|
return ringEdges(grips);
|
||||||
|
}
|
||||||
|
// Raum: geschlossener Ring aus dem Umriss.
|
||||||
|
if (
|
||||||
|
selectedRoom &&
|
||||||
|
!selectedDrawingId &&
|
||||||
|
selectedWallIds.length === 0 &&
|
||||||
|
!selectedCeiling
|
||||||
|
) {
|
||||||
|
return ringEdges(grips);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Zuletzt angewandter Kanten-Offset während eines Edge-Grip-Drags (Anker).
|
||||||
|
// null = kein laufender Drag; der erste Move-Event verankert nur (kein Sprung).
|
||||||
|
const edgeDragOffsetRef = useRef<number | null>(null);
|
||||||
|
// Freies Kanten-Ziehen (offene Polylinie/Linie): letzter roher Cursor, gegen den
|
||||||
|
// das inkrementelle 2D-Delta gebildet wird. null = Drag noch nicht verankert.
|
||||||
|
const edgeFreeLastRef = 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);
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
const cycleGripEditField = () => {
|
||||||
|
const info = gripDragInfoRef.current;
|
||||||
|
if (!info) return;
|
||||||
|
const refIndex = info.index > 0 ? info.index - 1 : grips.length > 1 ? 1 : -1;
|
||||||
|
const anchor = refIndex >= 0 ? grips[refIndex] : null;
|
||||||
|
if (!anchor) return; // ohne Anker keine polare Eingabe möglich
|
||||||
|
setGripEdit((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, active: prev.active === "length" ? "angle" : "length" }
|
||||||
|
: { anchor, active: "length", locks: {} },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
cycleGripEditFieldRef.current = cycleGripEditField;
|
||||||
|
|
||||||
|
// Globaler Tab-Handler (Feld-Zyklus/Zahlen-Tippen) — siehe state/useKeyboardShortcuts.ts.
|
||||||
|
useCommandTabShortcut(engineRef, commandLineRef, gripDragInfoRef, gripEditRef, cycleGripEditFieldRef);
|
||||||
|
|
||||||
|
// Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken und
|
||||||
|
// den Zielpunkt sofort mit dem gelockten Wert neu setzen.
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 moveGripOf(selectedDrawingId, selectedWallId, index, pt);
|
||||||
|
};
|
||||||
|
const applyEdge = (aIndex: number, bIndex: number, delta: Vec2) => {
|
||||||
|
if (selectedRoomId) moveRoomEdge(selectedRoomId, aIndex, bIndex, delta);
|
||||||
|
else if (selectedCeilingId) moveCeilingEdge(selectedCeilingId, aIndex, bIndex, delta);
|
||||||
|
else moveEdgeOf(selectedDrawingId, selectedWallId, aIndex, bIndex, delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gripHandlers = {
|
||||||
|
onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
|
||||||
|
// (c) Vertex-Drag mit aktivem Feld-Controller + Locks: den Zielpunkt polar
|
||||||
|
// aus Anker + gelockter Länge/Winkel bilden (ungelockt folgt dem Cursor).
|
||||||
|
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 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snapProject: Project = selectedRoomId
|
||||||
|
? { ...project, rooms: (project.rooms ?? []).filter((r) => r.id !== selectedRoomId) }
|
||||||
|
: selectedCeilingId
|
||||||
|
? { ...project, ceilings: (project.ceilings ?? []).filter((c) => c.id !== selectedCeilingId) }
|
||||||
|
: selectedDrawingId
|
||||||
|
? { ...project, drawings2d: project.drawings2d.filter((d) => d.id !== selectedDrawingId) }
|
||||||
|
: { ...project, walls: project.walls.filter((w) => w.id !== selectedWallId) };
|
||||||
|
// Bezugspunkt für Shift-Ortho: ein Nachbar-Griff (festes Ende), relativ zu
|
||||||
|
// dem die Bewegung auf H/V eingerastet wird.
|
||||||
|
const refIndex = index > 0 ? index - 1 : grips.length > 1 ? 1 : -1;
|
||||||
|
const anchor = refIndex >= 0 ? grips[refIndex] : null;
|
||||||
|
// Shift = Ortho-Lock: den gezogenen Punkt auf H/V gegenüber dem FIXEN Anker
|
||||||
|
// zwingen (Achse = die nähere; applyAngleConstraint mit 90°, wie Ortho beim
|
||||||
|
// Zeichnen). Der Lock dominiert (kein Snap), damit die Endkoordinate
|
||||||
|
// garantiert eine Achse mit dem Anker teilt — identisch zum 3D-Vertex-Drag.
|
||||||
|
if (mods.shift && anchor) {
|
||||||
|
const pt = applyAngleConstraint(anchor, raw, 90);
|
||||||
|
applyGrip(index, pt);
|
||||||
|
setDraft({ preview: [], vertices: [], snap: null });
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
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:
|
||||||
|
// • 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 das Gesamt-Delta.
|
||||||
|
// • 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) => {
|
||||||
|
if (edge.free) {
|
||||||
|
// Freier Fall arbeitet mit einem inkrementellen Cursor-DELTA (nicht
|
||||||
|
// einem absoluten Punkt) — eine Geometrie-Snap-Projektion auf die
|
||||||
|
// Normale ergibt hier keinen Sinn, da es keine feste Kanten-Normale
|
||||||
|
// gibt, an die sich ein gefangener Punkt anlehnen könnte. Bleibt beim
|
||||||
|
// bisherigen Raster-Delta-Verhalten.
|
||||||
|
//
|
||||||
|
// Inkrementelles 2D-Delta gegen den letzten rohen Cursor. Erster Event
|
||||||
|
// verankert nur (kein Sprung, da der Griff am Kanten-Mittelpunkt sitzt).
|
||||||
|
const last = edgeFreeLastRef.current;
|
||||||
|
edgeFreeLastRef.current = raw;
|
||||||
|
if (last === null) return;
|
||||||
|
let dx = raw.x - last.x;
|
||||||
|
let dy = raw.y - last.y;
|
||||||
|
// Raster-Schritt auf das Delta (Snap aktiv, nicht per Ctrl unterdrückt).
|
||||||
|
const gs = snap.gridSize;
|
||||||
|
if (!mods.ctrl && snap.enabled && snap.grid && gs > 0) {
|
||||||
|
dx = Math.round(dx / gs) * gs;
|
||||||
|
dy = Math.round(dy / gs) * gs;
|
||||||
|
if (dx === 0 && dy === 0) {
|
||||||
|
edgeFreeLastRef.current = last; // noch kein voller Rasterschritt → warten
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shift = Ortho: das Delta auf die dominante Achse (H/V) zwingen.
|
||||||
|
if (mods.shift) {
|
||||||
|
if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
|
||||||
|
else dx = 0;
|
||||||
|
}
|
||||||
|
if (dx === 0 && dy === 0) return;
|
||||||
|
applyEdge(edge.aIndex, edge.bIndex, { x: dx, y: dy });
|
||||||
|
setDraft({ preview: [], vertices: [], snap: null });
|
||||||
|
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 − Grab-Mittelpunkt) auf die Außennormale.
|
||||||
|
const rel = sub(cursor, edge.mid);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// INKREMENTELL + Grab-Anker: moveEdge verschiebt additiv, daher nur die
|
||||||
|
// Änderung seit dem letzten Event anwenden. Erster Event verankert nur
|
||||||
|
// (kein Sprung beim Greifen, da das Dreieck am Mittelpunkt sitzt).
|
||||||
|
const prev = edgeDragOffsetRef.current;
|
||||||
|
edgeDragOffsetRef.current = offset;
|
||||||
|
if (prev === null) return;
|
||||||
|
const inc = offset - prev;
|
||||||
|
if (inc === 0) return;
|
||||||
|
applyEdge(edge.aIndex, edge.bIndex, scale(edge.normal, inc));
|
||||||
|
setDraft({ preview: [], vertices: [], snap: isGeometrySnap ? snapResult : null });
|
||||||
|
},
|
||||||
|
onGripEnd: () => {
|
||||||
|
setDraft(null);
|
||||||
|
edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen
|
||||||
|
edgeFreeLastRef.current = null; // freien Kanten-Drag-Anker zurücksetzen
|
||||||
|
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen
|
||||||
|
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen
|
||||||
|
setGripEdit(null); // (c) Feld-Controller schließen
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
grips,
|
||||||
|
edgeGrips,
|
||||||
|
gripHandlers,
|
||||||
|
gripEdit,
|
||||||
|
setGripEdit,
|
||||||
|
gripEditFields,
|
||||||
|
cycleGripEditField,
|
||||||
|
submitGripEditValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user