Ansicht robuster: exaktes Hidden-Line-Clipping, T-Stoss-Ecken, Öffnungsschatten

Die bisherigen Näherungen versagten bei realen Grundrissen: Bounding-Box-
Occlusion liess Linien hinterer Wände durch die Vorderfassade scheinen, sobald
eine Hinterwand höher/breiter war als die verdeckende; die Eckverlängerung
schloss nur exakt geteilte Endpunkte, keine T-/versetzten Stösse.

- Hidden-Line: jede Umriss-/Kantenlinie wird jetzt exakt gegen die näheren
  opaken Flächen (inkl. Öffnungs-Rahmenquads, da die Fassade um Öffnungen
  ausgespart ist) geclippt statt per Bbox verworfen. Verdeckte Teilstücke
  entfallen, überstehende bleiben — keine Durchsicht mehr.
- Ecken: Wandenden verlängern sich bis zur Aussenfläche jeder anstossenden
  Wand (L/T/X über Körper-Enthaltung), aber nur für fassaden-PARALLELE Wände —
  facaden-senkrechte Innenwände würden sonst als Balken vor die Fassade ragen.
- Öffnungen: Fenster/Türen werfen mit Schatten-Toggle einen Laibungs-/Reveal-
  schatten (Band unter dem Sturz + an der linken Laibung, 45° von links oben),
  sodass sie als Vertiefung lesen. Verdeckte Öffnungen werden ganz gecullt.

Neue Tests decken die konkreten Fehlerbilder ab (deckungsgleiche/höhere
Hinterwand, verdecktes Fenster, Reveal-Schatten an/aus).
This commit is contained in:
2026-07-11 13:10:21 +02:00
parent 1c31e680c1
commit cdae20acbe
2 changed files with 389 additions and 93 deletions
+111
View File
@@ -257,6 +257,117 @@ function elevationProject(opts: { overhang: boolean }): Project {
return project; return project;
} }
describe("toElevation — Hidden-Line-Clipping (keine Durchsicht)", () => {
// Zwei deckungsgleiche Wände (NEAR vor FAR): die hintere Silhouette liegt
// vollständig hinter der vorderen und darf KEINE Umrisslinie beisteuern —
// sonst „scheint" die Hinterwand durch (Nutzer-Report). Erwartet: genau die
// 4 Kanten der vorderen Wand.
it("deckungsgleiche Hinterwand trägt keine Umrisslinie bei", () => {
const project = elevationProject({ overhang: false });
const plan = generateElevationPlan(project, project.drawingLevels[1])!;
const outlineLines = plan.primitives.filter(
(p) => p.kind === "line" && (p as { cls?: string }).cls === "elevation-outline",
);
// Nur die 4 Kanten der NEAR-Fassade (FAR ist vollständig geclippt).
expect(outlineLines.length).toBe(4);
});
it("höhere Hinterwand: nur der ÜBERSTEHENDE Teil bleibt sichtbar", () => {
const base = elevationProject({ overhang: false });
// FAR-Wand höher machen (4 statt 3 m) → ihr oberer Rand ragt über NEAR.
const project: Project = {
...base,
walls: base.walls.map((w) => (w.id === "FAR" ? { ...w, height: 4 } : w)),
};
const plan = generateElevationPlan(project, project.drawingLevels[1])!;
const outline = plan.primitives.filter(
(p) => p.kind === "line" && (p as { cls?: string }).cls === "elevation-outline",
) as Array<{ a: { x: number; y: number }; b: { x: number; y: number } }>;
// Eine horizontale Kante bei v≈4 (FAR-Oberkante, sichtbar über NEAR) …
const hasFarTop = outline.some(
(l) => Math.abs(l.a.y - 4) < 1e-4 && Math.abs(l.b.y - 4) < 1e-4,
);
expect(hasFarTop).toBe(true);
// … aber KEINE horizontale Kante bei v≈0 aus FAR im von NEAR verdeckten
// Bereich: die einzige v≈0-Kante ist NEARs Unterkante (die FAR-Unterkante
// liegt hinter NEAR und ist geclippt). Es gibt also nur EINE v≈0-Linie.
const bottomLines = outline.filter(
(l) => Math.abs(l.a.y) < 1e-4 && Math.abs(l.b.y) < 1e-4,
);
expect(bottomLines.length).toBe(1);
});
});
describe("toElevation — Öffnungen: Verdeckung + Reveal-Schatten", () => {
// Projekt mit einem Fenster auf NEAR und einem deckungsgleich dahinter auf FAR.
function openingProject(): Project {
const base = elevationProject({ overhang: false });
const wt = {
id: "wt",
name: "Fenster",
kind: "drehkipp" as const,
wingCount: 1,
glazing: "zweifach" as const,
frameWidth: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1,
defaultHeight: 1.2,
};
const mkOpening = (id: string, hostWallId: string) => ({
id,
type: "opening" as const,
hostWallId,
categoryCode: "21",
kind: "window" as const,
typeId: "wt",
position: 1.5,
width: 1,
height: 1.2,
sillHeight: 0.9,
});
return {
...base,
windowTypes: [wt],
openings: [mkOpening("op-near", "NEAR"), mkOpening("op-far", "FAR")],
} as unknown as Project;
}
it("Fenster auf der verdeckten Hinterwand wird NICHT emittiert (keine Durchsicht)", () => {
const project = openingProject();
const plan = generateElevationPlan(project, project.drawingLevels[1])!;
// Öffnungs-Symbollinien (DIN) — die des FAR-Fensters dürften durch NEAR
// scheinen, wenn nicht gecullt. Nur das NEAR-Fenster liefert Symbole.
const symbols = plan.primitives.filter(
(p) => p.kind === "line" && (p as { cls?: string }).cls === "elevation-opening-symbol",
);
const nearOnly = generateElevationPlan(
{ ...project, openings: (project.openings ?? []).filter((o) => o.id === "op-near") },
project.drawingLevels[1],
)!;
const nearSymbols = nearOnly.primitives.filter(
(p) => p.kind === "line" && (p as { cls?: string }).cls === "elevation-opening-symbol",
);
// Mit beiden Fenstern gleich viele Symbole wie mit NUR dem vorderen → das
// hintere trägt nichts bei (vollständig verdeckt).
expect(symbols.length).toBe(nearSymbols.length);
expect(symbols.length).toBeGreaterThan(0);
});
it("Reveal-Schatten: shadows=true erzeugt Öffnungs-Schattenbänder, false keine", () => {
const project = openingProject();
const level = project.drawingLevels[1];
const off = generateElevationPlan(project, level, { shadows: false })!;
const on = generateElevationPlan(project, level, { shadows: true })!;
const revealShadows = (plan: typeof on) =>
plan.primitives.filter(
(p) => p.kind === "polygon" && p.stroke === "none" && p.fill === "#a7adb5",
).length;
expect(revealShadows(off)).toBe(0);
// Ein sichtbares Fenster → zwei Reveal-Bänder (Kopf + linke Laibung).
expect(revealShadows(on)).toBe(2);
});
});
describe("Dächer in der Ansicht (roofWorldFacesAll)", () => { describe("Dächer in der Ansicht (roofWorldFacesAll)", () => {
it("Dachflächen erscheinen als roofId-Polygone bis zur Firsthöhe", () => { it("Dachflächen erscheinen als roofId-Polygone bis zur Firsthöhe", () => {
// Sattel 4×3 über dem Fixture-Haus, First entlang X, Traufe 3 m — die // Sattel 4×3 über dem Fixture-Haus, First entlang X, Traufe 3 m — die
+274 -89
View File
@@ -82,6 +82,10 @@ const FRAME_MM = 0.18;
const GLASS_FILL_MONO = "#c4c8cd"; const GLASS_FILL_MONO = "#c4c8cd";
const GLASS_FILL_COLOR = "#9fc2d9"; const GLASS_FILL_COLOR = "#9fc2d9";
const SHADOW_FILL = "#8f959e"; const SHADOW_FILL = "#8f959e";
/** Laibungs-/Reveal-Schatten der Öffnungen (Fenster/Türen) — etwas heller als
* der Bauteil-Schlagschatten {@link SHADOW_FILL}, damit die Vertiefung dezent
* liest und Sprossen/Symbole darüber noch erkennbar bleiben. */
const OPENING_SHADOW_FILL = "#a7adb5";
/** Bodenlinie (Terrain-/Nulllinie): kräftige Tinte über die volle Breite. */ /** Bodenlinie (Terrain-/Nulllinie): kräftige Tinte über die volle Breite. */
const GROUND_INK = "#1f242c"; const GROUND_INK = "#1f242c";
/** Strichstärke der Bodenlinie (mm Papier). */ /** Strichstärke der Bodenlinie (mm Papier). */
@@ -277,31 +281,42 @@ export function wallWorldFaces(w: RWall): WorldFace[] {
return faces; return faces;
} }
/** Rundet einen Wandachsen-Endpunkt auf ein Gitter, um Knoten robust zu gruppieren (wie `model/joins.ts::roundKey`). */ /**
function cornerKey(p: Vec2): string { * Enthält der Wandkörper von `w` (Achse ± `half`) den Punkt `p`? Perpendikular-
const r = (v: number) => Math.round(v * 1e3) / 1e3; * abstand ≤ half UND Projektion innerhalb der Achsenlänge (mit half-Toleranz an
return `${r(p.x)},${r(p.y)}`; * den Enden). Basis der T-/L-/X-Stoss-Erkennung für die Eckverlängerung.
*/
function wallBodyContains(w: Wall, half: number, p: Vec2): boolean {
const dx = w.end.x - w.start.x;
const dy = w.end.y - w.start.y;
const len = Math.hypot(dx, dy);
if (len < 1e-9) return false;
const ux = dx / len;
const uy = dy / len;
const rx = p.x - w.start.x;
const ry = p.y - w.start.y;
const along = rx * ux + ry * uy;
const perp = Math.abs(-uy * rx + ux * ry);
return along >= -1e-6 && along <= len + 1e-6 && perp <= half + 1e-6;
} }
/** /**
* Achsen-Verlängerung je Wandende (Meter, ≥0) für die ANSICHT: axis-zu-axis * Achsen-Verlängerung je Wandende (Meter, ≥0) für die ANSICHT: axis-zu-axis
* gebaute Wandboxen (s. {@link wallWorldFaces}) decken einen Aussen-/Innen- * gebaute Wandboxen (s. {@link wallWorldFaces}) decken einen Eckknoten nicht
* Eckknoten nicht voll ab — an jeder Ecke bleibt ein dreieckiger Spalt der * voll ab — an jeder Ecke bleibt ein dreieckiger Spalt der Grösse der halben
* Grösse der halben Dicke der anschliessenden Wand (sichtbar als Kerbe in der * Dicke der anschliessenden Wand (sichtbar als Kerbe in der Silhouette bzw. als
* Silhouette bzw. als Lücke im Schlagschatten). Wo eine ANDERE Wand DERSELBEN * Lücke im Schlagschatten). Jedes Wandende wird darum bis zur AUSSENFLÄCHE der
* Ebene mit ihrem Start-/Endpunkt an diesem Knoten anschliesst, wird um deren * dicksten anstossenden anderen Wand verlängert — die Boxen überlappen dann im
* halbe Dicke verlängert — die Boxen überlappen dann vollständig im Eckbereich. * Eckbereich vollständig.
* *
* Bewusst eine EIGENE, einfache Näherung (nur „teilt sich einen Endpunkt") * Erfasst L-Ecken (Endpunkt teilt sich mit anderem Endpunkt), T-Stösse (Ende
* statt der vollen Gehrungs-Logik aus `model/joins.ts` (die bedient den 2D- * trifft die SEITE/Achse einer durchlaufenden Wand) UND versetzte Stösse — über
* Grundriss/3D-Viewer-Pfad mit L-/T-/X-Knoten-Klassifikation und bleibt dafür * {@link wallBodyContains} statt exakter Endpunkt-Gleichheit. Bewusst eine
* unangetastet, s. Moduldoc oben): die Ansicht ist ein Painter-Silhouetten- * EIGENE, tolerante Näherung statt der Gehrungs-Logik aus `model/joins.ts` (die
* Verfahren, dem eine leichte Überlappung an Ecken nichts ausmacht — anders als * bedient 2D-Grundriss/3D-Viewer und bleibt unangetastet): die Ansicht ist ein
* der 2D-Grundriss (Schicht-Poché) oder der Schnitt (Boolean-Dominanz), die * Painter-Silhouetten-Verfahren, dem eine leichte Eck-Überlappung nichts
* eine exakte, überlappungsfreie Gehrung brauchen. T-Stösse (Zwischenwand * ausmacht — anders als Grundriss-Poché/Schnitt-Dominanz, die exakte Gehrung
* trifft auf die SEITE einer durchlaufenden Wand statt deren Endpunkt) fängt * brauchen.
* diese einfache Näherung nicht ab — dort deckt die durchlaufende Wand die
* Fuge in der Ansicht i. d. R. ohnehin ab.
*/ */
function wallCornerExtensions(project: Project, walls: Wall[]): Map<string, { start: number; end: number }> { function wallCornerExtensions(project: Project, walls: Wall[]): Map<string, { start: number; end: number }> {
const thicknessOf = (w: Wall): number => { const thicknessOf = (w: Wall): number => {
@@ -311,20 +326,15 @@ function wallCornerExtensions(project: Project, walls: Wall[]): Map<string, { st
return 0.2; return 0.2;
} }
}; };
const nodeMembers = new Map<string, Array<{ id: string; half: number }>>(); const halves = new Map<string, number>();
for (const w of walls) { for (const w of walls) halves.set(w.id, thicknessOf(w) / 2);
const half = thicknessOf(w) / 2;
for (const p of [w.start, w.end]) {
const k = cornerKey(p);
const arr = nodeMembers.get(k);
if (arr) arr.push({ id: w.id, half });
else nodeMembers.set(k, [{ id: w.id, half }]);
}
}
const extAt = (w: Wall, p: Vec2): number => { const extAt = (w: Wall, p: Vec2): number => {
const members = nodeMembers.get(cornerKey(p)) ?? [];
let maxOther = 0; let maxOther = 0;
for (const m of members) if (m.id !== w.id) maxOther = Math.max(maxOther, m.half); for (const other of walls) {
if (other.id === w.id) continue;
const ho = halves.get(other.id) ?? 0.1;
if (wallBodyContains(other, ho, p)) maxOther = Math.max(maxOther, ho);
}
return maxOther; return maxOther;
}; };
const out = new Map<string, { start: number; end: number }>(); const out = new Map<string, { start: number; end: number }>();
@@ -340,8 +350,15 @@ function wallCornerExtensions(project: Project, walls: Wall[]): Map<string, { st
* damit sich nur Wände desselben Grundrisses zu Ecken verbinden. Mutiert die * damit sich nur Wände desselben Grundrisses zu Ecken verbinden. Mutiert die
* `start`/`end`-Koordinaten der jeweils äussersten Teilstücke (Pfeiler vor der * `start`/`end`-Koordinaten der jeweils äussersten Teilstücke (Pfeiler vor der
* ersten bzw. nach der letzten Öffnung) in-place. * ersten bzw. nach der letzten Öffnung) in-place.
*
* WICHTIG — nur Wände verlängern, die etwa PARALLEL zur Ansichtsebene laufen
* (Fassaden-Silhouette): eine facaden-SENKRECHTE Wand (Innenwand quer zur
* Blickrichtung) würde sonst entlang ihrer Achse in die Tiefe — u. U. VOR die
* Fassade auf den Betrachter zu — geschoben und erschiene als störender
* Balken. `uModel` ist die Ansichts-u-Achse in Grundriss-Koordinaten (Modell
* x,y = Welt x,z), gegen die die Wandachse projiziert wird.
*/ */
function extendWallCornersForElevation(project: Project, rwalls: RWall[]): void { function extendWallCornersForElevation(project: Project, rwalls: RWall[], uModel: Vec2): void {
const byFloor = new Map<string, Wall[]>(); const byFloor = new Map<string, Wall[]>();
for (const w of project.walls) { for (const w of project.walls) {
const key = w.floorId ?? ""; const key = w.floorId ?? "";
@@ -369,6 +386,9 @@ function extendWallCornersForElevation(project: Project, rwalls: RWall[]): void
if (len < 1e-9) continue; if (len < 1e-9) continue;
const ux = dx / len; const ux = dx / len;
const uz = dz / len; const uz = dz / len;
// Parallelität zur Ansichtsebene: |Achse · u| ≈ 1 (parallel) … 0 (senkrecht).
// Facaden-senkrechte Wände NICHT verlängern (sonst Tiefen-Protrusion).
if (Math.abs(ux * uModel.x + uz * uModel.y) < 0.35) continue;
if (e.start > EPS) { if (e.start > EPS) {
const first = pieces.find((p) => closeTo(p.start, w.start)); const first = pieces.find((p) => closeTo(p.start, w.start));
if (first) first.start = [first.start[0] - ux * e.start, first.start[1] - uz * e.start]; if (first) first.start = [first.start[0] - ux * e.start, first.start[1] - uz * e.start];
@@ -838,31 +858,102 @@ function bboxOf(pts: Array<[number, number]>): UVRect {
} }
/** /**
* Deckt eine NÄHERE, achsparallele Wand-Bounding-Box `bbox`/`depth` VOLLSTÄNDIG * Ein opakes Verdeckungs-Polygon (projizierte Fläche) mit seiner FERNSTEN Tiefe
* ab? Grobe NÄHERUNG (Bounding-Box statt exaktes Polygon, wie * (`depthMax`): eine Linie/Fläche gilt nur dann als verdeckt, wenn sie hinter
* {@link buildShadows}) — genügt, um zu verhindern, dass Öffnungssymbole/ * dem gesamten Occluder liegt (`> depthMax`) — konservativ, damit gekippte
* Fugenlinien einer HINTEREN Fassade (z. B. Nordwand) durch eine NÄHERE Wand * Flächen (Dach) keine sichtbaren Kanten fälschlich wegschneiden.
* (Südwand) "durchscheinen": die GL-Ansicht zeichnet ALLE Linien (inkl. der
* Umriss-Konturen von Polygonen) in einem EIGENEN Stift-Durchgang ÜBER allen
* Füllungen (s. `glPlan/glPlanCompile.ts`) — eine reine Tiefensortierung der
* Flächen (Painter) reicht dafür NICHT, die Linien bräuchten sonst eine echte
* Tiefenprüfung im Renderer. Statt dessen werden verdeckte Elemente hier, beim
* Aufbau des Ansichts-Plans, gar nicht erst emittiert.
*/ */
function isOccluded(bbox: UVRect, depth: number, blockers: Array<{ bbox: UVRect; depth: number }>): boolean { interface Occluder {
const MARGIN = 1e-3; poly: Array<[number, number]>;
for (const b of blockers) { bbox: UVRect;
if (b.depth >= depth - EPS) continue; // nicht echt näher depth: number;
if ( }
b.bbox.uMin - MARGIN <= bbox.uMin &&
b.bbox.uMax + MARGIN >= bbox.uMax && /** Warum überhaupt clippen: die GL-Ansicht zeichnet ALLE Linien in einem
b.bbox.vMin - MARGIN <= bbox.vMin && * EIGENEN Stift-Durchgang ÜBER allen Füllungen (s. `glPlan/glPlanCompile.ts`);
b.bbox.vMax + MARGIN >= bbox.vMax * eine reine Painter-Tiefensortierung der Flächen verdeckt Linien also NICHT.
) { * Verdeckte Linienstücke werden darum hier, beim Planaufbau, exakt entfernt. */
return true; const OCC_MARGIN = 2e-3;
/**
* Teilintervall `[t0,t1] ⊆ [0,1]` des Segments A→B, das INNERHALB des konvexen
* Polygons liegt (Halbebenen-Schnitt auf dem Parameter t), oder `null`. Die
* Polygon-Windung wird aus der Signfläche bestimmt, sodass die Innen-Seite jeder
* Kante korrekt orientiert ist (kein Vorwissen über CW/CCW nötig).
*/
function segmentInsidePolyInterval(
a: [number, number],
b: [number, number],
poly: Array<[number, number]>,
): [number, number] | null {
const n = poly.length;
if (n < 3) return null;
let area = 0;
for (let i = 0; i < n; i++) {
const p = poly[i];
const q = poly[(i + 1) % n];
area += p[0] * q[1] - q[0] * p[1];
}
const sign = area >= 0 ? 1 : -1;
const dx = b[0] - a[0];
const dy = b[1] - a[1];
let t0 = 0;
let t1 = 1;
for (let i = 0; i < n; i++) {
const p = poly[i];
const q = poly[(i + 1) % n];
const ex = q[0] - p[0];
const ey = q[1] - p[1];
// Innen: sign·cross(edge, Xp) ≥ 0. f(t) = c0 + cd·t.
const c0 = sign * (ex * (a[1] - p[1]) - ey * (a[0] - p[0]));
const cd = sign * (ex * dy - ey * dx);
if (Math.abs(cd) < 1e-12) {
if (c0 < 0) return null; // Segment komplett ausserhalb dieser Kante
} else {
const tHit = -c0 / cd;
if (cd > 0) t0 = Math.max(t0, tHit);
else t1 = Math.min(t1, tHit);
if (t0 > t1) return null;
} }
} }
return false; return t0 <= t1 ? [t0, t1] : null;
}
/**
* Sichtbare Teilstücke eines Segments A→B (Tiefe `depth`): entfernt alle Anteile,
* die innerhalb eines NÄHEREN opaken Occluders (`o.depth < depth margin`)
* liegen. Liefert die verbleibenden Segmente (leer, wenn ganz verdeckt). Exakt
* (Polygon-Halbebenen), nicht die frühere Bounding-Box-Näherung.
*/
function clipSegmentVisible(
a: [number, number],
b: [number, number],
depth: number,
occluders: Occluder[],
): Array<[[number, number], [number, number]]> {
const cuts: Array<[number, number]> = [];
const segMin: [number, number] = [Math.min(a[0], b[0]), Math.min(a[1], b[1])];
const segMax: [number, number] = [Math.max(a[0], b[0]), Math.max(a[1], b[1])];
for (const o of occluders) {
if (o.depth >= depth - OCC_MARGIN) continue; // nicht echt näher
// Billiger Bounding-Box-Vorabtest.
if (o.bbox.uMax < segMin[0] || o.bbox.uMin > segMax[0]) continue;
if (o.bbox.vMax < segMin[1] || o.bbox.vMin > segMax[1]) continue;
const iv = segmentInsidePolyInterval(a, b, o.poly);
if (iv && iv[1] - iv[0] > 1e-6) cuts.push(iv);
}
if (cuts.length === 0) return [[a, b]];
cuts.sort((x, y) => x[0] - y[0]);
const at = (t: number): [number, number] => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
const out: Array<[[number, number], [number, number]]> = [];
let cursor = 0;
for (const [s, e] of cuts) {
if (s > cursor + 1e-6) out.push([at(cursor), at(Math.min(s, 1))]);
cursor = Math.max(cursor, e);
if (cursor >= 1) break;
}
if (cursor < 1 - 1e-6) out.push([at(cursor), at(1)]);
return out;
} }
/** /**
@@ -1050,7 +1141,8 @@ export function generateElevationPlan(
// Eine Voll-Box je Wand (Öffnungen als Segment-Aussparungen) + Decken-Prismen. // Eine Voll-Box je Wand (Öffnungen als Segment-Aussparungen) + Decken-Prismen.
const model = projectToModel3d(project, { layeredWalls: false }); const model = projectToModel3d(project, { layeredWalls: false });
extendWallCornersForElevation(project, model.walls); // Ansichts-u-Achse in Grundriss-Koordinaten (Welt x,z = Modell x,y).
extendWallCornersForElevation(project, model.walls, { x: frame.U[0], y: frame.U[2] });
const faces = collectElevationFaces(model, frame); const faces = collectElevationFaces(model, frame);
// Dächer analytisch (roofGeometry) — nicht Teil von RModel3d walls/slabs. // Dächer analytisch (roofGeometry) — nicht Teil von RModel3d walls/slabs.
for (const rf of roofWorldFacesAll(project)) { for (const rf of roofWorldFacesAll(project)) {
@@ -1060,13 +1152,43 @@ export function generateElevationPlan(
const wallFaces = faces.filter((f) => f.role === "wall"); const wallFaces = faces.filter((f) => f.role === "wall");
const slabFaces = faces.filter((f) => f.role === "slab"); const slabFaces = faces.filter((f) => f.role === "slab");
const roofFaces = faces.filter((f) => f.role === "roof"); const roofFaces = faces.filter((f) => f.role === "roof");
// Blocker für die Durchsicht-Prüfung (s. {@link isOccluded}): NÄHERE, frontal // Öffnungen (Fenster/Türen) einmal sammeln — als Fläche gebraucht (Occluder,
// zum Betrachter stehende Wandflächen (ganze Fassaden-Ebenen) — der Regelfall // s. u.) UND weiter unten für die Rahmen-/Glas-/Symbol-Emission.
// einer verdeckenden Vorderfassade. Schräge/Stirnflächen sind selten volle const openings = collectOpenings(project, frame);
// Blocker und bleiben aussen vor (Näherung). // Occluder für die exakte Durchsicht-Entfernung (s. {@link clipSegmentVisible}):
const wallBlockers = wallFaces // ALLE opaken Flächen (Wände, Decken, Dächer) als Verdeckungs-Polygone mit
.filter((f) => f.depthMax - f.depthMin < 1e-4) // ihrer FERNSTEN Tiefe (`depthMax`, konservativ), PLUS die Öffnungs-Rahmen-
.map((f) => ({ bbox: bboxOf(f.pts), depth: f.depthMin })); // quads (die Fassaden-Boxen sind um die Öffnungen ausgespart — ohne diese
// Occluder schiene eine hintere Wandlinie durch das Fensterloch der vorderen
// Fassade). Linien HINTER einem Occluder werden exakt herausgeschnitten —
// nötig, weil die GL-Pipeline alle Linien in einem eigenen Durchgang über die
// Füllungen legt.
const occluders: Occluder[] = faces.map((f) => ({
poly: f.pts,
bbox: bboxOf(f.pts),
depth: f.depthMax,
}));
for (const op of openings) {
occluders.push({ poly: op.pts, bbox: bboxOf(op.pts), depth: op.depth });
}
// Ist ein Punkt hinter einer näheren opaken Fläche verborgen? (Ganz-Öffnung-Cull.)
const pointInPoly = (pt: [number, number], poly: Array<[number, number]>): boolean => {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i];
const [xj, yj] = poly[j];
if ((yi > pt[1]) !== (yj > pt[1]) && pt[0] < ((xj - xi) * (pt[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
};
const isPointOccluded = (pt: [number, number], depth: number): boolean => {
for (const o of occluders) {
if (o.depth >= depth - OCC_MARGIN) continue;
if (pt[0] < o.bbox.uMin || pt[0] > o.bbox.uMax || pt[1] < o.bbox.vMin || pt[1] > o.bbox.vMax) continue;
if (pointInPoly(pt, o.poly)) return true;
}
return false;
};
const primitives: Primitive[] = []; const primitives: Primitive[] = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -1100,11 +1222,27 @@ export function generateElevationPlan(
const items: Item[] = []; const items: Item[] = [];
let seq = 0; let seq = 0;
// Emittiert ein Linien-Segment, exakt gegen nähere Occluder geclippt (verdeckte
// Teilstücke entfallen). `depth` = Tiefe der Linie (leicht vor ihrer Trägerfläche).
const pushClippedLine = (
a: [number, number],
b: [number, number],
depth: number,
prim: (a: [number, number], b: [number, number]) => Primitive,
): void => {
for (const [sa, sb] of clipSegmentVisible(a, b, depth, occluders)) {
acc(sa[0], sa[1]);
acc(sb[0], sb[1]);
items.push({ depth, seq: seq++, prim: prim(sa, sb) });
}
};
// Frontale Wand-/Deckenflächen (alle Ecken auf gleicher Tiefe) werden je // Frontale Wand-/Deckenflächen (alle Ecken auf gleicher Tiefe) werden je
// Tiefen-Gruppe gesammelt: die Flächen selbst weiss OHNE Kontur, dann der // Tiefen-Gruppe gesammelt: die Flächen selbst OHNE Kontur, dann der
// XOR-Vereinigungs-Umriss der Gruppe als Linienzug — Innenkanten der // XOR-Vereinigungs-Umriss der Gruppe als (geclippter) Linienzug — Innenkanten
// Teilboxen (Pfeiler/Sturz/Geschossfugen) heben sich auf, übrig bleibt die // der Teilboxen (Pfeiler/Sturz/Geschossfugen) heben sich auf, übrig bleibt die
// Silhouette samt Öffnungslöchern (VW-Linienzeichnungs-Look). // Silhouette samt Öffnungslöchern (VW-Linienzeichnungs-Look). Dach/schräge
// Flächen tragen ihre eigene, ebenfalls geclippte Kanten-Kontur (unten).
const frontalGroups = new Map<number, { faces: ProjectedFace[]; depth: number }>(); const frontalGroups = new Map<number, { faces: ProjectedFace[]; depth: number }>();
for (const f of faces) { for (const f of faces) {
const pts: Vec2[] = f.pts.map(([u, v]) => { const pts: Vec2[] = f.pts.map(([u, v]) => {
@@ -1117,11 +1255,22 @@ export function generateElevationPlan(
const gk = Math.round(f.depth * 1e4); const gk = Math.round(f.depth * 1e4);
if (!frontalGroups.has(gk)) frontalGroups.set(gk, { faces: [], depth: f.depth }); if (!frontalGroups.has(gk)) frontalGroups.set(gk, { faces: [], depth: f.depth });
frontalGroups.get(gk)!.faces.push(f); frontalGroups.get(gk)!.faces.push(f);
} else {
// Dach/schräg angeschnittene Fläche: jede Polygonkante als geclippte Linie
// (verdeckte Teile hinter näheren Fassaden entfallen exakt).
for (let i = 0; i < f.pts.length; i++) {
const a = f.pts[i];
const b = f.pts[(i + 1) % f.pts.length];
pushClippedLine(a, b, f.depthMin - EPS * 5, (sa, sb) => ({
kind: "line",
a: { x: sa[0], y: sa[1] },
b: { x: sb[0], y: sb[1] },
cls: "elevation-outline",
weightMm: EDGE_MM,
color: EDGE_INK,
}));
}
} }
// Eigene Kontur (Dach/schräg angeschnittene Flächen) nur zeichnen, wenn sie
// nicht ohnehin hinter einer näheren Fassade verschwindet (s. {@link isOccluded}
// — die Linien-Pipeline verdeckt Umrisslinien sonst NICHT automatisch).
const ownStroke = (isRoof || !frontal) && !isOccluded(bboxOf(f.pts), f.depthMin, wallBlockers);
items.push({ items.push({
depth: f.depth, depth: f.depth,
seq: seq++, seq: seq++,
@@ -1133,10 +1282,10 @@ export function generateElevationPlan(
kind: "polygon", kind: "polygon",
pts, pts,
fill: fillFor(f.role, f.fill), fill: fillFor(f.role, f.fill),
// Dach + schräg angeschnittene Flächen tragen ihre eigene Kontur; // Kontur trägt die geclippte Kanten-/Gruppen-Linie (oben/unten) —
// frontale Flächen bekommen den Gruppen-Umriss (unten). // die Fläche selbst füllt nur (nähere Füllung überdeckt fernere).
stroke: ownStroke ? EDGE_INK : "none", stroke: "none",
strokeWidthMm: ownStroke ? EDGE_MM : 0, strokeWidthMm: 0,
hatch: NO_HATCH, hatch: NO_HATCH,
...(f.wallId ? { wallId: f.wallId } : {}), ...(f.wallId ? { wallId: f.wallId } : {}),
...(f.ceilingId ? { ceilingId: f.ceilingId } : {}), ...(f.ceilingId ? { ceilingId: f.ceilingId } : {}),
@@ -1145,21 +1294,19 @@ export function generateElevationPlan(
}); });
} }
for (const g of frontalGroups.values()) { for (const g of frontalGroups.values()) {
const groupBbox = bboxOf(g.faces.flatMap((f) => f.pts)); // Der XOR-Silhouetten-Umriss der Gruppe, JEDE Kante exakt gegen nähere
if (isOccluded(groupBbox, g.depth, wallBlockers)) continue; // opake Flächen geclippt: verdeckte Teile einer hinteren Fassade/Decke
// entfallen, die vorderste Silhouette bleibt vollständig (Ecken via
// Wandverlängerung geschlossen). Ersetzt die frühere Bbox-Occlusion.
for (const [a, b] of xorOutlineEdges(g.faces.map((f) => f.pts))) { for (const [a, b] of xorOutlineEdges(g.faces.map((f) => f.pts))) {
items.push({ pushClippedLine(a, b, g.depth - EPS * 5, (sa, sb) => ({
depth: g.depth - EPS * 5,
seq: seq++,
prim: {
kind: "line", kind: "line",
a: { x: a[0], y: a[1] }, a: { x: sa[0], y: sa[1] },
b: { x: b[0], y: b[1] }, b: { x: sb[0], y: sb[1] },
cls: "elevation-outline", cls: "elevation-outline",
weightMm: EDGE_MM, weightMm: EDGE_MM,
color: EDGE_INK, color: EDGE_INK,
}, }));
});
} }
} }
@@ -1188,11 +1335,15 @@ export function generateElevationPlan(
} }
// Fenster/Türen: Rahmen+Glas, knapp VOR der Wirtsfläche (auf der Fassade). // Fenster/Türen: Rahmen+Glas, knapp VOR der Wirtsfläche (auf der Fassade).
// Ganz verdeckte Öffnungen (hinter einer näheren Fassade, s. {@link isOccluded}) // Ganz verdeckte Öffnungen (Mittelpunkt hinter einer näheren opaken Fläche)
// werden gar nicht erst emittiert — sonst scheinen ihre Symbol-/Sprossen-Linien // werden gar nicht erst emittiert — sonst scheinen ihre Symbol-/Sprossen-Linien
// (eigener Linien-Durchgang, s. Blocker-Kommentar oben) durch die Vorderwand. // (eigener Linien-Durchgang, s. Occluder-Kommentar oben) durch die Vorderwand.
for (const op of collectOpenings(project, frame)) { for (const op of openings) {
if (isOccluded(bboxOf(op.pts), op.depth, wallBlockers)) continue; const opCenter: [number, number] = [
(op.pts[0][0] + op.pts[1][0] + op.pts[2][0] + op.pts[3][0]) / 4,
(op.pts[0][1] + op.pts[1][1] + op.pts[2][1] + op.pts[3][1]) / 4,
];
if (isPointOccluded(opCenter, op.depth)) continue;
const toVec = (q: Array<[number, number]>): Vec2[] => const toVec = (q: Array<[number, number]>): Vec2[] =>
q.map(([u, v]) => { q.map(([u, v]) => {
acc(u, v); acc(u, v);
@@ -1257,6 +1408,40 @@ export function generateElevationPlan(
}, },
}); });
} }
// Reveal-/Laibungsschatten (nur mit Schatten-Toggle): das zurückliegende
// Fenster wirft — Sonne 45° von links oben — einen Schatten in die Laibung.
// Klassische Architektur-Konvention: ein Band UNTER dem Sturz (Kopf, volle
// Breite) und eines RECHTS der linken Laibung. Über dem Glas (nach den
// Glasflächen, vor den Symbolen), damit die Öffnung als Vertiefung liest.
// Bilinear im Öffnungs-Quad (pts [unten-links, unten-rechts, oben-rechts,
// oben-links]).
if (shadows) {
const q = op.pts;
const lerpP = (a: [number, number], b: [number, number], t: number): [number, number] => [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
];
const quadAt = (fa: number, fz: number): [number, number] =>
lerpP(lerpP(q[0], q[1], fa), lerpP(q[3], q[2], fa), fz);
const wU = Math.hypot(q[1][0] - q[0][0], q[1][1] - q[0][1]);
const hV = Math.hypot(q[3][0] - q[0][0], q[3][1] - q[0][1]);
const fU = Math.min(0.3, 0.08 / Math.max(wU, 1e-6));
const fV = Math.min(0.3, 0.08 / Math.max(hV, 1e-6));
const shadowRect = (fa0: number, fa1: number, fz0: number, fz1: number): Vec2[] =>
[quadAt(fa0, fz0), quadAt(fa1, fz0), quadAt(fa1, fz1), quadAt(fa0, fz1)].map(([u, v]) => {
acc(u, v);
return { x: u, y: v };
});
const pushShadow = (pts: Vec2[]): void => {
items.push({
depth: d,
seq: seq++,
prim: { kind: "polygon", pts, fill: OPENING_SHADOW_FILL, stroke: "none", strokeWidthMm: 0, hatch: NO_HATCH },
});
};
pushShadow(shadowRect(0, 1, 1 - fV, 1)); // Kopf/Sturz-Schatten (oben, volle Breite)
pushShadow(shadowRect(0, fU, 0, 1 - fV)); // linke Laibung (Rest der Höhe)
}
for (const [a, bLn] of op.divisions) { for (const [a, bLn] of op.divisions) {
items.push({ items.push({
depth: d, depth: d,