From c34b7e5771aff873c7c9bb9a966dc7699b954d00 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 01:46:01 +0200 Subject: [PATCH] Schnitt: Boolean-Dominanz der Schichten nach joinPriority Wo sich Schicht-Baender verschiedener Elemente im Schnitt ueberlappen, gewinnt die staerkere Schicht (hoehere joinPriority) und schneidet die schwaechere per Rechteck-Subtraktion weg (elementuebergreifend: die Betondecke schneidet den Wand-Putz). Gleiche Prioritaet koexistiert (durchgehende Betonflaeche Wand+ Decke). attachCutStyles sammelt jetzt alle Baender mit joinPriority und laesst subtractDominantBands global gegen alle staerkeren Cutter subtrahieren; die ueberlappungsfreie Zerlegung liefert exakt die Material-/Fugenlinien zwischen den Schichten. Neuer subtractRect-Helfer, 11 neue Tests, 152 gruen. --- src/plan/toSection.boolean.test.ts | 187 +++++++++++++++++++++++++++++ src/plan/toSection.ts | 183 +++++++++++++++++++++++++++- 2 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 src/plan/toSection.boolean.test.ts diff --git a/src/plan/toSection.boolean.test.ts b/src/plan/toSection.boolean.test.ts new file mode 100644 index 0000000..7546b81 --- /dev/null +++ b/src/plan/toSection.boolean.test.ts @@ -0,0 +1,187 @@ +/** + * Unit-Tests für die Schnitt-Boolean-Dominanz (toSection): an Wand-Decken- + * Übergängen schneidet die STÄRKERE Schicht (höhere `Component.joinPriority`) + * die schwächere per achsparalleler Rechteck-Subtraktion weg. Gleiche Priorität + * koexistiert (kein Schnitt). + * + * Getestet ohne WASM: + * 1. `subtractRect` direkt (Ecken-/Kanten-/Enthalten-/Kein-Overlap-Fälle). + * 2. `subtractDominantBands` gegen den Nutzer-Testfall — ELEMENTÜBERGREIFEND: + * ein Decken-Beton-Band (100) schneidet ein überlappendes Wand-Putz-Band + * (10) weg; Beton bleibt voll; gleiche Priorität koexistiert. + */ + +import { describe, it, expect } from "vitest"; +import { + subtractRect, + subtractDominantBands, + type Rect, + type SectionCutPolygon, +} from "./toSection"; + +/** Rechteck-Kurzform. */ +function r(uMin: number, uMax: number, vMin: number, vMax: number): Rect { + return { uMin, uMax, vMin, vMax }; +} + +/** Rechteck einer Rest-Menge normalisieren (auf 1e-9 gerundet, sortiert) für stabile Vergleiche. */ +function norm(rects: Rect[]): Rect[] { + const round = (x: number) => Math.round(x * 1e9) / 1e9; + return rects + .map((x) => ({ uMin: round(x.uMin), uMax: round(x.uMax), vMin: round(x.vMin), vMax: round(x.vMax) })) + .sort((a, b) => a.uMin - b.uMin || a.vMin - b.vMin); +} + +/** Ein achsparalleles Cut-Band mit joinPriority. */ +function band( + uMin: number, + uMax: number, + vMin: number, + vMax: number, + joinPriority: number, + tag = "", +): SectionCutPolygon { + return { + component: { kind: "wall", index: 0 }, + color: [0.5, 0.5, 0.5], + pts: [ + [uMin, vMax], + [uMax, vMax], + [uMax, vMin], + [uMin, vMin], + ], + fill: tag || "#fff", + joinPriority, + }; +} + +/** Bounding-Box eines Bands als Rect. */ +function boxOf(b: SectionCutPolygon): Rect { + const us = b.pts.map((p) => p[0]); + const vs = b.pts.map((p) => p[1]); + return { uMin: Math.min(...us), uMax: Math.max(...us), vMin: Math.min(...vs), vMax: Math.max(...vs) }; +} + +/** Gesamtfläche einer Band-/Rect-Menge. */ +function area(items: Array): number { + return items.reduce((s, it) => { + const b = "pts" in it ? boxOf(it) : it; + return s + (b.uMax - b.uMin) * (b.vMax - b.vMin); + }, 0); +} + +describe("subtractRect: achsparallele Rechteck-Subtraktion", () => { + it("kein Overlap → base unverändert", () => { + expect(norm(subtractRect(r(0, 1, 0, 1), r(2, 3, 2, 3)))).toEqual(norm([r(0, 1, 0, 1)])); + }); + + it("nur Kantenberührung (Fläche 0) → base unverändert", () => { + // cutter grenzt bei u=1 an, überlappt aber nicht. + expect(norm(subtractRect(r(0, 1, 0, 1), r(1, 2, 0, 1)))).toEqual(norm([r(0, 1, 0, 1)])); + }); + + it("cutter enthält base vollständig → leere Restmenge", () => { + expect(subtractRect(r(1, 2, 1, 2), r(0, 3, 0, 3))).toEqual([]); + }); + + it("cutter mittig in base → 4 Rest-Rechtecke, Flächenbilanz stimmt", () => { + const rest = subtractRect(r(0, 3, 0, 3), r(1, 2, 1, 2)); + expect(rest).toHaveLength(4); + // 9 (base) − 1 (overlap) = 8. + expect(area(rest)).toBeCloseTo(8, 9); + // Rechtecke sind disjunkt: Summe der Einzelflächen = Gesamtfläche. + const sum = rest.reduce((s, x) => s + (x.uMax - x.uMin) * (x.vMax - x.vMin), 0); + expect(sum).toBeCloseTo(8, 9); + }); + + it("Ecküberlappung → 2 Rest-Rechtecke (L-Form)", () => { + // cutter überdeckt die obere-rechte Ecke von base. + const rest = subtractRect(r(0, 2, 0, 2), r(1, 3, 1, 3)); + // overlap = [1,2]×[1,2] (Fläche 1). Rest = 4 − 1 = 3. + expect(area(rest)).toBeCloseTo(3, 9); + expect(norm(rest)).toEqual( + norm([ + r(0, 1, 0, 2), // links, volle Höhe + r(1, 2, 0, 1), // mittlerer Streifen unten + ]), + ); + }); + + it("Kantenüberlappung (cutter deckt einen Randstreifen) → 1 Rest-Rechteck", () => { + // cutter deckt den oberen Rand v∈[1,2] über die volle Breite. + const rest = subtractRect(r(0, 2, 0, 2), r(-1, 3, 1, 3)); + expect(norm(rest)).toEqual(norm([r(0, 2, 0, 1)])); + }); +}); + +describe("subtractDominantBands: stärkere Schicht schneidet schwächere", () => { + it("Nutzer-Testfall ELEMENTÜBERGREIFEND: Decken-Beton(100) schneidet Wand-Putz(10) weg", () => { + // Wand-Putz-Band (Element B): schwach, joinPriority 10, u∈[0,2], v∈[0,3]. + const putz = band(0, 2, 0, 3, 10, "putz"); + // Decken-Beton-Band (Element A): stark, joinPriority 100, überlappt den + // oberen Teil des Putzes: u∈[0,2], v∈[2,4]. + const beton = band(0, 2, 2, 4, 100, "beton"); + + const out = subtractDominantBands([putz, beton]); + // Beton bleibt voll erhalten (kein stärkeres Band). + const betonOut = out.filter((b) => b.fill === "beton"); + expect(betonOut).toHaveLength(1); + expect(boxOf(betonOut[0])).toEqual(boxOf(beton)); + + // Putz ist im Überlappungsbereich v∈[2,3] weggeschnitten → Rest nur v∈[0,2]. + const putzOut = out.filter((b) => b.fill === "putz"); + expect(putzOut).toHaveLength(1); + expect(boxOf(putzOut[0])).toEqual(r(0, 2, 0, 2)); + // In der Überlappungsregion existiert KEIN Putz mehr. + expect(putzOut[0].pts.every((p) => p[1] <= 2 + 1e-9)).toBe(true); + }); + + it("gestaffelte Kette Beton100 > Backstein50 > Dämmung20 > Putz10", () => { + // Alle vier Bänder überlappen in derselben Region u∈[0,1], gestapelt in v. + // Jedes schwächere wird von JEDEM stärkeren geschnitten, wo sie überlappen. + const beton = band(0, 1, 3, 4, 100, "beton"); + const backstein = band(0, 1, 2.5, 3.5, 50, "backstein"); // überlappt Beton in v∈[3,3.5] + const daemmung = band(0, 1, 2, 3, 20, "daemmung"); // überlappt Backstein in v∈[2.5,3] + const putz = band(0, 1, 0, 3.2, 10, "putz"); // überlappt alle darüber + + const out = subtractDominantBands([beton, backstein, daemmung, putz]); + + // Beton unangetastet. + expect(area(out.filter((b) => b.fill === "beton"))).toBeCloseTo(1, 9); + // Backstein: [2.5,3.5] minus Beton-Overlap [3,3.5] = [2.5,3] → Fläche 0.5. + expect(area(out.filter((b) => b.fill === "backstein"))).toBeCloseTo(0.5, 9); + // Dämmung: [2,3] minus Backstein-Overlap [2.5,3] = [2,2.5] → Fläche 0.5. + // (Beton [3,4] überlappt Dämmung nicht.) + expect(area(out.filter((b) => b.fill === "daemmung"))).toBeCloseTo(0.5, 9); + // Putz: [0,3.2] minus Overlap mit ALLEN stärkeren. + // Beton[3,4]→[3,3.2], Backstein[2.5,3.5]→[2.5,3.2], Dämmung[2,3]→[2,3] überdecken + // zusammen v∈[2,3.2]; Rest = v∈[0,2] → Fläche 2. + expect(area(out.filter((b) => b.fill === "putz"))).toBeCloseTo(2, 9); + }); + + it("gleiche Priorität koexistiert: Betonwand + Betondecke schneiden sich NICHT", () => { + const betonWand = band(0, 1, 0, 3, 100, "wand"); + const betonDecke = band(0, 1, 2, 4, 100, "decke"); // überlappt in v∈[2,3] + const out = subtractDominantBands([betonWand, betonDecke]); + // Beide voll erhalten (durchgehende Betonfläche). + expect(area(out.filter((b) => b.fill === "wand"))).toBeCloseTo(3, 9); + expect(area(out.filter((b) => b.fill === "decke"))).toBeCloseTo(2, 9); + }); + + it("Band ohne joinPriority nimmt an keiner Subtraktion teil", () => { + const stark = band(0, 2, 0, 2, 100, "stark"); + const ohne: SectionCutPolygon = { ...band(0, 2, 0, 2, 0, "ohne"), joinPriority: undefined }; + const out = subtractDominantBands([stark, ohne]); + // Beide unverändert durchgereicht. + expect(area(out.filter((b) => b.fill === "stark"))).toBeCloseTo(4, 9); + expect(area(out.filter((b) => b.fill === "ohne"))).toBeCloseTo(4, 9); + }); + + it("vollständig überdecktes schwächeres Band verschwindet", () => { + const stark = band(0, 3, 0, 3, 100, "stark"); + const schwach = band(1, 2, 1, 2, 10, "schwach"); // ganz im starken enthalten + const out = subtractDominantBands([stark, schwach]); + expect(out.filter((b) => b.fill === "schwach")).toHaveLength(0); + expect(area(out.filter((b) => b.fill === "stark"))).toBeCloseTo(9, 9); + }); +}); diff --git a/src/plan/toSection.ts b/src/plan/toSection.ts index 3bd9653..cb87119 100644 --- a/src/plan/toSection.ts +++ b/src/plan/toSection.ts @@ -47,6 +47,15 @@ export interface SectionCutPolygon { 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; } /** Ein projiziertes Liniensegment in (u, v)-Metern. */ @@ -254,7 +263,11 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section const uWorld = sectionUAxisModel(plane); const walls = wallSegmentOwners(project); const slabs = slabSegmentOwners(project); - const next: SectionCutPolygon[] = []; + + // 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") { @@ -267,7 +280,7 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section if (owner) { const wt = getWallType(project, owner); if (wt.layers.length > 1) { - next.push(...splitWallLayers(cp, project, wt, owner, uWorld)); + bands.push(...splitWallLayers(cp, project, wt, owner, uWorld)); continue; } } @@ -276,7 +289,8 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section cp.fill = style.fill; cp.hatch = style.hatch; } - next.push(cp); + cp.joinPriority = owner ? wallRepresentativePriority(project, owner) : undefined; + bands.push(cp); continue; } // Decke: bei mehrschichtigem Deckentyp wird das EINE geschnittene Rechteck @@ -288,7 +302,7 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section if (owner) { const wt = getCeilingType(project, owner); if (wt.layers.length > 1) { - next.push(...splitSlabLayers(cp, project, wt)); + bands.push(...splitSlabLayers(cp, project, wt)); continue; } const style = resolveCeilingSectionStyle(project, owner); @@ -296,10 +310,165 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section cp.fill = style.fill; cp.hatch = style.hatch; } + cp.joinPriority = ceilingRepresentativePriority(project, owner); } - next.push(cp); + bands.push(cp); } - output.cutPolygons = next; + + // 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; + } +} + +/** + * 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)); + } + 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, + }; } /** @@ -349,6 +518,7 @@ function splitSlabLayers( ], fill: hatch.pattern === "solid" ? "#000000" : "#ffffff", hatch, + joinPriority: comp.joinPriority, }); } return out; @@ -420,6 +590,7 @@ export function splitWallLayers( ], fill: hatch.pattern === "solid" ? "#000000" : "#ffffff", hatch, + joinPriority: comp.joinPriority, }); } return out;