From 0e02c2b20f429044ea91b9f9e78e9dda053fc5e4 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 10 Jul 2026 23:59:31 +0200 Subject: [PATCH] 2D-Schnitt = 3D: Wand-Terminierung (sliceTermination) auch im Vertikalschnitt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nutzer-Report: 'Wand unten geschnitten' wirkte im 3D, aber nicht im 2D-Schnitt — die Terminierung lief nur im layered-3D-Pfad (terminateOrSubtractSpans), der Schnitt stanzte die Decke nur per Prioritäts-Subtraktion heraus (die Wand tauchte darüber wieder auf). Neu: applyWallTermination (Phase 1b in attachCutStyles) spiegelt die 3D-Regel im (u,v)-Schnittraum: 'below' kappt jedes Wand-Band an der UNTERKANTE der dominanten Decken-Bänder (strikt höhere joinPriority + u-Überlapp), 'above' spiegelbildlich an der Oberkante; 'both'/undefined unverändert (heutige Subtraktion). +6 Tests. 696/696 grün. --- src/plan/toSection.termination.test.ts | 106 +++++++++++++++++++++++++ src/plan/toSection.ts | 106 ++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/plan/toSection.termination.test.ts diff --git a/src/plan/toSection.termination.test.ts b/src/plan/toSection.termination.test.ts new file mode 100644 index 0000000..e7a2368 --- /dev/null +++ b/src/plan/toSection.termination.test.ts @@ -0,0 +1,106 @@ +// Decken-Terminierung im 2D-Schnitt (applyWallTermination) — der Spiegel von +// terminateOrSubtractSpans (3D): Wände mit sliceTermination "below" enden an +// der Decken-Unterkante, "above" beginnen an der Oberkante; "both"/undefined +// bleibt bei der reinen Prioritäts-Subtraktion (unverändert hier). + +import { describe, it, expect } from "vitest"; +import { applyWallTermination } from "./toSection"; +import type { SectionCutPolygon } from "./toSection"; +import type { Wall } from "../model/types"; + +const rect = ( + uMin: number, + uMax: number, + vMin: number, + vMax: number, +): Array<[number, number]> => [ + [uMin, vMax], + [uMax, vMax], + [uMax, vMin], + [uMin, vMin], +]; + +const wallBand = (joinPriority: number | undefined, pts = rect(0, 0.3, 0, 2.6)): SectionCutPolygon => ({ + component: { kind: "wall", index: 0 }, + color: [0.8, 0.8, 0.8], + pts, + joinPriority, +}); + +const slabBand = (joinPriority: number, vMin: number, vMax: number, uMin = -2, uMax = 2): SectionCutPolygon => ({ + component: { kind: "slab", index: 0 }, + color: [0.9, 0.9, 0.9], + pts: rect(uMin, uMax, vMin, vMax), + joinPriority, +}); + +const wall = (sliceTermination?: Wall["sliceTermination"]): Wall => + ({ + id: "W1", + start: { x: 0, y: 0 }, + end: { x: 5, y: 0 }, + height: 2.6, + wallTypeId: "wt", + floorId: "eg", + categoryCode: "21", + sliceTermination, + }) as Wall; + +const bbox = (cp: SectionCutPolygon) => { + const vs = cp.pts.map((p) => p[1]); + return { vMin: Math.min(...vs), vMax: Math.max(...vs) }; +}; + +describe("applyWallTermination — 2D-Schnitt = 3D-Terminierung", () => { + it("'below': Wand-Band endet an der Unterkante der dominanten Decke", () => { + const bands = [wallBand(50), slabBand(100, 2.2, 2.5)]; + const out = applyWallTermination(bands, [wall("below")]); + const wb = out.find((b) => b.component.kind === "wall")!; + expect(bbox(wb).vMax).toBeCloseTo(2.2, 9); + expect(bbox(wb).vMin).toBeCloseTo(0, 9); + }); + + it("'above': Wand-Band beginnt an der Oberkante der dominanten Decke", () => { + const bands = [wallBand(50), slabBand(100, 2.2, 2.5)]; + const out = applyWallTermination(bands, [wall("above")]); + const wb = out.find((b) => b.component.kind === "wall")!; + expect(bbox(wb).vMin).toBeCloseTo(2.5, 9); + expect(bbox(wb).vMax).toBeCloseTo(2.6, 9); + }); + + it("'both'/undefined: Band bleibt unverändert (Prioritäts-Subtraktion regelt)", () => { + const bands = [wallBand(50), slabBand(100, 2.2, 2.5)]; + for (const term of ["both", undefined] as const) { + const out = applyWallTermination(bands, [wall(term)]); + const wb = out.find((b) => b.component.kind === "wall")!; + expect(wb.pts).toEqual(rect(0, 0.3, 0, 2.6)); + } + }); + + it("nur STRIKT dominante Decken terminieren (schwächere/gleiche nicht)", () => { + for (const prio of [50, 10]) { + const bands = [wallBand(50), slabBand(prio, 2.2, 2.5)]; + const out = applyWallTermination(bands, [wall("below")]); + const wb = out.find((b) => b.component.kind === "wall")!; + expect(bbox(wb).vMax).toBeCloseTo(2.6, 9); + } + }); + + it("ohne u-Überlapp keine Terminierung; Band unter der Decken-UK verschwindet nicht", () => { + // Decke liegt in u komplett rechts neben der Wand. + const far = [wallBand(50), slabBand(100, 2.2, 2.5, 5, 8)]; + const outFar = applyWallTermination(far, [wall("below")]); + expect(bbox(outFar.find((b) => b.component.kind === "wall")!).vMax).toBeCloseTo(2.6, 9); + + // Decke UNTER dem Wandfuss ⇒ 'below'-Clip entfernt das ganze Band. + const under = [wallBand(50), slabBand(100, -0.5, -0.2)]; + const outUnder = applyWallTermination(under, [wall("below")]); + expect(outUnder.some((b) => b.component.kind === "wall")).toBe(false); + }); + + it("Bänder ohne joinPriority (unauflösbares Bauteil) bleiben unangetastet", () => { + const bands = [wallBand(undefined), slabBand(100, 2.2, 2.5)]; + const out = applyWallTermination(bands, [wall("below")]); + expect(out.find((b) => b.component.kind === "wall")!.pts).toEqual(rect(0, 0.3, 0, 2.6)); + }); +}); diff --git a/src/plan/toSection.ts b/src/plan/toSection.ts index 5dc0243..82fb48d 100644 --- a/src/plan/toSection.ts +++ b/src/plan/toSection.ts @@ -344,8 +344,112 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section bands.push(cp); } + // Phase 1b — Decken-Terminierung: Wände mit `sliceTermination` "below"/"above" + // ENDEN im Schnitt an der Decke (2D = 3D, s. terminateOrSubtractSpans in + // toWalls3d), statt dass die Decke nur per Priorität herausgestanzt wird und + // die Wand darüber wieder auftaucht. + const terminated = applyWallTermination(bands, walls); + // Phase 2 — Boolean-Dominanz: die stärkere Schicht schneidet die schwächere. - output.cutPolygons = subtractDominantBands(bands); + output.cutPolygons = subtractDominantBands(terminated); +} + +/** + * Decken-Terminierung der Wand-Bänder im (u,v)-Schnittraum — der 2D-Spiegel von + * `terminateOrSubtractSpans` (toWalls3d, 3D-Pfad): trägt die Wand eines Bands + * `sliceTermination: "below"`, wird das Band an der UNTERKANTE der dominanten + * Decken-Bänder gekappt (kein über der Decke wieder auftauchender Rest); + * `"above"` spiegelbildlich an der Oberkante. Dominant = Decken-Band mit STRIKT + * höherer `joinPriority` UND u-Überlapp (u-Überlapp ↔ Plan-Überdeckung am Ort + * der Schnittebene). `"both"`/undefined ⇒ unverändert (die reine Prioritäts- + * Subtraktion in Phase 2 bleibt das heutige Verhalten). Bänder sind Rechtecke + * (splitWallLayers/Rust-Extraktor) — gekappt wird über die BBox. + */ +export function applyWallTermination( + bands: SectionCutPolygon[], + walls: Wall[], +): SectionCutPolygon[] { + const EPS_T = 1e-9; + const bbox = ( + cp: SectionCutPolygon, + ): { uMin: number; uMax: number; vMin: number; vMax: number } => { + let uMin = Infinity; + let uMax = -Infinity; + let vMin = Infinity; + let vMax = -Infinity; + for (const [u, v] of cp.pts) { + uMin = Math.min(uMin, u); + uMax = Math.max(uMax, u); + vMin = Math.min(vMin, v); + vMax = Math.max(vMax, v); + } + return { uMin, uMax, vMin, vMax }; + }; + const slabBands = bands + .filter((b) => b.component.kind === "slab" && b.joinPriority !== undefined) + .map((b) => ({ box: bbox(b), joinPriority: b.joinPriority as number })); + if (slabBands.length === 0) return bands; + + const out: SectionCutPolygon[] = []; + for (const b of bands) { + if (b.component.kind !== "wall") { + out.push(b); + continue; + } + const owner = walls[b.component.index]; + const term = owner?.sliceTermination; + if ((term !== "below" && term !== "above") || b.joinPriority === undefined) { + out.push(b); + continue; + } + const wb = bbox(b); + // Dominante Decken-Cutter mit u-Überlapp — dieselbe Auswahl wie die + // bandZCuts im 3D (nur STRIKT höhere Priorität schneidet/terminiert). + const cutters = slabBands.filter( + (s) => + s.joinPriority > (b.joinPriority as number) && + s.box.uMax > wb.uMin + EPS_T && + s.box.uMin < wb.uMax - EPS_T, + ); + if (cutters.length === 0) { + out.push(b); + continue; + } + if (term === "below") { + // Wand endet an der Decken-UNTERKANTE (kleinstes vMin der Cutter). + let clipTop = Infinity; + for (const c of cutters) clipTop = Math.min(clipTop, c.box.vMin); + const top = Math.min(clipTop, wb.vMax); + if (top > wb.vMin + EPS_T) { + out.push({ + ...b, + pts: [ + [wb.uMin, top], + [wb.uMax, top], + [wb.uMax, wb.vMin], + [wb.uMin, wb.vMin], + ], + }); + } + continue; + } + // "above": Wand beginnt an der Decken-OBERKANTE (grösstes vMax der Cutter). + let clipBottom = -Infinity; + for (const c of cutters) clipBottom = Math.max(clipBottom, c.box.vMax); + const bottom = Math.max(clipBottom, wb.vMin); + if (bottom < wb.vMax - EPS_T) { + out.push({ + ...b, + pts: [ + [wb.uMin, wb.vMax], + [wb.uMax, wb.vMax], + [wb.uMax, bottom], + [wb.uMin, bottom], + ], + }); + } + } + return out; } /**