Files
DOSSIER-STANDALONE/src/tools/snapping.test.ts
T
karim 605e8910d3 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.
2026-08-22 00:42:40 +02:00

416 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit-Tests für den Wand-Schichttrennlinien-Snap:
* • `wallLayerBoundarySegments` muss die kumulierten Schicht-Offsets EXAKT so
* berechnen wie `addWallPoche` in `plan/generatePlan.ts` (Start bei
* `refOff − total/2`, danach `+= layer.thickness` je Schicht, inkl. beider
* Aussenflächen) — inklusive Referenzversatz (`center`/`left`/`right`).
* • `computeSnap` muss auf diesen Grenzlinien fangen (Endpunkt-Snap ist per
* Default aktiv), damit z. B. ein Decken-Umrisspunkt an einer einzelnen
* Wandschicht statt nur an Achse/Aussenkante enden kann.
*/
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";
/** Minimalprojekt mit einer dreischichtigen Wand (0.1 / 0.15 / 0.2 m). */
function wallProject(wall: Wall): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [
{
id: "aw",
name: "AW",
layers: [
{ componentId: "c", thickness: 0.1 },
{ componentId: "c", thickness: 0.15 },
{ componentId: "c", thickness: 0.2 },
],
},
],
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [wall],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function wall(id: string, start: Vec2, end: Vec2, referenceLine?: Wall["referenceLine"]): Wall {
return {
id,
type: "wall",
floorId: "eg",
categoryCode: "20",
start,
end,
wallTypeId: "aw",
height: 2.6,
referenceLine,
};
}
describe("wallLayerBoundarySegments", () => {
it("liefert layers.length+1 Grenzlinien mit kumulierten Offsets (referenceLine=center)", () => {
const w = wall("W1", { x: 0, y: 0 }, { x: 4, y: 0 });
const segs = wallLayerBoundarySegments(wallProject(w), w);
expect(segs).toHaveLength(4); // 3 Schichten → 4 Grenzlinien
// Achse horizontal → leftNormal = (0,1); Offset erscheint als y-Koordinate.
const offsets = segs.map(([a]) => a.y);
expect(offsets[0]).toBeCloseTo(-0.225, 9); // −T/2 (Aussenfläche)
expect(offsets[1]).toBeCloseTo(-0.125, 9); // −T/2 + 0.10
expect(offsets[2]).toBeCloseTo(0.025, 9); // −T/2 + 0.10 + 0.15
expect(offsets[3]).toBeCloseTo(0.225, 9); // +T/2 (Aussenfläche, innen)
// Jede Grenzlinie läuft parallel zur Achse über die volle Wandlänge.
for (const [a, b] of segs) {
expect(a.y).toBeCloseTo(b.y, 9);
expect(a.x).toBeCloseTo(0, 9);
expect(b.x).toBeCloseTo(4, 9);
}
});
it("verschiebt die Offsets um wallReferenceOffset bei referenceLine=left/right", () => {
const wLeft = wall("W1", { x: 0, y: 0 }, { x: 4, y: 0 }, "left");
const offLeft = wallLayerBoundarySegments(wallProject(wLeft), wLeft).map(([a]) => a.y);
// refOff = +T/2 = 0.225 → Offsets laufen von 0 bis T (0.45).
expect(offLeft[0]).toBeCloseTo(0, 9);
expect(offLeft[3]).toBeCloseTo(0.45, 9);
const wRight = wall("W1", { x: 0, y: 0 }, { x: 4, y: 0 }, "right");
const offRight = wallLayerBoundarySegments(wallProject(wRight), wRight).map(([a]) => a.y);
// refOff = −T/2 = −0.225 → Offsets laufen von −0.45 bis 0.
expect(offRight[0]).toBeCloseTo(-0.45, 9);
expect(offRight[3]).toBeCloseTo(0, 9);
});
it("liefert eine leere Liste bei unbekanntem Wandtyp (kein Crash im Snap-Pfad)", () => {
const w = wall("W1", { x: 0, y: 0 }, { x: 4, y: 0 });
w.wallTypeId = "unknown";
expect(wallLayerBoundarySegments(wallProject(w), w)).toEqual([]);
});
});
describe("computeSnap — fängt auf Wand-Schichttrennlinien", () => {
it("fängt den Endpunkt einer inneren Schichtgrenze (nicht nur Achse/Aussenkante)", () => {
const w = wall("W1", { x: 0, y: 0 }, { x: 4, y: 0 });
const project = wallProject(w);
// Endpunkt der Schichtgrenze bei y=0.025 (Fuge zwischen Schicht 2 und 3).
const raw: Vec2 = { x: 0.01, y: 0.02 };
const result = computeSnap({
raw,
project,
levelId: "eg",
visibleCodes: new Set(["20"]),
settings: DEFAULT_SNAP,
draftPoints: [],
lastPoint: null,
pxPerMeter: 100,
shift: false,
ctrl: false,
});
expect(result).not.toBeNull();
expect(result!.point.x).toBeCloseTo(0, 6);
expect(result!.point.y).toBeCloseTo(0.025, 6);
});
});
// ── Kreis-/Bogen-Snaps (Center/Quadrant/Bogen-Endpunkt) ──────────────────────
/** Minimalprojekt mit gegebenen 2D-Geometrien auf dem Geschoss „eg". */
function drawProject(geoms: Drawing2D["geom"][]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [],
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: geoms.map((geom, i) => ({
id: `d${i}`,
type: "drawing2d" as const,
levelId: "eg",
categoryCode: "20",
geom,
})),
context: [],
};
}
function snapAt(project: Project, raw: Vec2, settings = DEFAULT_SNAP) {
return computeSnap({
raw,
project,
levelId: "eg",
visibleCodes: new Set(["20"]),
settings,
draftPoints: [],
lastPoint: null,
pxPerMeter: 100,
shift: false,
ctrl: false,
});
}
describe("computeSnap — Kreis/Bogen", () => {
const withCenter = { ...DEFAULT_SNAP, center: true };
it("fängt Mittelpunkt + Quadranten eines Kreises (center aktiv)", () => {
const p = drawProject([{ shape: "circle", center: { x: 0, y: 0 }, r: 1 }]);
const c = snapAt(p, { x: 0, y: 0 }, withCenter);
expect(c?.kind).toBe("center");
expect(c!.point.x).toBeCloseTo(0, 6);
expect(c!.point.y).toBeCloseTo(0, 6);
const q = snapAt(p, { x: 1, y: 0 }, withCenter);
expect(q?.kind).toBe("quadrant");
expect(q!.point.x).toBeCloseTo(1, 6);
expect(q!.point.y).toBeCloseTo(0, 6);
});
it("fängt Bogen-Endpunkte (endpoint per Default aktiv)", () => {
// Viertelbogen um (5,0), r=1, 0…90° → Endpunkte (6,0) und (5,1).
const p = drawProject([
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
]);
const e = snapAt(p, { x: 6, y: 0 });
expect(e?.kind).toBe("endpoint");
expect(e!.point.x).toBeCloseTo(6, 6);
expect(e!.point.y).toBeCloseTo(0, 6);
});
it("bietet Quadranten AUSSERHALB der Bogen-Spanne NICHT an", () => {
// Viertelbogen 0…90°: der West-Quadrant (4,0) liegt NICHT im Bogen.
const p = drawProject([
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
]);
const r = snapAt(p, { x: 4, y: 0 }, withCenter);
expect(r?.kind).not.toBe("quadrant");
});
});
// ── Weiches Winkel-Einrasten (snapCommonAngle) ──────────────────────────────
describe("snapCommonAngle", () => {
const O: Vec2 = { x: 0, y: 0 };
it("lässt einen exakten 45°-Winkel unverändert", () => {
const r = snapCommonAngle(O, { x: 2, y: 2 });
expect(r).not.toBeNull();
expect(r!.angleDeg).toBeCloseTo(45, 9);
expect(r!.point.x).toBeCloseTo(2, 9);
expect(r!.point.y).toBeCloseTo(2, 9);
});
it("rastet 43.8° (innerhalb 2.5° Toleranz) auf 45°", () => {
const rad = (43.8 * Math.PI) / 180;
const raw: Vec2 = { x: Math.cos(rad) * 5, y: Math.sin(rad) * 5 };
const r = snapCommonAngle(O, raw, 2.5);
expect(r).not.toBeNull();
expect(r!.angleDeg).toBeCloseTo(45, 9);
});
it("rastet NICHT bei 40° (5° Abweichung > 2.5° Toleranz)", () => {
const rad = (40 * Math.PI) / 180;
const raw: Vec2 = { x: Math.cos(rad) * 5, y: Math.sin(rad) * 5 };
const r = snapCommonAngle(O, raw, 2.5);
expect(r).toBeNull();
});
it("Länge = Projektion der Roh-Distanz auf den gerasterten Strahl", () => {
const rad = (43.8 * Math.PI) / 180;
const rawLen = 5;
const raw: Vec2 = { x: Math.cos(rad) * rawLen, y: Math.sin(rad) * rawLen };
const r = snapCommonAngle(O, raw, 2.5);
expect(r).not.toBeNull();
const expectedLen = rawLen * Math.cos(((45 - 43.8) * Math.PI) / 180);
const len = Math.hypot(r!.point.x, r!.point.y);
expect(len).toBeCloseTo(expectedLen, 9);
// Der gerastete Punkt liegt exakt auf dem 45°-Strahl.
expect(r!.point.x).toBeCloseTo(r!.point.y, 9);
});
it("normalisiert auf den Bereich (−180,180] — z. B. −135°", () => {
const rad = (-133 * Math.PI) / 180;
const raw: Vec2 = { x: Math.cos(rad) * 3, y: Math.sin(rad) * 3 };
const r = snapCommonAngle(O, raw, 2.5);
expect(r).not.toBeNull();
expect(r!.angleDeg).toBeCloseTo(-135, 9);
});
it("deckt alle 15°-Vielfachen ab (0/30/45/60/90/135/…)", () => {
for (const deg of [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, -15, -90, -135]) {
const rad = (deg * Math.PI) / 180;
const raw: Vec2 = { x: Math.cos(rad) * 4, y: Math.sin(rad) * 4 };
const r = snapCommonAngle(O, raw, 2.5);
expect(r, `deg=${deg}`).not.toBeNull();
expect(r!.angleDeg, `deg=${deg}`).toBeCloseTo(deg === -180 ? 180 : deg, 9);
}
});
it("liefert null bei Roh-Distanz ≈ 0 (kein Winkel bestimmbar)", () => {
expect(snapCommonAngle(O, { x: 1e-10, y: 0 })).toBeNull();
});
});
describe("computeSnap — Objekt-Snap hat Vorrang vor dem weichen Winkelraster", () => {
it("fängt den Endpunkt statt auf das Winkelraster einzurasten, wenn beide zutreffen", () => {
// Wand-Endpunkt bei (5,5) liegt exakt auf dem 45°-Strahl ab (0,0) — beide
// Kandidaten (Endpunkt UND Winkelraster) träfen zu; der Endpunkt muss gewinnen.
const w = wall2d({ x: 5, y: 5 }, { x: 8, y: 5 });
const project = drawProject([]);
project.walls = [w];
const result = computeSnap({
raw: { x: 5.02, y: 5.02 },
project,
levelId: "eg",
visibleCodes: new Set(["20"]),
settings: DEFAULT_SNAP,
draftPoints: [],
lastPoint: { x: 0, y: 0 },
pxPerMeter: 100,
shift: false,
ctrl: false,
});
expect(result?.kind).toBe("endpoint");
expect(result!.point.x).toBeCloseTo(5, 6);
expect(result!.point.y).toBeCloseTo(5, 6);
});
it("rastet auf das Winkelraster, wenn KEIN Objekt-Snap in Reichweite ist", () => {
const project = drawProject([]);
const rad = (43.8 * Math.PI) / 180;
const raw: Vec2 = { x: Math.cos(rad) * 5, y: Math.sin(rad) * 5 };
const result = computeSnap({
raw,
project,
levelId: "eg",
visibleCodes: new Set(["20"]),
settings: DEFAULT_SNAP,
draftPoints: [],
lastPoint: { x: 0, y: 0 },
pxPerMeter: 100,
shift: false,
ctrl: false,
});
expect(result?.kind).toBe("angle");
expect(result?.angleDeg).toBeCloseTo(45, 9);
});
it("respektiert settings.commonAngles=false (kein Winkelraster-Fallback)", () => {
const project = drawProject([]);
const rad = (45 * Math.PI) / 180;
const raw: Vec2 = { x: Math.cos(rad) * 5, y: Math.sin(rad) * 5 };
const result = computeSnap({
raw,
project,
levelId: "eg",
visibleCodes: new Set(["20"]),
settings: { ...DEFAULT_SNAP, commonAngles: false, grid: false },
draftPoints: [],
lastPoint: { x: 0, y: 0 },
pxPerMeter: 100,
shift: false,
ctrl: false,
});
expect(result).toBeNull();
});
});
/** Minimal-Wand (nur für Endpunkt-Snap-Vorrang-Tests) — kein Wandtyp nötig. */
function wall2d(start: Vec2, end: Vec2): Wall {
return {
id: "w-angle-test",
type: "wall",
floorId: "eg",
categoryCode: "20",
start,
end,
wallTypeId: "unknown",
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");
});
});