From 31a5b64836bf934575a05c32e03c4ed744fef838 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 21 Aug 2026 23:56:48 +0200 Subject: [PATCH] 2D: Kanten-Griffe (Seite parallel verschieben) bekommen dieselbe Bearbeitung wie Punkt-Griffe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nutzer-Wunsch: "das Ganze soll auch an den Seiten bei diesen Dreiecken gehen bei denen man eine Fläche parallel verschieben kann" -- also dieselbe Klick-loslassen-tippen-bestätigen-Geste, dieselbe Cursor-HUD- Anzeige und dieselbe getippte Werteingabe wie bei den Punkt-Griffen aus den letzten Commits, jetzt auch für die Kanten-Anfasser. useGripEditing.ts: onEdgeMove rechnet jetzt mit einem ABSOLUTEN Zielpunkt relativ zum ORIGINALEN Kanten-Mittelpunkt (Momentaufnahme vom Grab), nicht mehr rein inkrementell pro Frame -- eine neue edgeAppliedDeltaRef verankert, wie viel Delta bereits angewandt wurde, und wendet jeweils nur die Differenz zum neuen Ziel an (moveEdgeOf/ moveRoomEdge/moveCeilingEdge sind additiv). Neuer edgeEdit-Feld- Controller (Pendant zu gripEdit): geführte Kanten (rect/geschlossene Polylinie/Wand) haben nur EIN Feld ("Länge" = vorzeichenbehafteter Abstand entlang der festen Normale, kein Winkel), freie Kanten (offene Polylinie/Linie) wie ein Punkt-Griff Länge+Winkel relativ zum Ursprung. Cursor-HUD zeigt bei geführten Kanten "Δ: …m" als Freitext (das L/W-Schema passt nicht für ein Einzelfeld), bei freien Kanten L/W wie beim Punkt-Griff. onEdgeCancel verwirft exakt auf den Ursprung zurück (Ziel = Anker selbst). PlanView.tsx: Kanten-Griff-Grab läuft jetzt ohne Pointer-Capture (dasselbe "bewaffnet nach Loslassen"-Verhalten wie beim Punkt-Griff), Abschluss per erneutem Klick/Enter, Verwerfen per Escape. Neue confirmEdgeEdit()-Methode am Imperativ-Handle (Pendant zu confirmGripEdit) fürs Enter-mit-leerem-Text aus der Befehlszeile. App.tsx: CommandLine-Verdrahtung (active/promptKey/fields/onSubmit/ onCycleField/onCancel) um den edgeEdit-Zweig erweitert, neuer i18n- Prompt "cmd.edit.edge" (de/en). useToolNumberShortcuts blockt Zifferntasten jetzt auch während eines bewaffneten Kanten-Griffs (gleicher Bug wie zuvor bei Punkt-Griffen, präventiv mitgefixt). useCommandTabShortcut (useKeyboardShortcuts.ts) generalisiert: Tab zykelt/öffnet jetzt sowohl den Punkt- als auch den neuen Kanten-Feld- Controller (dieselbe Instanz, zusätzliche optionale Parameter). --- src/App.tsx | 102 ++++++++--- src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/plan/PlanView.tsx | 79 ++++++--- src/state/useGripEditing.ts | 284 +++++++++++++++++++++++------- src/state/useKeyboardShortcuts.ts | 15 ++ 6 files changed, 368 insertions(+), 114 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2d63bbc..9edca0b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1294,7 +1294,13 @@ export default function App() { gripEditFields, cycleGripEditField, submitGripEditValue, + edgeEdit, + setEdgeEdit, + edgeEditFields, + cycleEdgeEditField, + submitEdgeEditValue, gripDragInfoRef, + edgeDragInfoRef, } = useGripEditing({ project, selectedDrawing, @@ -1330,30 +1336,39 @@ export default function App() { // Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 1 Text, 2 Linie, // 3 Kreis, 4 Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum. - // Siehe state/useKeyboardShortcuts.ts. Braucht gripDragInfoRef (s. o.), daher - // erst hier nach useGripEditing() registriert. - useToolNumberShortcuts(onSelectTool, activeLevel.kind === "floor", engineRef, gripDragInfoRef); + // Siehe state/useKeyboardShortcuts.ts. Braucht gripDragInfoRef/edgeDragInfoRef + // (s. o.), daher erst hier nach useGripEditing() registriert. + useToolNumberShortcuts( + onSelectTool, + activeLevel.kind === "floor", + engineRef, + gripDragInfoRef, + edgeDragInfoRef, + ); - // Feld-Zustand fürs Cursor-HUD (neben der Maus, VW-Stil): läuft ein - // Punkt-Griff-Drag, hat er Vorrang (dieselben Felder wie unten in der - // Befehlszeile — Länge/Winkel-Tab-Zyklus), sonst die Befehls-Engine-Felder - // beim Zeichnen. Vorher zeigte das Cursor-HUD beim Griff-Ziehen GAR KEINE - // Länge/Winkel-Werte an (nur die Befehlszeile unten) — Nutzer-Wunsch: „du - // kannst [Länge/Winkel] gleich bei der Maus anzeigen wie beim Erstellen". + // Feld-Zustand fürs Cursor-HUD (neben der Maus, VW-Stil): läuft ein Punkt- + // ODER Kanten-Griff-Drag, hat er Vorrang (dieselben Felder wie unten in der + // Befehlszeile — Länge/Winkel- bzw. Abstand-Tab-Zyklus), sonst die Befehls- + // Engine-Felder beim Zeichnen. Vorher zeigte das Cursor-HUD beim Griff-Ziehen + // GAR KEINE Länge/Winkel-Werte an (nur die Befehlszeile unten) — Nutzer- + // Wunsch: „du kannst [Länge/Winkel] gleich bei der Maus anzeigen wie beim + // Erstellen". const hudFields = gripEdit ? { fields: gripEditFields(), typed: cmdTyped } - : commandActive && engineView.fields.length > 0 - ? { - fields: engineView.fields.map((f) => ({ - id: f.id, - value: f.value, - locked: f.locked, - active: f.active, - })), - typed: cmdTyped, - } - : null; + : edgeEdit + ? { fields: edgeEditFields(), typed: cmdTyped } + : commandActive && engineView.fields.length > 0 + ? { + fields: engineView.fields.map((f) => ({ + id: f.id, + value: f.value, + locked: f.locked, + active: f.active, + })), + typed: cmdTyped, + } + : null; // Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen — siehe // state/useKeyboardShortcuts.ts. @@ -2796,14 +2811,17 @@ export default function App() { Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */} { if (gripEdit) { @@ -2826,6 +2852,12 @@ export default function App() { else planViewRef.current?.confirmGripEdit(); return; } + if (edgeEdit) { + const v = parseFloat(text.replace(",", ".")); + if (isFinite(v)) submitEdgeEditValue(v); + else planViewRef.current?.confirmEdgeEdit(); + return; + } if (activeTransform) { const v = parseFloat(text.replace(",", ".")); if (isFinite(v)) submitTransformValue(v); @@ -2844,10 +2876,22 @@ export default function App() { activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id) } onCycleField={() => - gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField() + gripEdit + ? cycleGripEditField() + : edgeEdit + ? cycleEdgeEditField() + : activeTransform + ? undefined + : engine.cycleField() } onCancel={() => - gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel() + gripEdit + ? setGripEdit(null) + : edgeEdit + ? setEdgeEdit(null) + : activeTransform + ? cancelTransform() + : engine.cancel() } suggest={(prefix) => engine.suggest(prefix)} onTextChange={setCmdTyped} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index c8a3ffb..2842e34 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1211,6 +1211,7 @@ export const de = { "cmd.field.distance": "Distanz", "cmd.field.thickness": "Dicke", "cmd.edit.point": "Griff ziehen (Tab: Länge/Winkel):", + "cmd.edit.edge": "Seite verschieben (Tab: Abstand):", // ── Befehle: Import / Gelände ──────────────────────────────────────────── "cmd.import.label": "Importieren", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0b10a1c..0dea9ae 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1199,6 +1199,7 @@ export const en: Record = { "cmd.field.distance": "Distance", "cmd.field.thickness": "Thickness", "cmd.edit.point": "Drag grip (Tab: length/angle):", + "cmd.edit.edge": "Move side (Tab: offset):", // Commands: import / terrain. "cmd.import.label": "Import", diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index b23d3d5..0c71f96 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -119,6 +119,8 @@ export interface PlanViewHandle { * No-op ohne bewaffneten Griff. */ confirmGripEdit: () => void; + /** Dasselbe für einen bewaffneten Kanten-Griff (s. edgeDrag). */ + confirmEdgeEdit: () => void; } /** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */ @@ -438,6 +440,14 @@ export interface GripHandlers { * {@link onGripEnd} auf. */ onGripCancel(index: number, origin: Vec2): void; + /** Bewaffneten Kanten-Griff an seiner aktuellen Position abschließen. */ + onEdgeEnd(): void; + /** + * Bewaffneten Kanten-Griff verwerfen (Escape): setzt ihn exakt auf `anchor` + * (den originalen Kanten-Mittelpunkt) zurück und räumt danach wie + * {@link onEdgeEnd} auf. + */ + onEdgeCancel(edge: EdgeGrip, anchor: Vec2): void; } export const PlanView = forwardRef( @@ -737,6 +747,12 @@ export const PlanView = forwardRef( gripHandlersRef.current?.onGripEnd(); } }, + confirmEdgeEdit: () => { + if (edgeDrag.current) { + edgeDrag.current = null; + gripHandlersRef.current?.onEdgeEnd(); + } + }, fitSelection: () => { const sel = selectedRef.current; const prim = sel !== null ? planRef.current.primitives[sel] : undefined; @@ -808,7 +824,11 @@ export const PlanView = forwardRef( null, ); // Transiente Kanten-Griff-Zieh-Daten (linke Taste auf einem Dreieck-Anfasser). - // Hält den gegriffenen EdgeGrip; die Mutation läuft live über onEdgeMove. + // Hält den gegriffenen EdgeGrip (Momentaufnahme vom Grab — sein `.mid` ist der + // Anker für Feld-Eingabe/Escape-Revert); die Mutation läuft live über + // onEdgeMove. Dasselbe „bewaffnet"-Verhalten wie gripDrag oben: Loslassen + // beendet die Bearbeitung NICHT, erst ein weiterer Klick/Enter (bestätigt) + // oder Escape (verwirft zurück auf `edge.mid`). const edgeDrag = useRef<{ pointerId: number; edge: EdgeGrip } | null>(null); // Transiente „Körper verschieben"-Daten: letzter Modellpunkt, gegen den das // inkrementelle Delta gebildet wird (parallel-Verschiebung des Elements). @@ -826,23 +846,31 @@ export const PlanView = forwardRef( // Erkennung auf Raum-Stempel (Pointer-Capture unterdrückt natives dblclick). const lastClickRef = useRef<{ t: number; x: number; y: number } | null>(null); - // Tastatur-Abschluss eines bewaffneten Punkt-Griffs (s. gripDrag oben): Escape - // verwirft (zurück auf `origin`), Enter bestätigt die aktuelle Position — beide - // NUR wenn kein Eingabefeld fokussiert ist (die Befehlszeile behandelt ihr - // eigenes Enter für getippte Länge/Winkel-Werte selbst, s. App.tsx). + // Tastatur-Abschluss eines bewaffneten Punkt- ODER Kanten-Griffs (s. gripDrag/ + // edgeDrag oben): Escape verwirft (zurück auf die Ursprungsposition), Enter + // bestätigt die aktuelle Position — beide NUR wenn kein Eingabefeld + // fokussiert ist (die Befehlszeile behandelt ihr eigenes Enter für getippte + // Werte selbst, s. App.tsx). useEffect(() => { const onKey = (e: KeyboardEvent) => { const gd = gripDrag.current; - if (!gd || !gripHandlers) return; + const ed = edgeDrag.current; + if ((!gd && !ed) || !gripHandlers) return; if (e.key !== "Escape" && e.key !== "Enter") return; const el = e.target as HTMLElement | null; if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { return; } e.preventDefault(); - gripDrag.current = null; - if (e.key === "Escape") gripHandlers.onGripCancel(gd.index, gd.origin); - else gripHandlers.onGripEnd(); + if (gd) { + gripDrag.current = null; + if (e.key === "Escape") gripHandlers.onGripCancel(gd.index, gd.origin); + else gripHandlers.onGripEnd(); + } else if (ed) { + edgeDrag.current = null; + if (e.key === "Escape") gripHandlers.onEdgeCancel(ed.edge, ed.edge.mid); + else gripHandlers.onEdgeEnd(); + } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); @@ -1258,16 +1286,23 @@ export const PlanView = forwardRef( // Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan), // LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom. const onPointerDown = (e: React.PointerEvent) => { - // Ein Punkt-Griff ist bereits „bewaffnet" (Maus wurde losgelassen, der - // Punkt folgt seither dem Cursor) — JEDER weitere Linksklick bestätigt nur - // die aktuelle Position, statt irgendeine andere Aktion (Doppelklick, - // Schere, neue Auswahl, …) an derselben Stelle auszulösen. + // Ein Punkt- ODER Kanten-Griff ist bereits „bewaffnet" (Maus wurde + // losgelassen, der Griff folgt seither dem Cursor) — JEDER weitere + // Linksklick bestätigt nur die aktuelle Position, statt irgendeine andere + // Aktion (Doppelklick, Schere, neue Auswahl, …) an derselben Stelle + // auszulösen. if (e.button === 0 && gripDrag.current && gripHandlers) { e.preventDefault(); gripDrag.current = null; gripHandlers.onGripEnd(); return; } + if (e.button === 0 && edgeDrag.current && gripHandlers) { + e.preventDefault(); + edgeDrag.current = null; + gripHandlers.onEdgeEnd(); + return; + } // Doppelklick (zweiter Klick der Sequenz) auf einen Raum-Stempel/-Fläche → // Stempel-Editor öffnen. Manuell über Zeit/Abstand erkannt, weil PlanView // Pointer-Capture nutzt und der Browser dann kein natives `dblclick` @@ -1353,12 +1388,13 @@ export const PlanView = forwardRef( gripDrag.current = { pointerId: e.pointerId, index: gi, origin: grips![gi] }; return; } - // Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen. + // Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen. KEIN + // Pointer-Capture — dasselbe „bewaffnet nach Loslassen"-Verhalten wie + // beim Punkt-Griff (s. Kommentar an der edgeDrag-Deklaration). if (edgeGripsActive) { const eg = edgeGripAt(e.clientX, e.clientY); if (eg) { e.preventDefault(); - e.currentTarget.setPointerCapture(e.pointerId); edgeDrag.current = { pointerId: e.pointerId, edge: eg }; return; } @@ -1631,14 +1667,11 @@ export const PlanView = forwardRef( stampDrag.current = null; return; } - // Kanten-Griff loslassen: Ziehen abschließen. - const ed = edgeDrag.current; - if (ed && ed.pointerId === e.pointerId) { - if (e.currentTarget.hasPointerCapture(e.pointerId)) { - e.currentTarget.releasePointerCapture(e.pointerId); - } - edgeDrag.current = null; - gripHandlers!.onGripEnd(); + // Kanten-Griff: Maus loslassen beendet die Bearbeitung NICHT mehr — kein + // Pointer-Capture gehalten, der Griff bleibt bewaffnet und folgt weiter dem + // Cursor. Abschluss erst per erneutem Klick (oben in onPointerDown) oder + // Enter, Verwerfen per Escape (beides im Tastatur-Effekt unten). + if (edgeDrag.current && edgeDrag.current.pointerId === e.pointerId) { return; } diff --git a/src/state/useGripEditing.ts b/src/state/useGripEditing.ts index f2230aa..791f8f4 100644 --- a/src/state/useGripEditing.ts +++ b/src/state/useGripEditing.ts @@ -304,12 +304,17 @@ export function useGripEditing(opts: { return out; })(); - // 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(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(null); + // 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(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 @@ -472,9 +477,6 @@ export function useGripEditing(opts: { }; 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) => { @@ -489,6 +491,121 @@ export function useGripEditing(opts: { applyGrip(info.index, pt); }; + // 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(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; bei freien Kanten zykelt es + // zusätzlich Länge ↔ Winkel (geführte Kanten haben nur das eine Feld). + const cycleEdgeEditField = () => { + const info = edgeDragInfoRef.current; + if (!info) return; + const { edge } = info; + setEdgeEdit((prev) => + prev + ? edge.free + ? { ...prev, active: prev.active === "length" ? "angle" : "length" } + : prev + : { anchor: edge.mid, active: "length", 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); + }; + // 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) => { @@ -533,6 +650,20 @@ export function useGripEditing(opts: { 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- @@ -545,16 +676,32 @@ export function useGripEditing(opts: { 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 => { + 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-Drags (geteilt von onGripEnd - // und onGripCancel). - const endGripEditing = () => { + // 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); - edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen - edgeFreeLastRef.current = null; // freien Kanten-Drag-Anker zurücksetzen + 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 setGripEdit(null); // (c) Feld-Controller schließen + edgeDragInfoRef.current = null; // Kanten-Drag-Info verwerfen + setEdgeEdit(null); // Kanten-Feld-Controller schließen }; const gripHandlers = { @@ -659,47 +806,45 @@ export function useGripEditing(opts: { moveGripOf(c.drawingId, c.wallId, c.index, c.pos); } }, - // Kanten-Griff ziehen. Zwei Verhalten: + // 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 das Gesamt-Delta. + // 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 + // (Maus wirkt für das gelockte Feld nicht mehr — Lock dominiert). + 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) { - // 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). + // 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) { - 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; - } + 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 }; } - // 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, { x: dx, y: dy }); - setDraft({ preview: [], vertices: [], snap: null }); + 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 @@ -710,8 +855,8 @@ export function useGripEditing(opts: { 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); + // 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. @@ -719,26 +864,34 @@ export function useGripEditing(opts: { 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, scale(edge.normal, inc)); - setDraft({ preview: [], vertices: [], snap: isGeometrySnap ? snapResult : null }); + 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: () => { - endGripEditing(); + 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); - endGripEditing(); + 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(); }, }; @@ -751,10 +904,17 @@ export function useGripEditing(opts: { gripEditFields, cycleGripEditField, submitGripEditValue, - // Für useToolNumberShortcuts: solange ein 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 griff dieser Wächter sonst nicht). + edgeEdit, + setEdgeEdit, + edgeEditFields, + cycleEdgeEditField, + submitEdgeEditValue, + // 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, }; } diff --git a/src/state/useKeyboardShortcuts.ts b/src/state/useKeyboardShortcuts.ts index 929cd65..dc3c705 100644 --- a/src/state/useKeyboardShortcuts.ts +++ b/src/state/useKeyboardShortcuts.ts @@ -69,6 +69,12 @@ export function useCommandTabShortcut( locks: { length?: number; angle?: number }; } | null>, cycleGripEditFieldRef: RefObject<() => void>, + // Dasselbe für einen Kanten-Griff (Seite parallel verschieben) — separater + // Drag-Kanal, aber identisches Tab-Verhalten (öffnen/zyklen + Befehlsfeld + // fokussieren). + edgeDragInfoRef?: RefObject<{ edge: unknown; raw: Vec2 } | null>, + edgeEditRef?: RefObject, + cycleEdgeEditFieldRef?: RefObject<() => void>, ): void { useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -105,6 +111,13 @@ export function useCommandTabShortcut( if (!wasOpen) commandLineRef.current?.focus(); return; } + // Dasselbe für einen laufenden Kanten-Drag (Seite parallel verschieben). + if (edgeDragInfoRef?.current) { + const wasOpen = edgeEditRef?.current != null; + cycleEdgeEditFieldRef?.current?.(); + if (!wasOpen) commandLineRef.current?.focus(); + return; + } commandLineRef.current?.focus(); }; window.addEventListener("keydown", onKey); @@ -125,6 +138,7 @@ export function useToolNumberShortcuts( isFloor: boolean, engineRef: RefObject, gripDragInfoRef?: RefObject<{ index: number; raw: Vec2 } | null>, + edgeDragInfoRef?: RefObject, ): void { const toolShortcutRef = useRef({ onSelectTool, floor: isFloor }); toolShortcutRef.current = { onSelectTool, floor: isFloor }; @@ -150,6 +164,7 @@ export function useToolNumberShortcuts( // getippter Wert schaltete auf das per Ziffer gemappte Zeichenwerkzeug // um, sobald das leere, fokussierte Befehlsfeld erkannt wurde). if (gripDragInfoRef?.current) return; + if (edgeDragInfoRef?.current) return; const el = e.target as HTMLElement | null; const isInput = !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);