23dddf893e
Am materialbewussten T-Stoss wurde die Durchgangswand bisher nur uebermalt (Zeichenreihenfolge), nicht geometrisch ausgeschnitten: ihr Nah-Putz-Band, die Schichtfugen- und die Nahflaechenlinie liefen weiter ueber die Durchstoss-Breite des Abzweig-Backsteins. Neu liefert computeJoins erstmals auch Cuts fuer die DURCHGANGSWAND: WallCuts.spanCutouts (Achsen-Intervall = Projektion der Abzweig- Kernbreite, Quer-Offsetzone = Nahflaeche bis Rueckgrat-Nahflaeche = Nah-Putz- Tiefe aus Phase 1b). addWallPoche splittet betroffene Schicht-Baender entlang der Achse in Teil-Baender vor/nach dem Intervall, unterbricht die Schichtfugen im Merge-Bereich und macht die Nahflaechen-Umrisskante ueber dem Durchstoss strokelos (separate Segmente davor/danach) — keine Trennlinie an der Verschmelzungsflaeche, der T-Stoss liest als EIN Join. Rust-Paritaet additiv (span_cutouts), Aggregat-startCut/endCut unveraendert -> parity gruen. vitest 227 (+5), cargo 8 (+1), tsc sauber.
536 lines
22 KiB
TypeScript
536 lines
22 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)[];
|
||
}
|
||
|
||
/**
|
||
* Aussparung der DURCHGANGSWAND an einem materialbewussten T-Stoss: über das
|
||
* Achsen-Intervall `[from,to]` (Meter ab `wall.start` entlang der Achse) und den
|
||
* Quer-Offsetbereich `[offA,offB]` wird die Nah-Putz-Zone der Durchgangswand
|
||
* echt ausgeschnitten, damit der durchstechende Abzweig-Kern (Phase 1b) die
|
||
* Durchgangswand nicht nur übermalt, sondern geometrisch aufbricht.
|
||
*
|
||
* OFFSET-KONVENTION (identisch zu den Schicht-Bändern in `addWallPoche`):
|
||
* `offA`/`offB` sind Querabstände von der Wandachse ENTLANG der linken Normalen
|
||
* `leftNormal(dir)`, inkl. Referenzversatz — also im selben Bereich wie
|
||
* `refOff − total/2` (außen) … `refOff + total/2` (innen). `offA` ist stets die
|
||
* NAHFLÄCHE (Wandoberfläche, `refOff + sign·total/2`), `offB` die dem Abzweig
|
||
* zugewandte Fläche des Rückgrat-KERNS (`refOff + sign·(total/2 − nearPlaster)`)
|
||
* — die Zone dazwischen ist exakt der Nah-Putz, den der Abzweig-Kern durchstösst.
|
||
* `from ≤ to` ist die Projektion der Abzweig-Kernbreite auf die Durchgangsachse.
|
||
*/
|
||
export interface SpanCutout {
|
||
from: number;
|
||
to: number;
|
||
offA: number;
|
||
offB: number;
|
||
}
|
||
|
||
/** 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;
|
||
/**
|
||
* Additive Erweiterung (Durchgangswand-Seite): Aussparungen der Nah-Putz-Zone,
|
||
* durch die ein Abzweig-Kern sticht. Fehlt/leer ⇒ die Wand wird unverändert
|
||
* gezeichnet. Wird nur an der DURCHGANGSWAND gesetzt (nie am Abzweig), analog
|
||
* additiv zu {@link layerCuts}. Siehe {@link SpanCutout}.
|
||
*/
|
||
spanCutouts?: SpanCutout[];
|
||
}
|
||
|
||
/**
|
||
* 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, sign);
|
||
}
|
||
|
||
/**
|
||
* 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 einen Cut an der
|
||
* NAHFLÄCHE DES RÜCKGRAT-KERNS der Durchgangswand: der verschmelzende Abzweig-
|
||
* Kern (z. B. Backstein) sticht nur durch den Nah-Putz der Durchgangswand und
|
||
* stoppt am Durchgangs-Kern — er läuft NICHT bis zur Achse durch und überlappt
|
||
* die Durchgangswand nicht (dort verschmelzen die beiden Kerne nahtlos).
|
||
* Getrimmte Schichten, die direkt an eine verschmelzende Nachbarschicht
|
||
* grenzen, bekommen zusätzlich eine seitliche Cut-Linie an eben dieser
|
||
* Rückgrat-Nahfläche (L-Anschluss, z. B. Putz neben durchstechendem 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).
|
||
*
|
||
* `sign` ist die Nahseite der Durchgangswand (der Abzweig-zugewandte `nT`-
|
||
* Vorzeichen, aus {@link applyTJunction}/{@link applyMidSpanTee}); er bestimmt,
|
||
* welche Durchgangswand-Schichten als Nah-Putz vor dem Rückgrat liegen.
|
||
*/
|
||
function applyLayerCuts(
|
||
project: Project,
|
||
result: Map<string, WallCuts>,
|
||
branchEnd: WallEnd,
|
||
branchWall: Wall,
|
||
throughWall: Wall,
|
||
faceCut: Line,
|
||
axisPoint: Vec2,
|
||
throughDir: Vec2,
|
||
sign: number,
|
||
): 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
|
||
// (Index mitgeführt, um die Nah-Putz-Dicke davor zu bestimmen).
|
||
let backbone = getComponent(project, throughWt.layers[0].componentId);
|
||
let backboneIdx = 0;
|
||
for (let i = 0; i < throughWt.layers.length; i++) {
|
||
const c = getComponent(project, throughWt.layers[i].componentId);
|
||
if (c.joinPriority > backbone.joinPriority) {
|
||
backbone = c;
|
||
backboneIdx = i;
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
// Nahflächen-Cut des verschmelzenden Kerns: von der Durchgangswand-Nahfläche
|
||
// (`offT + sign·tT/2`) um die Nah-Putz-Dicke nach innen versetzt — also genau
|
||
// die dem Abzweig zugewandte Fläche des Rückgrat-Kerns. Nah-Putz = Summe der
|
||
// Durchgangswand-Schichten, die zwischen der Nahseite (sign) und dem Rückgrat
|
||
// liegen. Die Schichten stapeln entlang +nT (Index 0 = −tT/2); auf der
|
||
// +nT-Nahseite (sign>0) sind das die Schichten HINTER dem Rückgrat
|
||
// (Index > backboneIdx), auf der −nT-Nahseite die davor (Index < backboneIdx).
|
||
const tT = wallTypeThickness(throughWt);
|
||
const offT = wallReferenceOffset(throughWall, tT);
|
||
let nearPlaster = 0;
|
||
for (let i = 0; i < throughWt.layers.length; i++) {
|
||
if (sign > 0 ? i > backboneIdx : i < backboneIdx)
|
||
nearPlaster += throughWt.layers[i].thickness;
|
||
}
|
||
const nT = leftNormal(throughDir);
|
||
const mergePoint = add(
|
||
axisPoint,
|
||
scale(nT, offT + sign * (tT / 2 - nearPlaster)),
|
||
);
|
||
const mergeCut: Line = { point: mergePoint, dir: throughDir };
|
||
|
||
// Getrimmte Nachbarschichten schließen mit ihrer L-Seitenlinie an eben dieser
|
||
// Rückgrat-Nahfläche (nicht an der Wandachse) — dort endet der Kern und die
|
||
// Nachbarschicht kappt ihre Breite.
|
||
const sideLine: Line = mergeCut;
|
||
|
||
for (let i = 0; i < n; i++) {
|
||
faceArr[i] = merged[i] ? mergeCut : faceCut;
|
||
const adjMerged =
|
||
!merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1]));
|
||
sideArr[i] = adjMerged ? sideLine : null;
|
||
}
|
||
|
||
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand über
|
||
// die Breite des durchstechenden Abzweig-Kerns ECHT ausschneiden. Nur nötig,
|
||
// wenn davor überhaupt Nah-Putz liegt (nearPlaster > 0) — sonst schliesst der
|
||
// Kern flächenbündig an und es gibt nichts aufzubrechen.
|
||
if (nearPlaster > 1e-9) {
|
||
// Kernbreite des Abzweigs im Quer-Offset (Offset-Konvention der Bänder:
|
||
// gestapelt entlang leftNormal ab refOff − total/2). Die verschmelzenden
|
||
// Schichten (`merged`) bilden den Kern; Lo/Hi = ihre Offset-Hülle.
|
||
const totalBranch = wallTypeThickness(branchWt);
|
||
const refOffBranch = wallReferenceOffset(branchWall, totalBranch);
|
||
let bOff = refOffBranch - totalBranch / 2;
|
||
let coreLo = Infinity;
|
||
let coreHi = -Infinity;
|
||
for (let i = 0; i < n; i++) {
|
||
const th = branchWt.layers[i].thickness;
|
||
if (merged[i]) {
|
||
if (bOff < coreLo) coreLo = bOff;
|
||
if (bOff + th > coreHi) coreHi = bOff + th;
|
||
}
|
||
bOff += th;
|
||
}
|
||
if (coreHi > coreLo) {
|
||
// Projektion der Kern-Offsethülle auf die Durchgangsachse: der Achsenpunkt
|
||
// liegt bei `axisAt` (Meter ab Durchgangs-Start), ein Abzweig-Offset `o`
|
||
// verschiebt sich entlang der Achse um `o · (nBranch · uT)`.
|
||
const nBranch = leftNormal(dirOf(branchWall));
|
||
const proj = dot(nBranch, throughDir);
|
||
const axisAt = dot(sub(axisPoint, throughWall.start), throughDir);
|
||
const e1 = axisAt + coreLo * proj;
|
||
const e2 = axisAt + coreHi * proj;
|
||
// Quer-Offsetbereich = Nah-Putz-Zone der Durchgangswand: Nahfläche (offA)
|
||
// bis Rückgrat-Nahfläche (offB, = mergeCut-Offset). Beide in derselben
|
||
// Offset-Konvention wie die Schicht-Bänder der Durchgangswand.
|
||
const offA = offT + sign * (tT / 2);
|
||
const offB = offT + sign * (tT / 2 - nearPlaster);
|
||
const throughCuts = result.get(throughWall.id);
|
||
if (throughCuts) {
|
||
(throughCuts.spanCutouts ??= []).push({
|
||
from: Math.min(e1, e2),
|
||
to: Math.max(e1, e2),
|
||
offA,
|
||
offB,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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, sign);
|
||
}
|