00b5035541
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.
425 lines
17 KiB
TypeScript
425 lines
17 KiB
TypeScript
// 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);
|
||
}
|