Files
DOSSIER-STANDALONE/src/model/joins.ts
T
karim 400ec890b0 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).
2026-07-03 23:53:25 +02:00

165 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Wand-Verschneidung (Gehrung): an einer Ecke, wo zwei Wände aufeinander­
// treffen, sollen sich die Schicht-Bänder nicht überlappen, sondern an einer
// gemeinsamen Gehrungslinie sauber stoßen. Dieses Modul berechnet pro Wand
// die optionalen Schnittlinien an Start- und Endpunkt.
import type { Project, Vec2, Wall } from "./types";
import { getWallType, wallTypeThickness } from "./types";
import { wallReferenceOffset } from "./wall";
import {
add,
dot,
leftNormal,
len,
lineIntersect,
normalize,
scale,
sub,
} from "./geometry";
import type { Line } from "./geometry";
/** Schnittlinien einer Wand an ihren beiden Achsenden. */
export interface WallCuts {
startCut: Line | null;
endCut: Line | null;
}
/** Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren. */
const roundKey = (p: Vec2): string => {
const r = (v: number) => Math.round(v * 1e4) / 1e4;
return `${r(p.x)},${r(p.y)}`;
};
interface WallEnd {
wallId: string;
end: "start" | "end";
}
/**
* 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).
*
* `walls` ist die bereits gefilterte Wandmenge (z. B. ein Geschoss); die
* Gehrung wird nur innerhalb dieser Menge gebildet.
*/
export function computeJoins(
project: Project,
walls: Wall[],
): Map<string, WallCuts> {
const byId = new Map(walls.map((w) => [w.id, w]));
const result = new Map<string, WallCuts>();
for (const w of walls) result.set(w.id, { startCut: null, endCut: null });
// Knotenkarte: gerundeter Endpunkt → Liste der dort endenden Wandenden.
const junctions = new Map<string, WallEnd[]>();
const push = (p: Vec2, we: WallEnd) => {
const key = roundKey(p);
const list = junctions.get(key);
if (list) list.push(we);
else junctions.set(key, [we]);
};
for (const w of walls) {
push(w.start, { wallId: w.id, end: "start" });
push(w.end, { wallId: w.id, end: "end" });
}
for (const [, ends] of junctions) {
// Freies Ende → kein Schnitt.
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
setCut(result, ends[0], cut);
setCut(result, ends[1], cut);
}
return result;
}
/** Trägt eine Schnittlinie am passenden Ende einer Wand ein. */
function setCut(result: Map<string, WallCuts>, we: WallEnd, cut: Line): void {
const cuts = result.get(we.wallId)!;
if (we.end === "start") cuts.startCut = cut;
else cuts.endCut = cut;
}
/** Achsrichtung start→end, normalisiert. */
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, 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,
a: Wall,
aEnd: "start" | "end",
b: Wall,
): Line | null {
const j = aEnd === "start" ? a.start : a.end;
const tA = wallTypeThickness(getWallType(project, a));
const tB = wallTypeThickness(getWallType(project, b));
const uA = dirOf(a);
const uB = dirOf(b);
const nA = leftNormal(uA);
const nB = leftNormal(uB);
// Referenzlinien-Versatz: liegt die Achse nicht mittig, sind die beiden
// Wandflächen um diesen Betrag entlang +n verschoben (außen T/2+off, innen
// +T/2+off). Für „center" (Default) ist off=0 → unverändert.
const offA = wallReferenceOffset(a, tA);
const offB = wallReferenceOffset(b, tB);
// 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));
// 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;
const dir = sub(c2, c1);
if (len(dir) < 1e-9) return null;
return { point: c1, dir };
}