2D: Kanten-Griffe (Seite parallel verschieben) bekommen dieselbe

Bearbeitung wie Punkt-Griffe

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).
This commit is contained in:
2026-08-21 23:56:48 +02:00
parent cdfef949bf
commit 31a5b64836
6 changed files with 368 additions and 114 deletions
+62 -18
View File
@@ -1294,7 +1294,13 @@ export default function App() {
gripEditFields, gripEditFields,
cycleGripEditField, cycleGripEditField,
submitGripEditValue, submitGripEditValue,
edgeEdit,
setEdgeEdit,
edgeEditFields,
cycleEdgeEditField,
submitEdgeEditValue,
gripDragInfoRef, gripDragInfoRef,
edgeDragInfoRef,
} = useGripEditing({ } = useGripEditing({
project, project,
selectedDrawing, selectedDrawing,
@@ -1330,19 +1336,28 @@ export default function App() {
// Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 1 Text, 2 Linie, // 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. // 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 // Siehe state/useKeyboardShortcuts.ts. Braucht gripDragInfoRef/edgeDragInfoRef
// erst hier nach useGripEditing() registriert. // (s. o.), daher erst hier nach useGripEditing() registriert.
useToolNumberShortcuts(onSelectTool, activeLevel.kind === "floor", engineRef, gripDragInfoRef); useToolNumberShortcuts(
onSelectTool,
activeLevel.kind === "floor",
engineRef,
gripDragInfoRef,
edgeDragInfoRef,
);
// Feld-Zustand fürs Cursor-HUD (neben der Maus, VW-Stil): läuft ein // Feld-Zustand fürs Cursor-HUD (neben der Maus, VW-Stil): läuft ein Punkt-
// Punkt-Griff-Drag, hat er Vorrang (dieselben Felder wie unten in der // ODER Kanten-Griff-Drag, hat er Vorrang (dieselben Felder wie unten in der
// Befehlszeile — Länge/Winkel-Tab-Zyklus), sonst die Befehls-Engine-Felder // Befehlszeile — Länge/Winkel- bzw. Abstand-Tab-Zyklus), sonst die Befehls-
// beim Zeichnen. Vorher zeigte das Cursor-HUD beim Griff-Ziehen GAR KEINE // Engine-Felder beim Zeichnen. Vorher zeigte das Cursor-HUD beim Griff-Ziehen
// Länge/Winkel-Werte an (nur die Befehlszeile unten) — Nutzer-Wunsch: „du // GAR KEINE Länge/Winkel-Werte an (nur die Befehlszeile unten) — Nutzer-
// kannst [Länge/Winkel] gleich bei der Maus anzeigen wie beim Erstellen". // Wunsch: „du kannst [Länge/Winkel] gleich bei der Maus anzeigen wie beim
// Erstellen".
const hudFields = const hudFields =
gripEdit gripEdit
? { fields: gripEditFields(), typed: cmdTyped } ? { fields: gripEditFields(), typed: cmdTyped }
: edgeEdit
? { fields: edgeEditFields(), typed: cmdTyped }
: commandActive && engineView.fields.length > 0 : commandActive && engineView.fields.length > 0
? { ? {
fields: engineView.fields.map((f) => ({ fields: engineView.fields.map((f) => ({
@@ -2796,14 +2811,17 @@ export default function App() {
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */} Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
<CommandLine <CommandLine
ref={commandLineRef} ref={commandLineRef}
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das // (c) Während eines Vertex- ODER Kanten-Griff-Drags belegt der Feld-
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen // Controller das Befehlsfeld (Länge/Winkel bzw. Abstand). Läuft eine
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die // Auswahl-Transformation (Drehen etc.), belegt sie Prompt + U/I/O/P +
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus). // Winkel-/Distanzfeld. Sonst gilt die Engine (Zeichnen/Ruhe). Griff-/
active={gripEdit ? true : activeTransform ? true : engineView.active} // Kanten-Drag haben Vorrang (schließen sich sonst aus).
active={gripEdit ? true : edgeEdit ? true : activeTransform ? true : engineView.active}
promptKey={ promptKey={
gripEdit gripEdit
? "cmd.edit.point" ? "cmd.edit.point"
: edgeEdit
? "cmd.edit.edge"
: activeTransform : activeTransform
? `transform.hint.${activeTransform.op}.${Math.min( ? `transform.hint.${activeTransform.op}.${Math.min(
activeTransform.points.length, activeTransform.points.length,
@@ -2811,9 +2829,17 @@ export default function App() {
)}` )}`
: engineView.promptKey : engineView.promptKey
} }
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options} options={
gripEdit || edgeEdit ? [] : activeTransform ? transformOptions() : engineView.options
}
fields={ fields={
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields gripEdit
? gripEditFields()
: edgeEdit
? edgeEditFields()
: activeTransform
? transformFields()
: engineView.fields
} }
onSubmit={(text) => { onSubmit={(text) => {
if (gripEdit) { if (gripEdit) {
@@ -2826,6 +2852,12 @@ export default function App() {
else planViewRef.current?.confirmGripEdit(); else planViewRef.current?.confirmGripEdit();
return; return;
} }
if (edgeEdit) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitEdgeEditValue(v);
else planViewRef.current?.confirmEdgeEdit();
return;
}
if (activeTransform) { if (activeTransform) {
const v = parseFloat(text.replace(",", ".")); const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitTransformValue(v); if (isFinite(v)) submitTransformValue(v);
@@ -2844,10 +2876,22 @@ export default function App() {
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id) activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
} }
onCycleField={() => onCycleField={() =>
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField() gripEdit
? cycleGripEditField()
: edgeEdit
? cycleEdgeEditField()
: activeTransform
? undefined
: engine.cycleField()
} }
onCancel={() => onCancel={() =>
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel() gripEdit
? setGripEdit(null)
: edgeEdit
? setEdgeEdit(null)
: activeTransform
? cancelTransform()
: engine.cancel()
} }
suggest={(prefix) => engine.suggest(prefix)} suggest={(prefix) => engine.suggest(prefix)}
onTextChange={setCmdTyped} onTextChange={setCmdTyped}
+1
View File
@@ -1211,6 +1211,7 @@ export const de = {
"cmd.field.distance": "Distanz", "cmd.field.distance": "Distanz",
"cmd.field.thickness": "Dicke", "cmd.field.thickness": "Dicke",
"cmd.edit.point": "Griff ziehen (Tab: Länge/Winkel):", "cmd.edit.point": "Griff ziehen (Tab: Länge/Winkel):",
"cmd.edit.edge": "Seite verschieben (Tab: Abstand):",
// ── Befehle: Import / Gelände ──────────────────────────────────────────── // ── Befehle: Import / Gelände ────────────────────────────────────────────
"cmd.import.label": "Importieren", "cmd.import.label": "Importieren",
+1
View File
@@ -1199,6 +1199,7 @@ export const en: Record<TranslationKey, string> = {
"cmd.field.distance": "Distance", "cmd.field.distance": "Distance",
"cmd.field.thickness": "Thickness", "cmd.field.thickness": "Thickness",
"cmd.edit.point": "Drag grip (Tab: length/angle):", "cmd.edit.point": "Drag grip (Tab: length/angle):",
"cmd.edit.edge": "Move side (Tab: offset):",
// Commands: import / terrain. // Commands: import / terrain.
"cmd.import.label": "Import", "cmd.import.label": "Import",
+53 -20
View File
@@ -119,6 +119,8 @@ export interface PlanViewHandle {
* No-op ohne bewaffneten Griff. * No-op ohne bewaffneten Griff.
*/ */
confirmGripEdit: () => void; confirmGripEdit: () => void;
/** Dasselbe für einen bewaffneten Kanten-Griff (s. edgeDrag). */
confirmEdgeEdit: () => void;
} }
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */ /** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
@@ -438,6 +440,14 @@ export interface GripHandlers {
* {@link onGripEnd} auf. * {@link onGripEnd} auf.
*/ */
onGripCancel(index: number, origin: Vec2): void; 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<PlanViewHandle, PlanViewProps>( export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
@@ -737,6 +747,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
gripHandlersRef.current?.onGripEnd(); gripHandlersRef.current?.onGripEnd();
} }
}, },
confirmEdgeEdit: () => {
if (edgeDrag.current) {
edgeDrag.current = null;
gripHandlersRef.current?.onEdgeEnd();
}
},
fitSelection: () => { fitSelection: () => {
const sel = selectedRef.current; const sel = selectedRef.current;
const prim = sel !== null ? planRef.current.primitives[sel] : undefined; const prim = sel !== null ? planRef.current.primitives[sel] : undefined;
@@ -808,7 +824,11 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
null, null,
); );
// Transiente Kanten-Griff-Zieh-Daten (linke Taste auf einem Dreieck-Anfasser). // 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); const edgeDrag = useRef<{ pointerId: number; edge: EdgeGrip } | null>(null);
// Transiente „Körper verschieben"-Daten: letzter Modellpunkt, gegen den das // Transiente „Körper verschieben"-Daten: letzter Modellpunkt, gegen den das
// inkrementelle Delta gebildet wird (parallel-Verschiebung des Elements). // inkrementelle Delta gebildet wird (parallel-Verschiebung des Elements).
@@ -826,23 +846,31 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// Erkennung auf Raum-Stempel (Pointer-Capture unterdrückt natives dblclick). // Erkennung auf Raum-Stempel (Pointer-Capture unterdrückt natives dblclick).
const lastClickRef = useRef<{ t: number; x: number; y: number } | null>(null); const lastClickRef = useRef<{ t: number; x: number; y: number } | null>(null);
// Tastatur-Abschluss eines bewaffneten Punkt-Griffs (s. gripDrag oben): Escape // Tastatur-Abschluss eines bewaffneten Punkt- ODER Kanten-Griffs (s. gripDrag/
// verwirft (zurück auf `origin`), Enter bestätigt die aktuelle Position — beide // edgeDrag oben): Escape verwirft (zurück auf die Ursprungsposition), Enter
// NUR wenn kein Eingabefeld fokussiert ist (die Befehlszeile behandelt ihr // bestätigt die aktuelle Position — beide NUR wenn kein Eingabefeld
// eigenes Enter für getippte Länge/Winkel-Werte selbst, s. App.tsx). // fokussiert ist (die Befehlszeile behandelt ihr eigenes Enter für getippte
// Werte selbst, s. App.tsx).
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
const gd = gripDrag.current; 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; if (e.key !== "Escape" && e.key !== "Enter") return;
const el = e.target as HTMLElement | null; const el = e.target as HTMLElement | null;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) {
return; return;
} }
e.preventDefault(); e.preventDefault();
if (gd) {
gripDrag.current = null; gripDrag.current = null;
if (e.key === "Escape") gripHandlers.onGripCancel(gd.index, gd.origin); if (e.key === "Escape") gripHandlers.onGripCancel(gd.index, gd.origin);
else gripHandlers.onGripEnd(); 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); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
@@ -1258,16 +1286,23 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan), // Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan),
// LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom. // LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom.
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => { const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
// Ein Punkt-Griff ist bereits „bewaffnet" (Maus wurde losgelassen, der // Ein Punkt- ODER Kanten-Griff ist bereits „bewaffnet" (Maus wurde
// Punkt folgt seither dem Cursor) — JEDER weitere Linksklick bestätigt nur // losgelassen, der Griff folgt seither dem Cursor) — JEDER weitere
// die aktuelle Position, statt irgendeine andere Aktion (Doppelklick, // Linksklick bestätigt nur die aktuelle Position, statt irgendeine andere
// Schere, neue Auswahl, …) an derselben Stelle auszulösen. // Aktion (Doppelklick, Schere, neue Auswahl, …) an derselben Stelle
// auszulösen.
if (e.button === 0 && gripDrag.current && gripHandlers) { if (e.button === 0 && gripDrag.current && gripHandlers) {
e.preventDefault(); e.preventDefault();
gripDrag.current = null; gripDrag.current = null;
gripHandlers.onGripEnd(); gripHandlers.onGripEnd();
return; 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 → // Doppelklick (zweiter Klick der Sequenz) auf einen Raum-Stempel/-Fläche →
// Stempel-Editor öffnen. Manuell über Zeit/Abstand erkannt, weil PlanView // Stempel-Editor öffnen. Manuell über Zeit/Abstand erkannt, weil PlanView
// Pointer-Capture nutzt und der Browser dann kein natives `dblclick` // Pointer-Capture nutzt und der Browser dann kein natives `dblclick`
@@ -1353,12 +1388,13 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
gripDrag.current = { pointerId: e.pointerId, index: gi, origin: grips![gi] }; gripDrag.current = { pointerId: e.pointerId, index: gi, origin: grips![gi] };
return; 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) { if (edgeGripsActive) {
const eg = edgeGripAt(e.clientX, e.clientY); const eg = edgeGripAt(e.clientX, e.clientY);
if (eg) { if (eg) {
e.preventDefault(); e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
edgeDrag.current = { pointerId: e.pointerId, edge: eg }; edgeDrag.current = { pointerId: e.pointerId, edge: eg };
return; return;
} }
@@ -1631,14 +1667,11 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
stampDrag.current = null; stampDrag.current = null;
return; return;
} }
// Kanten-Griff loslassen: Ziehen abschließen. // Kanten-Griff: Maus loslassen beendet die Bearbeitung NICHT mehr — kein
const ed = edgeDrag.current; // Pointer-Capture gehalten, der Griff bleibt bewaffnet und folgt weiter dem
if (ed && ed.pointerId === e.pointerId) { // Cursor. Abschluss erst per erneutem Klick (oben in onPointerDown) oder
if (e.currentTarget.hasPointerCapture(e.pointerId)) { // Enter, Verwerfen per Escape (beides im Tastatur-Effekt unten).
e.currentTarget.releasePointerCapture(e.pointerId); if (edgeDrag.current && edgeDrag.current.pointerId === e.pointerId) {
}
edgeDrag.current = null;
gripHandlers!.onGripEnd();
return; return;
} }
+222 -62
View File
@@ -304,12 +304,17 @@ export function useGripEditing(opts: {
return out; return out;
})(); })();
// Zuletzt angewandter Kanten-Offset während eines Edge-Grip-Drags (Anker). // Bereits angewandtes 2D-Delta seit Grab-Beginn eines Kanten-Drags, relativ
// null = kein laufender Drag; der erste Move-Event verankert nur (kein Sprung). // zum ORIGINALEN Kanten-Mittelpunkt (`edge.mid` — die beim Grab erfasste
const edgeDragOffsetRef = useRef<number | null>(null); // Momentaufnahme, ändert sich während des Drags nicht). null = noch kein
// Freies Kanten-Ziehen (offene Polylinie/Linie): letzter roher Cursor, gegen den // Move-Event verarbeitet (erstes Event verankert nur, kein Sprung). Ein
// das inkrementelle 2D-Delta gebildet wird. null = Drag noch nicht verankert. // ABSOLUTER Zielpunkt (aus Maus ODER gelocktem Feldwert) wird jeden Frame neu
const edgeFreeLastRef = useRef<Vec2 | null>(null); // berechnet; angewandt wird nur die Differenz zum zuletzt angewandten Delta
// (moveEdgeOf/moveRoomEdge/moveCeilingEdge sind additiv). Dieselbe Formel
// trägt sowohl den geführten (Normalen-)Fall als auch den freien Fall — vorher
// zwei getrennte, rein inkrementelle Mechanismen ohne Bezug zum Ursprung, was
// eine getippte Ziel-Distanz (s. edgeEdit unten) unmöglich gemacht hätte.
const edgeAppliedDeltaRef = useRef<Vec2 | null>(null);
// Körper-Verschieben (2D): koinzidente Endpunkte ANDERER gewählter Elemente, die // Körper-Verschieben (2D): koinzidente Endpunkte ANDERER gewählter Elemente, die
// an einer Ecke des verschobenen Elements hängen, werden um dasselbe Delta // an einer Ecke des verschobenen Elements hängen, werden um dasselbe Delta
// mitgeführt (gemeinsame Ecke bleibt verbunden). Lazy beim ersten Move-Event // mitgeführt (gemeinsame Ecke bleibt verbunden). Lazy beim ersten Move-Event
@@ -472,9 +477,6 @@ export function useGripEditing(opts: {
}; };
cycleGripEditFieldRef.current = cycleGripEditField; 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 // Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken und
// den Zielpunkt sofort mit dem gelockten Wert neu setzen. // den Zielpunkt sofort mit dem gelockten Wert neu setzen.
const submitGripEditValue = (value: number) => { const submitGripEditValue = (value: number) => {
@@ -489,6 +491,121 @@ export function useGripEditing(opts: {
applyGrip(info.index, pt); 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<typeof edgeEdit>(null);
edgeEditRef.current = edgeEdit;
// Letzter roher Cursor + der gezogene Kanten-Griff (Momentaufnahme vom Grab).
const edgeDragInfoRef = useRef<{ edge: EdgeGrip; raw: Vec2 } | null>(null);
// Zielpunkt aus Anker + (gelockten/Live-)Werten — bei freien Kanten wie ein
// Punkt-Griff (polar), bei geführten Kanten nur entlang der festen Normale.
const edgeEditPoint = (
edge: EdgeGrip,
anchor: Vec2,
locks: { length?: number; angle?: number },
raw: Vec2,
): Vec2 => {
if (edge.free) return gripEditPoint(anchor, locks, raw);
const live = dot(sub(raw, anchor), edge.normal);
const len = locks.length != null ? locks.length : live;
return add(anchor, scale(edge.normal, len));
};
// Felder fürs Befehlsfeld während des Kanten-Drags. Geführte Kanten zeigen
// NUR "Länge" (kein Winkel — der ist durch die feste Normale vorgegeben).
const edgeEditFields = (): GripEditFieldsState[] => {
const ee = edgeEdit;
const info = edgeDragInfoRef.current;
if (!ee || !info) return [];
const { edge, raw } = info;
if (!edge.free) {
const live = dot(sub(raw, ee.anchor), edge.normal);
return [
{
id: "length",
labelKey: "cmd.field.length",
value: ee.locks.length ?? live,
locked: ee.locks.length != null,
active: true,
},
];
}
const dx = raw.x - ee.anchor.x;
const dy = raw.y - ee.anchor.y;
const liveLen = Math.hypot(dx, dy);
const liveAng = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 360) + 360) % 360;
return [
{
id: "length",
labelKey: "cmd.field.length",
value: ee.locks.length ?? liveLen,
locked: ee.locks.length != null,
active: ee.active === "length",
},
{
id: "angle",
labelKey: "cmd.field.angle",
value: ee.locks.angle ?? liveAng,
locked: ee.locks.angle != null,
active: ee.active === "angle",
},
];
};
const cycleEdgeEditFieldRef = useRef<() => void>(() => {});
// Tab im Kanten-Drag: Feld-Controller öffnen; 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 // 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. // auf die Wand/2D-Aktionen. So teilen Plan- UND 3D-Griffe denselben Pfad.
const applyGrip = (index: number, pt: Vec2) => { 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 if (selectedCeilingId) moveCeilingEdge(selectedCeilingId, edge.aIndex, edge.bIndex, delta);
else moveEdgeOf(edge.owner?.drawingId ?? selectedDrawingId, edge.owner?.wallId ?? selectedWallId, 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 // Maß-/Winkel-HUD am Cursor für einen gezogenen Punkt-Griff — dieselbe
// Darstellung wie beim Zeichnen (DraftHud, VW-Stil). Ohne Anker (Einzelgriff- // 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; const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { at: pt, length: Math.hypot(dx, dy), angleDeg }; return { at: pt, length: Math.hypot(dx, dy), angleDeg };
}; };
// Dasselbe für einen gezogenen Kanten-Griff: freie Kanten zeigen Länge+Winkel
// (wie ein Punkt-Griff), geführte Kanten nur EINEN Wert (Abstand entlang der
// Normale) — dafür reicht das HUD-Schema nicht (length+angleDeg gehören
// zusammen), daher als freier Text `Δ: …m`.
const edgeHudFor = (edge: EdgeGrip, anchor: Vec2, pt: Vec2): NonNullable<ToolDraft["hud"]> => {
const dx = pt.x - anchor.x;
const dy = pt.y - anchor.y;
if (edge.free) {
const angleDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return { at: pt, length: Math.hypot(dx, dy), angleDeg };
}
const offset = dx * edge.normal.x + dy * edge.normal.y;
return { at: pt, text: `Δ: ${offset.toFixed(3)}m` };
};
// Aufräumen nach Abschluss/Verwerfen eines Griff-Drags (geteilt von onGripEnd // Aufräumen nach Abschluss/Verwerfen eines Griff- ODER Kanten-Drags (geteilt
// und onGripCancel). // von onGripEnd/onGripCancel/onEdgeEnd/onEdgeCancel — nie beide gleichzeitig
const endGripEditing = () => { // aktiv, ein gemeinsames Aufräumen ist daher unschädlich und einfacher).
const endEditing = () => {
setDraft(null); setDraft(null);
edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen edgeAppliedDeltaRef.current = null; // Kanten-Drag beendet → Anker zurücksetzen
edgeFreeLastRef.current = null; // freien Kanten-Drag-Anker zurücksetzen
moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen
gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen
setGripEdit(null); // (c) Feld-Controller schließen setGripEdit(null); // (c) Feld-Controller schließen
edgeDragInfoRef.current = null; // Kanten-Drag-Info verwerfen
setEdgeEdit(null); // Kanten-Feld-Controller schließen
}; };
const gripHandlers = { const gripHandlers = {
@@ -659,47 +806,45 @@ export function useGripEditing(opts: {
moveGripOf(c.drawingId, c.wallId, c.index, c.pos); 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 // • free (offene Polylinie/Linie, §Task B): das Segment folgt dem VOLLEN
// Cursor-Delta — beide Endpunkte wandern gleich, die geteilten Ecken ziehen // 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 // • sonst (rect/geschlossene Polylinie/Wand): NUR die Komponente senkrecht
// zur Kante (Seite verschiebt sich parallel nach außen/innen). Fängt an // zur Kante (Seite verschiebt sich parallel nach außen/innen). Fängt an
// eingeschalteten Geometrie-Snaps (Endpunkt/Mittelpunkt/Schnittpunkt/…); // eingeschalteten Geometrie-Snaps (Endpunkt/Mittelpunkt/Schnittpunkt/…);
// ohne Geometrie-Snap wird der Offset wie bisher aufs Raster gerundet. // ohne Geometrie-Snap wird der Offset wie bisher aufs Raster gerundet.
onEdgeMove: (edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods) => { onEdgeMove: (edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods) => {
if (edge.free) { edgeDragInfoRef.current = { edge, raw };
// Freier Fall arbeitet mit einem inkrementellen Cursor-DELTA (nicht const anchor = edge.mid; // Momentaufnahme vom Grab, ändert sich während des Drags nicht
// einem absoluten Punkt) — eine Geometrie-Snap-Projektion auf die const ee = edgeEditRef.current;
// Normale ergibt hier keinen Sinn, da es keine feste Kanten-Normale // Feld-Controller mit Lock aktiv: Zielpunkt aus Anker + gelocktem Wert
// gibt, an die sich ein gefangener Punkt anlehnen könnte. Bleibt beim // (Maus wirkt für das gelockte Feld nicht mehr — Lock dominiert).
// bisherigen Raster-Delta-Verhalten. if (ee && (ee.locks.length != null || ee.locks.angle != null)) {
// const pt = edgeEditPoint(edge, anchor, ee.locks, raw);
// Inkrementelles 2D-Delta gegen den letzten rohen Cursor. Erster Event applyEdgeToward(edge, anchor, pt);
// verankert nur (kein Sprung, da der Griff am Kanten-Mittelpunkt sitzt). setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
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; return;
} }
} if (edge.free) {
// Shift = Ortho: das Delta auf die dominante Achse (H/V) zwingen. // 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) { if (mods.shift) {
if (Math.abs(dx) >= Math.abs(dy)) dy = 0; const dx = raw.x - anchor.x;
else dx = 0; 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 };
} }
if (dx === 0 && dy === 0) return; const gs = snap.gridSize;
applyEdge(edge, { x: dx, y: dy }); if (!mods.ctrl && snap.enabled && snap.grid && gs > 0) {
setDraft({ preview: [], vertices: [], snap: null }); const dx = Math.round((pt.x - anchor.x) / gs) * gs;
const dy = Math.round((pt.y - anchor.y) / gs) * gs;
pt = { x: anchor.x + dx, y: anchor.y + dy };
}
applyEdgeToward(edge, anchor, pt);
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
return; return;
} }
// Cursor zuerst durch die Snap-Engine schicken (Ctrl unterdrückt Snap // 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 snapResult = snapFor(raw, pxPerMeter, mods);
const isGeometrySnap = !!snapResult && snapResult.kind !== "grid" && snapResult.kind !== "ortho"; const isGeometrySnap = !!snapResult && snapResult.kind !== "grid" && snapResult.kind !== "ortho";
const cursor = isGeometrySnap ? snapResult!.point : raw; const cursor = isGeometrySnap ? snapResult!.point : raw;
// Senkrechte Projektion von (Cursor − Grab-Mittelpunkt) auf die Außennormale. // Senkrechte Projektion von (Cursor − Anker) auf die Außennormale.
const rel = sub(cursor, edge.mid); const rel = sub(cursor, anchor);
let offset = rel.x * edge.normal.x + rel.y * edge.normal.y; let offset = rel.x * edge.normal.x + rel.y * edge.normal.y;
// Raster-Schritt (Snap) nur, wenn kein Geometrie-Snap gefangen wurde — // Raster-Schritt (Snap) nur, wenn kein Geometrie-Snap gefangen wurde —
// sonst gewinnt der gefangene Punkt. // sonst gewinnt der gefangene Punkt.
@@ -719,26 +864,34 @@ export function useGripEditing(opts: {
if (!isGeometrySnap && !mods.ctrl && snap.enabled && snap.grid && step > 0) { if (!isGeometrySnap && !mods.ctrl && snap.enabled && snap.grid && step > 0) {
offset = Math.round(offset / step) * step; offset = Math.round(offset / step) * step;
} }
// INKREMENTELL + Grab-Anker: moveEdge verschiebt additiv, daher nur die const pt = add(anchor, scale(edge.normal, offset));
// Änderung seit dem letzten Event anwenden. Erster Event verankert nur applyEdgeToward(edge, anchor, pt);
// (kein Sprung beim Greifen, da das Dreieck am Mittelpunkt sitzt). setDraft({
const prev = edgeDragOffsetRef.current; preview: [],
edgeDragOffsetRef.current = offset; vertices: [],
if (prev === null) return; snap: isGeometrySnap ? snapResult : null,
const inc = offset - prev; hud: edgeHudFor(edge, anchor, pt),
if (inc === 0) return; });
applyEdge(edge, scale(edge.normal, inc));
setDraft({ preview: [], vertices: [], snap: isGeometrySnap ? snapResult : null });
}, },
onGripEnd: () => { onGripEnd: () => {
endGripEditing(); endEditing();
}, },
// Bewaffneten Punkt-Griff verwerfen (Escape): exakt auf `origin` // Bewaffneten Punkt-Griff verwerfen (Escape): exakt auf `origin`
// zurücksetzen — ohne Snap/Ortho-Neuauswertung, ein reiner Revert — und // zurücksetzen — ohne Snap/Ortho-Neuauswertung, ein reiner Revert — und
// danach wie onGripEnd aufräumen. // danach wie onGripEnd aufräumen.
onGripCancel: (index: number, origin: Vec2) => { onGripCancel: (index: number, origin: Vec2) => {
applyGrip(index, origin); 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, gripEditFields,
cycleGripEditField, cycleGripEditField,
submitGripEditValue, submitGripEditValue,
// Für useToolNumberShortcuts: solange ein Griff bewaffnet ist, dürfen edgeEdit,
// Zifferntasten NICHT als Werkzeug-Wechsel interpretiert werden (auch setEdgeEdit,
// nicht, wenn das Befehlsfeld fokussiert und leer ist — die Zeichen- edgeEditFields,
// Engine selbst läuft ja nicht, daher griff dieser Wächter sonst nicht). 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, gripDragInfoRef,
edgeDragInfoRef,
}; };
} }
+15
View File
@@ -69,6 +69,12 @@ export function useCommandTabShortcut(
locks: { length?: number; angle?: number }; locks: { length?: number; angle?: number };
} | null>, } | null>,
cycleGripEditFieldRef: RefObject<() => void>, 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<unknown>,
cycleEdgeEditFieldRef?: RefObject<() => void>,
): void { ): void {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -105,6 +111,13 @@ export function useCommandTabShortcut(
if (!wasOpen) commandLineRef.current?.focus(); if (!wasOpen) commandLineRef.current?.focus();
return; 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(); commandLineRef.current?.focus();
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
@@ -125,6 +138,7 @@ export function useToolNumberShortcuts(
isFloor: boolean, isFloor: boolean,
engineRef: RefObject<CommandEngine | null>, engineRef: RefObject<CommandEngine | null>,
gripDragInfoRef?: RefObject<{ index: number; raw: Vec2 } | null>, gripDragInfoRef?: RefObject<{ index: number; raw: Vec2 } | null>,
edgeDragInfoRef?: RefObject<unknown>,
): void { ): void {
const toolShortcutRef = useRef({ onSelectTool, floor: isFloor }); const toolShortcutRef = useRef({ onSelectTool, floor: isFloor });
toolShortcutRef.current = { onSelectTool, floor: isFloor }; toolShortcutRef.current = { onSelectTool, floor: isFloor };
@@ -150,6 +164,7 @@ export function useToolNumberShortcuts(
// getippter Wert schaltete auf das per Ziffer gemappte Zeichenwerkzeug // getippter Wert schaltete auf das per Ziffer gemappte Zeichenwerkzeug
// um, sobald das leere, fokussierte Befehlsfeld erkannt wurde). // um, sobald das leere, fokussierte Befehlsfeld erkannt wurde).
if (gripDragInfoRef?.current) return; if (gripDragInfoRef?.current) return;
if (edgeDragInfoRef?.current) return;
const el = e.target as HTMLElement | null; const el = e.target as HTMLElement | null;
const isInput = const isInput =
!!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable); !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);