Grundriss-Gehrung: Flächenpaarung orientierungsbasiert statt per Distanz

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).
This commit is contained in:
2026-07-03 23:53:25 +02:00
parent 0c82d01cd9
commit a2c8fcd6de
3 changed files with 139 additions and 9 deletions
+3
View File
@@ -19,6 +19,9 @@ export const leftNormal = (a: Vec2): Vec2 => ({ x: -a.y, y: a.x });
/** Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren. */ /** Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren. */
export const cross = (p: Vec2, q: Vec2): number => p.x * q.y - p.y * q.x; 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. */ /** Eine unendliche Gerade als Stützpunkt + Richtung. */
export interface Line { export interface Line {
point: Vec2; point: Vec2;
+104
View File
@@ -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);
});
});
+32 -9
View File
@@ -8,6 +8,7 @@ import { getWallType, wallTypeThickness } from "./types";
import { wallReferenceOffset } from "./wall"; import { wallReferenceOffset } from "./wall";
import { import {
add, add,
dot,
leftNormal, leftNormal,
len, len,
lineIntersect, 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. * 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 * Robust gegen beliebige Wicklung, ungleiche Dicken und JEDEN Öffnungswinkel
* mit der NÄCHSTGELEGENEN Fläche von B verschnitten, A's Innenfläche mit der * (inkl. spitzer): Die Wandflächen werden orientierungsbasiert (vorzeichen­
* jeweils anderen. Die Gerade durch beide Eckpunkte ist die Gehrung. * 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( function miterLine(
project: Project, project: Project,
@@ -119,18 +127,33 @@ function miterLine(
const offA = wallReferenceOffset(a, tA); const offA = wallReferenceOffset(a, tA);
const offB = wallReferenceOffset(b, tB); 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 pLA = add(j, scale(nA, offA + tA / 2));
const pRA = 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 pLB = add(j, scale(nB, offB + tB / 2));
const pRB = 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. // Auslauf-Richtungen der Achsen vom Knoten weg, in den jeweiligen Wandkörper.
const lbCloser = const dA = aEnd === "start" ? uA : scale(uA, -1);
len(sub(pLA, pLB)) <= len(sub(pLA, pRB)); const bAtStart = len(sub(b.start, j)) <= len(sub(b.end, j));
const bForLeft = lbCloser ? pLB : pRB; const dB = bAtStart ? uB : scale(uB, -1);
const bForRight = lbCloser ? pRB : pLB;
// 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 c1 = lineIntersect(pLA, uA, bForLeft, uB);
const c2 = lineIntersect(pRA, uA, bForRight, uB); const c2 = lineIntersect(pRA, uA, bForRight, uB);
if (!c1 || !c2) return null; if (!c1 || !c2) return null;