God-Files aufgeteilt (ResourceManager/generatePlan/toWalls3d/App.tsx-Handler), Totcode entfernt (geometry-Crate, OCCT/HLR-Spike), PDF/Bild-Import ergänzt.

ResourceManager.tsx, generatePlan.ts und toWalls3d.ts sind nach Bauteil-
Domäne in src/ui/resourceManager/, src/plan/generatePlan/ und
src/plan/toWalls3d/ aufgeteilt (reine Verschiebung, keine Verhaltens-
änderung). App.tsx verliert weitere Handler-Gruppen an eigene Hooks
(useExportHandlers, useImportHandling, useKeyboardShortcuts) sowie
Hit-Testing an src/viewport/planHitTest.ts.

Unbenutzte Rust-geometry-Crate (durch WASM-Joins abgelöst) sowie der alte
OCCT/HLR-Schnitt-Spike (hlr.ts/occt.ts, vite occt-Aliases) entfernt.

Neu: PDF-Import als Rasterbild (src/io/pdfImport.ts) + gemeinsamer
Bild-Asset-Helfer (src/io/imageAsset.ts) für Plan- und Layout-Bilder.

tsc -b sauber, vitest 891/891 grün.
This commit is contained in:
2026-08-19 22:45:41 +02:00
parent 06c0143d32
commit 1507c2bf00
85 changed files with 12461 additions and 11280 deletions
+923
View File
@@ -0,0 +1,923 @@
// Wand-Domäne der 3D-Ableitung (SCHICHTEN → Bänder → Boxen, siehe Moduldoc in
// `../../toWalls3d.ts`): löst den Schichtaufbau EINER Wand in quer zur Dicke
// gestapelte Materialbänder auf und emittiert daraus Wandkörper — inklusive
// aller materialbewussten Verschneidungen (Gehrungs-/Anschluss-Cuts an L-/
// T-Stössen, Durchgangswand-Span-Cutouts, Decken-Dominanz pro Schicht, Dach-
// Unterkanten-Kappung). Das 3D-Pendant zu `generatePlan/walls.ts::addWallPoche`.
import type {
Ceiling,
Layer,
Project,
SliceTermination,
Vec2,
Wall,
} from "../../model/types";
import {
getCeilingType,
getComponent,
getWallType,
getWindowType,
openingsOfWall,
roofLayers,
wallTypeThickness,
} from "../../model/types";
import { ceilingVerticalExtent, wallVerticalExtent } from "../../model/wall";
import { pointInOutline } from "../../geometry/ceiling";
import { openingInterval, openingVerticalExtent } from "../../geometry/opening";
import { roofBaseElevation, roofGeometry, roofUndersideAt } from "../../geometry/roof";
import type { RoofGeometry } from "../../geometry/roof";
import type { WallCuts } from "../../model/joins";
import { add, dot, leftNormal, lineIntersect, scale, sub } from "../../model/geometry";
import type { Line } from "../../model/geometry";
import {
WALL_RGB,
dominantLayerColor,
dominantLayerHatch,
hexToRgb,
resolveComponentHatch,
resolveComponentMaterialLayer,
resolveCutLineStyle,
EPS,
type RCutMeta,
type RHatch,
type RHole,
type RRgb,
type RWall,
} from "./shared";
/**
* Achswinkel einer GESCHNITTENEN Wand im Schnitt-Koordinatensystem (Modell-
* Konvention, identisch zu `SECTION_WALL_AXIS_ANGLE_DEG` in toSection.ts): das
* Wand-Band steht vertikal (Achse entlang v, atan2(1,0) = 90°). Damit drehen
* `relativeToWall`-Muster (Dämmung, Backstein) im 3D-Schnitt genauso mit der Wand
* wie im 2D-Schnitt (`splitWallLayers` → `resolveHatch(…, 90, …)`). Für DECKEN gibt
* es keinen Wandachsen-Bezug — dort bleibt der Winkel absolut (`emitSlabs` ruft
* `resolveComponentHatch` ohne Achswinkel, wie `splitSlabLayers` → `resolveHatch(…,
* undefined, …)`), sodass die liegende Boden-Dämmung vertikal statt horizontal läuft.
*/
const SECTION_WALL_AXIS_ANGLE_DEG = 90;
/**
* Eine quer zur Wanddicke gestapelte Materiallage als 3D-Band: eigene Dicke,
* Materialfarbe und Mitten-Versatz `offset` (Meter, entlang der Wand-Normalen,
* relativ zur Wandachse). Jede solche Lage wird pro Achsen-Teilquader zu einer
* eigenen Voll-Box (siehe {@link emitWall}/{@link pushSegment}).
*/
interface WallBand {
thickness: number;
color: RRgb;
offset: number;
/** Schnitt-Schraffur des Band-Bauteils (siehe {@link RHatch}). */
hatch?: RHatch;
/** Material-Textur-Ebene des Band-Bauteils (1-basiert, siehe `wallMaterialColorMaps`). */
materialIndex?: number;
/**
* Verschneidungs-Rang dieser Materiallage (`Component.joinPriority`) — treibt
* die per-Schicht-Dominanz gegen Decken im layered-3D-Pfad (s.
* {@link collectCeilingCutters}/{@link emitWall}): eine Decke mit STRIKT höherer
* `joinPriority` schneidet nur die Bänder mit niedrigerem Rang aus ihrem
* z-Intervall (der Kern höherer Priorität läuft durch). `undefined` (kein
* auflösbarer WallType / synthetisches Fallback-Band) ⇒ keine Decken-
* Subtraktion (konservativ, wie das frühere Trim-Verhalten bei unbekanntem Typ).
*/
priority?: number;
/**
* Bauteil-Identität dieser Materiallage (`Component.id`) — treibt die MERGE-Regel
* der Schnitt-Boolean-Dominanz (gleiche Komponente + Priorität verschmelzen, keine
* innere Trennlinie). Reist als `RCutMeta.componentId` mit.
*/
componentId?: string;
/** Farbe der Schnitt-Umrisslinie dieser Schicht (aus `jointLineStyleId`, sonst Default). */
cutLineColor?: RRgb;
/** Strichstärke der Schnitt-Umrisslinie in mm-Papier (aus `jointLineStyleId`, sonst Default). */
cutLineWeight?: number;
}
/**
* Baut die {@link RCutMeta}-Nutzlast eines Wand-Bandes (Priorität/Identität/
* Schnittkanten-Stil), oder `undefined`, wenn das Band keinerlei Schnitt-Metadaten
* trägt (z. B. das Fallback-Vollkörper-Band ohne auflösbaren WallType). Nur gesetzte
* Felder werden übernommen, damit `#[serde(default)]` auf der Rust-Seite greift.
*/
function bandCutMeta(b: WallBand): RCutMeta | undefined {
const meta: RCutMeta = {};
if (b.priority !== undefined) meta.joinPriority = b.priority;
if (b.componentId !== undefined) meta.componentId = b.componentId;
if (b.cutLineColor !== undefined) meta.lineColor = b.cutLineColor;
if (b.cutLineWeight !== undefined) meta.lineWeight = b.cutLineWeight;
return Object.keys(meta).length > 0 ? meta : undefined;
}
/**
* Löst den Schichtstapel EINER Wand auf: je Materiallage ein {@link WallBand}
* mit ihrer Dicke, Component-Farbe und dem Mitten-Versatz entlang der Normalen.
* Die Versätze zentrieren den Stapel um die Wandachse; als Gesamtdicke T dient
* die SUMME der tatsächlichen Lagen-Dicken (nicht `wallTypeThickness`, falls es
* abweicht) — so schließen die Bänder lückenlos aneinander an. Kein auflösbarer
* WallType / keine Lagen ⇒ EIN Vollkörper-Band über die volle Dicke im
* neutralen Grundton (heutiges Fallback-Verhalten).
*/
function resolveWallBands(project: Project, wall: Wall, layered = true): WallBand[] {
try {
const wt = getWallType(project, wall);
if (wt.layers.length > 0) {
// Gesamtdicke = Summe der Lagen (konsistent mit den Offsets); wenn diese
// von wallTypeThickness abwiche, gewinnt die Lagen-Summe (keine Lücken).
const total = wt.layers.reduce((s, l) => s + l.thickness, 0);
// Schnitt-Eingabe (layered=false): EINE Voll-Box über die ganze Dicke statt
// je Lage eine Box. So bleibt die Emission 1:1 Wand→Rechteck — passend zu
// `wallSegmentOwners`/`splitWallLayers` in toSection.ts, die die Schicht-
// Zerlegung TS-seitig aus diesem einen Rechteck machen. (Der 3D-Viewer ruft
// mit Default layered=true auf und bekommt unverändert die Schicht-Bänder.)
if (!layered) {
const dominant = wt.layers.reduce((best, l) => (l.thickness > best.thickness ? l : best));
return [
{
thickness: total,
color: dominantLayerColor(wt.layers, project, WALL_RGB),
offset: 0,
hatch: dominantLayerHatch(wt.layers, project, SECTION_WALL_AXIS_ANGLE_DEG),
materialIndex: resolveComponentMaterialLayer(project, dominant.componentId),
},
];
}
const bands: WallBand[] = [];
let prefix = 0;
for (const l of wt.layers) {
const comp = getComponent(project, l.componentId);
const color = hexToRgb(comp.color);
const lineStyle = resolveCutLineStyle(project, l.jointLineStyleId);
bands.push({
thickness: l.thickness,
color,
offset: -total / 2 + prefix + l.thickness / 2,
priority: comp.joinPriority,
componentId: comp.id,
cutLineColor: lineStyle?.color,
cutLineWeight: lineStyle?.weight,
hatch: resolveComponentHatch(project, l.componentId, SECTION_WALL_AXIS_ANGLE_DEG),
materialIndex: resolveComponentMaterialLayer(project, l.componentId),
});
prefix += l.thickness;
}
return bands;
}
// WallType ohne Schichten: EIN Vollkörper über die Typ-Gesamtdicke.
return [{ thickness: wallTypeThickness(wt), color: WALL_RGB, offset: 0 }];
} catch {
// Kein auflösbarer WallType: EIN Vollkörper mit Default-Dicke/Grundton.
return [{ thickness: 0.2, color: WALL_RGB, offset: 0 }];
}
}
/**
* Hängt eine Wand-Schicht-Box an: das Achsenstück [from..to] (Meter ab
* Wand-Startpunkt) über den Höhenbereich [zBottom..zTop], quer um `normalOffset`
* Meter entlang der Wand-Normalen versetzt (Schichtmitte relativ zur Achse) und
* mit Schicht-`thickness`/`color`. Entartete Stücke (Länge ≤ 0 oder Höhe ≤ 0)
* werden übersprungen — so überleben Öffnungen dicht an den Wandenden ohne
* Nullquader.
*
* Optionale `holes`: echte rechteckige Aussparungen in DIESER Box (siehe
* {@link RHole}). Ihre `from`/`to` sind Achsen-Meter ab Wand-Start (dieselbe
* Achse wie `from`/`to` dieser Box), `zBottom`/`zTop` absolute Weltmeter — beides
* unverändert an render3d durchgereicht (der Querversatz `normalOffset` wirkt nur
* senkrecht zur Achse und lässt die Loch-Parametrisierung unberührt). Werden nur
* im 3D-Pfad (voller Achsen-Quader) gesetzt.
*/
function pushSegment(
out: RWall[],
wall: Wall,
from: number,
to: number,
zBottom: number,
zTop: number,
thickness: number,
normalOffset: number,
color: RRgb,
wallId: string,
hatch?: RHatch,
materialIndex?: number,
holes?: RHole[],
cut?: RCutMeta,
): void {
if (to - from <= EPS) return;
if (zTop - zBottom <= EPS) return;
const dx = wall.end.x - wall.start.x;
const dy = wall.end.y - wall.start.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) return;
const ux = dx / len;
const uy = dy / len;
// LINKE Grundriss-Normale (identisch zu `leftNormal(u)` = (−uy, ux) in
// generatePlan.ts/clippedBand — dieselbe Konvention, die addWallPoche für
// den Schichtstapel im 2D-Grundriss benutzt). Der Schicht-Versatz verschiebt
// Start/Ende der Box quer zur Wand, sodass render3d die Box um ihre eigene
// (versetzte) Achse zentriert aufbaut — mit dieser Normale liegt Schicht 0
// (Aussenseite, kleinster `offset`) auf derselben Weltseite wie im Grundriss.
// Bewusst NICHT identisch zur Normale, die raycast3d/selectionHighlightLines
// für den Box-Rebuild nutzen (n = (uy, −ux), rechte Normale) — das ist
// unschädlich, weil jene Boxen symmetrisch um die (hier bereits versetzte)
// Achse jedes einzelnen Layer-Records aufgebaut werden und daher unabhängig
// von der Vorzeichen-Konvention des Versatzes sind.
const ox = -uy * normalOffset;
const oy = ux * normalOffset;
const p1: [number, number] = [wall.start.x + ux * from + ox, wall.start.y + uy * from + oy];
const p2: [number, number] = [wall.start.x + ux * to + ox, wall.start.y + uy * to + oy];
out.push({
start: p1,
end: p2,
thickness,
height: zTop - zBottom,
baseElevation: zBottom,
color,
layers: [],
wallId,
...(hatch ? { hatch } : {}),
...(materialIndex != null ? { materialIndex } : {}),
...(holes && holes.length > 0 ? { holes } : {}),
...(cut ? { cut } : {}),
});
}
/**
* Achsenparalleles Grundriss-Rechteck (min/max je Achse), Meter, Modell-XY.
* Dient hier NUR der groben Footprint-Überlapp-Prüfung Wand↔Decke (s.
* {@link collectCeilingCutters}) — kein exakter Polygon-Test, siehe dort.
*/
interface Bounds2 {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
/** Bounding-Box der Wandachse, um `halfThickness` in beide Richtungen geweitet
* (sichere Über-Approximation des tatsächlichen Wandkörpers, unabhängig von
* dessen Orientierung). */
function wallFootprintBounds(wall: Wall, halfThickness: number): Bounds2 {
return {
minX: Math.min(wall.start.x, wall.end.x) - halfThickness,
maxX: Math.max(wall.start.x, wall.end.x) + halfThickness,
minY: Math.min(wall.start.y, wall.end.y) - halfThickness,
maxY: Math.max(wall.start.y, wall.end.y) + halfThickness,
};
}
/** Bounding-Box eines geschlossenen Umrisses (Decke). */
function outlineBounds(outline: Array<{ x: number; y: number }>): Bounds2 {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const p of outline) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
return { minX, maxX, minY, maxY };
}
/** Überlappen sich zwei Bounding-Boxen (mit EPS-Toleranz an den Rändern)? */
function boundsOverlap(a: Bounds2, b: Bounds2): boolean {
return (
a.minX <= b.maxX + EPS &&
a.maxX >= b.minX - EPS &&
a.minY <= b.maxY + EPS &&
a.maxY >= b.minY - EPS
);
}
/**
* Dicke einer Wand für die Footprint-Näherung (s. {@link wallFootprintBounds}):
* Summe der WallType-Schichtdicken, sonst der Fallback-Grundton-Dicke aus
* {@link resolveWallBands} (0.2 m), falls kein WallType auflösbar ist. Auch von
* `../openings.ts::resolveOpeningFrame` genutzt (Rahmen-Tiefe auf die Wanddicke
* klemmen).
*/
export function wallThicknessSafe(project: Project, wall: Wall): number {
try {
return wallTypeThickness(getWallType(project, wall));
} catch {
return 0.2;
}
}
/**
* Repräsentative `joinPriority` einer Decke (höchste ihrer Schichten) — bestimmt,
* ob die Decke am Wand/Decke-Stoss "durchläuft" (gewinnt) und in die Wandschichten
* niedrigerer Priorität einschneidet (s. {@link collectCeilingCutters}).
*/
function ceilingDominantPriority(project: Project, ceiling: Ceiling): number | undefined {
try {
const ct = getCeilingType(project, ceiling);
let best: number | undefined;
for (const layer of ct.layers) {
const p = getComponent(project, layer.componentId).joinPriority;
if (best === undefined || p > best) best = p;
}
return best;
} catch {
return undefined;
}
}
/**
* Ein Decken-„Cutter": vertikales z-Intervall + Dominanz-Rang + Grundriss-Umriss
* (s. {@link collectCeilingCutters}). Der `outline` erlaubt die PER-BAND-genaue
* Überdeckungsprüfung in {@link emitWall} (statt eines wandweiten BBox-Tests): eine
* Decke schneidet ein Schicht-Band nur dort, wo ihre Outline die Footprint-
* Mittellinie des Bandes im Grundriss überdeckt.
*/
interface CeilingCutter {
zBottom: number;
zTop: number;
priority: number;
outline: Vec2[];
}
/**
* Sammelt die Decken, die eine Wand im layered-3D-Pfad PRO SCHICHT einschneiden —
* die 3D-Entsprechung der Schnitt-Boolean-Dominanz (`toSection.ts::
* subtractDominantBands`): statt die GANZE Wand am Deckenanschluss zu kappen
* (früheres `trimWallTopForCeilings`), behält die Wand ihre volle Höhe und JEDES
* Schicht-Band verliert nur das z-Intervall der Decken mit STRIKT höherer
* `joinPriority` als das Band (der Kern höherer Priorität läuft durch die Decke).
* Ein Band kann dadurch vertikal in zwei Boxen zerfallen (unter Deckenunterkante
* + über Deckenoberkante, falls die Wand die Decke überragt).
*
* Kriterien je Decke (alle müssen zutreffen):
* • dasselbe Geschoss (`ceiling.floorId === wall.floorId`),
* • vertikale Überlappung mit der (vollen) Wand-Ausdehnung,
* • grober Footprint-Überlapp (Bounding-Box, s. {@link boundsOverlap}) — nur ein
* billiger Vor-Filter (keine Wand berührt, wenn die Wand-BBox die Decken-BBox
* nicht schneidet); die EXAKTE, per-Band geometrische Überdeckung entscheidet
* dann in {@link emitWall} (s. {@link bandCoverageIntervals}).
* • auflösbare Decken-`joinPriority` (`ceilingDominantPriority`).
*
* Das z-Intervall/die Priorität der Cutter ist PER DECKENSCHICHT (nicht die ganze
* Decke als EIN Block der dominanten Priorität): jede Deckenschicht wird zu einem
* eigenen Cutter mit IHREM z-Band (proportional auf [zBottom,zTop] verteilt, exakt
* wie `../ceilings.ts::emitSlabs`) und IHRER `Component.joinPriority`. Das ist
* zwingend, damit der 3D-Cut EXAKT wie der 2D-Schnitt (`toSection.ts::
* subtractDominantBands`) verschneidet: dort trägt jede Schicht ihre eigene
* Priorität. Mit dem früheren EINEN Block (dominante Prio 100 über die volle
* Decke) verlor z. B. der Backstein (50) das GANZE Decken-z-Band [UK,OK] —
* obwohl er die schwimmenden Deckenschichten Estrich (30)/Dämmung (20)
* ÜBERRAGEN und schneiden soll; nur die Betonschicht (100) darf ihn kappen. So
* blieb im 3D (Solid UND Schnitt) über dem Backstein eine Lücke, durch die
* Estrich/Dämmung „rausliefen" (Nutzer-Bug). Per Schicht läuft der Backstein
* zwischen Beton-OK und Wandkopf durch und schneidet dort Estrich/Dämmung weg —
* identisch zum 2D-Schnitt. Der `outline` reist mit, damit {@link emitWall} PRO
* BAND prüft, ob die Decke die Footprint-Mittellinie dieses Bandes überdeckt
* (geometrischer Überlapp, kein wandweiter BBox-Test). Der Prioritätsvergleich pro
* Band passiert in {@link emitWall} (`cutter.priority > band.priority`) — eine
* Wand-Schicht mit ≥ Rang der jeweiligen Deckenschicht läuft durch deren z-Band durch.
* Ein-Schicht-/nicht auflösbare Deckentypen fallen auf EINEN Cutter über die volle
* Ausdehnung mit der dominanten Priorität zurück (Alt-Verhalten).
*/
function collectCeilingCutters(
project: Project,
wall: Wall,
zBottom: number,
zTop: number,
): CeilingCutter[] {
const halfThickness = wallThicknessSafe(project, wall) / 2;
const wallBounds = wallFootprintBounds(wall, halfThickness);
const cutters: CeilingCutter[] = [];
for (const c of project.ceilings ?? []) {
if (c.floorId !== wall.floorId) continue;
if (!c.outline || c.outline.length < 3) continue;
const cExtent = ceilingVerticalExtent(project, c);
if (cExtent.zBottom >= zTop - EPS || cExtent.zTop <= zBottom + EPS) continue;
if (!boundsOverlap(wallBounds, outlineBounds(c.outline))) continue;
// Per-Schicht-Cutter: je Deckenschicht ein eigenes z-Band + eigene Priorität
// (proportional auf die tatsächliche Decken-z-Spanne skaliert, oben→unten wie
// emitSlabs). Nur so schneidet Beton (100) den Backstein (50), Backstein aber
// Estrich (30)/Dämmung (20) — identisch zu subtractDominantBands.
let layers: Layer[] | undefined;
try {
layers = getCeilingType(project, c).layers;
} catch {
layers = undefined;
}
const total = layers?.reduce((s, l) => s + l.thickness, 0) ?? 0;
if (layers && layers.length > 0 && total > EPS) {
const span = cExtent.zTop - cExtent.zBottom;
let prefixTop = 0; // von OK (zTop) nach unten aufsummiert (Meter)
for (const l of layers) {
const t = (l.thickness / total) * span;
const layerTop = cExtent.zTop - prefixTop;
const layerBottom = layerTop - t;
prefixTop += t;
let prio: number | undefined;
try {
prio = getComponent(project, l.componentId).joinPriority;
} catch {
prio = undefined;
}
if (prio === undefined) continue;
cutters.push({ zBottom: layerBottom, zTop: layerTop, priority: prio, outline: c.outline });
}
} else {
const ceilingPriority = ceilingDominantPriority(project, c);
if (ceilingPriority === undefined) continue;
cutters.push({ zBottom: cExtent.zBottom, zTop: cExtent.zTop, priority: ceilingPriority, outline: c.outline });
}
}
return cutters;
}
/**
* Achsen-Teilintervalle (Meter ab `wall.start` entlang `u`, geklemmt auf
* `[lo,hi]`), auf denen die MITTELLINIE eines Schicht-Bands (Querversatz
* `bandOffset` entlang `n = leftNormal(u)`) im Decken-Umriss `outline` liegt —
* die per-Band-Entsprechung des geometrischen Rechteck-Überlapps in
* `toSection.ts::subtractDominantBands`. Nur diese Teilintervalle bekommen später
* den Decken-z-Schnitt; unbedeckte Achsenabschnitte behalten die volle Höhe (so
* laufen Dämmung/Aussenputz, deren Mittellinie AUSSERHALB des Decken-Umrisses
* liegt, ununterbrochen durch — der Nutzer-Bug „Kranz durch die Dämmung").
*
* Die Bandmittellinie wird entlang der Achse gerastert (~5-cm-Schritte, min. 32)
* und gegen {@link pointInOutline} geprüft; Innen/Aussen-Übergänge werden per
* Bisektion auf ~EPS verfeinert, sodass die Überdeckungsgrenzen scharf sind (auch
* eine Decke, die nur einen Teil der Wandlänge überdeckt, wird korrekt getrimmt).
*/
function bandCoverageIntervals(
wall: Wall,
u: Vec2,
n: Vec2,
bandOffset: number,
outline: Vec2[],
lo: number,
hi: number,
): Array<[number, number]> {
if (hi - lo <= EPS) return [];
const base = add(wall.start, scale(n, bandOffset));
const inside = (t: number): boolean => pointInOutline(add(base, scale(u, t)), outline);
const steps = Math.max(32, Math.ceil((hi - lo) / 0.05));
const dt = (hi - lo) / steps;
const intervals: Array<[number, number]> = [];
let prevInside = inside(lo);
let start: number | null = prevInside ? lo : null;
let prevT = lo;
for (let i = 1; i <= steps; i++) {
const t = i === steps ? hi : lo + i * dt;
const now = inside(t);
if (now !== prevInside) {
const cross = bisectBoundary(inside, prevT, t);
if (now) start = cross;
else if (start !== null) {
intervals.push([start, cross]);
start = null;
}
}
prevInside = now;
prevT = t;
}
if (start !== null) intervals.push([start, hi]);
return intervals;
}
/**
* Bisektion des Innen/Aussen-Übergangs der Bandmittellinie zwischen `a` und `b`
* (verschiedene {@link pointInOutline}-Seiten) auf ~EPS-Genauigkeit — liefert die
* scharfe Achsen-Position der Überdeckungsgrenze.
*/
function bisectBoundary(inside: (t: number) => boolean, a: number, b: number): number {
const aInside = inside(a);
let lo = a;
let hi = b;
for (let i = 0; i < 40 && hi - lo > EPS * 0.01; i++) {
const mid = (lo + hi) / 2;
if (inside(mid) === aInside) lo = mid;
else hi = mid;
}
return (lo + hi) / 2;
}
/**
* Zieht die Cutter-Intervalle `holes` vom Intervall `[lo,hi]` ab und liefert die
* verbleibenden Teil-Intervalle (Meter). Löcher werden auf `[lo,hi]` geklemmt,
* sortiert und vereinigt; leeres `holes` ⇒ exakt `[[lo,hi]]` (unveränderter Fall).
* Dient sowohl der Achsen-Zerlegung durch Durchgangswand-Span-Cutouts als auch
* der vertikalen Decken-Subtraktion (identische 1D-Semantik wie
* `generatePlan/walls.ts::subtractIntervals`, hier lokal gehalten).
*/
function subtractSpans(lo: number, hi: number, holes: Array<[number, number]>): Array<[number, number]> {
const clipped = holes
.map(([a, b]): [number, number] => [Math.max(lo, Math.min(a, b)), Math.min(hi, Math.max(a, b))])
.filter(([a, b]) => b > a + EPS)
.sort((p, q) => p[0] - q[0]);
const parts: Array<[number, number]> = [];
let cursor = lo;
for (const [a, b] of clipped) {
if (a > cursor + EPS) parts.push([cursor, a]);
if (b > cursor) cursor = b;
}
if (cursor < hi - EPS) parts.push([cursor, hi]);
return parts;
}
/**
* Wendet die Decken-z-Cutter `cutters` (dominante horizontale Cut-Bänder [zBottom,
* zTop]) auf das Wandband-Intervall `[lo,hi]` an — je nach Terminierungs-Regel der
* Wand (`Wall.sliceTermination`). Bewusst als EIGENER, klar benannter Schritt
* neben der Priorität gehalten (Terminierung = Zuschnitt, KEINE Prioritätsregel):
*
* • "both"/undefined ⇒ heutiges Verhalten: `subtractSpans` stanzt jedes Cutter-
* z-Band aus (oben+unten Reste bleiben erhalten). Bit-identisch zu vorher.
* • "below" ⇒ die Wand ENDET an der Decke: alles AB der Decken-UNTERKANTE wird
* verworfen (kein über der Decke wieder auftauchender Rest). Clip-Grenze = das
* KLEINSTE `zBottom` aller dominanten Cutter dieses Bandes. Da die dominanteste
* (tragende) Deckenschicht — z. B. Beton (Prio 100) — jedes schwächere Band
* kappt UND zugleich die unterste Deckenlage ist, terminieren so ALLE Bänder
* einheitlich an der Deckenunterkante (kein Durchstoss/Staffel, keine
* Interpenetration mit Beton/Dämmung darüber). Für den Backstein, den nur der
* Beton kappt, ist das exakt „unter dem dominanten Cut" (dessen Unterkante).
* • "above" ⇒ spiegelbildlich: alles BIS zur Decken-OBERKANTE wird verworfen.
* Clip-Grenze = das GRÖSSTE `zTop` aller dominanten Cutter dieses Bandes.
*
* Ohne Cutter (`cutters` leer) bleibt `[lo,hi]` in allen Modi unverändert — ein
* Band ausserhalb des Decken-Umrisses (Aussenputz/Dämmung) läuft also weiter voll
* durch, unabhängig von der Terminierung.
*/
function terminateOrSubtractSpans(
lo: number,
hi: number,
cutters: Array<[number, number]>,
termination: SliceTermination | undefined,
): Array<[number, number]> {
if (cutters.length === 0) return [[lo, hi]];
if (termination === "below") {
// Deckenunterkante = kleinstes zBottom der Cutter; die Wand endet dort.
let clipTop = Infinity;
for (const [cb] of cutters) clipTop = Math.min(clipTop, cb);
const top = Math.min(clipTop, hi);
return top > lo + EPS ? [[lo, top]] : [];
}
if (termination === "above") {
// Deckenoberkante = grösstes zTop der Cutter; die Wand beginnt dort.
let clipBottom = -Infinity;
for (const [, ct] of cutters) clipBottom = Math.max(clipBottom, ct);
const bottom = Math.max(clipBottom, lo);
return bottom < hi - EPS ? [[bottom, hi]] : [];
}
return subtractSpans(lo, hi, cutters);
}
/**
* Achsen-Distanz (Meter ab `wall.start` entlang der Wandrichtung `u`), an der die
* MITTELLINIE eines Schicht-Bands (Querversatz `bandOffset` entlang `leftNormal(u)`)
* die Gehrungs-/Anschluss-Schnittlinie `cut` trifft — die 3D-Achsenbox-Entsprechung
* des 2D-`clippedBand`. Da die Box achsparallel endet (keine gekippte Stirnfläche),
* wird die Band-Länge auf den Schnittpunkt der MITTELLINIE gekürzt/verlängert; die
* beiden 2D-Bandkanten (bei ±thickness/2) treffen die Schnittlinie an leicht
* versetzten Achsenpunkten — der Box-Wert ist ihr Mittelwert. `null` bei
* (nahezu) parallelem Schnitt (kollineare Achsen) ⇒ Aufrufer behält das Bandende.
*/
function bandCutDistance(wall: Wall, u: Vec2, n: Vec2, bandOffset: number, cut: Line): number | null {
const base = add(wall.start, scale(n, bandOffset));
const hit = lineIntersect(base, u, cut.point, cut.dir);
if (!hit) return null;
return dot(sub(hit, wall.start), u);
}
/** Schrittweite (Meter) der Dach-Unterkanten-Annäherung, s. {@link clipPieceToRoofs}. */
const ROOF_CLIP_STEP = 0.15;
/**
* Kappt ein Wand-Achsenstück [a,bb] (z-Bereich [zb,zt]) an der Dach-UNTERKANTE
* (falls ein Dach darüber liegt) und emittiert es via `emit` — in FEINE Achsen-
* Schritte (~{@link ROOF_CLIP_STEP}) zerlegt, je Schritt auf die dort gemessene
* Dach-Unterkante geklemmt. Das ist eine TREPPENSTUFEN-Annäherung an eine echte
* geneigte Giebelwand-Stirnfläche (render3d-Wandkörper (`RWall`) haben nur einen
* FLACHEN Top — echte Schrägflächen bräuchten einen neuen Mesh-Pfad); bei der
* gewählten Schrittweite optisch glatt genug UND behebt das eigentliche Problem
* (Wand durchdringt die Dachfläche → Z-Fighting, s. PENDENZEN.md „Dach-Wand-
* Verschneidung"), ohne render3d/Rust anzufassen.
*
* Ohne Dächer (oder keinem, das dieses Wandstück überdeckt) läuft das Stück
* UNVERÄNDERT als EIN Body durch (kein Overhead im Normalfall).
*/
function clipPieceToRoofs(
project: Project,
wall: Wall,
u: Vec2,
n: Vec2,
bandOffset: number,
a: number,
bb: number,
zb: number,
zt: number,
emit: (a: number, bb: number, zb: number, zt: number) => void,
): void {
if (zt - zb <= EPS || bb - a <= EPS) return;
const wallBounds = wallFootprintBounds(wall, Math.abs(bandOffset) + 0.01);
const roofs = (project.roofs ?? []).filter((r) => {
if (r.floorId !== wall.floorId) return false;
const eavesZ = roofBaseElevation(project, r);
// Grober Höhen-Ausschluss: Dach-Traufe weit unter dem Wandstück ⇒ kann nicht
// schneiden (übliche Dächer haben keine nach unten ausufernden Übermasse).
return eavesZ + 50 >= zb; // grosszügige Schranke statt exakter First-Höhe
});
if (roofs.length === 0) {
emit(a, bb, zb, zt);
return;
}
const roofData = roofs
.map((r) => {
const eavesZ = roofBaseElevation(project, r);
const geo = roofGeometry(r, eavesZ);
if (!boundsOverlap(wallBounds, outlineBounds(geo.eaves))) return null;
const total = roofLayers(project, r).reduce((s, l) => s + Math.max(0, l.thickness), 0);
return { geo, total };
})
.filter((d): d is { geo: RoofGeometry; total: number } => d !== null);
if (roofData.length === 0) {
emit(a, bb, zb, zt);
return;
}
const span = bb - a;
const steps = Math.max(1, Math.ceil(span / ROOF_CLIP_STEP));
const stepLen = span / steps;
// Je Stufe die geklemmte Oberkante bestimmen …
const tops: number[] = [];
for (let i = 0; i < steps; i++) {
const mid = a + (i + 0.5) * stepLen;
const p = add(add(wall.start, scale(u, mid)), scale(n, bandOffset));
let clip = zt;
for (const { geo, total } of roofData) {
const under = roofUndersideAt(geo, total, p.x, p.y);
if (under !== null && under < clip) clip = under;
}
tops.push(Math.max(zb, clip));
}
// … und AUFEINANDERFOLGENDE Stufen gleicher Oberkante zu EINEM Body verschmelzen.
// Wo das Dach das Stück gar nicht klippt (Oberkante == zt über die ganze Länge,
// z. B. nahe dem First oder wenn kein Dach wirklich überdeckt), entsteht so wieder
// EIN durchgehender Body statt vieler identischer 0.15-m-Fragmente — nur der echt
// geneigte Bereich bleibt gestuft. Das entlastet auch den Live-Schnitt, der genau
// diese Wandkörper cuttet (weniger, saubere Schnitt-Kappen).
let runStart = 0;
for (let i = 1; i <= steps; i++) {
if (i < steps && Math.abs(tops[i] - tops[runStart]) <= EPS) continue;
const sa = a + runStart * stepLen;
const sb = a + i * stepLen;
const zte = tops[runStart];
if (zte - zb > EPS) emit(sa, sb, zb, zte);
runStart = i;
}
}
/**
* Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen).
*
* ZWEI PFADE je `layered`:
* • layered=true (3D-Viewer, Default): EIN Körper pro Schicht-Band über die
* VOLLE Achse mit allen Öffnungen als echte `holes` (render3d stanzt sie in
* die Langseiten). Ermöglicht auch versetzt übereinanderliegende Fenster in
* EINEM Körper (keine Segment-Nähte).
* • layered=false (Schnitt-Pfad, `computeSection`): UNVERÄNDERTE Pfeiler/
* Brüstung/Sturz-Segmentierung (EIN Voll-Band je Segment), damit die
* `ComponentRef.index`-Reihenfolge exakt zu `toSection.ts::wallSegmentOwners`
* passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher
* werden hier NICHT emittiert.
*/
export function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cuts?: WallCuts): void {
const bands = resolveWallBands(project, wall, layered);
const { zBottom, zTop } = wallVerticalExtent(project, wall);
// Anders als früher (`trimWallTopForCeilings` kappte im layered-Pfad die GANZE
// Wand an der Deckenunterkante) behält die Wand jetzt IN BEIDEN PFADEN ihre
// volle Höhe; die per-Schicht-Decken-Subtraktion (unten, nur layered) schneidet
// Bänder niedrigerer Priorität im Decken-z-Band aus — die 3D-Entsprechung der
// Schnitt-Boolean-Dominanz. Der Schnitt-Pfad (layered=false) bleibt unverändert
// (volle Rohgeometrie; seine Dominanz macht toSection.ts::subtractDominantBands).
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;
// Ein Achsen-Teilquader × jede Materiallage = eine Box (Schichtstapel quer
// zur Wanddicke). Alle tragen die Wand-Id, sodass ein Klick auf irgendeine
// Schicht die GANZE Wand selektiert und das Highlight alle Bänder umrandet.
const emitSeg = (from: number, to: number, zb: number, zt: number): void => {
for (const b of bands) {
pushSegment(out, wall, from, to, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, undefined, bandCutMeta(b));
}
};
// Aussparungen der Wand als vereinheitlichte Cutouts sammeln:
// • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
// • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz).
// Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung.
const cutouts: Array<{
from: number;
to: number;
oBottom: number;
oTop: number;
/** Schicht-Rücksprung der innersten Schicht an DIESER Öffnung (nur Fenster
* mit `WindowType.innerLayerRecess`), s. Moduldoc unten. */
recessInnerLayer: boolean;
}> = [];
for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op);
if (!iv) continue;
const v = openingVerticalExtent(project, wall, op);
const recessInnerLayer =
op.kind === "window" && getWindowType(project, op)?.innerLayerRecess === true;
cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop, recessInnerLayer });
}
for (const d of project.doors ?? []) {
if (d.hostWallId !== wall.id) continue;
const from = Math.max(0, Math.min(d.position, axisLen));
const to = Math.max(from, Math.min(d.position + d.width, axisLen));
if (to - from < EPS) continue;
// Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe.
const oTop = Math.min(zTop, zBottom + d.height);
cutouts.push({ from, to, oBottom: zBottom, oTop, recessInnerLayer: false });
}
cutouts.sort((a, b) => a.from - b.from);
// ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle
// Achse mit allen Öffnungen als echte Löcher (render3d stanzt sie), PLUS die
// materialbewussten Verschneidungen (identisch zum 2D-Grundriss/Schnitt):
// 1. End-Cuts/Merge an L-/T-Stössen: jede Schicht wird an ihrem Achsenende an
// der Gehrungs-/Anschluss-Schnittlinie individuell gekürzt/verlängert
// (`WallCuts.startCut`/`endCut`, bzw. pro Schicht `layerCuts.start/end` —
// merge: Kern läuft bis zur Rückgrat-Nahfläche durch; trim: Putz stoppt an
// der Wandoberfläche). Wie im 2D wird die Band-MITTELLINIE geschnitten.
// 2. Durchgangswand-Span-Cutouts: der Nah-Putz einer Durchgangswand wird über
// die Achsen-Breite des durchstechenden Abzweig-Kerns ausgeschnitten — ein
// Band zerfällt dabei entlang der Achse in mehrere Teilstücke.
// 3. Decken-Dominanz pro Schicht: Bänder niedrigerer `joinPriority` verlieren
// das z-Intervall überlappender Decken (vertikaler Split), der Kern läuft
// durch (s. {@link collectCeilingCutters}).
// Die Öffnungs-Löcher (`holes`, Achsen-Meter ab Wand-Start) werden je Teilstück
// auf dessen Achsen-Start umgerechnet (rebased), damit render3d sie relativ zum
// jeweiligen Box-Start korrekt stanzt.
if (layered) {
const holes: (RHole & { recessInnerLayer: boolean })[] = cutouts.map((c) => ({
from: c.from,
to: c.to,
zBottom: c.oBottom,
zTop: c.oTop,
recessInnerLayer: c.recessInnerLayer,
}));
const startCut = cuts?.startCut ?? null;
const endCut = cuts?.endCut ?? null;
const layerCuts = cuts?.layerCuts;
const spanCutouts = cuts?.spanCutouts ?? [];
const ux = (wall.end.x - wall.start.x) / axisLen;
const uy = (wall.end.y - wall.start.y) / axisLen;
const u: Vec2 = { x: ux, y: uy };
const n = leftNormal(u); // (-uy, ux) — dieselbe Konvention wie pushSegment/2D.
const ceilingCutters = collectCeilingCutters(project, wall, zBottom, zTop);
for (let li = 0; li < bands.length; li++) {
const b = bands[li];
// (1) Per-Schicht-Cuts an den Achsenden. `layerCuts` (materialbewusster
// T-Stoss) überschreibt — wo gesetzt — den einheitlichen start/endCut PRO
// Schicht; `null` an einem Index heisst „keine Kürzung an diesem Ende".
// Exakt die 2D-Wahl aus generatePlan/walls.ts::addWallPoche.
const sCut = layerCuts ? layerCuts.start[li] : startCut;
const eCut = layerCuts ? layerCuts.end[li] : endCut;
let from0 = 0;
let to0 = axisLen;
if (sCut) {
const d = bandCutDistance(wall, u, n, b.offset, sCut);
if (d !== null) from0 = d;
}
if (eCut) {
const d = bandCutDistance(wall, u, n, b.offset, eCut);
if (d !== null) to0 = d;
}
// (2) Span-Cutouts, deren Quer-Offsetbereich dieses Band schneidet, brechen
// die Achse [from0,to0] auf (Nah-Putz-Durchstoss der Durchgangswand).
const bandLo = b.offset - b.thickness / 2;
const bandHi = b.offset + b.thickness / 2;
const axisHoles: Array<[number, number]> = [];
for (const c of spanCutouts) {
const cLo = Math.min(c.offA, c.offB);
const cHi = Math.max(c.offA, c.offB);
if (bandLo < cHi - EPS && bandHi > cLo + EPS) axisHoles.push([c.from, c.to]);
}
const axisPieces = subtractSpans(from0, to0, axisHoles);
// (3) Decken-Subtraktion GEOMETRISCH pro Band (Replik der Schnitt-Boolean-
// Dominanz `toSection.ts::subtractDominantBands`): eine Decke mit STRIKT
// höherer Priorität schneidet dieses Band nur dort aus, wo ihr Grundriss-
// Umriss die Footprint-MITTELLINIE dieses Bandes überdeckt (nicht mehr der
// wandweite BBox-Test). Aussenschichten (Dämmung/Aussenputz), deren
// Mittellinie ausserhalb des Decken-Umrisses liegt, laufen ununterbrochen
// durch — nur der überdeckte Kern verliert das Decken-z-Intervall. Ohne
// auflösbare Band-Priorität bleibt die volle Höhe (konservativ).
const bandZCuts: Array<{ from: number; to: number; zBottom: number; zTop: number }> = [];
if (b.priority !== undefined) {
for (const cc of ceilingCutters) {
if (cc.priority <= b.priority) continue;
for (const [ca, cb] of bandCoverageIntervals(wall, u, n, b.offset, cc.outline, from0, to0)) {
bandZCuts.push({ from: ca, to: cb, zBottom: cc.zBottom, zTop: cc.zTop });
}
}
}
// Öffnungs-Löcher, die ein z-Teilstück vertikal berühren, auf den Box-Start
// `a` umrechnen (render3d klemmt u/z selbst auf die Box). An der INNERSTEN
// Schicht (li = letztes Band) weicht das Loch bei Fenstern mit
// `WindowType.innerLayerRecess` rundum um die eigene Schichtdicke zurück
// (Schicht-Rücksprung an der Laibung) — die anderen Schichten bleiben bündig.
const isInnermostBand = li === bands.length - 1;
const recessDepth = b.thickness;
const emitPiece = (a: number, bb: number, zb: number, zt: number): void => {
const segHoles = holes
.filter((h) => h.zTop > zb + EPS && h.zBottom < zt - EPS)
.map((h) => {
const widen = isInnermostBand && h.recessInnerLayer;
return widen
? {
from: h.from - a - recessDepth,
to: h.to - a + recessDepth,
zBottom: h.zBottom - recessDepth,
zTop: h.zTop + recessDepth,
}
: { from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop };
});
pushSegment(out, wall, a, bb, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, segHoles, bandCutMeta(b));
};
for (const [a, bb] of axisPieces) {
// Nur die Decken-z-Schnitte, die dieses Achsen-Teilstück wirklich treffen.
const local = bandZCuts
.map((c) => ({ from: Math.max(a, c.from), to: Math.min(bb, c.to), zBottom: c.zBottom, zTop: c.zTop }))
.filter((c) => c.to > c.from + EPS);
if (local.length === 0) {
// Kein Decken-Überlapp auf diesem Achsenstück ⇒ volle Höhe (Band läuft
// durch) — ausser ein Dach kappt den Top (s. {@link clipPieceToRoofs}).
clipPieceToRoofs(project, wall, u, n, b.offset, a, bb, zBottom, zTop, emitPiece);
continue;
}
// Achse [a,bb] an allen Überdeckungs-Grenzen in elementare Teilstücke
// zerlegen; je Teilstück nur die dort greifenden Decken-z-Intervalle
// subtrahieren (unbedeckte Teilstücke behalten die volle Höhe).
const bounds = new Set<number>([a, bb]);
for (const c of local) {
bounds.add(c.from);
bounds.add(c.to);
}
const xs = [...bounds].sort((p, q) => p - q);
for (let i = 0; i < xs.length - 1; i++) {
const xa = xs[i];
const xb = xs[i + 1];
if (xb - xa <= EPS) continue;
const mid = (xa + xb) / 2;
const zCutters = local
.filter((c) => c.from <= mid && c.to >= mid)
.map((c): [number, number] => [c.zBottom, c.zTop]);
// Terminierung (Zuschnitt) statt reiner z-Subtraktion: bei "below"/"above"
// endet die Wand an der Decke, statt oben wieder aufzutauchen (s.
// {@link terminateOrSubtractSpans}). "both"/undefined = heutiges Verhalten.
for (const [zb, zt] of terminateOrSubtractSpans(zBottom, zTop, zCutters, wall.sliceTermination)) {
clipPieceToRoofs(project, wall, u, n, b.offset, xa, xb, zb, zt, emitPiece);
}
}
}
}
return;
}
// ── Schnitt-Pfad (layered=false): unveränderte Pfeiler/Brüstung/Sturz-
// Segmentierung (Owner-Kompatibilität mit toSection.ts::wallSegmentOwners).
// Ohne Aussparungen: ein durchgehender Quader je Schicht.
if (cutouts.length === 0) {
emitSeg(0, axisLen, zBottom, zTop);
return;
}
// Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Aussparungen,
// Brüstung/Sturz im Öffnungsbereich. Überlappende Cutouts werden über `cursor`
// zusammengefasst.
let cursor = 0;
for (const { from, to, oBottom, oTop } of cutouts) {
const segFrom = Math.max(cursor, from);
if (from > cursor) {
// Voller Wandpfeiler bis zur Aussparung.
emitSeg(cursor, from, zBottom, zTop);
}
if (to > segFrom) {
// Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom).
if (oBottom > zBottom + EPS) {
emitSeg(segFrom, to, zBottom, oBottom);
}
// Sturz über der Öffnung (bis zum Wandkopf).
if (oTop < zTop - EPS) {
emitSeg(segFrom, to, oTop, zTop);
}
}
cursor = Math.max(cursor, to);
}
// Restlicher Wandpfeiler bis zum Achsenende.
if (cursor < axisLen) {
emitSeg(cursor, axisLen, zBottom, zTop);
}
}