From 0d3a0a081fe2851206e484d71364e96b49e4bbb0 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 13:12:00 +0200 Subject: [PATCH] Wand-T-Verbindungen im Grundriss: T-Knoten + Mittelspannen-Stoss computeJoins behandelt jetzt neben L-Ecken auch T-Stoesse: - T-Knoten (3 Wandenden am selben Punkt): das kollineare Paar bildet die Durchgangswand und bleibt ungeschnitten; das abzweigende Ende wird an der zugewandten Flaeche der Durchgangswand abgeschnitten. - Mittelspannen-Stoss (freies Wandende trifft die Seite einer anderen Wand): das Ende wird an deren zugewandter Flaeche geschnitten. L-Ecken bleiben bit-identisch (miterLine unveraendert, ends===2-Zweig Zeile fuer Zeile gleich). Die Schnittlinien sind strukturell identisch zu den bisherigen (Line|null) und werden von generatePlan/clippedBand ohne Aenderung angewendet. +3 Tests (205 gesamt gruen), tsc clean. --- src/model/joins.test.ts | 83 ++++++++++++++++++++++ src/model/joins.ts | 154 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 225 insertions(+), 12 deletions(-) diff --git a/src/model/joins.test.ts b/src/model/joins.test.ts index bb622f0..f6a6d21 100644 --- a/src/model/joins.test.ts +++ b/src/model/joins.test.ts @@ -6,6 +6,12 @@ * 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. + * • T-Knoten (drei Wandenden am selben Punkt): das abzweigende Ende bekommt + * einen Schnitt auf der zugewandten Fläche der Durchgangswand, die beiden + * kollinearen Enden bleiben ungeschnitten. + * • Mittelspannen-T-Stoß: ein freies Ende, das mitten auf die Seite einer + * anderen Wand trifft, bekommt einen Schnitt auf deren Fläche; ohne Treffer + * bleibt es rechtwinklig (null). */ import { describe, it, expect } from "vitest"; @@ -45,6 +51,18 @@ function twoWallProject(w1: Wall, w2: Wall, thickness: number): Project { }; } +/** Minimalprojekt aus drei Wänden mit einem Wandtyp gegebener Gesamtdicke. */ +function threeWallProject( + w1: Wall, + w2: Wall, + w3: Wall, + thickness: number, +): Project { + const proj = twoWallProject(w1, w2, thickness); + proj.walls = [w1, w2, w3]; + return proj; +} + function wall(id: string, start: Vec2, end: Vec2): Wall { return { id, @@ -102,3 +120,68 @@ describe("computeJoins — Gehrung an der L-Ecke", () => { expect(Math.abs(dir.x) / len(dir)).toBeCloseTo(0, 6); }); }); + +describe("computeJoins — T-Knoten (drei Wandenden am selben Punkt)", () => { + it("schneidet nur das abzweigende Ende, auf der zugewandten Fläche der Durchgangswand", () => { + // Durchgangswand in zwei Hälften WA1 (0,0)→(5,0) und WA2 (5,0)→(10,0), + // Abzweig WB (5,0)→(5,3) nach oben. T=0.4 → halbe Dicke 0.2. + const proj = threeWallProject( + wall("WA1", { x: 0, y: 0 }, { x: 5, y: 0 }), + wall("WA2", { x: 5, y: 0 }, { x: 10, y: 0 }), + wall("WB", { x: 5, y: 0 }, { x: 5, y: 3 }), + 0.4, + ); + const joins = computeJoins(proj, proj.walls); + + // Die kollinearen Hälften der Durchgangswand bleiben ungeschnitten. + expect(joins.get("WA1")!.endCut).toBeNull(); + expect(joins.get("WA2")!.startCut).toBeNull(); + + // Der Abzweig bekommt einen Schnitt auf der ihm zugewandten (oberen) + // Fläche der Durchgangswand: die horizontale Gerade y = 0.2. + const cut = joins.get("WB")!.startCut; + expect(cut).not.toBeNull(); + expect(distToLine(cut!, { x: 5, y: 0.2 })).toBeCloseTo(0, 6); + expect(distToLine(cut!, { x: 0, y: 0.2 })).toBeCloseTo(0, 6); + // Gehrungsgerade horizontal (parallel zur Durchgangsachse). + expect(Math.abs(cut!.dir.y) / len(cut!.dir)).toBeCloseTo(0, 6); + }); +}); + +describe("computeJoins — Mittelspannen-T-Stoß (Ende trifft Wandseite)", () => { + it("schneidet ein freies Ende, das auf die Seite einer anderen Wand trifft, entlang deren Fläche", () => { + // Durchgangswand W (0,0)→(10,0), T=0.4 → halbe Dicke 0.2. Abzweig WB2 + // trifft mit seinem freien Ende (4, 0.2) genau auf die obere Fläche. + const proj = threeWallProject( + wall("W", { x: 0, y: 0 }, { x: 10, y: 0 }), + wall("WB2", { x: 4, y: 3 }, { x: 4, y: 0.2 }), + wall("Filler", { x: 100, y: 100 }, { x: 100, y: 103 }), // unbeteiligt + 0.4, + ); + const joins = computeJoins(proj, proj.walls); + + const cut = joins.get("WB2")!.endCut; + expect(cut).not.toBeNull(); + expect(distToLine(cut!, { x: 0, y: 0.2 })).toBeCloseTo(0, 6); + expect(distToLine(cut!, { x: 5, y: 0.2 })).toBeCloseTo(0, 6); + expect(Math.abs(cut!.dir.y) / len(cut!.dir)).toBeCloseTo(0, 6); + + // Die Wand W selbst bleibt an ihren (freien) Enden ungeschnitten. + expect(joins.get("W")!.startCut).toBeNull(); + expect(joins.get("W")!.endCut).toBeNull(); + // Das obere, unbeteiligte Ende von WB2 bleibt ebenfalls ungeschnitten. + expect(joins.get("WB2")!.startCut).toBeNull(); + }); + + it("lässt ein freies Ende rechtwinklig (null), wenn keine Wand nah genug getroffen wird", () => { + // Gleiche Anordnung, aber Abzweig-Ende bei y=0.5 — außerhalb der + // Wandfläche (Dicke/2 + Toleranz = 0.201). + const proj = twoWallProject( + wall("W", { x: 0, y: 0 }, { x: 10, y: 0 }), + wall("WB3", { x: 4, y: 3 }, { x: 4, y: 0.5 }), + 0.4, + ); + const joins = computeJoins(proj, proj.walls); + expect(joins.get("WB3")!.endCut).toBeNull(); + }); +}); diff --git a/src/model/joins.ts b/src/model/joins.ts index 0d005e0..3b74ed1 100644 --- a/src/model/joins.ts +++ b/src/model/joins.ts @@ -36,9 +36,11 @@ interface WallEnd { } /** - * Berechnet für jede Wand die Gehrungs-Schnittlinien. - * Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie - * Enden und T-/X-Stöße bleiben rechtwinklig (siehe Kommentare unten). + * Berechnet für jede Wand die Gehrungs-/Anschluss-Schnittlinien. + * Behandelt werden: L-Ecken (genau zwei Wandenden treffen sich), T-Knoten + * (drei Wandenden treffen sich, zwei davon kollinear) und Mittelspannen- + * T-Stöße (ein freies Wandende trifft auf die Seite einer anderen Wand). + * X-Stöße (vier und mehr Enden) bleiben vorerst rechtwinklig. * * `walls` ist die bereits gefilterte Wandmenge (z. B. ein Geschoss); die * Gehrung wird nur innerhalb dieser Menge gebildet. @@ -66,18 +68,36 @@ export function computeJoins( } for (const [, ends] of junctions) { - // Freies Ende → kein Schnitt. + // Freies Ende → kein Schnitt hier (wird unten separat auf Mittelspannen-T + // geprüft, Fall 2). if (ends.length === 1) continue; - // T-/X-Stöße (>2 Enden): vorerst rechtwinklig lassen (Folge-Arbeit). - if (ends.length !== 2) continue; - const a = byId.get(ends[0].wallId)!; - const b = byId.get(ends[1].wallId)!; - const cut = miterLine(project, a, ends[0].end, b); - if (!cut) continue; // kollinear → kein Schnitt + if (ends.length === 2) { + const a = byId.get(ends[0].wallId)!; + const b = byId.get(ends[1].wallId)!; + const cut = miterLine(project, a, ends[0].end, b); + if (!cut) continue; // kollinear → kein Schnitt - setCut(result, ends[0], cut); - setCut(result, ends[1], cut); + setCut(result, ends[0], cut); + setCut(result, ends[1], cut); + continue; + } + + if (ends.length === 3) { + applyTJunction(project, byId, ends, result); + continue; + } + + // X-Stöße u. ä. (>3 Enden): vorerst rechtwinklig lassen (Folge-Arbeit). + } + + // Fall 2: Mittelspannen-T-Stoß. Ein freies Wandende (kein anderes Wandende + // teilt sich seinen Knoten) kann trotzdem auf die Seite einer anderen Wand + // treffen (nicht an deren Endpunkten). In dem Fall bekommt das freie Ende + // einen Schnitt entlang der zugewandten Fläche dieser Wand. + for (const [, ends] of junctions) { + if (ends.length !== 1) continue; + applyMidSpanTee(project, byId, walls, ends[0], result); } return result; @@ -162,3 +182,113 @@ function miterLine( if (len(dir) < 1e-9) return null; return { point: c1, dir }; } + +/** + * Fall 1 — T-Knoten: drei Wandenden treffen sich im selben (gerundeten) + * Knoten. Typisch sind zwei davon kollinear (die zwei Hälften der + * Durchgangswand, die geometrisch als eine Achse durchläuft) und das dritte + * zweigt ab. Die kollinearen zwei bleiben ungeschnitten; der Abzweig bekommt + * einen Schnitt entlang der ihm zugewandten Fläche der Durchgangswand. + * Findet sich kein eindeutig kollineares Paar (z. B. echter Y-/X-Knoten), + * bleibt der Knoten unangetastet (rechtwinklig). + */ +function applyTJunction( + project: Project, + byId: Map, + ends: WallEnd[], + result: Map, +): void { + const dirs = ends.map((e) => dirOf(byId.get(e.wallId)!)); + + // Suche das (näherungsweise) kollineare Paar: |cos(Winkel der Achsen)| ≈ 1. + let throughPair: [number, number] | null = null; + for (let i = 0; i < 3 && !throughPair; i++) { + for (let k = i + 1; k < 3; k++) { + if (1 - Math.abs(dot(dirs[i], dirs[k])) < 1e-6) { + throughPair = [i, k]; + break; + } + } + } + if (!throughPair) return; // kein eindeutiger Durchgang erkennbar + + const branchIdx = [0, 1, 2].find( + (i) => i !== throughPair![0] && i !== throughPair![1], + )!; + const branchEnd = ends[branchIdx]; + const throughEnd = ends[throughPair[0]]; + const branchWall = byId.get(branchEnd.wallId)!; + const throughWall = byId.get(throughEnd.wallId)!; + + const j = throughEnd.end === "start" ? throughWall.start : throughWall.end; + const tT = wallTypeThickness(getWallType(project, throughWall)); + const uT = dirOf(throughWall); + const nT = leftNormal(uT); + const offT = wallReferenceOffset(throughWall, tT); + + // Auslaufrichtung des Abzweigs vom Knoten weg, in seinen eigenen Körper + // (gleiche Konvention wie dA/dB in miterLine). + const dBranch = + branchEnd.end === "start" + ? dirOf(branchWall) + : scale(dirOf(branchWall), -1); + + // Zugewandte Fläche: die Seite der Durchgangsachse, zu der der Abzweig + // zeigt (positive nT-Seite, wenn der Abzweig dorthin ausläuft). + const sign = dot(nT, dBranch) >= 0 ? 1 : -1; + const facePoint = add(j, scale(nT, offT + sign * (tT / 2))); + + setCut(result, branchEnd, { point: facePoint, dir: uT }); +} + +/** + * Fall 2 — Mittelspannen-T-Stoß: prüft, ob das freie Wandende `freeEnd` auf + * die innere Spanne einer anderen Wand trifft (projizierter Parameter im + * Inneren, nicht an deren Endpunkten) und dabei senkrecht nah genug an deren + * Achse liegt (≤ halbe Dicke + Toleranz). Bei Treffer bekommt das freie Ende + * einen Schnitt entlang der zugewandten Fläche der getroffenen Wand; bei + * mehreren Treffern gewinnt die nächstliegende Wand. + */ +function applyMidSpanTee( + project: Project, + byId: Map, + walls: Wall[], + freeEnd: WallEnd, + result: Map, +): void { + const freeWall = byId.get(freeEnd.wallId)!; + const p = freeEnd.end === "start" ? freeWall.start : freeWall.end; + + const endGap = 1e-4; // Mindestabstand zu den Wandenden für „innere Spanne" + const tol = 1e-3; // Toleranz für den senkrechten Abstand zur Wandfläche + + let best: { wall: Wall; perp: number; dist: number } | null = null; + for (const w of walls) { + if (w.id === freeWall.id) continue; + const u = dirOf(w); + const wLen = len(sub(w.end, w.start)); + if (wLen < 1e-9) continue; + + const rel = sub(p, w.start); + const distAlong = dot(rel, u); + if (distAlong <= endGap || distAlong >= wLen - endGap) continue; // an Endpunkt, kein Mittelspann + + const perp = dot(rel, leftNormal(u)); + const tw = wallTypeThickness(getWallType(project, w)); + const dist = Math.abs(perp); + if (dist > tw / 2 + tol) continue; + + if (!best || dist < best.dist) best = { wall: w, perp, dist }; + } + if (!best) return; // kein Treffer → rechtwinklig + + const w = best.wall; + const tw = wallTypeThickness(getWallType(project, w)); + const uW = dirOf(w); + const nW = leftNormal(uW); + const offW = wallReferenceOffset(w, tw); + const sign = best.perp >= 0 ? 1 : -1; + const facePoint = add(w.start, scale(nW, offW + sign * (tw / 2))); + + setCut(result, freeEnd, { point: facePoint, dir: uW }); +}