1507c2bf00
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.
924 lines
41 KiB
TypeScript
924 lines
41 KiB
TypeScript
// 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);
|
||
}
|
||
}
|