// 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 0–4 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, ): 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, 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 { 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; }