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:
@@ -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
@@ -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, X−p) ≥ 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user