From 2af8d6aa1b7096e3e607b3fba032373327f5ad90 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 22 Aug 2026 00:34:59 +0200 Subject: [PATCH] =?UTF-8?q?2D:=20Hover-Teilpunkte=20auf=20Linien=20(konfig?= =?UTF-8?q?urierbare=20Verz=C3=B6gerung=20+=20Anzahl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Feature (Nutzer-Wunsch): ruht der Cursor im Auswahl-Ruhezustand (kein Werkzeug/Drag) länger als eine einstellbare Zeit (Default 300ms) auf einem 2D-Linien-Segment, erscheinen dessen gleiche Teilpunkte kurz mit Akzentfarbe -- bei 2 Teilen (Default) nur der Mittelpunkt, bei 3/4/… entsprechend mehr. Reine Sichtbarkeits-Hilfe, kein neues Snap-Ziel. Verzögerung (ms) und Teile-Anzahl sind unten im Footer einstellbar, im bestehenden Fang-Popover (StatusBar.tsx SnapControl) als zwei weitere Zahlenfelder -- neue Felder hoverDivideDelayMs/hoverDivideCount auf SnapSettings (tools/types.ts), NICHT an snap.enabled gekoppelt (eigenes, vom Fang unabhängiges visuelles Feature). PlanView.tsx: neue nearestLineSegment()-Suche (dieselbe Bildschirm-Nähe- Logik wie pickDrawing, liefert aber die zwei Endpunkte des konkret gehoverten Segments einer Polylinie, nicht nur die drawingId). Ein Timer verankert/verwirft die Anzeige bei Segment-Wechsel; die beiden neuen Props (hoverDivideDelayMs/hoverDivideCount) werden wie snapColor durch viewportContent.tsx bis zu PlanView durchgereicht. tsc/vitest 922/922 grün. Manuell in der App zu prüfen (Pointer-Hover ist nicht automatisiert testbar, wie die übrigen Griff-Interaktions- Fixes dieser Session). --- src/App.tsx | 2 + src/i18n/de.ts | 2 + src/i18n/en.ts | 2 + src/plan/PlanView.tsx | 120 +++++++++++++++++++++++++++++++++- src/tools/types.ts | 14 ++++ src/ui/StatusBar.tsx | 29 ++++++++ src/views/viewportContent.tsx | 22 +++++++ 7 files changed, 189 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index cd7c661..4327652 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2739,6 +2739,8 @@ export default function App() { hairline={lineMode === "display"} marqueeColor={marqueeColor} snapColor={snapColor} + hoverDivideDelayMs={snap.hoverDivideDelayMs} + hoverDivideCount={snap.hoverDivideCount} renderMode={renderMode} view3d={view3d} fov={fov} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 2842e34..6a025f7 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -241,6 +241,8 @@ export const de = { "snap.angleStep": "Winkelraster", "snap.commonAngles": "Gängige Winkel", "snap.commonAngleTolerance": "Winkeltoleranz (°)", + "snap.hoverDivideDelayMs": "Hover-Verzögerung (ms)", + "snap.hoverDivideCount": "Teilpunkte (Anzahl)", // ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ─────────────── "transform.bar": "Transformation", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0dea9ae..39e60d4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -239,6 +239,8 @@ export const en: Record = { "snap.angleStep": "Angle step", "snap.commonAngles": "Common angles", "snap.commonAngleTolerance": "Angle tolerance (°)", + "snap.hoverDivideDelayMs": "Hover delay (ms)", + "snap.hoverDivideCount": "Division points (count)", // Transform (move/mirror/rotate) + mode bar. "transform.bar": "Transform", diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 0c71f96..a1f7ac4 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -410,6 +410,19 @@ export interface PlanViewProps { * Einstellungs-Fenster (State in ViewSlice); Default = Akzent „Sora". */ snapColor?: string; + /** + * Ruht der Cursor (kein Werkzeug/Drag aktiv) länger als diese Zeit (ms) auf + * einer 2D-Linie, erscheinen ihre Teilpunkte (s. `hoverDivideCount`) kurz + * mit Akzentfarbe — reine Sichtbarkeits-Hilfe, kein Snap-Ziel. Einstellbar + * im Footer (`SnapSettings.hoverDivideDelayMs`); Default 300 ms. + */ + hoverDivideDelayMs?: number; + /** + * In wie viele gleiche Teile die gehoverte Linie dabei angezeigt wird + * (2 = nur der Mittelpunkt). Einstellbar im Footer + * (`SnapSettings.hoverDivideCount`); Default 2. + */ + hoverDivideCount?: number; } /** Treiber für das Ziehen von Editier-Griffen (von App bereitgestellt). */ @@ -493,6 +506,8 @@ export const PlanView = forwardRef( gripHandlers, marqueeColor = DEFAULT_MARQUEE_COLOR, snapColor = DEFAULT_SNAP_COLOR, + hoverDivideDelayMs = 300, + hoverDivideCount = 2, }, ref, ) { @@ -813,6 +828,50 @@ export const PlanView = forwardRef( crossing: boolean; } | null>(null); + // Hover-Teilpunkte (Nutzer-Wunsch): ruht der Cursor im Auswahl-Ruhezustand + // (kein Werkzeug/Drag/Marquee — s. Aufruf in onPointerMove) länger als + // `hoverDivideDelayMs` auf einem 2D-Linien-Segment, werden dessen gleiche + // Teilpunkte (Anzahl `hoverDivideCount`, 2 = nur Mitte) kurz mit + // Akzentfarbe eingeblendet. Reine Sichtbarkeits-Hilfe, kein Snap-Ziel. + const [hoverDivideSeg, setHoverDivideSeg] = useState<{ a: Vec2; b: Vec2 } | null>(null); + // Zuletzt erkanntes Segment (Serialisiert als Schlüssel) — erkennt einen + // Wechsel, ohne bei jedem Move-Event neu zu timern. + const hoverDivideKeyRef = useRef(null); + const hoverDivideTimerRef = useRef(null); + + // Nächstliegendes Linien-Segment unterm Cursor ermitteln und die + // Hover-Teilpunkte-Anzeige entsprechend (neu) timern/verwerfen. + const updateHoverDivide = (clientX: number, clientY: number) => { + const seg = nearestLineSegment(clientX, clientY); + const key = seg ? `${seg.a.x},${seg.a.y}|${seg.b.x},${seg.b.y}` : null; + if (key === hoverDivideKeyRef.current) return; // dasselbe Segment (oder weiter keins) + hoverDivideKeyRef.current = key; + if (hoverDivideTimerRef.current !== null) { + window.clearTimeout(hoverDivideTimerRef.current); + hoverDivideTimerRef.current = null; + } + setHoverDivideSeg(null); // Segment gewechselt → alte Anzeige sofort weg + if (!seg) return; + hoverDivideTimerRef.current = window.setTimeout(() => { + hoverDivideTimerRef.current = null; + setHoverDivideSeg(seg); + }, hoverDivideDelayMs); + }; + const clearHoverDivide = () => { + if (hoverDivideTimerRef.current !== null) { + window.clearTimeout(hoverDivideTimerRef.current); + hoverDivideTimerRef.current = null; + } + hoverDivideKeyRef.current = null; + setHoverDivideSeg(null); + }; + // Weg, sobald ein Werkzeug aktiv wird (sonst könnte eine veraltete Anzeige + // ohne weiteres Move-Event über einem neuen Werkzeug-Entwurf stehen bleiben). + useEffect(() => { + if (toolActive) clearHoverDivide(); + }, [toolActive]); + useEffect(() => () => clearHoverDivide(), []); + // Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je // Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove. // Anders als bei Kante/Körper braucht das Loslassen der Maus die Bearbeitung @@ -1067,6 +1126,28 @@ export const PlanView = forwardRef( return pickDrawingText(clientX, clientY) ?? pickDrawingImage(clientX, clientY); }; + // Nächstgelegenes EINZELNES Geraden-Segment (nicht das ganze Element) für + // die Hover-Teilpunkte-Anzeige (s. hoverDivideSeg unten) — dieselbe Nähe- + // Logik wie pickDrawing, liefert aber die zwei Endpunkte des getroffenen + // Segments selbst: eine Polylinie besteht aus mehreren "line"-Primitiven + // (eins je Segment, gleiche drawingId), unterteilt werden soll nur das + // konkret gehoverte Segment, nicht das ganze Element. + const nearestLineSegment = (clientX: number, clientY: number): { a: Vec2; b: Vec2 } | null => { + let best: { a: Vec2; b: Vec2 } | null = null; + let bestDist = LINE_PICK_PX; + for (const p of plan.primitives) { + if (p.kind !== "line" || !p.drawingId) continue; + const a = vbToClient(toScreen(p.a).x, toScreen(p.a).y); + const b = vbToClient(toScreen(p.b).x, toScreen(p.b).y); + const d = pointSegDistPx({ x: clientX, y: clientY }, a, b); + if (d < bestDist) { + bestDist = d; + best = { a: p.a, b: p.b }; + } + } + return best; + }; + // Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell- // Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen); // Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null. @@ -1615,7 +1696,12 @@ export const PlanView = forwardRef( } const d = drag.current; - if (!d || d.pointerId !== e.pointerId) return; + if (!d || d.pointerId !== e.pointerId) { + // Echter Ruhezustand (kein Drag/Werkzeug/Marquee/Pan) — Hover- + // Teilpunkte-Erkennung. + updateHoverDivide(e.clientX, e.clientY); + return; + } const { scale } = measure(d.startView); // Bildschirm-Delta → viewBox-Einheiten; Inhalt folgt dem Cursor, also // verschiebt sich der Ausschnitt entgegengesetzt. @@ -2224,7 +2310,7 @@ export const PlanView = forwardRef( onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} - onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); }} + onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); clearHoverDivide(); }} onWheel={onWheel} onDoubleClick={onDoubleClick} onContextMenu={onContextMenuHandler} @@ -2577,6 +2663,36 @@ export const PlanView = forwardRef( hudFields={hudFields ?? null} /> )} + {/* Hover-Teilpunkte (s. hoverDivideSeg oben): kleine Punkte in Akzentfarbe + an den `hoverDivideCount`-gleichen Teilpunkten des gehoverten Segments + (2 = nur Mitte). Nur im Ruhezustand (kein Werkzeug aktiv). */} + {hoverDivideSeg && !toolActive && (() => { + const vbPerPx = 1 / (meetScale(view, svgRef.current) ?? 1); + const r = 3.5 * vbPerPx; + const n = Math.max(2, Math.round(hoverDivideCount)); + const { a, b } = hoverDivideSeg; + const pts = Array.from({ length: n - 1 }, (_, i) => { + const t = (i + 1) / n; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; + }); + return ( + + {pts.map((pt, i) => { + const s = toScreen(pt); + return ( + + ); + })} + + ); + })()} {/* Inline-Text-Editor DIREKT auf der Zeichenfläche (kein Dialog, Nutzer- Feedback) — für ein frisch über `text`/`textbox` erzeugtes Element diff --git a/src/tools/types.ts b/src/tools/types.ts index d974d86..d70b793 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -71,6 +71,18 @@ export interface SnapSettings { commonAngles: boolean; /** Toleranz (Grad) für das weiche Winkel-Einrasten. */ commonAngleTolerance: number; + /** + * Verweilt der Cursor länger als diese Zeit (ms) ruhig auf einer Linie + * (2D-Element, kein aktiver Zug/Werkzeug), werden ihre Teilpunkte (s. + * `hoverDivideCount`) kurz mit Akzentfarbe eingeblendet — reine + * Sichtbarkeits-Hilfe, kein zusätzliches Snap-Ziel. + */ + hoverDivideDelayMs: number; + /** + * In wie viele gleiche Teile die gehoverte Linie dabei angezeigt wird + * (2 = nur der Mittelpunkt, 3 = zwei Drittelpunkte, usw.). + */ + hoverDivideCount: number; } /** Default-Snap-Einstellungen (endpoint/midpoint/intersection/onEdge + grid). */ @@ -88,6 +100,8 @@ export const DEFAULT_SNAP: SnapSettings = { tolerancePx: 12, commonAngles: true, commonAngleTolerance: 2.5, + hoverDivideDelayMs: 300, + hoverDivideCount: 2, }; // ── Werkzeug-Schnittstelle ────────────────────────────────────────────────── diff --git a/src/ui/StatusBar.tsx b/src/ui/StatusBar.tsx index 7a74031..445373d 100644 --- a/src/ui/StatusBar.tsx +++ b/src/ui/StatusBar.tsx @@ -245,6 +245,35 @@ function SnapControl({ } /> + {/* Hover-Teilpunkte (rein visuell, unabhängig vom Fang selbst — daher + NICHT an snap.enabled gekoppelt): Ruht der Cursor auf einer Linie, + erscheinen nach der Verzögerung ihre Teilpunkte mit Akzentfarbe. */} + + )} diff --git a/src/views/viewportContent.tsx b/src/views/viewportContent.tsx index bf9a561..4db2a33 100644 --- a/src/views/viewportContent.tsx +++ b/src/views/viewportContent.tsx @@ -62,6 +62,8 @@ export function Content({ hairline, marqueeColor, snapColor, + hoverDivideDelayMs, + hoverDivideCount, renderMode, view3d, fov, @@ -141,6 +143,8 @@ export function Content({ marqueeColor: string; /** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */ snapColor: string; + hoverDivideDelayMs?: number; + hoverDivideCount?: number; renderMode: RenderMode; /** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */ view3d: View3d; @@ -291,6 +295,8 @@ export function Content({ hairline={hairline} marqueeColor={marqueeColor} snapColor={snapColor} + hoverDivideDelayMs={hoverDivideDelayMs} + hoverDivideCount={hoverDivideCount} mono={mono} planViewRef={planViewRef} onCursor={onCursor} @@ -353,6 +359,8 @@ export function Content({ hairline={hairline} marqueeColor={marqueeColor} snapColor={snapColor} + hoverDivideDelayMs={hoverDivideDelayMs} + hoverDivideCount={hoverDivideCount} mono={mono} planViewRef={planViewRef} onCursor={onCursor} @@ -458,6 +466,8 @@ export function Content({ hairline={hairline} marqueeColor={marqueeColor} snapColor={snapColor} + hoverDivideDelayMs={hoverDivideDelayMs} + hoverDivideCount={hoverDivideCount} mono={mono} planViewRef={planViewRef} onCursor={onCursor} @@ -511,6 +521,8 @@ function LevelPlanView({ hairline, marqueeColor, snapColor, + hoverDivideDelayMs, + hoverDivideCount, mono, planViewRef, onCursor, @@ -557,6 +569,8 @@ function LevelPlanView({ hairline: boolean; marqueeColor: string; snapColor: string; + hoverDivideDelayMs?: number; + hoverDivideCount?: number; mono: boolean; planViewRef: React.RefObject; onCursor: (model: Vec2 | null) => void; @@ -678,6 +692,8 @@ function LevelPlanView({ hairline={hairline} marqueeColor={marqueeColor} snapColor={snapColor} + hoverDivideDelayMs={hoverDivideDelayMs} + hoverDivideCount={hoverDivideCount} selectedDrawingId={selectedDrawingId} selectedDrawingIds={selectedDrawingIds} gripHandlers={gripHandlers} @@ -703,6 +719,8 @@ function SectionPlanView({ hairline, marqueeColor, snapColor, + hoverDivideDelayMs, + hoverDivideCount, mono, planViewRef, onCursor, @@ -744,6 +762,8 @@ function SectionPlanView({ hairline: boolean; marqueeColor: string; snapColor: string; + hoverDivideDelayMs?: number; + hoverDivideCount?: number; mono: boolean; planViewRef: React.RefObject; onCursor: (model: Vec2 | null) => void; @@ -890,6 +910,8 @@ function SectionPlanView({ hairline={hairline} marqueeColor={marqueeColor} snapColor={snapColor} + hoverDivideDelayMs={hoverDivideDelayMs} + hoverDivideCount={hoverDivideCount} selectedDrawingId={selectedDrawingId} selectedDrawingIds={selectedDrawingIds} gripHandlers={gripHandlers}