diff --git a/src/tools/snapping.test.ts b/src/tools/snapping.test.ts new file mode 100644 index 0000000..e154ec8 --- /dev/null +++ b/src/tools/snapping.test.ts @@ -0,0 +1,136 @@ +/** + * 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 } from "vitest"; +import { computeSnap, wallLayerBoundarySegments } from "./snapping"; +import { DEFAULT_SNAP } from "./types"; +import type { 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); + }); +}); diff --git a/src/tools/snapping.ts b/src/tools/snapping.ts index 41ca434..3321b29 100644 --- a/src/tools/snapping.ts +++ b/src/tools/snapping.ts @@ -6,8 +6,10 @@ // Prioritäts-Reihenfolge (Vorrang bei nahem Abstand): endpoint > intersection > // midpoint > onEdge > grid; ortho/Winkelraster ist eine Projektion (überlagert). -import type { Project, Vec2 } from "../model/types"; -import { lineIntersect } from "../model/geometry"; +import type { Project, Vec2, Wall } from "../model/types"; +import { getWallType, wallTypeThickness } from "../model/types"; +import { add, leftNormal, lineIntersect, normalize, scale, sub } from "../model/geometry"; +import { wallReferenceOffset } from "../model/wall"; import { centroid as roomCentroid } from "../geometry/roomArea"; import type { SnapKind, SnapResult, SnapSettings } from "./types"; @@ -43,6 +45,39 @@ export interface SnapInput { /** Eine Strecke als Snap-Quelle (Wandachse oder 2D-Segment). */ type Seg = [Vec2, Vec2]; +/** + * Schichttrennlinien einer Wand: für jede kumulierte Schichtgrenze (inkl. + * beider Aussenflächen) eine zur Achse parallele Strecke, um den Betrag + * entlang `leftNormal(u)` verschoben. Spiegelt EXAKT die Offset-Berechnung aus + * `generatePlan.ts` (`addWallPoche`): Start bei `refOff − total/2` + * (`wallReferenceOffset`), danach je Schicht `+= layer.thickness` aufsummiert. + * Liefert `layers.length + 1` Segmente (leer bei unbekanntem Wandtyp). + */ +export function wallLayerBoundarySegments(project: Project, wall: Wall): Seg[] { + let wt; + try { + wt = getWallType(project, wall); + } catch { + return []; + } + const total = wallTypeThickness(wt); + const refOff = wallReferenceOffset(wall, total); + const u = normalize(sub(wall.end, wall.start)); + const n = leftNormal(u); + const out: Seg[] = []; + let off = refOff - total / 2; + const boundary = (o: number): Seg => [ + add(wall.start, scale(n, o)), + add(wall.end, scale(n, o)), + ]; + out.push(boundary(off)); // äussere Fläche + for (const layer of wt.layers) { + off += layer.thickness; + out.push(boundary(off)); // Schichtfuge (letzte = innere Fläche) + } + return out; +} + /** Sammelt alle sichtbaren Strecken des Geschosses (Wandachsen + 2D-Segmente). */ function collectSegments(input: SnapInput): Seg[] { const segs: Seg[] = []; @@ -50,6 +85,7 @@ function collectSegments(input: SnapInput): Seg[] { if (w.floorId !== input.levelId) continue; if (!input.visibleCodes.has(w.categoryCode)) continue; segs.push([w.start, w.end]); + for (const seg of wallLayerBoundarySegments(input.project, w)) segs.push(seg); } for (const d of input.project.drawings2d) { if (d.levelId !== input.levelId) continue;