diff --git a/src/plan/planView/overlays.tsx b/src/plan/planView/overlays.tsx index da2f337..cf1e353 100644 --- a/src/plan/planView/overlays.tsx +++ b/src/plan/planView/overlays.tsx @@ -356,6 +356,17 @@ function SnapMarker({ vectorEffect="non-scaling-stroke" /> )} + {/* Teilpunkt (nur fangbar nach Verweilen, s. computeSnap-Dwell) — kleine + gefüllte Raute, bewusst anders als Mittelpunkt (Linie) und Raster + (Kreis), damit „warum snappt es hier" nachvollziehbar bleibt. */} + {snap.kind === "divide" && ( + + )} {snap.kind === "intersection" && ( <> diff --git a/src/tools/snapping.test.ts b/src/tools/snapping.test.ts index 2496524..4b9e090 100644 --- a/src/tools/snapping.test.ts +++ b/src/tools/snapping.test.ts @@ -9,7 +9,7 @@ * Wandschicht statt nur an Achse/Aussenkante enden kann. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { computeSnap, snapCommonAngle, wallLayerBoundarySegments } from "./snapping"; import { DEFAULT_SNAP } from "./types"; import type { Drawing2D, Project, Vec2, Wall } from "../model/types"; @@ -368,3 +368,48 @@ function wall2d(start: Vec2, end: Vec2): Wall { height: 2.6, }; } + +// ── Teilpunkte (divide) — nur fangbar nach Verweilen ──────────────────────── +// computeSnap hält den Verweil-Zustand modulweit (mehrere, unabhängige +// Aufrufer teilen ihn sich, s. Kommentar dort) — Date.now()-basiert, daher +// hier mit Fake-Timern gesteuert statt echter Wartezeit. +describe("computeSnap — Teilpunkte (divide) nach Verweilen", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("fängt NICHT auf dem Drittelpunkt, solange der Cursor noch nicht lange genug verweilt hat", () => { + const project = drawProject([{ shape: "line", a: { x: 0, y: 0 }, b: { x: 9, y: 0 } }]); + const settings = { ...DEFAULT_SNAP, hoverDivideDelayMs: 300, hoverDivideCount: 3 }; + const result = snapAt(project, { x: 3, y: 0.02 }, settings); + expect(result?.kind).not.toBe("divide"); + }); + + it("fängt auf dem Drittelpunkt, sobald der Cursor lange genug auf derselben Linie verweilt hat", () => { + const project = drawProject([{ shape: "line", a: { x: 0, y: 0 }, b: { x: 9, y: 0 } }]); + const settings = { ...DEFAULT_SNAP, hoverDivideDelayMs: 300, hoverDivideCount: 3 }; + const raw: Vec2 = { x: 3, y: 0.02 }; + snapAt(project, raw, settings); // erster Aufruf verankert nur den Verweil-Beginn + vi.advanceTimersByTime(350); + const result = snapAt(project, raw, settings); + expect(result?.kind).toBe("divide"); + expect(result?.point.x).toBeCloseTo(3, 6); // 1/3 von 9 + expect(result?.point.y).toBeCloseTo(0, 6); + }); + + it("verwirft den Verweil, sobald der Cursor zwischenzeitlich auf eine ANDERE Linie wechselt", () => { + const project = drawProject([ + { shape: "line", a: { x: 0, y: 0 }, b: { x: 9, y: 0 } }, + { shape: "line", a: { x: 0, y: 5 }, b: { x: 9, y: 5 } }, + ]); + const settings = { ...DEFAULT_SNAP, hoverDivideDelayMs: 300, hoverDivideCount: 3 }; + snapAt(project, { x: 3, y: 0.02 }, settings); + vi.advanceTimersByTime(350); + snapAt(project, { x: 3, y: 5.02 }, settings); // andere Linie dazwischen + const result = snapAt(project, { x: 3, y: 0.02 }, settings); + expect(result?.kind).not.toBe("divide"); + }); +}); diff --git a/src/tools/snapping.ts b/src/tools/snapping.ts index 94d0f64..aa850c8 100644 --- a/src/tools/snapping.ts +++ b/src/tools/snapping.ts @@ -28,6 +28,7 @@ const PRIORITY: Record = { ortho: 0, extension: 0, angle: 0, + divide: 3, }; export interface SnapInput { @@ -141,6 +142,58 @@ function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 { return { x: a.x + dx * t, y: a.y + dy * t }; } +// ── Verweil-Erkennung fürs Teilpunkte-Snapping ("divide") ─────────────────── +// Modul-weiter Zustand (wie `lastCoalesceKey` in projectSlice.ts) statt in +// SnapInput/computeSnap-Parametern: computeSnap wird von mehreren, völlig +// unabhängigen Stellen aufgerufen (Zeichnen über die Command-Engine UND die +// älteren Legacy-Werkzeuge, Griff-Ziehen, Transformieren) — der Verweil- +// Zustand muss all diese Aufrufer TEILEN, damit "verweilt der Cursor lange +// genug auf derselben Linie" aufrufer-übergreifend gilt (Nutzer-Wunsch: +// "wenn der Snappunkt eines neuen Elements auch [z. B. 200ms] auf der Linie +// ist, sollten die Teilpunkte auch snapbar sein — aber NUR dann"). +let dwellSegKey: string | null = null; +let dwellSince = 0; +let dwellRevealedSeg: Seg | null = null; + +const segKey = (seg: Seg): string => + `${seg[0].x},${seg[0].y}|${seg[1].x},${seg[1].y}`; + +/** Nächstgelegene Strecke zu `raw` innerhalb der Fangtoleranz (Meter), oder null. */ +function nearestSegment(raw: Vec2, segs: Seg[], tolM: number): Seg | null { + let best: Seg | null = null; + let bestD = tolM; + for (const seg of segs) { + const d = dist(perpFoot(raw, seg[0], seg[1]), raw); + if (d < bestD) { + bestD = d; + best = seg; + } + } + return best; +} + +/** + * Verankert/verwirft den Verweil-Zustand für den aktuellen Cursor und liefert + * die "freigegebene" Strecke (Teilpunkte fangbar), sobald der Cursor lange + * genug (`delayMs`) auf DERSELBEN Strecke geblieben ist — sonst `null`. Ein + * Streckenwechsel (auch zu "keine Strecke") setzt die Verweildauer zurück. + */ +function updateDwell(raw: Vec2, segs: Seg[], delayMs: number, tolM: number): Seg | null { + const seg = nearestSegment(raw, segs, tolM); + const key = seg ? segKey(seg) : null; + const now = Date.now(); + if (key !== dwellSegKey) { + dwellSegKey = key; + dwellSince = now; + dwellRevealedSeg = null; + return null; + } + if (seg && dwellRevealedSeg === null && now - dwellSince >= delayMs) { + dwellRevealedSeg = seg; + } + return dwellRevealedSeg; +} + /** * Bestimmt den effektiven Snap für den rohen Cursor-Punkt (oder null). * Punkt-Snaps haben Vorrang vor Ortho und Raster (Prioritäts-Bonus). @@ -164,10 +217,22 @@ export function computeSnap(input: SnapInput): SnapResult | null { } }; - const segs = - settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint - ? collectSegments(input) - : []; + const needSegs = + settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint; + const segs = needSegs ? collectSegments(input) : []; + + // Teilpunkt-Verweilerkennung: braucht dieselben Strecken wie oben. Ohne sie + // (alle vier Objekt-Snap-Arten aus) bleibt "divide" ebenfalls inaktiv — + // konsistent dazu, dass dann ohnehin keine Linien-bezogenen Snaps gelten. + const revealed = updateDwell(raw, segs, settings.hoverDivideDelayMs, tolM); + if (revealed) { + const [a, b] = revealed; + const n = Math.max(2, Math.round(settings.hoverDivideCount)); + for (let i = 1; i < n; i++) { + const t = i / n; + consider({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }, "divide"); + } + } // endpoint (Strecken-Endpunkte + Draft-Knoten + Raum-Stempel-Anker). if (settings.endpoint) { diff --git a/src/tools/types.ts b/src/tools/types.ts index d70b793..d3fb446 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -37,7 +37,15 @@ export type SnapKind = | "grid" | "ortho" | "extension" - | "angle"; + | "angle" + /** + * Teilpunkt einer Linie, NUR fangbar, solange der Cursor lange genug (s. + * `SnapSettings.hoverDivideDelayMs`) auf DERSELBEN Linie verweilt hat (s. + * `computeSnap`s Verweil-Erkennung) — bewusst NICHT dauerhaft aktiv wie die + * übrigen Snap-Arten, sonst würde jede N-tel-Position jeder Linie den + * Fang-Kandidatenraum überfluten. + */ + | "divide"; export interface SnapResult { /** Gefangener Punkt (Meter). */