2D: Hover-Teilpunkte auf Linien (konfigurierbare Verzögerung + Anzahl)

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).
This commit is contained in:
2026-08-22 00:34:59 +02:00
parent c5c60d3df5
commit 2af8d6aa1b
7 changed files with 189 additions and 2 deletions
+2
View File
@@ -2739,6 +2739,8 @@ export default function App() {
hairline={lineMode === "display"} hairline={lineMode === "display"}
marqueeColor={marqueeColor} marqueeColor={marqueeColor}
snapColor={snapColor} snapColor={snapColor}
hoverDivideDelayMs={snap.hoverDivideDelayMs}
hoverDivideCount={snap.hoverDivideCount}
renderMode={renderMode} renderMode={renderMode}
view3d={view3d} view3d={view3d}
fov={fov} fov={fov}
+2
View File
@@ -241,6 +241,8 @@ export const de = {
"snap.angleStep": "Winkelraster", "snap.angleStep": "Winkelraster",
"snap.commonAngles": "Gängige Winkel", "snap.commonAngles": "Gängige Winkel",
"snap.commonAngleTolerance": "Winkeltoleranz (°)", "snap.commonAngleTolerance": "Winkeltoleranz (°)",
"snap.hoverDivideDelayMs": "Hover-Verzögerung (ms)",
"snap.hoverDivideCount": "Teilpunkte (Anzahl)",
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ─────────────── // ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
"transform.bar": "Transformation", "transform.bar": "Transformation",
+2
View File
@@ -239,6 +239,8 @@ export const en: Record<TranslationKey, string> = {
"snap.angleStep": "Angle step", "snap.angleStep": "Angle step",
"snap.commonAngles": "Common angles", "snap.commonAngles": "Common angles",
"snap.commonAngleTolerance": "Angle tolerance (°)", "snap.commonAngleTolerance": "Angle tolerance (°)",
"snap.hoverDivideDelayMs": "Hover delay (ms)",
"snap.hoverDivideCount": "Division points (count)",
// Transform (move/mirror/rotate) + mode bar. // Transform (move/mirror/rotate) + mode bar.
"transform.bar": "Transform", "transform.bar": "Transform",
+118 -2
View File
@@ -410,6 +410,19 @@ export interface PlanViewProps {
* Einstellungs-Fenster (State in ViewSlice); Default = Akzent „Sora". * Einstellungs-Fenster (State in ViewSlice); Default = Akzent „Sora".
*/ */
snapColor?: string; 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). */ /** Treiber für das Ziehen von Editier-Griffen (von App bereitgestellt). */
@@ -493,6 +506,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
gripHandlers, gripHandlers,
marqueeColor = DEFAULT_MARQUEE_COLOR, marqueeColor = DEFAULT_MARQUEE_COLOR,
snapColor = DEFAULT_SNAP_COLOR, snapColor = DEFAULT_SNAP_COLOR,
hoverDivideDelayMs = 300,
hoverDivideCount = 2,
}, },
ref, ref,
) { ) {
@@ -813,6 +828,50 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
crossing: boolean; crossing: boolean;
} | null>(null); } | 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<string | null>(null);
const hoverDivideTimerRef = useRef<number | null>(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 // Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je
// Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove. // Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove.
// Anders als bei Kante/Körper braucht das Loslassen der Maus die Bearbeitung // Anders als bei Kante/Körper braucht das Loslassen der Maus die Bearbeitung
@@ -1067,6 +1126,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return pickDrawingText(clientX, clientY) ?? pickDrawingImage(clientX, clientY); 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- // Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
// Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen); // Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen);
// Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null. // Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null.
@@ -1615,7 +1696,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
} }
const d = drag.current; 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); const { scale } = measure(d.startView);
// Bildschirm-Delta → viewBox-Einheiten; Inhalt folgt dem Cursor, also // Bildschirm-Delta → viewBox-Einheiten; Inhalt folgt dem Cursor, also
// verschiebt sich der Ausschnitt entgegengesetzt. // verschiebt sich der Ausschnitt entgegengesetzt.
@@ -2224,7 +2310,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={endDrag} onPointerUp={endDrag}
onPointerCancel={endDrag} onPointerCancel={endDrag}
onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); }} onPointerLeave={() => { setRulerCursor(null); onCursor?.(null); clearHoverDivide(); }}
onWheel={onWheel} onWheel={onWheel}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
onContextMenu={onContextMenuHandler} onContextMenu={onContextMenuHandler}
@@ -2577,6 +2663,36 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
hudFields={hudFields ?? null} 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 (
<g className="plan-hover-divide" pointerEvents="none">
{pts.map((pt, i) => {
const s = toScreen(pt);
return (
<circle
key={i}
cx={s.x}
cy={s.y}
r={r}
style={{ fill: snapColor, stroke: snapColor }}
vectorEffect="non-scaling-stroke"
/>
);
})}
</g>
);
})()}
</svg> </svg>
{/* Inline-Text-Editor DIREKT auf der Zeichenfläche (kein Dialog, Nutzer- {/* Inline-Text-Editor DIREKT auf der Zeichenfläche (kein Dialog, Nutzer-
Feedback) — für ein frisch über `text`/`textbox` erzeugtes Element Feedback) — für ein frisch über `text`/`textbox` erzeugtes Element
+14
View File
@@ -71,6 +71,18 @@ export interface SnapSettings {
commonAngles: boolean; commonAngles: boolean;
/** Toleranz (Grad) für das weiche Winkel-Einrasten. */ /** Toleranz (Grad) für das weiche Winkel-Einrasten. */
commonAngleTolerance: number; 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). */ /** Default-Snap-Einstellungen (endpoint/midpoint/intersection/onEdge + grid). */
@@ -88,6 +100,8 @@ export const DEFAULT_SNAP: SnapSettings = {
tolerancePx: 12, tolerancePx: 12,
commonAngles: true, commonAngles: true,
commonAngleTolerance: 2.5, commonAngleTolerance: 2.5,
hoverDivideDelayMs: 300,
hoverDivideCount: 2,
}; };
// ── Werkzeug-Schnittstelle ────────────────────────────────────────────────── // ── Werkzeug-Schnittstelle ──────────────────────────────────────────────────
+29
View File
@@ -245,6 +245,35 @@ function SnapControl({
} }
/> />
</label> </label>
{/* 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. */}
<label className="sb-snap-row sb-snap-row-num">
<span>{t("snap.hoverDivideDelayMs")}</span>
<input
type="number"
step={50}
min={0}
max={3000}
value={snap.hoverDivideDelayMs}
onChange={(e) =>
onSnapChange({ ...snap, hoverDivideDelayMs: Number(e.target.value) })
}
/>
</label>
<label className="sb-snap-row sb-snap-row-num">
<span>{t("snap.hoverDivideCount")}</span>
<input
type="number"
step={1}
min={2}
max={12}
value={snap.hoverDivideCount}
onChange={(e) =>
onSnapChange({ ...snap, hoverDivideCount: Number(e.target.value) })
}
/>
</label>
</div> </div>
)} )}
</span> </span>
+22
View File
@@ -62,6 +62,8 @@ export function Content({
hairline, hairline,
marqueeColor, marqueeColor,
snapColor, snapColor,
hoverDivideDelayMs,
hoverDivideCount,
renderMode, renderMode,
view3d, view3d,
fov, fov,
@@ -141,6 +143,8 @@ export function Content({
marqueeColor: string; marqueeColor: string;
/** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */ /** Farbe der Snap-/Endpunkt-Hervorhebung im Grundriss. */
snapColor: string; snapColor: string;
hoverDivideDelayMs?: number;
hoverDivideCount?: number;
renderMode: RenderMode; renderMode: RenderMode;
/** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */ /** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */
view3d: View3d; view3d: View3d;
@@ -291,6 +295,8 @@ export function Content({
hairline={hairline} hairline={hairline}
marqueeColor={marqueeColor} marqueeColor={marqueeColor}
snapColor={snapColor} snapColor={snapColor}
hoverDivideDelayMs={hoverDivideDelayMs}
hoverDivideCount={hoverDivideCount}
mono={mono} mono={mono}
planViewRef={planViewRef} planViewRef={planViewRef}
onCursor={onCursor} onCursor={onCursor}
@@ -353,6 +359,8 @@ export function Content({
hairline={hairline} hairline={hairline}
marqueeColor={marqueeColor} marqueeColor={marqueeColor}
snapColor={snapColor} snapColor={snapColor}
hoverDivideDelayMs={hoverDivideDelayMs}
hoverDivideCount={hoverDivideCount}
mono={mono} mono={mono}
planViewRef={planViewRef} planViewRef={planViewRef}
onCursor={onCursor} onCursor={onCursor}
@@ -458,6 +466,8 @@ export function Content({
hairline={hairline} hairline={hairline}
marqueeColor={marqueeColor} marqueeColor={marqueeColor}
snapColor={snapColor} snapColor={snapColor}
hoverDivideDelayMs={hoverDivideDelayMs}
hoverDivideCount={hoverDivideCount}
mono={mono} mono={mono}
planViewRef={planViewRef} planViewRef={planViewRef}
onCursor={onCursor} onCursor={onCursor}
@@ -511,6 +521,8 @@ function LevelPlanView({
hairline, hairline,
marqueeColor, marqueeColor,
snapColor, snapColor,
hoverDivideDelayMs,
hoverDivideCount,
mono, mono,
planViewRef, planViewRef,
onCursor, onCursor,
@@ -557,6 +569,8 @@ function LevelPlanView({
hairline: boolean; hairline: boolean;
marqueeColor: string; marqueeColor: string;
snapColor: string; snapColor: string;
hoverDivideDelayMs?: number;
hoverDivideCount?: number;
mono: boolean; mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>; planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void; onCursor: (model: Vec2 | null) => void;
@@ -678,6 +692,8 @@ function LevelPlanView({
hairline={hairline} hairline={hairline}
marqueeColor={marqueeColor} marqueeColor={marqueeColor}
snapColor={snapColor} snapColor={snapColor}
hoverDivideDelayMs={hoverDivideDelayMs}
hoverDivideCount={hoverDivideCount}
selectedDrawingId={selectedDrawingId} selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds} selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers} gripHandlers={gripHandlers}
@@ -703,6 +719,8 @@ function SectionPlanView({
hairline, hairline,
marqueeColor, marqueeColor,
snapColor, snapColor,
hoverDivideDelayMs,
hoverDivideCount,
mono, mono,
planViewRef, planViewRef,
onCursor, onCursor,
@@ -744,6 +762,8 @@ function SectionPlanView({
hairline: boolean; hairline: boolean;
marqueeColor: string; marqueeColor: string;
snapColor: string; snapColor: string;
hoverDivideDelayMs?: number;
hoverDivideCount?: number;
mono: boolean; mono: boolean;
planViewRef: React.RefObject<PlanViewHandle>; planViewRef: React.RefObject<PlanViewHandle>;
onCursor: (model: Vec2 | null) => void; onCursor: (model: Vec2 | null) => void;
@@ -890,6 +910,8 @@ function SectionPlanView({
hairline={hairline} hairline={hairline}
marqueeColor={marqueeColor} marqueeColor={marqueeColor}
snapColor={snapColor} snapColor={snapColor}
hoverDivideDelayMs={hoverDivideDelayMs}
hoverDivideCount={hoverDivideCount}
selectedDrawingId={selectedDrawingId} selectedDrawingId={selectedDrawingId}
selectedDrawingIds={selectedDrawingIds} selectedDrawingIds={selectedDrawingIds}
gripHandlers={gripHandlers} gripHandlers={gripHandlers}