From a2c8fcd6de9d12a3d54bf2e09710f2c44ac2e660 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 23:53:25 +0200 Subject: [PATCH] =?UTF-8?q?Grundriss-Gehrung:=20Fl=C3=A4chenpaarung=20orie?= =?UTF-8?q?ntierungsbasiert=20statt=20per=20Distanz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An spitzen Wandecken wählte die Distanz-Heuristik in miterLine die falsche B-Fläche und lieferte eine um 90° verdrehte Gehrungslinie — die Poché-Bänder überlappten kreuzweise. Die Paarung erfolgt jetzt orientierungsbasiert: über die Auslaufrichtungen der Achsen (dot(n, d)) wird Aussenfläche mit Aussen-, Innen mit Innenfläche verschnitten, nie über Kreuz — für jeden Winkel. Rechte und stumpfe Ecken bleiben bit-identisch (Rust-Parität gewahrt). Kein Miter-Limit; die Gehrungslinie ist geometrisch exakt. Neuer Test joins.test.ts mit rechtwinkligem und spitzwinkligem Fall (fängt den alten Bug). --- src/model/geometry.ts | 3 ++ src/model/joins.test.ts | 104 ++++++++++++++++++++++++++++++++++++++++ src/model/joins.ts | 41 ++++++++++++---- 3 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 src/model/joins.test.ts diff --git a/src/model/geometry.ts b/src/model/geometry.ts index 50cdb3e..19aba38 100644 --- a/src/model/geometry.ts +++ b/src/model/geometry.ts @@ -19,6 +19,9 @@ export const leftNormal = (a: Vec2): Vec2 => ({ x: -a.y, y: a.x }); /** Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren. */ export const cross = (p: Vec2, q: Vec2): number => p.x * q.y - p.y * q.x; +/** Skalarprodukt zweier 2D-Vektoren. */ +export const dot = (p: Vec2, q: Vec2): number => p.x * q.x + p.y * q.y; + /** Eine unendliche Gerade als Stützpunkt + Richtung. */ export interface Line { point: Vec2; diff --git a/src/model/joins.test.ts b/src/model/joins.test.ts new file mode 100644 index 0000000..bb622f0 --- /dev/null +++ b/src/model/joins.test.ts @@ -0,0 +1,104 @@ +/** + * Unit-Tests für die Wand-Gehrung (computeJoins / miterLine): + * • Rechtwinklige L-Ecke: die Gehrung liegt auf der 45°-Diagonalen und trifft + * die korrekten Fläche×Fläche-Apexe. + * • SPITZE L-Ecke (Öffnungswinkel < 90°): die Gehrung muss weiterhin Außen mit + * Außen und Innen mit Innen paaren. Die frühere Distanz-Heuristik kippte hier + * und lieferte eine um 90° verdrehte Gehrung (Kreuz-Overlap der Poché-Bänder); + * dieser Test fängt den Regress. + */ + +import { describe, it, expect } from "vitest"; +import { computeJoins } from "./joins"; +import { cross, len, sub } from "./geometry"; +import type { Line } from "./geometry"; +import type { Project, Vec2, Wall } from "./types"; + +/** Abstand eines Punktes P von der unendlichen Geraden `line`. */ +function distToLine(line: Line, p: Vec2): number { + return Math.abs(cross(line.dir, sub(p, line.point))) / len(line.dir); +} + +/** Minimalprojekt aus zwei Wänden mit einem Wandtyp gegebener Gesamtdicke. */ +function twoWallProject(w1: Wall, w2: Wall, thickness: number): 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 }] }, + ], + 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: [w1, w2], + doors: [], + openings: [], + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + }; +} + +function wall(id: string, start: Vec2, end: Vec2): Wall { + return { + id, + type: "wall", + floorId: "eg", + categoryCode: "20", + start, + end, + wallTypeId: "aw", + height: 2.6, + }; +} + +describe("computeJoins — Gehrung an der L-Ecke", () => { + it("legt die rechtwinklige Gehrung auf die 45°-Diagonale (Fläche×Fläche-Apexe)", () => { + // W1 (0,0)→(5,0), W2 (5,0)→(5,4); Knoten (5,0). T=0.4, half=0.2. + const proj = twoWallProject( + wall("W1", { x: 0, y: 0 }, { x: 5, y: 0 }), + wall("W2", { x: 5, y: 0 }, { x: 5, y: 4 }), + 0.4, + ); + const joins = computeJoins(proj, proj.walls); + const cut = joins.get("W1")!.endCut; + expect(cut).not.toBeNull(); + // Außen-Apex (5.2,−0.2) und Innen-Apex (4.8,0.2) liegen auf der Gehrung; + // Richtung ist die 45°-Diagonale. + expect(distToLine(cut!, { x: 5.2, y: -0.2 })).toBeCloseTo(0, 6); + expect(distToLine(cut!, { x: 4.8, y: 0.2 })).toBeCloseTo(0, 6); + expect(Math.abs(cut!.dir.x)).toBeCloseTo(Math.abs(cut!.dir.y), 6); + }); + + it("paart bei SPITZEM Winkel Außen-mit-Außen und Innen-mit-Innen (kein Kreuz-Overlap)", () => { + // Symmetrische V-Ecke, Knoten (0,0), Öffnungswinkel 60° (spitz), nach oben. + // Beide Wandkörper laufen unter ±30° zur +y-Achse aus: + // gA = (−sin30, cos30) = (−0.5, √3/2), gB = (+0.5, √3/2). + // W_A: end im Knoten (start = gA·2), W_B: start im Knoten (end = gB·2). + const s3 = Math.sqrt(3) / 2; + const proj = twoWallProject( + wall("WA", { x: -1, y: 2 * s3 }, { x: 0, y: 0 }), + wall("WB", { x: 0, y: 0 }, { x: 1, y: 2 * s3 }), + 0.4, + ); + const joins = computeJoins(proj, proj.walls); + // Beide Wände teilen dieselbe Gehrung; WA endet im Knoten, WB startet dort. + const cut = joins.get("WA")!.endCut ?? joins.get("WB")!.startCut; + expect(cut).not.toBeNull(); + + // Korrekt: Außen-Apex (0,−0.4) und Innen-Apex (0,0.4) → vertikale Gehrung + // (Richtung parallel zur y-Achse). Die buggy Distanz-Heuristik lieferte + // stattdessen eine HORIZONTALE Gehrung (Apexe (±0.23,0)) → hier rot. + expect(distToLine(cut!, { x: 0, y: -0.4 })).toBeCloseTo(0, 6); + expect(distToLine(cut!, { x: 0, y: 0.4 })).toBeCloseTo(0, 6); + // Gehrung vertikal: dir.x ≈ 0 (die falsche, horizontale Gehrung hätte dir.y ≈ 0). + const dir = cut!.dir; + expect(Math.abs(dir.x) / len(dir)).toBeCloseTo(0, 6); + }); +}); diff --git a/src/model/joins.ts b/src/model/joins.ts index 5d64ea5..0d005e0 100644 --- a/src/model/joins.ts +++ b/src/model/joins.ts @@ -8,6 +8,7 @@ import { getWallType, wallTypeThickness } from "./types"; import { wallReferenceOffset } from "./wall"; import { add, + dot, leftNormal, len, lineIntersect, @@ -94,9 +95,16 @@ const dirOf = (w: Wall): Vec2 => normalize(sub(w.end, w.start)); /** * Gemeinsame Gehrungslinie zweier Wände A, B, die sich im Knoten J treffen. - * Robust gegen beliebige Wicklung und ungleiche Dicken: A's Außenfläche wird - * mit der NÄCHSTGELEGENEN Fläche von B verschnitten, A's Innenfläche mit der - * jeweils anderen. Die Gerade durch beide Eckpunkte ist die Gehrung. + * Robust gegen beliebige Wicklung, ungleiche Dicken und JEDEN Öffnungswinkel + * (inkl. spitzer): Die Wandflächen werden orientierungsbasiert (vorzeichen­ + * richtig) gepaart — A's Außenfläche verschneidet B's Außenfläche zum äußeren + * Apex, A's Innenfläche B's Innenfläche zum inneren Apex. „Außen" ist jeweils + * die vom Körper der anderen Wand ABGEWANDTE Fläche. Die Gerade durch beide + * Apexe ist die Gehrung; sie trennt beide Wandbänder überlappungsfrei. + * + * Die frühere Distanz-Heuristik („nächstgelegene B-Fläche") kippt bei spitzen + * Winkeln — dort wird die falsche Fläche zur näheren, sodass Außen mit Innen + * gepaart wird und die Poché-Bänder sich kreuzweise überlappen. */ function miterLine( project: Project, @@ -119,18 +127,33 @@ function miterLine( const offA = wallReferenceOffset(a, tA); const offB = wallReferenceOffset(b, tB); - // Flächen-Stützpunkte am Knoten (linke/rechte Wandseite), inkl. Referenzversatz. + // Flächen-Stützpunkte am Knoten: linke Fläche liegt auf +n, rechte auf −n + // (inkl. Referenzversatz). const pLA = add(j, scale(nA, offA + tA / 2)); const pRA = add(j, scale(nA, offA - tA / 2)); const pLB = add(j, scale(nB, offB + tB / 2)); const pRB = add(j, scale(nB, offB - tB / 2)); - // Für A's linke Fläche die nähere B-Fläche wählen; A's rechte bekommt die andere. - const lbCloser = - len(sub(pLA, pLB)) <= len(sub(pLA, pRB)); - const bForLeft = lbCloser ? pLB : pRB; - const bForRight = lbCloser ? pRB : pLB; + // Auslauf-Richtungen der Achsen vom Knoten weg, in den jeweiligen Wandkörper. + const dA = aEnd === "start" ? uA : scale(uA, -1); + const bAtStart = len(sub(b.start, j)) <= len(sub(b.end, j)); + const dB = bAtStart ? uB : scale(uB, -1); + // Orientierungsbasierte, vorzeichenrichtige Flächen-Paarung. „Außen" ist die + // vom Körper der anderen Wand ABGEWANDTE Fläche: A's +nA-Fläche (pLA) liegt + // außen, wenn nA·dB < 0; B's +nB-Fläche (pLB) außen, wenn nB·dA < 0. Gepaart + // wird Außen-mit-Außen und Innen-mit-Innen — nie über Kreuz. (nA·dB = 0 tritt + // nur bei kollinearen Achsen auf; dann sind die Flächen parallel und + // lineIntersect liefert unten ohnehin null.) + const bOuter = dot(nB, dA) < 0 ? pLB : pRB; + const bInner = dot(nB, dA) < 0 ? pRB : pLB; + const aLeftIsOuter = dot(nA, dB) < 0; + const bForLeft = aLeftIsOuter ? bOuter : bInner; + const bForRight = aLeftIsOuter ? bInner : bOuter; + + // c1 an A's linke Fläche (pLA), c2 an A's rechte (pRA) gebunden — Reihenfolge + // wie zuvor, damit rechte/stumpfe Ecken bit-identisch bleiben; nur die + // B-Partnerwahl ist jetzt orientierungs- statt distanzbasiert. const c1 = lineIntersect(pLA, uA, bForLeft, uB); const c2 = lineIntersect(pRA, uA, bForRight, uB); if (!c1 || !c2) return null;