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:
2026-07-12 21:24:48 +02:00
parent cc74396854
commit a631ddaace
6 changed files with 145 additions and 31 deletions
+9 -1
View File
@@ -386,7 +386,15 @@ export function windowSymbol(
const depth = Math.min(total, spec.frameDepth && spec.frameDepth > 1e-9 ? spec.frameDepth : DEFAULT_FRAME_DEPTH); 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 inset = Math.max(0, spec.insetFromFace ?? 0);
const halfT = total / 2; 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 // Rahmen-Vorderkante bündig mit der Bezugsfläche, um `inset` zurück; der
// Körper reicht `depth` zur Wandmitte. Auf die Wanddicke geklemmt (mirror // Körper reicht `depth` zur Wandmitte. Auf die Wanddicke geklemmt (mirror
// von resolveFrameNormalRange im 3D). Alles relativ zur Referenzlinie. // von resolveFrameNormalRange im 3D). Alles relativ zur Referenzlinie.
+7 -5
View File
@@ -215,14 +215,16 @@ describe("generatePlan — Schichteinzug (insetFromFace/insetFace)", () => {
// Die Wandachse ist horizontal (u=(1,0)) ⇒ Wandnormale n=(0,1); die // Die Wandachse ist horizontal (u=(1,0)) ⇒ Wandnormale n=(0,1); die
// y-Koordinate der Rahmenband-Punkte entspricht direkt dem Offset quer zur // y-Koordinate der Rahmenband-Punkte entspricht direkt dem Offset quer zur
// Wand. Die "aussen"-Fläche liegt bei +n — ihr Rahmenrand muss um genau // Wand. Die "aussen"-Fläche liegt bei n (erste Wandtyp-Schicht, s.
// `insetFromFace` (0.05 m) nach innen (kleineres y) rücken; die // addWallPoche/addOpeningFrameBand) — ihr Rahmenrand muss um genau
// gegenüberliegende ("innen"-)Fläche bleibt unverändert. // `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 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]); 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) - Math.min(...flushYs)).toBeCloseTo(0.05, 9);
expect(Math.min(...insetYs)).toBeCloseTo(Math.min(...flushYs), 9); expect(Math.max(...insetYs)).toBeCloseTo(Math.max(...flushYs), 9);
}); });
}); });
+44 -15
View File
@@ -1982,15 +1982,23 @@ function addOpeningFrameBand(
const { n } = wallAxisFrame(wall); const { n } = wallAxisFrame(wall);
const total = wallTypeThickness(getWallType(project, wall)); const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total); const refOff = wallReferenceOffset(wall, total);
const outerFace = refOff + total / 2; // "aussen"-Fläche (+n) // "aussen" = die Fläche der ERSTEN Wandtyp-Schicht (kleinster Achs-Offset,
const innerFace = refOff - total / 2; // "innen"-Fläche (n) // 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 // Schichteinzug: die GEWÄHLTE Fläche rückt um `insetFromFace` nach innen; die
// gegenüberliegende Fläche bleibt unverändert (der Rahmen reicht weiterhin // gegenüberliegende Fläche bleibt unverändert (der Rahmen reicht weiterhin
// bis dorthin) — vgl. DoorType.insetFromFace/WindowType.insetFromFace. // bis dorthin) — vgl. DoorType.insetFromFace/WindowType.insetFromFace.
const clampedInset = Math.max(0, Math.min(spec.insetFromFace, total)); const clampedInset = Math.max(0, Math.min(spec.insetFromFace, total));
const nearIsAussen = spec.insetFace !== "innen"; 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 faceFar = nearIsAussen ? innerFace : outerFace;
const lo = Math.min(faceNear, faceFar); const lo = Math.min(faceNear, faceFar);
const hi = Math.max(faceNear, faceFar); const hi = Math.max(faceNear, faceFar);
@@ -2014,10 +2022,12 @@ function addOpeningFrameBand(
if (isBlock) { if (isBlock) {
// Blockrahmen: volle Öffnungsbreite (kein Pfosten-Einzug), nur eine // Blockrahmen: volle Öffnungsbreite (kein Pfosten-Einzug), nur eine
// Tiefenscheibe von frameWidth ab der Nah-Fläche — sitzt proud VOR/auf der // 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); const depth = Math.min(frameWidth, hi - lo);
bandLo = nearIsAussen ? hi - depth : lo; bandLo = nearIsAussen ? lo : hi - depth;
bandHi = nearIsAussen ? hi : lo + depth; bandHi = nearIsAussen ? lo + depth : hi;
jStart = jambStart; jStart = jambStart;
jEnd = jambEnd; jEnd = jambEnd;
weightMm = SYMBOL_HAIRLINE_MM * 2.2; weightMm = SYMBOL_HAIRLINE_MM * 2.2;
@@ -2176,8 +2186,10 @@ function addOpeningSymbol(
const total = wallTypeThickness(getWallType(project, wall)); const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total); const refOff = wallReferenceOffset(wall, total);
const halfD = total / 2; const halfD = total / 2;
const outerOff = refOff + halfD; // "aussen" = negative Seite (erste Wandtyp-Schicht), s. Kommentar in
const innerOff = refOff - halfD; // addOpeningFrameBand — Bug bis 2026-07-12: stand hier vertauscht.
const outerOff = refOff - halfD;
const innerOff = refOff + halfD;
// Strichmuster: gestrichelt (Überkopf-Projektion, SIA-Konvention). // Strichmuster: gestrichelt (Überkopf-Projektion, SIA-Konvention).
const dash: number[] = [0.18, 0.09]; const dash: number[] = [0.18, 0.09];
const strokeColor = o.color ?? POCHE_STROKE; const strokeColor = o.color ?? POCHE_STROKE;
@@ -2352,18 +2364,26 @@ function addOpeningSymbol(
const { jambStart, jambEnd } = jb; const { jambStart, jambEnd } = jb;
// Tatsächliche Rahmen-Aussenkante (NICHT pauschal die volle Wandfläche — // Tatsächliche Rahmen-Aussenkante (NICHT pauschal die volle Wandfläche —
// ein eingezogener Rahmen, s. {@link WindowType.insetFromFace}, hat seine // ein eingezogener Rahmen, s. {@link WindowType.insetFromFace}, hat seine
// Aussenkante weiter innen; sym.frame[0]/[1] tragen das bereits korrekt). // Aussenkante weiter innen). "Aussen" ist der ALGEBRAISCH KLEINERE der
const outerN = dot(sub(sym.frame[0], jambStart), wn); // 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 // Fensterbank: schmales Rechteck vor der Aussenfläche, seitlich ~3 cm
// überstehend, ~4 cm auskragend (SIA-Sims-Andeutung im Grundriss). // überstehend, ~4 cm auskragend (SIA-Sims-Andeutung im Grundriss).
// Auskragung geht IMMER weiter nach aussen (algebraisch kleiner, s. o.).
const ov = 0.03; const ov = 0.03;
const proj = 0.04; const proj = 0.04;
const s0 = along(jambStart, jambEnd, -ov); const s0 = along(jambStart, jambEnd, -ov);
const s1 = along(jambEnd, jambStart, -ov); const s1 = along(jambEnd, jambStart, -ov);
const b0 = add(s0, scale(wn, outerN)); const b0 = add(s0, scale(wn, outerN));
const b1 = add(s1, scale(wn, outerN)); const b1 = add(s1, scale(wn, outerN));
const b2 = add(s1, scale(wn, outerN + proj)); const b2 = add(s1, scale(wn, outerN - proj));
const b3 = add(s0, 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][]) { 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 }); 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 dash = view === "unter" ? OVERHEAD_DASH : [0.04, 0.08];
const sillColor = o.sillLineStyle?.color ?? strokeColor; const sillColor = o.sillLineStyle?.color ?? strokeColor;
const sillWeight = o.sillLineStyle?.weight ?? SYMBOL_HAIRLINE_MM; 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") { 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") { 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 // Rollladen-/Sonnenschutzkasten (P0-Minimalfassung, reine 2D-Kontur): ein
@@ -2405,7 +2431,10 @@ function addOpeningSymbol(
const { u, n: nrm } = wallAxisFrame(wall); const { u, n: nrm } = wallAxisFrame(wall);
const total = wallTypeThickness(getWallType(project, wall)); const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total); 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( const axisWidth = Math.hypot(
jambs.jambEnd.x - jambs.jambStart.x, jambs.jambEnd.x - jambs.jambStart.x,
jambs.jambEnd.y - jambs.jambStart.y, jambs.jambEnd.y - jambs.jambStart.y,
+14 -8
View File
@@ -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", () => { 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 // Wand 0.3 m dick, Achse mittig (refOff=0). "Aussen" liegt bei der NEGATIVEN
// n=+0.15. Ein um 0.05 m eingezogener Rahmen (insetFromFace) sitzt aber bei // Wandnormalen-Seite (erste Wandtyp-Schicht, s. addWallPoche/windowSymbol) —
// n=+0.10 (halfT inset) — die Fensterbank muss DORT ansetzen, nicht an der // volle Wandfläche aussen wäre also bei n=0.15. Ein um 0.05 m eingezogener
// vollen Wandfläche (Regression für den Nutzer-Report "Sims sitzt falsch"). // 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", () => { it("Fensterbank sitzt an der eingezogenen Rahmen-Aussenkante, nicht an der vollen Wandfläche", () => {
const wt: WindowType = { const wt: WindowType = {
...baseWindowType, ...baseWindowType,
@@ -280,10 +284,12 @@ describe("generatePlan — Fensterbank (window-sill) folgt der eingezogenen Rahm
const ys = sill const ys = sill
.flatMap((pr) => (pr.kind === "line" ? [pr.a.y, pr.b.y] : [])) .flatMap((pr) => (pr.kind === "line" ? [pr.a.y, pr.b.y] : []))
.map((y) => Math.round(y * 1000) / 1000); .map((y) => Math.round(y * 1000) / 1000);
// Näher Rand der Fensterbank = Rahmen-Aussenkante (0.15 0.05 = 0.10), // Näher Rand (wandseitig) = eingezogene Rahmen-Aussenkante (0.10), NICHT
// NICHT die volle Wandfläche (0.15 — der alte, fehlerhafte Wert). // die volle Wandfläche (0.15 — der alte, fehlerhafte Wert vor dem Fix).
expect(Math.min(...ys)).toBeCloseTo(0.1, 6); expect(Math.max(...ys)).toBeCloseTo(-0.1, 6);
expect(Math.min(...ys)).not.toBeCloseTo(0.15, 2); 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);
}); });
}); });
+67 -1
View File
@@ -9,7 +9,7 @@ import { describe, it, expect } from "vitest";
import type { Project, Roof, Wall } from "../model/types"; import type { Project, Roof, Wall } from "../model/types";
import { sampleProject } from "../model/sampleProject"; import { sampleProject } from "../model/sampleProject";
import { selectionHighlightLines, projectToWalls3d, projectToModel3d, pickGeometry } from "./toWalls3d"; import { selectionHighlightLines, projectToWalls3d, projectToModel3d, pickGeometry } from "./toWalls3d";
import { resolveHatch } from "./generatePlan"; import { resolveHatch, generatePlan } from "./generatePlan";
import { roofGeometry, roofBaseElevation } from "../geometry/roof"; import { roofGeometry, roofBaseElevation } from "../geometry/roof";
const RGB: [number, number, number] = [1, 0.5, 0.1]; 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); 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)", () => { it("Öffnung ohne auflösbaren Typ -> KEIN Rahmen-Mesh (Rückwärtskompatibilität)", () => {
const p: Project = { const p: Project = {
...sampleProject, ...sampleProject,
+4 -1
View File
@@ -1973,7 +1973,10 @@ function openingAxisBox(
if (len < 1e-9) return null; if (len < 1e-9) return null;
const ux = dx / len; const ux = dx / len;
const uy = dy / 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 nx = uy;
const ny = -ux; const ny = -ux;
const positions: number[] = []; const positions: number[] = [];