2D: Teilpunkte werden auch beim Zeichnen snapbar, aber NUR nach Verweilen
Erweiterung des Hover-Teilpunkte-Features: bisher rein visuell im Auswahl-Ruhezustand (vorheriger Commit). Nutzer-Wunsch: "wenn der Snappunkt eines neuen Elements auch [z. B. 200ms] auf der Linie ist, sollten die Teilpunkte auch für eine gewisse Zeit angezeigt werden und snapbar sein. Aber nur dann!" -- also auch während des Zeichnens (Command-Engine, Legacy-Werkzeuge, Griff-Ziehen, Transformieren), aber bewusst NICHT dauerhaft (sonst würde jede N-tel-Position jeder Linie den Fang-Kandidatenraum überfluten). computeSnap() (tools/snapping.ts) bekommt eine modulweite Verweil- Erkennung (wie lastCoalesceKey in projectSlice.ts) -- bewusst MODULWEIT statt in SnapInput, weil computeSnap von mehreren unabhängigen Stellen aufgerufen wird (Command-Engine-Snap-Callback, Legacy-tool.onMove, Transform, Griff-Editieren) und der Verweil- Zustand aufrufer-übergreifend gelten muss, damit "verweilt der Cursor lange genug auf DERSELBEN Linie" unabhängig davon erkannt wird, WELCHER Aufrufer gerade snappt. Sobald erreicht, werden die `hoverDivideCount`- gleichen Teilpunkte der verweilten Strecke als normale Snap-Kandidaten (neuer SnapKind "divide", Priorität wie Mittelpunkt) berücksichtigt -- bei einem Streckenwechsel setzt sich die Verweildauer sofort zurück. Neuer Marker-Glyph (gefüllte Raute) in SnapMarker (overlays.tsx) für kind "divide" -- bewusst anders als Mittelpunkt (Linie) und Auf-Kante (Raute nur Umriss), damit nachvollziehbar bleibt, warum es gerade dort fängt. +3 Tests (Fake-Timer-gesteuert: kein Fang vor der Verzögerung, Fang danach exakt auf dem Drittelpunkt, Verwerfen bei Linienwechsel zwischendurch). tsc/vitest 925/925 grün.
This commit is contained in:
@@ -356,6 +356,17 @@ function SnapMarker({
|
|||||||
vectorEffect="non-scaling-stroke"
|
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" && (
|
||||||
|
<polygon
|
||||||
|
className="snap-glyph snap-fill"
|
||||||
|
points={`${s.x},${s.y - r} ${s.x + r},${s.y} ${s.x},${s.y + r} ${s.x - r},${s.y}`}
|
||||||
|
style={fillStyle}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{snap.kind === "intersection" && (
|
{snap.kind === "intersection" && (
|
||||||
<>
|
<>
|
||||||
<line className="snap-glyph" x1={s.x - r} y1={s.y - r} x2={s.x + r} y2={s.y + r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
|
<line className="snap-glyph" x1={s.x - r} y1={s.y - r} x2={s.x + r} y2={s.y + r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* Wandschicht statt nur an Achse/Aussenkante enden kann.
|
* 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 { computeSnap, snapCommonAngle, wallLayerBoundarySegments } from "./snapping";
|
||||||
import { DEFAULT_SNAP } from "./types";
|
import { DEFAULT_SNAP } from "./types";
|
||||||
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
|
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
|
||||||
@@ -368,3 +368,48 @@ function wall2d(start: Vec2, end: Vec2): Wall {
|
|||||||
height: 2.6,
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+69
-4
@@ -28,6 +28,7 @@ const PRIORITY: Record<SnapKind, number> = {
|
|||||||
ortho: 0,
|
ortho: 0,
|
||||||
extension: 0,
|
extension: 0,
|
||||||
angle: 0,
|
angle: 0,
|
||||||
|
divide: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SnapInput {
|
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 };
|
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).
|
* Bestimmt den effektiven Snap für den rohen Cursor-Punkt (oder null).
|
||||||
* Punkt-Snaps haben Vorrang vor Ortho und Raster (Prioritäts-Bonus).
|
* Punkt-Snaps haben Vorrang vor Ortho und Raster (Prioritäts-Bonus).
|
||||||
@@ -164,10 +217,22 @@ export function computeSnap(input: SnapInput): SnapResult | null {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const segs =
|
const needSegs =
|
||||||
settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint
|
settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint;
|
||||||
? collectSegments(input)
|
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).
|
// endpoint (Strecken-Endpunkte + Draft-Knoten + Raum-Stempel-Anker).
|
||||||
if (settings.endpoint) {
|
if (settings.endpoint) {
|
||||||
|
|||||||
+9
-1
@@ -37,7 +37,15 @@ export type SnapKind =
|
|||||||
| "grid"
|
| "grid"
|
||||||
| "ortho"
|
| "ortho"
|
||||||
| "extension"
|
| "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 {
|
export interface SnapResult {
|
||||||
/** Gefangener Punkt (Meter). */
|
/** Gefangener Punkt (Meter). */
|
||||||
|
|||||||
Reference in New Issue
Block a user