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). */