Files
DOSSIER-STANDALONE/src/model/joins.ts
T
karim 00b5035541 Joins Phase 1: materialbewusster T-Stoss (Backstein verschmilzt, Putz-L)
Am T-Stoss wird der Abzweig nicht mehr naiv ueber die volle Dicke an der
Durchgangswand-Flaeche gekappt. Neu (joinPriority-basiert):
- resolveJoinPriority(a,b): merge/coexist/trim je nach Prioritaet+Komponente.
- WallCuts.layerCuts (additiv): pro Schicht eine Cut-Linie + optionale L-Seiten-
  linie. Gleiche/hoehere Prioritaet wie der Durchgangs-Backbone -> kein Cut
  (Schicht laeuft durch = Merge); schwaechere Schicht -> Nahflaechen-Cut + L.
- addWallPoche nutzt layerCuts pro Schicht (Fallback: alte Aggregat-Cuts);
  neue 'layer-joint-l'-Linie als L-Rueckschnitt.
- Rust-Paritaet in geometry/lib.rs additiv gespiegelt (LayerInput serde default,
  Aggregat-Cuts bit-identisch -> parity.test gruen).

Einschichtige/nicht-passende T-Stoesse pixel-identisch. +8 Tests (216 gesamt),
cargo 7/7.
2026-07-04 14:13:55 +02:00

425 lines
17 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 { Component, Project, Vec2, Wall } from "./types";
import { getComponent, getWallType, wallTypeThickness } from "./types";
import { wallReferenceOffset } from "./wall";
import {
add,
dot,
leftNormal,
len,
lineIntersect,
normalize,
scale,
sub,
} from "./geometry";
import type { Line } from "./geometry";
/**
* Pro-Schicht-Override der Nahflächen-Cuts (materialbewusster T-Stoss, siehe
* {@link resolveJoinPriority}). Index = Schicht-Index im Wandtyp DIESER Wand
* (der Abzweig-Wand). `start`/`end` ersetzen — wo gesetzt — den einheitlichen
* `startCut`/`endCut` NUR für die jeweilige Schicht; `null` an einem Index
* heisst: diese Schicht bekommt KEINEN Cut (verschmilzt, läuft durch).
* `startSide`/`endSide` tragen die zusätzliche seitliche Cut-Linie einer
* getrimmten Schicht, die an eine verschmelzende Nachbarschicht angrenzt
* (L-Anschluss, z. B. Putz neben durchlaufendem Backstein-Kern).
*/
export interface LayerCuts {
start: (Line | null)[];
end: (Line | null)[];
startSide: (Line | null)[];
endSide: (Line | null)[];
}
/** Schnittlinien einer Wand an ihren beiden Achsenden. */
export interface WallCuts {
startCut: Line | null;
endCut: Line | null;
/**
* Additive Erweiterung: fehlt dieses Feld, gilt weiterhin `startCut`/
* `endCut` für ALLE Schichten (bisheriges Verhalten, bit-identisch). Wird
* nur gesetzt, wenn an einem T-Stoss mindestens eine Schicht materialbewusst
* verschmilzt (siehe {@link resolveJoinPriority}).
*/
layerCuts?: LayerCuts;
}
/**
* Verschneidungs-Entscheidung zweier Bauteile (Komponenten) am Stoss:
* • "merge" — gleiche Priorität UND gleiche Komponente (z. B. Backstein-
* Kern trifft Backstein-Kern): die Schichten verschmelzen, kein Cut.
* • "coexist" — gleiche Priorität, aber VERSCHIEDENE Komponente: keine
* eindeutige Rangfolge, bleibt beim heutigen (getrimmten) Verhalten.
* • "trim-a" — `b` hat die höhere Priorität und gewinnt; `a` wird getrimmt.
* • "trim-b" — `a` hat die höhere Priorität und gewinnt; `b` wird getrimmt.
*/
export type JoinPriorityResolution = "merge" | "trim-a" | "trim-b" | "coexist";
export function resolveJoinPriority(
a: Component,
b: Component,
): JoinPriorityResolution {
if (a.joinPriority === b.joinPriority) {
return a.id === b.id ? "merge" : "coexist";
}
return a.joinPriority > b.joinPriority ? "trim-b" : "trim-a";
}
/** 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-/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.
*/
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 hier (wird unten separat auf Mittelspannen-T
// geprüft, Fall 2).
if (ends.length === 1) continue;
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);
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;
}
/** 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 };
}
/**
* 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<string, Wall>,
ends: WallEnd[],
result: Map<string, WallCuts>,
): 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)));
const faceCut: Line = { point: facePoint, dir: uT };
setCut(result, branchEnd, faceCut);
applyLayerCuts(project, result, branchEnd, branchWall, throughWall, faceCut, j, uT);
}
/**
* Materialbewusste Pro-Schicht-Erweiterung eines T-Stoss-Cuts (siehe
* {@link resolveJoinPriority}): vergleicht jede Schicht der ABZWEIG-Wand mit
* dem Rückgrat (höchste `joinPriority`) der DURCHGANGSWAND. Verschmilzt eine
* Schicht (gleiche Komponente + Priorität wie das Rückgrat, oder die
* Abzweig-Schicht ist sogar prioritärer), bekommt sie KEINEN Cut — das Band
* läuft bis zum Wandachsenpunkt durch (die Durchgangswand selbst ist an einem
* T-Stoss unzerschnitten und deckt den Rest ab, s. `applyTJunction`-Doc).
* Getrimmte Schichten, die direkt an eine verschmelzende Nachbarschicht
* grenzen, bekommen zusätzlich eine seitliche Cut-Linie entlang der
* Durchgangsachse (L-Anschluss, z. B. Putz neben durchlaufendem Kern).
*
* Setzt `WallCuts.layerCuts` NUR, wenn mindestens eine Schicht verschmilzt —
* sonst bleibt das Feld `undefined` (additiv, bit-identisches Default-
* Verhalten für den ganz überwiegenden Fall ohne Materialübereinstimmung).
*/
function applyLayerCuts(
project: Project,
result: Map<string, WallCuts>,
branchEnd: WallEnd,
branchWall: Wall,
throughWall: Wall,
faceCut: Line,
axisPoint: Vec2,
throughDir: Vec2,
): void {
const branchWt = getWallType(project, branchWall);
const throughWt = getWallType(project, throughWall);
if (branchWt.layers.length === 0 || throughWt.layers.length === 0) return;
// Rückgrat der Durchgangswand: die Schicht mit der höchsten joinPriority.
let backbone = getComponent(project, throughWt.layers[0].componentId);
for (const l of throughWt.layers) {
const c = getComponent(project, l.componentId);
if (c.joinPriority > backbone.joinPriority) backbone = c;
}
const n = branchWt.layers.length;
const merged: boolean[] = new Array(n);
for (let i = 0; i < n; i++) {
const comp = getComponent(project, branchWt.layers[i].componentId);
const res = resolveJoinPriority(comp, backbone);
// "merge": identisches Rückgrat-Material -> durchgehender Kern.
// "trim-b": die Abzweig-Schicht ist SELBST prioritärer als das Rückgrat
// der Durchgangswand -> sie gewinnt ebenfalls und läuft durch (die
// Durchgangswand könnten wir hier nicht schneiden, ausserhalb des Scopes).
merged[i] = res === "merge" || res === "trim-b";
}
if (!merged.some(Boolean)) return; // keine Materialübereinstimmung -> Default
const cuts = result.get(branchWall.id)!;
if (!cuts.layerCuts) {
cuts.layerCuts = {
start: new Array(n).fill(null),
end: new Array(n).fill(null),
startSide: new Array(n).fill(null),
endSide: new Array(n).fill(null),
};
}
const lc = cuts.layerCuts;
if (lc.start.length !== n) return; // Schicht-Anzahl passt nicht zusammen (sollte nicht vorkommen)
const faceArr = branchEnd.end === "start" ? lc.start : lc.end;
const sideArr = branchEnd.end === "start" ? lc.startSide : lc.endSide;
// Seitliche Cut-Linie entlang der Durchgangsachse, auf Höhe des Wand-
// Achsenpunkts (= die Tiefe, bis zu der eine verschmelzende Nachbarschicht
// ungekappt durchläuft).
const sideLine: Line = { point: axisPoint, dir: throughDir };
for (let i = 0; i < n; i++) {
faceArr[i] = merged[i] ? null : faceCut;
const adjMerged =
!merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1]));
sideArr[i] = adjMerged ? sideLine : null;
}
}
/**
* 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<string, Wall>,
walls: Wall[],
freeEnd: WallEnd,
result: Map<string, WallCuts>,
): 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; distAlong: 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, distAlong };
}
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)));
const faceCut: Line = { point: facePoint, dir: uW };
setCut(result, freeEnd, faceCut);
// Achsenpunkt auf der getroffenen Wand, auf Höhe der Projektion des freien
// Endes (nicht zwingend w.start) — Anker für die materialbewusste
// Pro-Schicht-Erweiterung (analog zum T-Knoten-Fall).
const axisPoint = add(w.start, scale(uW, best.distAlong));
applyLayerCuts(project, result, freeEnd, freeWall, w, faceCut, axisPoint, uW);
}