Files
DOSSIER-STANDALONE/src/plan/toSection.ts
T
karim 07042ebc75 Joins Phase 2: Merge-Regel im Schnitt (gleiche Komponente verschmilzt)
Die Schnitt-Dominanz kannte nur 'strikt hoeher schneidet schwaecher'.
Neu: Baender GLEICHER joinPriority UND GLEICHER Komponente, die sich
beruehren/ueberlappen, verschmelzen zu EINEM Rechteck (rectUnionIfRect:
Union nur, wenn das Ergebnis exakt ein Rechteck ist; iterativ bis zum
Fixpunkt) - keine innere Trennlinie mehr durch gleichartiges Material,
analog resolveJoinPriority 'merge' im Grundriss. SectionCutPolygon
traegt dafuer componentId (Schicht-Split + repraesentatives Bauteil bei
einschichtiger Poche). Verschiedene Komponenten gleicher Prioritaet
koexistieren unveraendert; Subtraktion bit-identisch. +6 Tests (222).
2026-07-04 15:06:19 +02:00

800 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Schnitt-/Ansichts-Pipeline: bindet den analytischen render3d-Schnitt-Extraktor
// (`section.rs`, WASM-Export `cut_section_json`) an das Dokumentmodell an.
//
// Ablauf:
// 1. Aus einer Zeichnungsebene (DrawingLevel, kind "section"/"elevation") wird
// eine Schnittebene abgeleitet — die Grundriss-Schnittlinie `linePoints`
// plus `directionSign` (Blickrichtung).
// 2. Das Projekt wird über `projectToModel3d({ layeredWalls: false })` zu
// Wänden + Decken geflacht und als JSON an `cut_section_json` gereicht —
// pro Wand EINE Voll-Box (der 3D-Viewport bekommt dagegen je Materiallage
// eine Box). So bleiben die Cut-Polygone 1:1 zu `wallSegmentOwners`, und
// die per-Material-Schnitt-Schraffur macht `splitWallLayers` TS-seitig.
// 3. Das zurückgegebene `SectionOutput` (Cut-Polygone + sichtbare/verdeckte
// Kanten, alles in (u, v)-Metern) wird in `generateSectionPlan`
// (generatePlan.ts) zu Plan-Primitiven übersetzt.
//
// Einheiten: METER. Koordinaten der Ausgabe: u = horizontal entlang der Ebene,
// v = absolute Höhe (world.y). Siehe src-tauri/render3d/src/section.rs.
import type { Ceiling, DrawingLevel, Project, Vec2, Wall } from "../model/types";
import { getCeilingType, getComponent, getWallType, openingsOfWall } from "../model/types";
import { ceilingVerticalExtent, wallVerticalExtent } from "../model/wall";
import { dot, leftNormal, normalize, sub } from "../model/geometry";
import { openingInterval, openingVerticalExtent } from "../geometry/opening";
import { projectToModel3d } from "./toWalls3d";
import { loadEngine3d } from "../engine/engine3d";
import {
HATCH_INK,
HATCH_PAPER,
resolveCeilingSectionStyle,
resolveForeground,
resolveHatch,
resolveWallSectionStyle,
} from "./generatePlan";
import type { HatchRender } from "./generatePlan";
/** Bauteil-Referenz eines Cut-Polygons/einer Kante (Art + Eingabe-Index). */
export interface SectionComponentRef {
kind: "wall" | "slab";
index: number;
}
/** Ein geschnittenes Bauteil-Rechteck in (u, v)-Metern (Ring, implizit geschlossen). */
export interface SectionCutPolygon {
component: SectionComponentRef;
/** Albedo-Farbe des Bauteils (RGB 0..1) — Rohwert aus dem 3D-Modell (Fallback). */
color: [number, number, number];
/** Ring-Ecken (u, v). */
pts: Array<[number, number]>;
/**
* Echte Poché-Füllfarbe des geschnittenen Bauteils (tragende Wandschicht
* bzw. erste Deckenschicht), von `computeSection` aufgelöst. Fehlt, wenn das
* Quell-Bauteil nicht auflösbar war — der Aufrufer (generateSectionPlan)
* fällt dann auf `color` zurück.
*/
fill?: string;
/** Echte Schnitt-Schraffur des geschnittenen Bauteils, analog zu `fill`. */
hatch?: HatchRender;
/**
* Verschneidungs-Rang der Schicht dieses Bands (`Component.joinPriority` des
* Ursprungs-Bauteils). Steuert die Schnitt-Boolean-Dominanz in
* `attachCutStyles`: ein Band mit STRIKT höherer Priorität schneidet ein
* überlappendes schwächeres Band per Rechteck-Subtraktion weg (element-
* übergreifend, z. B. Decken-Beton schneidet Wand-Putz). Fehlt der Wert
* (unauflösbares Bauteil), nimmt das Band an keiner Subtraktion teil.
*/
joinPriority?: number;
/**
* Bauteil-Identität der Schicht dieses Bands (`Component.id` des Ursprungs-
* Bauteils bzw. — bei einschichtiger Poché — des tragenden Bauteils). Steuert
* die MERGE-Regel in `subtractDominantBands`: berühren/überlappen sich zwei
* Bänder mit GLEICHER `componentId` UND gleicher `joinPriority`, verschmelzen
* sie zu einem Körper (keine innere Trennlinie durch gleichartiges Material —
* analog zu `resolveJoinPriority` "merge" im Grundriss-Join-Modell). Fehlt der
* Wert (unauflösbares Bauteil), nimmt das Band an keiner Verschmelzung teil.
*/
componentId?: string;
}
/** Ein projiziertes Liniensegment in (u, v)-Metern. */
export interface SectionEdge {
component: SectionComponentRef;
a: [number, number];
b: [number, number];
}
/** Vollständige Ausgabe eines Schnitt-/Ansichts-Laufs (JSON von `cut_section_json`). */
export interface SectionOutput {
cutPolygons: SectionCutPolygon[];
visibleEdges: SectionEdge[];
hiddenEdges: SectionEdge[];
}
/** Minimal-Typ des WASM-Exports (das echte `.d.ts` entsteht erst beim wasm-Build). */
interface Engine3dSection {
cut_section_json(
modelJson: string,
px: number,
py: number,
pz: number,
nx: number,
ny: number,
nz: number,
): string;
}
/**
* Eine Schnittebene in world-Koordinaten (Y-up): Punkt auf der Ebene +
* Normale (= Blickrichtung, zeigt vom Betrachter ins Modell).
*/
export interface SectionPlaneSpec {
point: [number, number, number];
normal: [number, number, number];
}
/**
* Leitet aus einer Schnitt-/Ansichtsebene die world-Schnittebene ab. Die
* Grundriss-Schnittlinie `linePoints` [p0, p1] (Modell-Meter) liegt in der
* XZ-Ebene (world.x = model.x, world.z = model.y); die Ebenennormale steht
* senkrecht auf der Linie in der Horizontalen und wird über `directionSign`
* (Voreinstellung +1) in die gewünschte Blickrichtung gedreht.
*
* Gibt `null` zurück, wenn die Ebene (noch) keine Schnittlinie trägt (z. B. eine
* frisch angelegte Ansicht ohne gesetzte Linie) — der Aufrufer zeigt dann einen
* Hinweis statt eines leeren Plans.
*/
export function sectionPlaneFromLevel(level: DrawingLevel): SectionPlaneSpec | null {
const line = level.linePoints;
if (!line) return null;
const [p0, p1] = line;
const dx = p1.x - p0.x;
const dy = p1.y - p0.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) return null;
const ux = dx / len;
const uy = dy / len;
// Links-Normale der Linie im Grundriss (n = (-u.y, u.x)), per directionSign
// in die Blickrichtung gedreht. Modell (nx, ny) → world (nx, 0, ny).
const sign = level.directionSign ?? 1;
const nx = -uy * sign;
const ny = ux * sign;
return {
point: [p0.x, 0, p0.y],
normal: [nx, 0, ny],
};
}
/**
* Die in-plane-u-Weltrichtung der Schnittebene, projiziert in die Grundriss-
* Ebene (Modell-2D: model.x = world.x, model.y = world.z).
*
* Muss EXAKT der u-Achse des WASM-Extraktors entsprechen (`SectionPlane::u_axis`
* in section.rs): dort `u = normalize(cross(normal, up))` mit up = (0,1,0). Für
* eine horizontale Normale `[Nx, Ny, Nz]` ist `cross(normal, (0,1,0)) =
* (-Nz, 0, Nx)`; die Grundriss-Komponenten (x, z) sind also `(-Nz, Nx)`. Damit
* wächst die Ausgabe-u-Koordinate genau in dieser Modell-Richtung — die Referenz,
* gegen die die Wand-Aufbaurichtung projiziert wird. (Nicht normiert; für das
* Vorzeichen des Skalarprodukts irrelevant.)
*/
export function sectionUAxisModel(plane: SectionPlaneSpec): Vec2 {
const [nx, , nz] = plane.normal;
return { x: -nz, y: nx };
}
/**
* Ob die Schicht-Bänder einer Wand im Schnitt gegenüber der Default-Zuordnung
* (layers[0] → uMin) umzukehren sind, damit sie mit der GRUNDRISS-Poché
* übereinstimmen (Quelle der Wahrheit: `addWallPoche`/generatePlan).
*
* Konvention aus dem Grundriss: die Schichten stapeln entlang der linken Normalen
* `n = leftNormal(u)` der Wandachse `u = normalize(end start)`, von layers[0]
* am kleinsten Offset (T/2, `n`-Seite) zu layers[n] am größten Offset (`+n`-
* Seite). Die Aufbaurichtung layers[0] → layers[n] zeigt also nach `+n`. Der
* Referenzlinien-Versatz (`wallReferenceOffset`) verschiebt nur die absolute
* Lage, nicht diese Reihenfolge — daher hier ohne Belang.
*
* `splitWallLayers` legt layers[0] per Default an uMin (u wächst entlang der
* Schnitt-u-Achse `uWorld`). Zeigt die Aufbaurichtung `+n` GEGEN `uWorld`
* (Skalarprodukt < 0), muss die Band-Reihenfolge gespiegelt werden, damit
* layers[0] auf der korrekten Weltseite (z. B. Aussenputz aussen) liegt und
* gegenüberliegende Wände spiegelverkehrt erscheinen.
*/
export function wallLayersReversedInU(owner: Wall, uWorld: Vec2): boolean {
const axis = sub(owner.end, owner.start);
if (Math.hypot(axis.x, axis.y) < 1e-9) return false;
const buildDir = leftNormal(normalize(axis)); // +n: layers[0] → layers[n]
return dot(buildDir, uWorld) < 0;
}
// ── Cut-Polygon → Quell-Bauteil (für die echte Hatch-Auflösung) ────────────
// `cut_section_json` (section.rs) nummeriert jedes Cut-Polygon per
// `ComponentRef { kind, index }`, wobei `index` exakt die Position im
// `walls`/`slabs`-Array ist, das ihm als Modell-JSON übergeben wurde
// (`walls.iter().enumerate()` in section.rs) — also dieselbe Reihenfolge wie
// `projectToModel3d(project).walls`/`.slabs` (toWalls3d.ts).
//
// `projectToWalls3d` zerlegt EINE Wand mit Öffnungen in mehrere Teilquader
// (Pfeiler/Brüstung/Sturz) — die Emission ist also nicht 1:1 Wand→Element.
// Um einen `index` wieder auf seine Quellwand zurückzuführen, wird hier exakt
// dieselbe Segmentierungs-/Skip-Logik wie `emitWall`/`pushSegment`
// (toWalls3d.ts) repliziert — NICHT die Geometrie, nur welches Quellelement
// pro emittiertem Segment "besitzt". Bleibt diese Zählung nicht exakt parallel
// zu `emitWall`, verschieben sich die Indizes und die Hatch-Auflösung wird
// falsch (harmlos: der Aufrufer fällt dann pro Cut-Polygon auf die generische
// Schnitt-Schraffur zurück, siehe `resolveWallSectionStyle`/`generatePlan.ts`).
const OWNER_EPS = 1e-4;
/** Liefert je emittiertem Wand-Teilquader (in Emissions-Reihenfolge) die Quellwand. */
export function wallSegmentOwners(project: Project): Wall[] {
const owners: Wall[] = [];
const pushOwner = (wall: Wall, from: number, to: number, zBottom: number, zTop: number) => {
if (to - from <= OWNER_EPS) return;
if (zTop - zBottom <= OWNER_EPS) return;
owners.push(wall);
};
for (const wall of project.walls) {
const { zBottom, zTop } = wallVerticalExtent(project, wall);
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= OWNER_EPS) continue;
const cutouts: Array<{ from: number; to: number; oBottom: number; oTop: number }> = [];
for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op);
if (!iv) continue;
const v = openingVerticalExtent(project, wall, op);
cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop });
}
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 < OWNER_EPS) continue;
const oTop = Math.min(zTop, zBottom + d.height);
cutouts.push({ from, to, oBottom: zBottom, oTop });
}
cutouts.sort((a, b) => a.from - b.from);
if (cutouts.length === 0) {
pushOwner(wall, 0, axisLen, zBottom, zTop);
continue;
}
let cursor = 0;
for (const { from, to, oBottom, oTop } of cutouts) {
const segFrom = Math.max(cursor, from);
if (from > cursor) pushOwner(wall, cursor, from, zBottom, zTop);
if (to > segFrom) {
if (oBottom > zBottom + OWNER_EPS) pushOwner(wall, segFrom, to, zBottom, oBottom);
if (oTop < zTop - OWNER_EPS) pushOwner(wall, segFrom, to, oTop, zTop);
}
cursor = Math.max(cursor, to);
}
if (cursor < axisLen) pushOwner(wall, cursor, axisLen, zBottom, zTop);
}
return owners;
}
/** Liefert je emittierter Deckenplatte (in Emissions-Reihenfolge) die Quelldecke (1:1, keine Zerlegung). */
export function slabSegmentOwners(project: Project): Ceiling[] {
const owners: Ceiling[] = [];
for (const c of project.ceilings ?? []) {
if (!c.outline || c.outline.length < 3) continue;
const { zBottom, zTop } = ceilingVerticalExtent(project, c);
if (zTop - zBottom <= OWNER_EPS) continue;
owners.push(c);
}
return owners;
}
/**
* Hängt an jedes Cut-Polygon von `output` (in-place) die echte Poché-Füllfarbe
* + Schraffur des geschnittenen Bauteils an (`fill`/`hatch`), aufgelöst über
* die Wand-/Decken-Besitzer-Listen. Nicht auflösbare Referenzen (Index
* außerhalb der Besitzer-Liste, verwaistes `wallTypeId`/Bauteil) bleiben ohne
* `fill`/`hatch` — `generateSectionPlan` fällt dafür auf die generische
* Schnitt-Schraffur zurück.
*/
function attachCutStyles(output: SectionOutput, project: Project, plane: SectionPlaneSpec): void {
if (output.cutPolygons.length === 0) return;
// Schnitt-u-Weltachse (Modell-2D) — Referenz für die Aussen/Innen-Orientierung
// der mehrschichtigen Wand-Bänder (siehe `wallLayersReversedInU`).
const uWorld = sectionUAxisModel(plane);
const walls = wallSegmentOwners(project);
const slabs = slabSegmentOwners(project);
// Phase 1 — ALLE Cut-Bänder (aus allen Wänden UND Decken, mehr- wie einschichtig)
// als flache Liste sammeln, jedes mit seiner `joinPriority` getaggt (aus dem
// Bauteil der Schicht bzw. dem repräsentativen Bauteil bei einschichtig).
const bands: SectionCutPolygon[] = [];
for (const cp of output.cutPolygons) {
const ref = cp.component;
if (ref.kind === "wall") {
const owner = walls[ref.index];
// Wand: bei mehrschichtigem Wandtyp wird das EINE geschnittene Rechteck in
// stehende Teil-Rechtecke je Schicht quer zur Dicke (u) aufgeteilt — das
// vertikale Gegenstück zur Decken-Zerlegung (dort quer zur Höhe v), siehe
// `splitWallLayers`. Einschichtige Wände laufen unverändert über
// `resolveWallSectionStyle` (ein repräsentatives Bauteil).
if (owner) {
const wt = getWallType(project, owner);
if (wt.layers.length > 1) {
bands.push(...splitWallLayers(cp, project, wt, owner, uWorld));
continue;
}
}
const style = owner ? resolveWallSectionStyle(project, owner) : null;
if (style) {
cp.fill = style.fill;
cp.hatch = style.hatch;
}
cp.joinPriority = owner ? wallRepresentativePriority(project, owner) : undefined;
cp.componentId = owner ? wallRepresentativeComponentId(project, owner) : undefined;
bands.push(cp);
continue;
}
// Decke: bei mehrschichtigem Deckentyp wird das EINE geschnittene Rechteck
// (von der WASM-Schnitt-Extraktion) in liegende Teil-Rechtecke je Schicht
// aufgeteilt (oben/OK → unten/UK) — siehe `splitSlabLayers`. Nur im
// Schnitt sichtbar (im Grundriss bleibt die Decke bewusst flächig, siehe
// `addCeilingPoche`).
const owner = slabs[ref.index];
if (owner) {
const wt = getCeilingType(project, owner);
if (wt.layers.length > 1) {
bands.push(...splitSlabLayers(cp, project, wt));
continue;
}
const style = resolveCeilingSectionStyle(project, owner);
if (style) {
cp.fill = style.fill;
cp.hatch = style.hatch;
}
cp.joinPriority = ceilingRepresentativePriority(project, owner);
cp.componentId = ceilingRepresentativeComponentId(project, owner);
}
bands.push(cp);
}
// Phase 2 — Boolean-Dominanz: die stärkere Schicht schneidet die schwächere.
output.cutPolygons = subtractDominantBands(bands);
}
/**
* Repräsentative `joinPriority` einer Wand für die Schnitt-Dominanz, wenn ihre
* Poché einschichtig (ein Band über die volle Dicke) gezeichnet wird: die
* HÖCHSTE joinPriority ihrer Schichten — dieselbe Regel, mit der
* `resolveWallSectionStyle` das tragende Bauteil (dessen Füllung/Schraffur das
* Band trägt) wählt. `undefined`, wenn der Wandtyp/keine Schicht auflösbar ist
* (das Band nimmt dann an keiner Subtraktion teil).
*/
function wallRepresentativePriority(project: Project, wall: Wall): number | undefined {
try {
const wt = getWallType(project, wall);
let best: number | undefined;
for (const layer of wt.layers) {
const p = getComponent(project, layer.componentId).joinPriority;
if (best === undefined || p > best) best = p;
}
return best;
} catch {
return undefined;
}
}
/**
* Bauteil-Identität einer einschichtig gezeichneten Wand-Poché für die MERGE-
* Regel: die `id` des tragenden Bauteils (höchste joinPriority) — exakt das
* Bauteil, dessen Füllung/Schraffur `resolveWallSectionStyle` dem Band gibt und
* dessen `joinPriority` `wallRepresentativePriority` liefert. `undefined`, wenn
* Wandtyp/keine Schicht auflösbar ist.
*/
function wallRepresentativeComponentId(project: Project, wall: Wall): string | undefined {
try {
const wt = getWallType(project, wall);
let bestId: string | undefined;
let bestPrio = -Infinity;
for (const layer of wt.layers) {
const p = getComponent(project, layer.componentId).joinPriority;
if (bestId === undefined || p > bestPrio) {
bestId = layer.componentId;
bestPrio = p;
}
}
return bestId;
} catch {
return undefined;
}
}
/**
* Bauteil-Identität einer einschichtig gezeichneten Decken-Poché für die MERGE-
* Regel: die `id` der ERSTEN Schicht — dasselbe Bauteil, das
* `resolveCeilingSectionStyle`/`ceilingRepresentativePriority` verwenden.
* `undefined`, wenn Deckentyp/keine Schicht auflösbar ist.
*/
function ceilingRepresentativeComponentId(project: Project, ceiling: Ceiling): string | undefined {
try {
const wt = getCeilingType(project, ceiling);
const first = wt.layers[0];
if (!first) return undefined;
return first.componentId;
} catch {
return undefined;
}
}
/**
* Repräsentative `joinPriority` einer Decke für die Schnitt-Dominanz, wenn ihre
* Poché einschichtig (ein Band über die volle Dicke) gezeichnet wird: die der
* ERSTEN Schicht — dasselbe Bauteil, dessen Füllung/Schraffur
* `resolveCeilingSectionStyle` dem Band gibt. `undefined`, wenn Deckentyp/keine
* Schicht auflösbar ist.
*/
function ceilingRepresentativePriority(project: Project, ceiling: Ceiling): number | undefined {
try {
const wt = getCeilingType(project, ceiling);
const first = wt.layers[0];
if (!first) return undefined;
return getComponent(project, first.componentId).joinPriority;
} catch {
return undefined;
}
}
// ── Schnitt-Boolean-Dominanz (achsparallele Rechteck-Subtraktion) ───────────
// Kleine Toleranz für Überlappungs-/Rest-Rechtecke: Null-/Negativflächen und
// Rest-Streifen dünner als EPS werden verworfen (Float-Rauschen der u-/v-Meter).
const RECT_EPS = 1e-6;
/** Achsparalleles Rechteck in (u, v)-Metern. */
export interface Rect {
uMin: number;
uMax: number;
vMin: number;
vMax: number;
}
/** Bounding-Box eines (achsparallelen) Cut-Bands als `Rect`, oder `null` bei <3 Ecken. */
function rectOfBand(band: SectionCutPolygon): Rect | null {
if (band.pts.length < 3) return null;
const us = band.pts.map((p) => p[0]);
const vs = band.pts.map((p) => p[1]);
return {
uMin: Math.min(...us),
uMax: Math.max(...us),
vMin: Math.min(...vs),
vMax: Math.max(...vs),
};
}
/**
* Rechteck-minus-Rechteck (beide achsparallel): `base` ohne die Überlappung mit
* `cutter`, zerlegt in 04 disjunkte Rest-Rechtecke (links/rechts über die volle
* Höhe, dann oben/unten im mittleren u-Streifen — überlappungsfrei). Kein echter
* Überlapp (Fläche ≤ EPS in u oder v) ⇒ `base` bleibt ganz. Vollständige
* Überdeckung ⇒ leere Liste. Rest-Streifen dünner als EPS werden verworfen.
*/
export function subtractRect(base: Rect, cutter: Rect): Rect[] {
const oMinU = Math.max(base.uMin, cutter.uMin);
const oMaxU = Math.min(base.uMax, cutter.uMax);
const oMinV = Math.max(base.vMin, cutter.vMin);
const oMaxV = Math.min(base.vMax, cutter.vMax);
// Kein (nennenswerter) Überlapp — `base` unverändert.
if (oMaxU - oMinU <= RECT_EPS || oMaxV - oMinV <= RECT_EPS) return [base];
const out: Rect[] = [];
const push = (r: Rect) => {
if (r.uMax - r.uMin > RECT_EPS && r.vMax - r.vMin > RECT_EPS) out.push(r);
};
// Links / rechts der Überlappung, jeweils über die volle Basis-Höhe.
push({ uMin: base.uMin, uMax: oMinU, vMin: base.vMin, vMax: base.vMax });
push({ uMin: oMaxU, uMax: base.uMax, vMin: base.vMin, vMax: base.vMax });
// Unten / oben im mittleren u-Streifen (nur der von der Überlappung befreite Rest).
push({ uMin: oMinU, uMax: oMaxU, vMin: base.vMin, vMax: oMinV });
push({ uMin: oMinU, uMax: oMaxU, vMin: oMaxV, vMax: base.vMax });
return out;
}
/**
* Globale Schnitt-Dominanz: für jedes Band wird die Rechteck-Überlappung ALLER
* Bänder mit STRIKT höherer `joinPriority` subtrahiert (elementübergreifend —
* z. B. schneidet ein Decken-Beton-Band 100 ein überlappendes Wand-Putz-Band 10
* weg). Gleiche Priorität schneidet NICHT (koexistiert → durchgehende Fläche).
* Bänder ohne `joinPriority` (unauflösbares Bauteil) nehmen weder als Basis noch
* als Cutter teil und bleiben unverändert erhalten.
*
* Kanten: jedes emittierte Rest-Rechteck erhält in `generateSectionPlan` seine
* eigene Schnitt-Umrisslinie (unverändert). An der Schnittkante fällt die
* Rest-Rechteck-Kante mit der Kante des stärkeren Bands zusammen — das ist die
* gewünschte Material-/Fugenlinie zwischen den Schichten; im Inneren einer
* Schicht entstehen durch die überlappungsfreie Zerlegung keine falschen Kanten.
*/
export function subtractDominantBands(bands: SectionCutPolygon[]): SectionCutPolygon[] {
const rects = bands.map(rectOfBand);
const out: SectionCutPolygon[] = [];
for (let i = 0; i < bands.length; i++) {
const band = bands[i];
const base = rects[i];
const prio = band.joinPriority;
// Nicht-rechteckig oder ohne Priorität: unverändert durchreichen.
if (base === null || prio === undefined) {
out.push(band);
continue;
}
let pieces: Rect[] = [base];
for (let j = 0; j < bands.length && pieces.length > 0; j++) {
if (j === i) continue;
const otherPrio = bands[j].joinPriority;
const cutter = rects[j];
if (cutter === null || otherPrio === undefined || otherPrio <= prio) continue;
const next: Rect[] = [];
for (const p of pieces) next.push(...subtractRect(p, cutter));
pieces = next;
}
for (const p of pieces) out.push(bandFromRect(band, p));
}
// MERGE-Regel: nach der Dominanz-Subtraktion angrenzende/überlappende Bänder
// gleicher Komponente + Priorität zu einem Körper verschmelzen (keine innere
// Trennlinie durch gleichartiges Material). Die Subtraktion oben bleibt davon
// unberührt (bit-identisch) — hier werden nur gleichartige Reste vereinigt.
return mergeSameComponentBands(out);
}
/**
* Union zweier achsparalleler Rechtecke, ABER NUR wenn das Ergebnis EXAKT wieder
* ein Rechteck ist — sonst `null`. Zwei Fälle liefern eine rechteckige Union
* (jeweils mit `RECT_EPS`-Toleranz):
* • Enthaltensein: eines liegt vollständig im anderen ⇒ das umschließende.
* • Achs-Deckung + Berührung: in einer Achse deckungsgleich (gleiche min/max)
* UND in der anderen berührend/überlappend (kein Spalt) ⇒ Bounding-Box.
* Teil-/Eck-Überlappungen (Union wäre L-förmig) und Bänder mit Spalt liefern
* `null` — sie dürfen NICHT verschmolzen werden (die Bounding-Box deckte sonst
* leere Fläche ab). Genau die im Schnitt vorkommenden bündigen Rechtecke.
*/
function rectUnionIfRect(a: Rect, b: Rect): Rect | null {
const E = RECT_EPS;
// Enthaltensein (inkl. Deckungsgleichheit): Union = umschließendes Rechteck.
const aInB =
b.uMin - a.uMin <= E && a.uMax - b.uMax <= E && b.vMin - a.vMin <= E && a.vMax - b.vMax <= E;
if (aInB) return { uMin: b.uMin, uMax: b.uMax, vMin: b.vMin, vMax: b.vMax };
const bInA =
a.uMin - b.uMin <= E && b.uMax - a.uMax <= E && a.vMin - b.vMin <= E && b.vMax - a.vMax <= E;
if (bInA) return { uMin: a.uMin, uMax: a.uMax, vMin: a.vMin, vMax: a.vMax };
const uAligned = Math.abs(a.uMin - b.uMin) <= E && Math.abs(a.uMax - b.uMax) <= E;
const vAligned = Math.abs(a.vMin - b.vMin) <= E && Math.abs(a.vMax - b.vMax) <= E;
// Berührung/Überlappung in der jeweils ANDEREN Achse (kein Spalt größer EPS).
const vTouch = a.vMax >= b.vMin - E && b.vMax >= a.vMin - E;
const uTouch = a.uMax >= b.uMin - E && b.uMax >= a.uMin - E;
if ((uAligned && vTouch) || (vAligned && uTouch)) {
return {
uMin: Math.min(a.uMin, b.uMin),
uMax: Math.max(a.uMax, b.uMax),
vMin: Math.min(a.vMin, b.vMin),
vMax: Math.max(a.vMax, b.vMax),
};
}
return null;
}
/**
* MERGE-Phase der Schnitt-Dominanz (vgl. `resolveJoinPriority` "merge",
* src/model/joins.ts): Bänder mit GLEICHER `componentId` UND gleicher
* `joinPriority`, deren achsparallele Rechtecke sich berühren/überlappen und
* eine rechteckige Union bilden (`rectUnionIfRect`), werden zu EINEM Band
* zusammengefasst — so entsteht an der Berührkante keine innere Trennlinie durch
* gleichartiges Material (z. B. zwei Backstein-Kerne, Beton-Decke an Beton-Wand).
*
* Bänder VERSCHIEDENER Komponenten (auch bei gleicher Priorität) bleiben
* getrennt (coexist). Bänder ohne `componentId`/`joinPriority` oder ohne
* Rechteck-Form nehmen nicht teil und werden unverändert durchgereicht — daher
* ist die Phase für die bestehenden Dominanz-Tests (keine `componentId`) ein
* No-op. Iterativ bis zum Fixpunkt (Ketten aus ≥3 Rechtecken verschmelzen
* schrittweise); die Reihenfolge bleibt erhalten (das verschmolzene Rechteck
* ersetzt das erste Band an seiner Position).
*/
function mergeSameComponentBands(bands: SectionCutPolygon[]): SectionCutPolygon[] {
const out = bands.slice();
for (let merged = true; merged; ) {
merged = false;
for (let i = 0; i < out.length && !merged; i++) {
const bi = out[i];
if (bi.componentId === undefined || bi.joinPriority === undefined) continue;
const ri = rectOfBand(bi);
if (!ri) continue;
for (let j = i + 1; j < out.length; j++) {
const bj = out[j];
if (bj.componentId !== bi.componentId || bj.joinPriority !== bi.joinPriority) continue;
const rj = rectOfBand(bj);
if (!rj) continue;
const u = rectUnionIfRect(ri, rj);
if (!u) continue;
out[i] = bandFromRect(bi, u);
out.splice(j, 1);
merged = true; // Fixpunkt-Neustart: neu entstandene Nachbarschaften erfassen
break;
}
}
}
return out;
}
/** Erzeugt aus einem Rest-`Rect` ein Cut-Band, das Stil/Referenz von `src` erbt. */
function bandFromRect(src: SectionCutPolygon, r: Rect): SectionCutPolygon {
return {
component: src.component,
color: src.color,
pts: [
[r.uMin, r.vMax],
[r.uMax, r.vMax],
[r.uMax, r.vMin],
[r.uMin, r.vMin],
],
fill: src.fill,
hatch: src.hatch,
joinPriority: src.joinPriority,
componentId: src.componentId,
};
}
/**
* Zerlegt das EINE geschnittene Deckenrechteck (`cp`, vier Ecken in u/v-Metern)
* in liegende Teil-Rechtecke je Schicht des Deckentyps `wt` — das Schnitt-
* Gegenstück zur mehrschichtigen Wand-Poché (dort im Grundriss als Bänder quer
* zur Achse; hier im Schnitt als Bänder über die Höhe v, oben/OK → unten/UK,
* exakt wie der liegende Aufbau physisch stapelt). Die Modell-Schichtdicken
* werden proportional auf die tatsächlich geschnittene Höhe skaliert (falls
* `Ceiling.thickness` die Typ-Summe übersteuert). Jedes Teil-Rechteck trägt
* Füllung + Schraffur seines Bauteils; die Fugen ergeben sich implizit aus den
* aneinanderstoßenden Rechtecken (jedes bekommt in `generateSectionPlan` die
* generische Schnitt-Umrisslinie).
*/
function splitSlabLayers(
cp: SectionCutPolygon,
project: Project,
wt: ReturnType<typeof getCeilingType>,
): SectionCutPolygon[] {
const totalT = wt.layers.reduce((s, l) => s + l.thickness, 0);
const us = cp.pts.map((p) => p[0]);
const vs = cp.pts.map((p) => p[1]);
const uMin = Math.min(...us);
const uMax = Math.max(...us);
const vMin = Math.min(...vs);
const vMax = Math.max(...vs); // vMax = OK (oben), vMin = UK (unten)
const vSpan = vMax - vMin;
if (totalT <= 1e-9 || vSpan <= 1e-9) return [cp];
const scale = vSpan / totalT;
const out: SectionCutPolygon[] = [];
let acc = 0; // kumulierte Dicke von OK (oben) nach UK (unten)
for (const layer of wt.layers) {
const comp = getComponent(project, layer.componentId);
const hatch = resolveHatch(project, comp.hatchId, undefined, resolveForeground(comp));
const vTop = vMax - acc * scale;
acc += layer.thickness;
const vBottom = vMax - acc * scale;
out.push({
component: cp.component,
color: cp.color,
pts: [
[uMin, vTop],
[uMax, vTop],
[uMax, vBottom],
[uMin, vBottom],
],
fill: hatch.pattern === "solid" ? HATCH_INK : HATCH_PAPER,
hatch,
joinPriority: comp.joinPriority,
componentId: comp.id,
});
}
return out;
}
/**
* Achswinkel einer GESCHNITTENEN Wand im Schnitt-Koordinatensystem (Modell-
* Konvention wie `wallAngleDeg` in resolveHatch): das Wand-Band steht vertikal
* (Achse entlang v, atan2(1,0) = 90°). Damit drehen wandrelative Muster
* (Dämmung, Backstein) im Schnitt genauso mit der Wand wie im Grundriss.
*/
const SECTION_WALL_AXIS_ANGLE_DEG = 90;
/**
* Zerlegt das EINE geschnittene Wandrechteck (`cp`, vier Ecken in u/v-Metern) in
* stehende Teil-Rechtecke je Schicht des Wandtyps `wt` — das Wand-Gegenstück zu
* `splitSlabLayers`. Achsen-Spiegelung: eine Wand ist vertikal geschnitten, das
* Schnittrechteck hat die volle HÖHE in v (vMin..vMax) und spannt über die DICKE
* in u (uMin..uMax); die Schichten stapeln quer zur Dicke, also entlang u — im
* Gegensatz zur Decke, die quer zur Höhe (v) geschichtet ist. v bleibt darum je
* Band voll, u wird proportional zu den Schichtdicken zerlegt. Die Modell-
* Schichtdicken werden auf die tatsächlich geschnittene Dicke (uSpan) skaliert.
* Jedes Teil-Rechteck trägt Füllung + Schnitt-Schraffur seines Bauteils; die
* Fugen ergeben sich implizit aus den aneinanderstoßenden Rechtecken.
*
* Reihenfolge/Orientierung: `WallType.layers` ist von einer Wandseite zur anderen
* geordnet (layers[0] = Aussenseite). Welche u-Seite des Schnittrechtecks der
* Aussenseite entspricht, folgt NICHT aus dem (u, v)-Rechteck allein, sondern aus
* der Wandgeometrie + der Schnitt-u-Weltachse `uWorld`: `wallLayersReversedInU`
* projiziert die Grundriss-Aufbaurichtung (`+leftNormal` der Achse, dieselbe
* Konvention wie `addWallPoche`/generatePlan) auf `uWorld`. Ist das Vorzeichen
* negativ, wird die Band-Reihenfolge gespiegelt (layers[0] → uMax statt uMin), so
* dass Schnitt und Grundriss übereinstimmen und gegenüberliegende Wände spiegel-
* verkehrt erscheinen (Dämmung aussen, Backstein innen).
*
* Annahme: rechteckiges Schnittpolygon (min/max-Bounding-Box auf `cp.pts`,
* identisch zu `splitSlabLayers`).
*/
export function splitWallLayers(
cp: SectionCutPolygon,
project: Project,
wt: ReturnType<typeof getWallType>,
owner: Wall,
uWorld: Vec2,
): SectionCutPolygon[] {
const totalT = wt.layers.reduce((s, l) => s + l.thickness, 0);
const us = cp.pts.map((p) => p[0]);
const vs = cp.pts.map((p) => p[1]);
const uMin = Math.min(...us);
const uMax = Math.max(...us);
const vMin = Math.min(...vs);
const vMax = Math.max(...vs);
const uSpan = uMax - uMin;
if (totalT <= 1e-9 || uSpan <= 1e-9) return [cp];
const scale = uSpan / totalT;
// Aussen/Innen-Orientierung: per Default liegt layers[0] an uMin; zeigt die
// Wand-Aufbaurichtung gegen die Schnitt-u-Achse, werden die Bänder gespiegelt.
const layers = wallLayersReversedInU(owner, uWorld) ? [...wt.layers].reverse() : wt.layers;
const out: SectionCutPolygon[] = [];
let acc = 0; // kumulierte Dicke von uMin (erste einsortierte Schicht) nach uMax
for (const layer of layers) {
const comp = getComponent(project, layer.componentId);
// Wandrelative Muster (relativeToWall, z. B. Dämmung/Backstein) folgen der
// Wandachse — im Schnitt steht die geschnittene Wand VERTIKAL (Bandachse
// entlang v, Modellwinkel 90°). Wie im Grundriss (`addWallPoche` übergibt
// die Wandachse) wird der Achswinkel mitgegeben, sonst liefe die Dämmung
// vertikal statt quer zur Wanddicke (horizontal).
const hatch = resolveHatch(project, comp.hatchId, SECTION_WALL_AXIS_ANGLE_DEG, resolveForeground(comp));
const uLeft = uMin + acc * scale;
acc += layer.thickness;
const uRight = uMin + acc * scale;
out.push({
component: cp.component,
color: cp.color,
pts: [
[uLeft, vMax],
[uRight, vMax],
[uRight, vMin],
[uLeft, vMin],
],
fill: hatch.pattern === "solid" ? HATCH_INK : HATCH_PAPER,
hatch,
joinPriority: comp.joinPriority,
componentId: comp.id,
});
}
return out;
}
/**
* Berechnet den Schnitt/die Ansicht der Ebene `level` gegen das Modell `project`
* über den WASM-Extraktor. Asynchron (das render3d-WASM-Modul wird lazy geladen).
*
* Gibt `null` zurück, wenn die Ebene keine gültige Schnittlinie hat. Wirft, wenn
* das WASM-Modul den Export (noch) nicht bereitstellt — der Aufrufer fängt das ab
* und blendet einen Fallback ein (pkg3d muss dann über `npm run build:engine3d`
* neu gebaut werden, damit `cut_section_json` verfügbar ist).
*/
export async function computeSection(
project: Project,
level: DrawingLevel,
): Promise<SectionOutput | null> {
const plane = sectionPlaneFromLevel(level);
if (!plane) return null;
const mod = (await loadEngine3d()) as Engine3dSection;
if (typeof mod.cut_section_json !== "function") {
throw new Error(
"render3d-WASM ohne cut_section_json — pkg3d neu bauen (npm run build:engine3d)",
);
}
// Schnitt-Eingabe: EINE Voll-Box pro Wand (layeredWalls:false), damit die
// Cut-Polygone 1:1 zu `wallSegmentOwners` (Öffnungs-Segmentierung) liegen und
// `splitWallLayers` die materialspezifischen Schnitt-Bänder korrekt aus dem
// EINEN Wandrechteck erzeugt. Der 3D-Viewer bekommt weiter per-Schicht-Boxen.
const modelJson = JSON.stringify(projectToModel3d(project, { layeredWalls: false }));
const json = mod.cut_section_json(
modelJson,
plane.point[0],
plane.point[1],
plane.point[2],
plane.normal[0],
plane.normal[1],
plane.normal[2],
);
const output = JSON.parse(json) as SectionOutput;
attachCutStyles(output, project, plane);
return output;
}