Fenster/Tür: 2D und 3D setzen "aussen" jetzt auf dieselbe Wandfläche
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+44
-15
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user