605e8910d3
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.
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
/**
|
||
* 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");
|
||
});
|
||
});
|