Snap an Wand-Schichttrennlinien

Die Grenzlinien zwischen den Wandschichten (achsparallel, aus der addWallPoche-
Offset-Logik gespiegelt: refOff-total/2, je Schicht +thickness, n+1 Linien inkl.
Aussenflaechen) werden als Snap-Segmente in collectSegments eingereiht. Damit
fangen die bestehenden Snap-Arten (Endpunkt/Mittelpunkt/Schnittpunkt/Lot)
automatisch daran — Decken/Elemente koennen an einzelnen Wandschichten fangen.
Kein neuer SnapKind/Toggle noetig (Default-aktiv). 4 neue Tests, 156 gruen.
This commit is contained in:
2026-07-04 02:23:33 +02:00
parent d02781e8d2
commit 339202b355
2 changed files with 174 additions and 2 deletions
+136
View File
@@ -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);
});
});
+38 -2
View File
@@ -6,8 +6,10 @@
// Prioritäts-Reihenfolge (Vorrang bei nahem Abstand): endpoint > intersection > // Prioritäts-Reihenfolge (Vorrang bei nahem Abstand): endpoint > intersection >
// midpoint > onEdge > grid; ortho/Winkelraster ist eine Projektion (überlagert). // midpoint > onEdge > grid; ortho/Winkelraster ist eine Projektion (überlagert).
import type { Project, Vec2 } from "../model/types"; import type { Project, Vec2, Wall } from "../model/types";
import { lineIntersect } from "../model/geometry"; 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 { centroid as roomCentroid } from "../geometry/roomArea";
import type { SnapKind, SnapResult, SnapSettings } from "./types"; import type { SnapKind, SnapResult, SnapSettings } from "./types";
@@ -43,6 +45,39 @@ export interface SnapInput {
/** Eine Strecke als Snap-Quelle (Wandachse oder 2D-Segment). */ /** Eine Strecke als Snap-Quelle (Wandachse oder 2D-Segment). */
type Seg = [Vec2, Vec2]; 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). */ /** Sammelt alle sichtbaren Strecken des Geschosses (Wandachsen + 2D-Segmente). */
function collectSegments(input: SnapInput): Seg[] { function collectSegments(input: SnapInput): Seg[] {
const segs: Seg[] = []; const segs: Seg[] = [];
@@ -50,6 +85,7 @@ function collectSegments(input: SnapInput): Seg[] {
if (w.floorId !== input.levelId) continue; if (w.floorId !== input.levelId) continue;
if (!input.visibleCodes.has(w.categoryCode)) continue; if (!input.visibleCodes.has(w.categoryCode)) continue;
segs.push([w.start, w.end]); segs.push([w.start, w.end]);
for (const seg of wallLayerBoundarySegments(input.project, w)) segs.push(seg);
} }
for (const d of input.project.drawings2d) { for (const d of input.project.drawings2d) {
if (d.levelId !== input.levelId) continue; if (d.levelId !== input.levelId) continue;