From a631ddaace8cd848301bb8087d4ec4c52d998a6e Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 12 Jul 2026 21:24:48 +0200 Subject: [PATCH] =?UTF-8?q?Fenster/T=C3=BCr:=202D=20und=203D=20setzen=20"a?= =?UTF-8?q?ussen"=20jetzt=20auf=20dieselbe=20Wandfl=C3=A4che?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nutzer-Report: "im 2D innen bündig und im 3D aussen bündig". Root Cause: das 2D-Vorzeichen für insetFace:"aussen" (windowSymbol) war gegenüber der echten Aussenschicht der Wand (erste Wandtyp-Schicht, kleinster Achs-Offset in addWallPoche/pushSegment) invertiert — "aussen" landete am inneren Wandputz statt am äusseren. Dieselbe Vorzeichenverwechslung steckte auch in addOpeningFrameBand (Tür-Rahmenband), den Sturzlinien und dem Rollladenkasten. Die 3D-Seite (openingAxisBox/resolveFrameNormalRange) war bereits korrekt (zwei kompensierende Vorzeichen ergaben zufällig das richtige Ergebnis) und bleibt unverändert. +1 Regressionstest, der 2D- und 3D-Rahmenposition direkt gegen die bekannte Aussenschicht der Wand prüft. --- src/geometry/opening.ts | 10 +++- src/plan/generatePlan.doorwindow.test.ts | 12 ++-- src/plan/generatePlan.ts | 59 ++++++++++++++----- src/plan/generatePlan.windowFields.test.ts | 22 ++++--- src/plan/toWalls3d.test.ts | 68 +++++++++++++++++++++- src/plan/toWalls3d.ts | 5 +- 6 files changed, 145 insertions(+), 31 deletions(-) diff --git a/src/geometry/opening.ts b/src/geometry/opening.ts index bc472d4..f4cf931 100644 --- a/src/geometry/opening.ts +++ b/src/geometry/opening.ts @@ -386,7 +386,15 @@ export function windowSymbol( const depth = Math.min(total, spec.frameDepth && spec.frameDepth > 1e-9 ? spec.frameDepth : DEFAULT_FRAME_DEPTH); const inset = Math.max(0, spec.insetFromFace ?? 0); const halfT = total / 2; - const sign = (spec.insetFace ?? "aussen") === "innen" ? -1 : 1; + // "aussen" = die Wandfläche, an der die ERSTE Wandtyp-Schicht (Aussenputz + // etc., "Schichten außen → innen") liegt — das ist die Seite mit dem + // NEGATIVEN Achs-Offset (s. addWallPoche: `off` startet bei `refOff-total/2` + // für die erste Schicht). Bug bis 2026-07-12: hier stand `+1` für "aussen" + // (positiver, NICHT negativer Offset) — der Rahmen landete dadurch bündig + // an der INNEREN statt der äusseren Wandfläche (Nutzer-Report: "im 2D innen + // bündig und im 3D aussen bündig" — das 3D-Pendant nutzt kompensierend eine + // gespiegelte Normale, s. resolveFrameNormalRange/openingAxisBox). + const sign = (spec.insetFace ?? "aussen") === "aussen" ? -1 : 1; // Rahmen-Vorderkante bündig mit der Bezugsfläche, um `inset` zurück; der // Körper reicht `depth` zur Wandmitte. Auf die Wanddicke geklemmt (mirror // von resolveFrameNormalRange im 3D). Alles relativ zur Referenzlinie. diff --git a/src/plan/generatePlan.doorwindow.test.ts b/src/plan/generatePlan.doorwindow.test.ts index fa4ff2e..ea31f91 100644 --- a/src/plan/generatePlan.doorwindow.test.ts +++ b/src/plan/generatePlan.doorwindow.test.ts @@ -215,14 +215,16 @@ describe("generatePlan — Schichteinzug (insetFromFace/insetFace)", () => { // Die Wandachse ist horizontal (u=(1,0)) ⇒ Wandnormale n=(0,1); die // y-Koordinate der Rahmenband-Punkte entspricht direkt dem Offset quer zur - // Wand. Die "aussen"-Fläche liegt bei +n — ihr Rahmenrand muss um genau - // `insetFromFace` (0.05 m) nach innen (kleineres y) rücken; die - // gegenüberliegende ("innen"-)Fläche bleibt unverändert. + // Wand. Die "aussen"-Fläche liegt bei −n (erste Wandtyp-Schicht, s. + // addWallPoche/addOpeningFrameBand) — ihr Rahmenrand muss um genau + // `insetFromFace` (0.05 m) nach innen (RICHTUNG WANDMITTE, also grösseres/ + // weniger negatives y) rücken; die gegenüberliegende ("innen"-)Fläche bleibt + // unverändert. const insetYs = linesOfClass(inset, "opening-frame", "T1").flatMap((l) => [l.a.y, l.b.y]); const flushYs = linesOfClass(flush, "opening-frame", "T1").flatMap((l) => [l.a.y, l.b.y]); - expect(Math.max(...flushYs) - Math.max(...insetYs)).toBeCloseTo(0.05, 9); - expect(Math.min(...insetYs)).toBeCloseTo(Math.min(...flushYs), 9); + expect(Math.min(...insetYs) - Math.min(...flushYs)).toBeCloseTo(0.05, 9); + expect(Math.max(...insetYs)).toBeCloseTo(Math.max(...flushYs), 9); }); }); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index fc16b99..70dd0eb 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -1982,15 +1982,23 @@ function addOpeningFrameBand( const { n } = wallAxisFrame(wall); const total = wallTypeThickness(getWallType(project, wall)); const refOff = wallReferenceOffset(wall, total); - const outerFace = refOff + total / 2; // "aussen"-Fläche (+n) - const innerFace = refOff - total / 2; // "innen"-Fläche (−n) + // "aussen" = die Fläche der ERSTEN Wandtyp-Schicht (kleinster Achs-Offset, + // s. addWallPoche/`off` startet bei `refOff-total/2`) — mit `n = leftNormal(u)` + // ist das die NEGATIVE Seite. Bug bis 2026-07-12: hier stand `+total/2` für + // "aussen" (positive Seite, tatsächlich "innen") — Türrahmenbänder mit + // insetFace:"aussen" landeten dadurch an der falschen Wandfläche. + const outerFace = refOff - total / 2; // "aussen"-Fläche (−n, erste Schicht) + const innerFace = refOff + total / 2; // "innen"-Fläche (+n, letzte Schicht) // Schichteinzug: die GEWÄHLTE Fläche rückt um `insetFromFace` nach innen; die // gegenüberliegende Fläche bleibt unverändert (der Rahmen reicht weiterhin // bis dorthin) — vgl. DoorType.insetFromFace/WindowType.insetFromFace. const clampedInset = Math.max(0, Math.min(spec.insetFromFace, total)); const nearIsAussen = spec.insetFace !== "innen"; - const faceNear = nearIsAussen ? outerFace - clampedInset : innerFace + clampedInset; + // "Nach innen" heisst: von der gewählten Fläche RICHTUNG WANDMITTE (refOff) — + // von aussen (negative Seite) aus AUFWÄRTS (+clampedInset), von innen + // (positive Seite) aus ABWÄRTS (−clampedInset). + const faceNear = nearIsAussen ? outerFace + clampedInset : innerFace - clampedInset; const faceFar = nearIsAussen ? innerFace : outerFace; const lo = Math.min(faceNear, faceFar); const hi = Math.max(faceNear, faceFar); @@ -2014,10 +2022,12 @@ function addOpeningFrameBand( if (isBlock) { // Blockrahmen: volle Öffnungsbreite (kein Pfosten-Einzug), nur eine // Tiefenscheibe von frameWidth ab der Nah-Fläche — sitzt proud VOR/auf der - // Laibung, statt schmal darin zu verschwinden (Zargen-Optik). + // Laibung, statt schmal darin zu verschwinden (Zargen-Optik). "aussen" + // liegt bei `lo` (s. Kommentar zu outerFace/innerFace oben), die Scheibe + // beginnt dort und wächst RICHTUNG WANDMITTE (Richtung `hi`). const depth = Math.min(frameWidth, hi - lo); - bandLo = nearIsAussen ? hi - depth : lo; - bandHi = nearIsAussen ? hi : lo + depth; + bandLo = nearIsAussen ? lo : hi - depth; + bandHi = nearIsAussen ? lo + depth : hi; jStart = jambStart; jEnd = jambEnd; weightMm = SYMBOL_HAIRLINE_MM * 2.2; @@ -2176,8 +2186,10 @@ function addOpeningSymbol( const total = wallTypeThickness(getWallType(project, wall)); const refOff = wallReferenceOffset(wall, total); const halfD = total / 2; - const outerOff = refOff + halfD; - const innerOff = refOff - halfD; + // "aussen" = negative Seite (erste Wandtyp-Schicht), s. Kommentar in + // addOpeningFrameBand — Bug bis 2026-07-12: stand hier vertauscht. + const outerOff = refOff - halfD; + const innerOff = refOff + halfD; // Strichmuster: gestrichelt (Überkopf-Projektion, SIA-Konvention). const dash: number[] = [0.18, 0.09]; const strokeColor = o.color ?? POCHE_STROKE; @@ -2352,18 +2364,26 @@ function addOpeningSymbol( const { jambStart, jambEnd } = jb; // Tatsächliche Rahmen-Aussenkante (NICHT pauschal die volle Wandfläche — // ein eingezogener Rahmen, s. {@link WindowType.insetFromFace}, hat seine - // Aussenkante weiter innen; sym.frame[0]/[1] tragen das bereits korrekt). - const outerN = dot(sub(sym.frame[0], jambStart), wn); + // Aussenkante weiter innen). "Aussen" ist der ALGEBRAISCH KLEINERE der + // beiden Tiefen-Werte in sym.frame (s. windowSymbol: frontRel für + // insetFace:"aussen" ist negativ) — welcher der beiden Eckpunkt-Paare das + // ist, hängt von insetFace ab, daher robust über Math.min bestimmt statt + // fest über sym.frame[0]. + const outerN = Math.min( + dot(sub(sym.frame[0], jambStart), wn), + dot(sub(sym.frame[2], jambStart), wn), + ); // Fensterbank: schmales Rechteck vor der Aussenfläche, seitlich ~3 cm // überstehend, ~4 cm auskragend (SIA-Sims-Andeutung im Grundriss). + // Auskragung geht IMMER weiter nach aussen (algebraisch kleiner, s. o.). const ov = 0.03; const proj = 0.04; const s0 = along(jambStart, jambEnd, -ov); const s1 = along(jambEnd, jambStart, -ov); const b0 = add(s0, scale(wn, outerN)); const b1 = add(s1, scale(wn, outerN)); - const b2 = add(s1, scale(wn, outerN + proj)); - const b3 = add(s0, scale(wn, outerN + proj)); + const b2 = add(s1, scale(wn, outerN - proj)); + const b3 = add(s0, scale(wn, outerN - proj)); for (const [a, b] of [[b0, b1], [b1, b2], [b2, b3], [b3, b0]] as [Vec2, Vec2][]) { out.push({ kind: "line", a, b, cls: "window-sill", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id }); } @@ -2385,11 +2405,17 @@ function addOpeningSymbol( const dash = view === "unter" ? OVERHEAD_DASH : [0.04, 0.08]; const sillColor = o.sillLineStyle?.color ?? strokeColor; const sillWeight = o.sillLineStyle?.weight ?? SYMBOL_HAIRLINE_MM; + // Welches der beiden sym.frame-Eckpunkt-Paare physisch "aussen" ist, hängt + // von insetFace ab (s. windowSymbol) — robust über den Tiefen-Vergleich + // entlang wn bestimmt statt fest über den Index. + const pair01IsOuter = dot(sym.frame[0], wn) <= dot(sym.frame[3], wn); + const outerPair: [Vec2, Vec2] = pair01IsOuter ? [sym.frame[0], sym.frame[1]] : [sym.frame[3], sym.frame[2]]; + const innerPair: [Vec2, Vec2] = pair01IsOuter ? [sym.frame[3], sym.frame[2]] : [sym.frame[0], sym.frame[1]]; if (faces === "aussen" || faces === "beide") { - out.push({ kind: "line", a: sym.frame[0], b: sym.frame[1], cls: "window-sill", weightMm: sillWeight, dash, color: sillColor, greyed, openingId: o.id }); + out.push({ kind: "line", a: outerPair[0], b: outerPair[1], cls: "window-sill", weightMm: sillWeight, dash, color: sillColor, greyed, openingId: o.id }); } if (faces === "innen" || faces === "beide") { - out.push({ kind: "line", a: sym.frame[3], b: sym.frame[2], cls: "window-sill", weightMm: sillWeight, dash, color: sillColor, greyed, openingId: o.id }); + out.push({ kind: "line", a: innerPair[0], b: innerPair[1], cls: "window-sill", weightMm: sillWeight, dash, color: sillColor, greyed, openingId: o.id }); } } // Rollladen-/Sonnenschutzkasten (P0-Minimalfassung, reine 2D-Kontur): ein @@ -2405,7 +2431,10 @@ function addOpeningSymbol( const { u, n: nrm } = wallAxisFrame(wall); const total = wallTypeThickness(getWallType(project, wall)); const refOff = wallReferenceOffset(wall, total); - const outerFace = refOff + total / 2; + // "aussen" = negative Seite (erste Wandtyp-Schicht), s. Kommentar in + // addOpeningFrameBand — Bug bis 2026-07-12: stand hier vertauscht (der + // Rollladenkasten landete an der Innenfläche statt aussen am Sturz). + const outerFace = refOff - total / 2; const axisWidth = Math.hypot( jambs.jambEnd.x - jambs.jambStart.x, jambs.jambEnd.y - jambs.jambStart.y, diff --git a/src/plan/generatePlan.windowFields.test.ts b/src/plan/generatePlan.windowFields.test.ts index c47fae0..f00d409 100644 --- a/src/plan/generatePlan.windowFields.test.ts +++ b/src/plan/generatePlan.windowFields.test.ts @@ -262,10 +262,14 @@ describe("generatePlan — Stulp-/Flügelstoss-Marken (SIA fig. 37/38, window-st }); describe("generatePlan — Fensterbank (window-sill) folgt der eingezogenen Rahmenkante", () => { - // Wand 0.3 m dick, Achse mittig (refOff=0) -> volle Wandfläche aussen wäre bei - // n=+0.15. Ein um 0.05 m eingezogener Rahmen (insetFromFace) sitzt aber bei - // n=+0.10 (halfT − inset) — die Fensterbank muss DORT ansetzen, nicht an der - // vollen Wandfläche (Regression für den Nutzer-Report "Sims sitzt falsch"). + // Wand 0.3 m dick, Achse mittig (refOff=0). "Aussen" liegt bei der NEGATIVEN + // Wandnormalen-Seite (erste Wandtyp-Schicht, s. addWallPoche/windowSymbol) — + // volle Wandfläche aussen wäre also bei n=−0.15. Ein um 0.05 m eingezogener + // Rahmen (insetFromFace) sitzt bei n=−0.10 (−halfT + inset) — die Fensterbank + // muss DORT ansetzen (nicht an der vollen Wandfläche −0.15) und kragt von dort + // WEITER nach aussen aus (−0.04, also bis −0.14) — Regression für den Nutzer- + // Report "Sims sitzt falsch" (ursprünglich) bzw. "2D innen bündig, 3D aussen + // bündig" (die zugrundeliegende aussen/innen-Vorzeichenverwechslung). it("Fensterbank sitzt an der eingezogenen Rahmen-Aussenkante, nicht an der vollen Wandfläche", () => { const wt: WindowType = { ...baseWindowType, @@ -280,10 +284,12 @@ describe("generatePlan — Fensterbank (window-sill) folgt der eingezogenen Rahm const ys = sill .flatMap((pr) => (pr.kind === "line" ? [pr.a.y, pr.b.y] : [])) .map((y) => Math.round(y * 1000) / 1000); - // Näher Rand der Fensterbank = Rahmen-Aussenkante (0.15 − 0.05 = 0.10), - // NICHT die volle Wandfläche (0.15 — der alte, fehlerhafte Wert). - expect(Math.min(...ys)).toBeCloseTo(0.1, 6); - expect(Math.min(...ys)).not.toBeCloseTo(0.15, 2); + // Näher Rand (wandseitig) = eingezogene Rahmen-Aussenkante (−0.10), NICHT + // die volle Wandfläche (−0.15 — der alte, fehlerhafte Wert vor dem Fix). + expect(Math.max(...ys)).toBeCloseTo(-0.1, 6); + expect(Math.max(...ys)).not.toBeCloseTo(-0.15, 2); + // Auskragung geht weiter nach aussen (algebraisch kleiner): −0.10 − 0.04. + expect(Math.min(...ys)).toBeCloseTo(-0.14, 6); }); }); diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index d5051c3..b33ece0 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect } from "vitest"; import type { Project, Roof, Wall } from "../model/types"; import { sampleProject } from "../model/sampleProject"; import { selectionHighlightLines, projectToWalls3d, projectToModel3d, pickGeometry } from "./toWalls3d"; -import { resolveHatch } from "./generatePlan"; +import { resolveHatch, generatePlan } from "./generatePlan"; import { roofGeometry, roofBaseElevation } from "../geometry/roof"; const RGB: [number, number, number] = [1, 0.5, 0.1]; @@ -852,6 +852,72 @@ describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", ( expect(insetMinY - flushMinY).toBeCloseTo(0.1, 6); }); + it("2D (windowSymbol) und 3D (emitOpeningFrames) legen die 'aussen'-Rahmenkante auf DIESELBE Wandfläche — und die stimmt mit der echten Aussenschicht der Wand überein (Regression: 'im 2D innen bündig, im 3D aussen bündig')", () => { + // W1 (u=(1,0), Wandtyp "aw", Gesamtdicke 0.345 m, Achse mittig) -> die + // echte Aussenfläche (erste Schicht, render-ext) liegt bei WELT-y = -0.1725 + // exakt (unabhängig von diesem Bug herleitbar: halbe Wanddicke). Ein + // bündiges Fenster (insetFace:"aussen", insetFromFace:0) muss in BEIDEN + // Ansichten exakt dort ansetzen. + const flushWindow: Project = { + ...sampleProject, + windowTypes: [ + ...(sampleProject.windowTypes ?? []), + { + id: "win-consistency-test", + name: "Test bündig aussen", + kind: "fest", + wingCount: 1, + glazing: "einfach", + frameThickness: 0.06, + frameWidth: 0.06, + insetFromFace: 0, + insetFace: "aussen", + defaultSillHeight: 0.9, + defaultWidth: 1.0, + defaultHeight: 1.2, + }, + ], + openings: [ + { + id: "OC1", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "window", + typeId: "win-consistency-test", + position: 3.0, + width: 1.0, + height: 1.2, + sillHeight: 0.9, + }, + ], + doors: [], + context: [], + }; + const trueAussenY = -0.345 / 2; + + // 3D: minimale y-Koordinate aller Rahmen-Meshes dieser Öffnung. + const isFrame3d = (m: { color: [number, number, number] }): boolean => + m.color[0] === 0.85 && m.color[1] === 0.85 && m.color[2] === 0.83; + let min3d = Infinity; + for (const mesh of projectToModel3d(flushWindow).meshes.filter(isFrame3d)) { + for (let i = 1; i < mesh.positions.length; i += 3) min3d = Math.min(min3d, mesh.positions[i]); + } + expect(min3d).toBeCloseTo(trueAussenY, 6); + + // 2D: minimale y-Koordinate des Blendrahmen-Polygons (windowSymbol.frame, + // gerendert als "polygon" mit openingId — die unsichtbare Pick-Fläche hat + // stroke:"none" und wird ausgeschlossen). + const visible = new Set(["20", "21"]); + const plan = generatePlan(flushWindow, "eg", visible, undefined, "mittel"); + const framePoly = plan.primitives.find( + (pr) => pr.kind === "polygon" && pr.openingId === "OC1" && pr.stroke !== "none", + ); + expect(framePoly).toBeDefined(); + const min2d = Math.min(...(framePoly!.kind === "polygon" ? framePoly!.pts.map((p) => p.y) : [])); + expect(min2d).toBeCloseTo(trueAussenY, 6); + }); + it("Öffnung ohne auflösbaren Typ -> KEIN Rahmen-Mesh (Rückwärtskompatibilität)", () => { const p: Project = { ...sampleProject, diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index b335dfb..fea2520 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -1973,7 +1973,10 @@ function openingAxisBox( if (len < 1e-9) return null; const ux = dx / len; const uy = dy / len; - // Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie pushSegment. + // Grundriss-Normale (Dicken-Achse) — RECHTE Normale (uy,−ux), bewusst NICHT + // leftNormal (anders als pushSegment/windowSymbol) — kombiniert mit dem + // (ebenfalls gespiegelten) Vorzeichen in resolveFrameNormalRange ergibt sich + // daraus physisch die korrekte aussen/innen-Seite (s. dortigen Kommentar). const nx = uy; const ny = -ux; const positions: number[] = [];