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:
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user