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:
+62
-18
@@ -1294,7 +1294,13 @@ export default function App() {
|
||||
gripEditFields,
|
||||
cycleGripEditField,
|
||||
submitGripEditValue,
|
||||
edgeEdit,
|
||||
setEdgeEdit,
|
||||
edgeEditFields,
|
||||
cycleEdgeEditField,
|
||||
submitEdgeEditValue,
|
||||
gripDragInfoRef,
|
||||
edgeDragInfoRef,
|
||||
} = useGripEditing({
|
||||
project,
|
||||
selectedDrawing,
|
||||
@@ -1330,19 +1336,28 @@ 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 }
|
||||
: edgeEdit
|
||||
? { fields: edgeEditFields(), typed: cmdTyped }
|
||||
: commandActive && engineView.fields.length > 0
|
||||
? {
|
||||
fields: engineView.fields.map((f) => ({
|
||||
@@ -2796,14 +2811,17 @@ export default function App() {
|
||||
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
|
||||
<CommandLine
|
||||
ref={commandLineRef}
|
||||
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
|
||||
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
|
||||
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
|
||||
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
|
||||
active={gripEdit ? true : activeTransform ? true : engineView.active}
|
||||
// (c) Während eines Vertex- ODER Kanten-Griff-Drags belegt der Feld-
|
||||
// Controller das Befehlsfeld (Länge/Winkel bzw. Abstand). Läuft eine
|
||||
// Auswahl-Transformation (Drehen etc.), belegt sie Prompt + U/I/O/P +
|
||||
// Winkel-/Distanzfeld. Sonst gilt die Engine (Zeichnen/Ruhe). Griff-/
|
||||
// Kanten-Drag haben Vorrang (schließen sich sonst aus).
|
||||
active={gripEdit ? true : edgeEdit ? true : activeTransform ? true : engineView.active}
|
||||
promptKey={
|
||||
gripEdit
|
||||
? "cmd.edit.point"
|
||||
: edgeEdit
|
||||
? "cmd.edit.edge"
|
||||
: activeTransform
|
||||
? `transform.hint.${activeTransform.op}.${Math.min(
|
||||
activeTransform.points.length,
|
||||
@@ -2811,9 +2829,17 @@ export default function App() {
|
||||
)}`
|
||||
: engineView.promptKey
|
||||
}
|
||||
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
|
||||
options={
|
||||
gripEdit || edgeEdit ? [] : activeTransform ? transformOptions() : engineView.options
|
||||
}
|
||||
fields={
|
||||
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
|
||||
gripEdit
|
||||
? gripEditFields()
|
||||
: edgeEdit
|
||||
? edgeEditFields()
|
||||
: activeTransform
|
||||
? transformFields()
|
||||
: engineView.fields
|
||||
}
|
||||
onSubmit={(text) => {
|
||||
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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1199,6 +1199,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"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",
|
||||
|
||||
+53
-20
@@ -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<PlanViewHandle, PlanViewProps>(
|
||||
@@ -737,6 +747,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
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<PlanViewHandle, PlanViewProps>(
|
||||
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<PlanViewHandle, PlanViewProps>(
|
||||
// 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();
|
||||
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<PlanViewHandle, PlanViewProps>(
|
||||
// Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan),
|
||||
// LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom.
|
||||
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
// Ein Punkt-Griff ist bereits „bewaffnet" (Maus wurde losgelassen, der
|
||||
// Punkt folgt seither dem Cursor) — JEDER weitere Linksklick bestätigt nur
|
||||
// die aktuelle Position, statt irgendeine andere Aktion (Doppelklick,
|
||||
// Schere, neue Auswahl, …) an derselben Stelle auszulösen.
|
||||
// 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<PlanViewHandle, PlanViewProps>(
|
||||
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<PlanViewHandle, PlanViewProps>(
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+222
-62
@@ -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<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);
|
||||
// Bereits angewandtes 2D-Delta seit Grab-Beginn eines Kanten-Drags, relativ
|
||||
// zum ORIGINALEN Kanten-Mittelpunkt (`edge.mid` — die beim Grab erfasste
|
||||
// Momentaufnahme, ändert sich während des Drags nicht). null = noch kein
|
||||
// Move-Event verarbeitet (erstes Event verankert nur, kein Sprung). Ein
|
||||
// ABSOLUTER Zielpunkt (aus Maus ODER gelocktem Feldwert) wird jeden Frame neu
|
||||
// berechnet; angewandt wird nur die Differenz zum zuletzt angewandten Delta
|
||||
// (moveEdgeOf/moveRoomEdge/moveCeilingEdge sind additiv). Dieselbe Formel
|
||||
// trägt sowohl den geführten (Normalen-)Fall als auch den freien Fall — vorher
|
||||
// zwei getrennte, rein inkrementelle Mechanismen ohne Bezug zum Ursprung, was
|
||||
// eine getippte Ziel-Distanz (s. edgeEdit unten) unmöglich gemacht hätte.
|
||||
const edgeAppliedDeltaRef = useRef<Vec2 | null>(null);
|
||||
// Körper-Verschieben (2D): koinzidente Endpunkte ANDERER gewählter Elemente, die
|
||||
// an einer Ecke des verschobenen Elements hängen, werden um dasselbe Delta
|
||||
// mitgeführt (gemeinsame Ecke bleibt verbunden). Lazy beim ersten Move-Event
|
||||
@@ -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<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
|
||||
// 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<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
|
||||
// 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) => {
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Shift = Ortho: das Delta auf die dominante Achse (H/V) zwingen.
|
||||
if (edge.free) {
|
||||
// Keine Geometrie-Snap-Projektion (keine feste Kanten-Normale, an die
|
||||
// sich ein gefangener Punkt anlehnen könnte) — der Cursor bewegt den
|
||||
// Zielpunkt direkt, nur Ortho/Raster wirken.
|
||||
let pt = raw;
|
||||
if (mods.shift) {
|
||||
if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
|
||||
else dx = 0;
|
||||
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 };
|
||||
}
|
||||
if (dx === 0 && dy === 0) return;
|
||||
applyEdge(edge, { x: dx, y: dy });
|
||||
setDraft({ preview: [], vertices: [], snap: null });
|
||||
const gs = snap.gridSize;
|
||||
if (!mods.ctrl && snap.enabled && snap.grid && gs > 0) {
|
||||
const dx = Math.round((pt.x - anchor.x) / gs) * gs;
|
||||
const dy = Math.round((pt.y - anchor.y) / gs) * gs;
|
||||
pt = { x: anchor.x + dx, y: anchor.y + dy };
|
||||
}
|
||||
applyEdgeToward(edge, anchor, pt);
|
||||
setDraft({ preview: [], vertices: [], snap: null, hud: edgeHudFor(edge, anchor, pt) });
|
||||
return;
|
||||
}
|
||||
// Cursor zuerst durch die Snap-Engine schicken (Ctrl unterdrückt Snap
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<unknown>,
|
||||
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<CommandEngine | null>,
|
||||
gripDragInfoRef?: RefObject<{ index: number; raw: Vec2 } | null>,
|
||||
edgeDragInfoRef?: RefObject<unknown>,
|
||||
): 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);
|
||||
|
||||
Reference in New Issue
Block a user