3D-Öffnungen: Flügel-/Türblatt-Regression fixen; Ansicht: Eck-Joins, Durchsicht, Farbig-Modus
3D: Flügelrahmen sass fälschlich vor der Fassade (Rahmen-Glas-Rahmen-Sandwich) und Türen hatten kein Blatt — beides aus dem letzten 'fein'-Merge. Flügelband jetzt hinter die Blendrahmen-Vorderkante zurückgesetzt, Türblatt (Voll- und Teilverglasung) ergänzt. Ansicht: Wandboxen liefen achsenzu-achse ohne Eckverlängerung (dreieckige Kerbe an jeder Gebäudeecke, sichtbar v.a. im Schlagschatten) — Wände mit gemeinsamem Endpunkt verlängern sich jetzt um die halbe Nachbardicke. Die Linien-Pipeline zeichnet alle Konturen in einem eigenen Durchgang über allen Füllungen, wodurch Öffnungs-/Fugenlinien hinterer Fassaden durch nähere Wände schienen — verdeckte Flächen/Öffnungen werden jetzt gar nicht mehr emittiert. Farbig-Modus zeigte bisher nur Weiss statt Bauteilfarben; Wand/Dach/Fenster/ Tür tragen jetzt echte Farben, Mono bleibt die reine Linienzeichnung.
This commit is contained in:
+239
-27
@@ -33,9 +33,11 @@
|
||||
|
||||
import type { DrawingLevel, Project, Vec2, Wall } from "../model/types";
|
||||
import {
|
||||
getComponent,
|
||||
getWallType,
|
||||
getWindowType,
|
||||
openingsOfWall,
|
||||
roofLayers,
|
||||
sashesOfWindowType,
|
||||
wallTypeThickness,
|
||||
} from "../model/types";
|
||||
@@ -43,7 +45,7 @@ import { wallVerticalExtent } from "../model/wall";
|
||||
import { openingInterval, openingVerticalExtent } from "../geometry/opening";
|
||||
import { roofBaseElevation, roofGeometry } from "../geometry/roof";
|
||||
import { projectToModel3d } from "./toWalls3d";
|
||||
import type { RModel3d, RSlab, RWall } from "./toWalls3d";
|
||||
import type { RModel3d, RRgb, RSlab, RVec2, RWall } from "./toWalls3d";
|
||||
import { sectionPlaneFromLevel } from "./toSection";
|
||||
import type { SectionPlaneSpec } from "./toSection";
|
||||
import type { HatchRender, Plan, Primitive } from "./generatePlan";
|
||||
@@ -68,28 +70,53 @@ const EDGE_INK = "#2b3039";
|
||||
const EDGE_MM = 0.13;
|
||||
/** Fenster/Tür-Rahmen: etwas kräftigere Umrisslinie. */
|
||||
const FRAME_MM = 0.18;
|
||||
/** Blaugraues Fenster-Glas (dunkler als die Fassade → als Öffnung lesbar). */
|
||||
const GLASS_FILL = "#ffffff";
|
||||
/** Glas im Mono-Modus (neutrales Grau statt Blaugrau). */
|
||||
/**
|
||||
* Fenster-Glas/Rahmen/Türblatt/Wand/Dach: im MONO-Modus (SW/Print, VW-Linien-
|
||||
* zeichnungs-Look) bleibt alles weiss — nur Kontur trägt Information. Im
|
||||
* FARBIG-Modus tragen die Flächen echte Bauteilfarbe: Wand/Dach die dominante
|
||||
* Schicht (s. {@link WorldFace.fill}, gesetzt in {@link wallWorldFaces}/
|
||||
* {@link roofWorldFacesAll}), Fenster/Türen feste, an `toWalls3d.ts`
|
||||
* (FRAME_RGB/GLASS_RGB/SILL_RGB/DOOR_LEAF_RGB) angelehnte Töne, damit Ansicht
|
||||
* und 3D konsistent wirken.
|
||||
*/
|
||||
const GLASS_FILL_MONO = "#c4c8cd";
|
||||
/** Festes Schattengrau (über der Fassade, unter den Kanten). */
|
||||
const GLASS_FILL_COLOR = "#9fc2d9";
|
||||
const SHADOW_FILL = "#8f959e";
|
||||
/** Bodenlinie (Terrain-/Nulllinie): kräftige Tinte über die volle Breite. */
|
||||
const GROUND_INK = "#1f242c";
|
||||
/** Strichstärke der Bodenlinie (mm Papier). */
|
||||
const GROUND_MM = 0.5;
|
||||
/** Rollen-Füllungen: EINE ruhige Fassadenfläche statt Tiefen-Patchworks —
|
||||
* klassische Architektur-Ansicht (Flächen tragen, Kanten nur wo bedeutsam:
|
||||
* Dachrand, Öffnungen, Bodenlinie). Wand/Decke identisch (Decken-Stirnflächen
|
||||
* verschwinden optisch in der Fassade), Dach eine Stufe dunkler. */
|
||||
/** Rollen-Füllungen: MONO-Fallback, wenn eine Fläche keine Bauteilfarbe trägt
|
||||
* (z. B. Dach ohne auflösbare Schicht) — sonst gilt im Farbig-Modus immer die
|
||||
* echte Bauteilfarbe (s. `fillFor` in {@link generateElevationPlan}). */
|
||||
const WALL_FILL = "#ffffff";
|
||||
const ROOF_FILL = "#ffffff";
|
||||
/** Blendrahmen-Fläche der Öffnungen. */
|
||||
const FRAME_FILL = "#ffffff";
|
||||
/** Blendrahmen-/Sims-Fläche der Öffnungen. */
|
||||
const FRAME_FILL_COLOR = "#d9d9d4";
|
||||
const SILL_FILL_COLOR = "#cccccb";
|
||||
/** Türblatt-Fläche. */
|
||||
const DOOR_LEAF_FILL = "#ffffff";
|
||||
const DOOR_LEAF_FILL_COLOR = "#ede6d9";
|
||||
/** Öffnungssymbol (DIN-Andeutung): dezente Annotation, nicht Bauteilkante. */
|
||||
const SYMBOL_INK = "#7d858f";
|
||||
/** Neutraler Dach-Grauton, falls keine Eindeckungs-Schicht/-Farbe auflösbar ist (wie `toWalls3d.ts::ROOF_RGB`). */
|
||||
const ROOF_RGB_FALLBACK: RRgb = [0.72, 0.45, 0.36];
|
||||
|
||||
/** Wandelt eine Hex-Farbe ("#rrggbb") in float-RGB (0..1) um (wie `toWalls3d.ts::hexToRgb`, hier dupliziert — nicht exportiert). */
|
||||
function hexToRgb(hex: string, fallback: RRgb): RRgb {
|
||||
const h = hex?.trim().replace(/^#/, "") ?? "";
|
||||
if (h.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(h)) return fallback;
|
||||
return [
|
||||
parseInt(h.slice(0, 2), 16) / 255,
|
||||
parseInt(h.slice(2, 4), 16) / 255,
|
||||
parseInt(h.slice(4, 6), 16) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
/** Wandelt float-RGB (0..1) zurück in "#rrggbb" (für Primitive-Fill-Strings). */
|
||||
function rgbToHex(c: RRgb): string {
|
||||
const b = (v: number) => Math.max(0, Math.min(255, Math.round(v * 255))).toString(16).padStart(2, "0");
|
||||
return `#${b(c[0])}${b(c[1])}${b(c[2])}`;
|
||||
}
|
||||
|
||||
/** Sonnenrichtung als (u, v)-Schattenversatz je Tiefeneinheit: 45° von links
|
||||
* oben ⇒ Schatten fällt nach rechts unten (+u, −v). */
|
||||
@@ -168,6 +195,12 @@ export interface WorldFace {
|
||||
ceilingId?: string;
|
||||
/** Ursprungs-Dach (nur role "roof"). */
|
||||
roofId?: string;
|
||||
/**
|
||||
* Bauteil-Farbe (dominante Wandschicht bzw. äusserste Dach-/Eindeckungs-
|
||||
* schicht) für den Farbig-Modus (s. {@link ElevationOptions.mono}). Fehlt
|
||||
* ⇒ Fallback auf die neutrale Rollen-Füllung.
|
||||
*/
|
||||
fill?: RRgb;
|
||||
}
|
||||
|
||||
function normalize3(a: V3): V3 {
|
||||
@@ -237,10 +270,117 @@ export function wallWorldFaces(w: RWall): WorldFace[] {
|
||||
normal: [0, -1, 0],
|
||||
poly: [corner(0, -ht, zb), corner(len, -ht, zb), corner(len, ht, zb), corner(0, ht, zb)],
|
||||
});
|
||||
for (const f of faces) f.wallId = w.wallId;
|
||||
for (const f of faces) {
|
||||
f.wallId = w.wallId;
|
||||
f.fill = w.color;
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
/** Rundet einen Wandachsen-Endpunkt auf ein Gitter, um Knoten robust zu gruppieren (wie `model/joins.ts::roundKey`). */
|
||||
function cornerKey(p: Vec2): string {
|
||||
const r = (v: number) => Math.round(v * 1e3) / 1e3;
|
||||
return `${r(p.x)},${r(p.y)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Achsen-Verlängerung je Wandende (Meter, ≥0) für die ANSICHT: axis-zu-axis
|
||||
* gebaute Wandboxen (s. {@link wallWorldFaces}) decken einen Aussen-/Innen-
|
||||
* Eckknoten nicht voll ab — an jeder Ecke bleibt ein dreieckiger Spalt der
|
||||
* Grösse der halben Dicke der anschliessenden Wand (sichtbar als Kerbe in der
|
||||
* Silhouette bzw. als Lücke im Schlagschatten). Wo eine ANDERE Wand DERSELBEN
|
||||
* Ebene mit ihrem Start-/Endpunkt an diesem Knoten anschliesst, wird um deren
|
||||
* halbe Dicke verlängert — die Boxen überlappen dann vollständig im Eckbereich.
|
||||
*
|
||||
* Bewusst eine EIGENE, einfache Näherung (nur „teilt sich einen Endpunkt")
|
||||
* statt der vollen Gehrungs-Logik aus `model/joins.ts` (die bedient den 2D-
|
||||
* Grundriss/3D-Viewer-Pfad mit L-/T-/X-Knoten-Klassifikation und bleibt dafür
|
||||
* unangetastet, s. Moduldoc oben): die Ansicht ist ein Painter-Silhouetten-
|
||||
* Verfahren, dem eine leichte Überlappung an Ecken nichts ausmacht — anders als
|
||||
* der 2D-Grundriss (Schicht-Poché) oder der Schnitt (Boolean-Dominanz), die
|
||||
* eine exakte, überlappungsfreie Gehrung brauchen. T-Stösse (Zwischenwand
|
||||
* trifft auf die SEITE einer durchlaufenden Wand statt deren Endpunkt) fängt
|
||||
* 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 }> {
|
||||
const thicknessOf = (w: Wall): number => {
|
||||
try {
|
||||
return wallTypeThickness(getWallType(project, w));
|
||||
} catch {
|
||||
return 0.2;
|
||||
}
|
||||
};
|
||||
const nodeMembers = new Map<string, Array<{ id: string; half: number }>>();
|
||||
for (const w of walls) {
|
||||
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 members = nodeMembers.get(cornerKey(p)) ?? [];
|
||||
let maxOther = 0;
|
||||
for (const m of members) if (m.id !== w.id) maxOther = Math.max(maxOther, m.half);
|
||||
return maxOther;
|
||||
};
|
||||
const out = new Map<string, { start: number; end: number }>();
|
||||
for (const w of walls) out.set(w.id, { start: extAt(w, w.start), end: extAt(w, w.end) });
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verlängert die (offset-freien, s. {@link resolveWallBands} `layered:false`)
|
||||
* Wand-Boxen des Ansichts-Modells an gemeinsamen Eck-Knoten (s.
|
||||
* {@link wallCornerExtensions}), damit Fassaden-Ecken in der Ansicht ohne
|
||||
* dreieckige Kerbe/Schattenlücke erscheinen. Wirkt PRO GESCHOSS (`floorId`),
|
||||
* damit sich nur Wände desselben Grundrisses zu Ecken verbinden. Mutiert die
|
||||
* `start`/`end`-Koordinaten der jeweils äussersten Teilstücke (Pfeiler vor der
|
||||
* ersten bzw. nach der letzten Öffnung) in-place.
|
||||
*/
|
||||
function extendWallCornersForElevation(project: Project, rwalls: RWall[]): void {
|
||||
const byFloor = new Map<string, Wall[]>();
|
||||
for (const w of project.walls) {
|
||||
const key = w.floorId ?? "";
|
||||
const list = byFloor.get(key);
|
||||
if (list) list.push(w);
|
||||
else byFloor.set(key, [w]);
|
||||
}
|
||||
const byWallId = new Map<string, RWall[]>();
|
||||
for (const rw of rwalls) {
|
||||
const list = byWallId.get(rw.wallId);
|
||||
if (list) list.push(rw);
|
||||
else byWallId.set(rw.wallId, [rw]);
|
||||
}
|
||||
const closeTo = (a: RVec2, b: Vec2): boolean => Math.abs(a[0] - b.x) < 1e-6 && Math.abs(a[1] - b.y) < 1e-6;
|
||||
for (const floorWalls of byFloor.values()) {
|
||||
const ext = wallCornerExtensions(project, floorWalls);
|
||||
for (const w of floorWalls) {
|
||||
const e = ext.get(w.id);
|
||||
if (!e || (e.start <= EPS && e.end <= EPS)) continue;
|
||||
const pieces = byWallId.get(w.id);
|
||||
if (!pieces) continue;
|
||||
const dx = w.end.x - w.start.x;
|
||||
const dz = w.end.y - w.start.y;
|
||||
const len = Math.hypot(dx, dz);
|
||||
if (len < 1e-9) continue;
|
||||
const ux = dx / len;
|
||||
const uz = dz / len;
|
||||
if (e.start > EPS) {
|
||||
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 (e.end > EPS) {
|
||||
const last = pieces.find((p) => closeTo(p.end, w.end));
|
||||
if (last) last.end = [last.end[0] + ux * e.end, last.end[1] + uz * e.end];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Flächen EINER Deckenplatte (RSlab): Deckel + Boden (Umriss bei zTop/zBottom)
|
||||
* und je Umrisskante eine Seitenfläche (Stirn). Die Seitennormalen werden über
|
||||
@@ -279,7 +419,10 @@ export function slabWorldFaces(s: RSlab): WorldFace[] {
|
||||
poly: [bottom[i], bottom[j], top[j], top[i]],
|
||||
});
|
||||
}
|
||||
for (const f of faces) f.ceilingId = s.ceilingId;
|
||||
for (const f of faces) {
|
||||
f.ceilingId = s.ceilingId;
|
||||
f.fill = s.color;
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
@@ -298,6 +441,7 @@ export interface ProjectedFace {
|
||||
wallId?: string;
|
||||
ceilingId?: string;
|
||||
roofId?: string;
|
||||
fill?: RRgb;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,7 +466,17 @@ export function projectFace(frame: ElevationFrame, face: WorldFace): ProjectedFa
|
||||
}
|
||||
const depth = depthSum / face.poly.length;
|
||||
if (depth <= EPS) return null; // vor der Ebene → ignorieren
|
||||
return { pts, depth, depthMin, depthMax, role: face.role, wallId: face.wallId, ceilingId: face.ceilingId, roofId: face.roofId };
|
||||
return {
|
||||
pts,
|
||||
depth,
|
||||
depthMin,
|
||||
depthMax,
|
||||
role: face.role,
|
||||
wallId: face.wallId,
|
||||
ceilingId: face.ceilingId,
|
||||
roofId: face.roofId,
|
||||
fill: face.fill,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,6 +517,15 @@ export function roofWorldFacesAll(project: Project): WorldFace[] {
|
||||
for (const roof of project.roofs ?? []) {
|
||||
const eavesZ = roofBaseElevation(project, roof);
|
||||
const g = roofGeometry(roof, eavesZ);
|
||||
// Äusserste Schicht (Eindeckung) bzw. `roof.color`-Übersteuerung — dieselbe
|
||||
// Farbwahl wie {@link emitRoofs} (3D-Viewer), damit Ansicht/3D konsistent
|
||||
// wirken. Ohne auflösbare Schicht der neutrale Dach-Fallback.
|
||||
const outer = roofLayers(project, roof)[0];
|
||||
const fill: RRgb = roof.color
|
||||
? hexToRgb(roof.color, ROOF_RGB_FALLBACK)
|
||||
: outer?.componentId
|
||||
? hexToRgb(getComponent(project, outer.componentId).color, ROOF_RGB_FALLBACK)
|
||||
: ROOF_RGB_FALLBACK;
|
||||
// Dach-Schwerpunkt (Welt) für die Aussen-Orientierung der Giebel.
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
@@ -398,7 +561,7 @@ export function roofWorldFacesAll(project: Project): WorldFace[] {
|
||||
let n = newell(poly);
|
||||
// Dachflächen zeigen aufwärts (Welt-Y).
|
||||
if (n[1] < 0) n = [-n[0], -n[1], -n[2]];
|
||||
out.push({ role: "roof", normal: n, poly, roofId: roof.id });
|
||||
out.push({ role: "roof", normal: n, poly, roofId: roof.id, fill });
|
||||
}
|
||||
for (const gable of g.gables) {
|
||||
const poly: V3[] = gable.map((p) => [p[0], p[2], p[1]]);
|
||||
@@ -407,7 +570,7 @@ export function roofWorldFacesAll(project: Project): WorldFace[] {
|
||||
// Giebel zeigen vom Dach-Schwerpunkt weg nach aussen.
|
||||
const toC: V3 = [cx - poly[0][0], cy - poly[0][1], cz - poly[0][2]];
|
||||
if (n[0] * toC[0] + n[1] * toC[1] + n[2] * toC[2] > 0) n = [-n[0], -n[1], -n[2]];
|
||||
out.push({ role: "roof", normal: n, poly, roofId: roof.id });
|
||||
out.push({ role: "roof", normal: n, poly, roofId: roof.id, fill });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -674,6 +837,34 @@ function bboxOf(pts: Array<[number, number]>): UVRect {
|
||||
return { uMin, uMax, vMin, vMax };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deckt eine NÄHERE, achsparallele Wand-Bounding-Box `bbox`/`depth` VOLLSTÄNDIG
|
||||
* ab? Grobe NÄHERUNG (Bounding-Box statt exaktes Polygon, wie
|
||||
* {@link buildShadows}) — genügt, um zu verhindern, dass Öffnungssymbole/
|
||||
* Fugenlinien einer HINTEREN Fassade (z. B. Nordwand) durch eine NÄHERE Wand
|
||||
* (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 {
|
||||
const MARGIN = 1e-3;
|
||||
for (const b of blockers) {
|
||||
if (b.depth >= depth - EPS) continue; // nicht echt näher
|
||||
if (
|
||||
b.bbox.uMin - MARGIN <= bbox.uMin &&
|
||||
b.bbox.uMax + MARGIN >= bbox.uMax &&
|
||||
b.bbox.vMin - MARGIN <= bbox.vMin &&
|
||||
b.bbox.vMax + MARGIN >= bbox.vMax
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clippt ein Polygon an ein achsparalleles Rechteck (Sutherland–Hodgman, vier
|
||||
* Halbebenen). Liefert das (konvexe) Restpolygon oder [] bei leerem Schnitt.
|
||||
@@ -859,6 +1050,7 @@ export function generateElevationPlan(
|
||||
|
||||
// Eine Voll-Box je Wand (Öffnungen als Segment-Aussparungen) + Decken-Prismen.
|
||||
const model = projectToModel3d(project, { layeredWalls: false });
|
||||
extendWallCornersForElevation(project, model.walls);
|
||||
const faces = collectElevationFaces(model, frame);
|
||||
// Dächer analytisch (roofGeometry) — nicht Teil von RModel3d walls/slabs.
|
||||
for (const rf of roofWorldFacesAll(project)) {
|
||||
@@ -868,6 +1060,13 @@ export function generateElevationPlan(
|
||||
const wallFaces = faces.filter((f) => f.role === "wall");
|
||||
const slabFaces = faces.filter((f) => f.role === "slab");
|
||||
const roofFaces = faces.filter((f) => f.role === "roof");
|
||||
// Blocker für die Durchsicht-Prüfung (s. {@link isOccluded}): NÄHERE, frontal
|
||||
// zum Betrachter stehende Wandflächen (ganze Fassaden-Ebenen) — der Regelfall
|
||||
// einer verdeckenden Vorderfassade. Schräge/Stirnflächen sind selten volle
|
||||
// Blocker und bleiben aussen vor (Näherung).
|
||||
const wallBlockers = wallFaces
|
||||
.filter((f) => f.depthMax - f.depthMin < 1e-4)
|
||||
.map((f) => ({ bbox: bboxOf(f.pts), depth: f.depthMin }));
|
||||
|
||||
const primitives: Primitive[] = [];
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
@@ -878,10 +1077,13 @@ export function generateElevationPlan(
|
||||
if (y > maxY) maxY = y;
|
||||
};
|
||||
|
||||
// Rollen-Füllung: Fassade/Decke einheitlich, Dach eine Stufe dunkler —
|
||||
// KEINE Tiefenstaffelung (die machte aus der Fassade ein Grau-Patchwork).
|
||||
const fillFor = (role: FaceRole): string => {
|
||||
// Rollen-Füllung: im Farbig-Modus die echte Bauteilfarbe (dominante Wand-
|
||||
// schicht bzw. Dach-Eindeckung, s. {@link WorldFace.fill}); MONO bleibt eine
|
||||
// ruhige weisse Fläche — KEINE Tiefenstaffelung (die machte aus der Fassade
|
||||
// ein Grau-Patchwork).
|
||||
const fillFor = (role: FaceRole, fill: RRgb | undefined): string => {
|
||||
if (mono) return "#ffffff";
|
||||
if (fill) return rgbToHex(fill);
|
||||
return role === "roof" ? ROOF_FILL : WALL_FILL;
|
||||
};
|
||||
|
||||
@@ -916,6 +1118,10 @@ export function generateElevationPlan(
|
||||
if (!frontalGroups.has(gk)) frontalGroups.set(gk, { faces: [], depth: f.depth });
|
||||
frontalGroups.get(gk)!.faces.push(f);
|
||||
}
|
||||
// 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({
|
||||
depth: f.depth,
|
||||
seq: seq++,
|
||||
@@ -926,11 +1132,11 @@ export function generateElevationPlan(
|
||||
prim: {
|
||||
kind: "polygon",
|
||||
pts,
|
||||
fill: fillFor(f.role),
|
||||
fill: fillFor(f.role, f.fill),
|
||||
// Dach + schräg angeschnittene Flächen tragen ihre eigene Kontur;
|
||||
// frontale Flächen bekommen den Gruppen-Umriss (unten).
|
||||
stroke: isRoof || !frontal ? EDGE_INK : "none",
|
||||
strokeWidthMm: isRoof || !frontal ? EDGE_MM : 0,
|
||||
stroke: ownStroke ? EDGE_INK : "none",
|
||||
strokeWidthMm: ownStroke ? EDGE_MM : 0,
|
||||
hatch: NO_HATCH,
|
||||
...(f.wallId ? { wallId: f.wallId } : {}),
|
||||
...(f.ceilingId ? { ceilingId: f.ceilingId } : {}),
|
||||
@@ -939,6 +1145,8 @@ export function generateElevationPlan(
|
||||
});
|
||||
}
|
||||
for (const g of frontalGroups.values()) {
|
||||
const groupBbox = bboxOf(g.faces.flatMap((f) => f.pts));
|
||||
if (isOccluded(groupBbox, g.depth, wallBlockers)) continue;
|
||||
for (const [a, b] of xorOutlineEdges(g.faces.map((f) => f.pts))) {
|
||||
items.push({
|
||||
depth: g.depth - EPS * 5,
|
||||
@@ -980,7 +1188,11 @@ export function generateElevationPlan(
|
||||
}
|
||||
|
||||
// Fenster/Türen: Rahmen+Glas, knapp VOR der Wirtsfläche (auf der Fassade).
|
||||
// Ganz verdeckte Öffnungen (hinter einer näheren Fassade, s. {@link isOccluded})
|
||||
// werden gar nicht erst emittiert — sonst scheinen ihre Symbol-/Sprossen-Linien
|
||||
// (eigener Linien-Durchgang, s. Blocker-Kommentar oben) durch die Vorderwand.
|
||||
for (const op of collectOpenings(project, frame)) {
|
||||
if (isOccluded(bboxOf(op.pts), op.depth, wallBlockers)) continue;
|
||||
const toVec = (q: Array<[number, number]>): Vec2[] =>
|
||||
q.map(([u, v]) => {
|
||||
acc(u, v);
|
||||
@@ -995,13 +1207,13 @@ export function generateElevationPlan(
|
||||
prim: {
|
||||
kind: "polygon",
|
||||
pts: toVec(op.pts),
|
||||
fill: mono ? "#ffffff" : FRAME_FILL,
|
||||
fill: mono ? "#ffffff" : FRAME_FILL_COLOR,
|
||||
stroke: EDGE_INK,
|
||||
strokeWidthMm: FRAME_MM,
|
||||
hatch: NO_HATCH,
|
||||
},
|
||||
});
|
||||
// Sims (Bank + Tropfkante) — weiss gefüllt (verdeckt die Fassade), Kontur.
|
||||
// Sims (Bank + Tropfkante) — verdeckt die Fassade, Kontur.
|
||||
for (const sRect of op.sill) {
|
||||
items.push({
|
||||
depth: d,
|
||||
@@ -1009,7 +1221,7 @@ export function generateElevationPlan(
|
||||
prim: {
|
||||
kind: "polygon",
|
||||
pts: toVec(sRect),
|
||||
fill: mono ? "#ffffff" : FRAME_FILL,
|
||||
fill: mono ? "#ffffff" : SILL_FILL_COLOR,
|
||||
stroke: EDGE_INK,
|
||||
strokeWidthMm: EDGE_MM,
|
||||
hatch: NO_HATCH,
|
||||
@@ -1038,7 +1250,7 @@ export function generateElevationPlan(
|
||||
prim: {
|
||||
kind: "polygon",
|
||||
pts: toVec(g),
|
||||
fill: mono ? GLASS_FILL_MONO : op.isDoor ? DOOR_LEAF_FILL : GLASS_FILL,
|
||||
fill: mono ? GLASS_FILL_MONO : op.isDoor ? DOOR_LEAF_FILL_COLOR : GLASS_FILL_COLOR,
|
||||
stroke: EDGE_INK,
|
||||
strokeWidthMm: EDGE_MM,
|
||||
hatch: NO_HATCH,
|
||||
|
||||
+78
-10
@@ -856,21 +856,89 @@ describe("VW-fein-Öffnungsdetails: Fensterbank, DIN-Symbole, verschachtelte Fl
|
||||
}
|
||||
});
|
||||
|
||||
it("Verschachtelter Flügelrahmen steht ~12 mm vor dem Blendrahmen (nicht mehr flach)", () => {
|
||||
it("Verschachtelter Flügelrahmen: ~5 mm zurückgesetztes Band, kein Vorstand vor den Blendrahmen", () => {
|
||||
// Fest vs. öffenbar, sonst identisch (flush, frameThickness gleich). Der
|
||||
// Flügelrahmen des öffenbaren Fensters sitzt in einem nach aussen (nMax)
|
||||
// vorstehenden Band -> seine Aussenkante ragt 0.012 m weiter hinaus als der
|
||||
// Blendrahmen (bei W1 in Richtung negatives world-y, s. Inset-Test).
|
||||
const minY = (p: Project): number => {
|
||||
// Flügelrahmen sitzt in einem ~5 mm HINTER die Blendrahmen-Vorderkante
|
||||
// (nMax) zurückgesetzten Band — er ragt also nirgends weiter nach aussen
|
||||
// als der Blendrahmen (kein Fassaden-Vorstand; bei W1 zeigt aussen in
|
||||
// Richtung negatives world-y, s. Inset-Test).
|
||||
const meshMinY = (mesh: { positions: number[] }): number => {
|
||||
let m = Infinity;
|
||||
for (const mesh of projectToModel3d(p).meshes.filter(isFrame)) {
|
||||
for (let i = 1; i < mesh.positions.length; i += 3) m = Math.min(m, mesh.positions[i]);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
const fixedMinY = minY(winProject({ ...baseWin, id: "win-proud-fixed", kind: "fest", sillBoard: "keine" }));
|
||||
const openMinY = minY(winProject({ ...baseWin, id: "win-proud-open", kind: "drehkipp", sillBoard: "keine" }));
|
||||
expect(fixedMinY - openMinY).toBeCloseTo(0.012, 6);
|
||||
const frameMeshes = (p: Project) => projectToModel3d(p).meshes.filter(isFrame);
|
||||
const fixed = frameMeshes(winProject({ ...baseWin, id: "win-proud-fixed", kind: "fest", sillBoard: "keine" }));
|
||||
const open = frameMeshes(winProject({ ...baseWin, id: "win-proud-open", kind: "drehkipp", sillBoard: "keine" }));
|
||||
const blendFront = Math.min(...fixed.map(meshMinY));
|
||||
// Kein Vorstand: öffenbares Fenster reicht nicht weiter nach aussen.
|
||||
expect(Math.min(...open.map(meshMinY))).toBeCloseTo(blendFront, 6);
|
||||
// Die 4 zusätzlichen Flügelrahmen-Riegel liegen mit ihrer Vorderkante
|
||||
// exakt 5 mm hinter der Blendrahmen-Vorderkante (zurückgesetzte Stufe).
|
||||
const sashMeshes = open.filter((m) => meshMinY(m) > blendFront + 1e-6);
|
||||
expect(sashMeshes.length).toBe(4);
|
||||
for (const m of sashMeshes) expect(meshMinY(m)).toBeCloseTo(blendFront + 0.005, 6);
|
||||
});
|
||||
|
||||
it("Türblatt: Vollblatt füllt das Feld, Teilverglasung nur den blinden Rest, Fenster nie", () => {
|
||||
const isLeaf = (m: { color: [number, number, number] }): boolean =>
|
||||
m.color[0] === 0.93 && m.color[1] === 0.9 && m.color[2] === 0.85;
|
||||
const doorProject = (dt: NonNullable<Project["doorTypes"]>[number]): Project => ({
|
||||
...sampleProject,
|
||||
doorTypes: [dt],
|
||||
openings: [
|
||||
{
|
||||
id: "DL",
|
||||
type: "opening",
|
||||
hostWallId: "W1",
|
||||
categoryCode: "31",
|
||||
kind: "door",
|
||||
typeId: dt.id,
|
||||
position: 3.0,
|
||||
width: 0.9,
|
||||
height: 2.0,
|
||||
sillHeight: 0,
|
||||
},
|
||||
],
|
||||
doors: [],
|
||||
context: [],
|
||||
});
|
||||
const baseDoor = {
|
||||
id: "door-leaf-test",
|
||||
name: "Blatt-Test",
|
||||
kind: "dreh" as const,
|
||||
leafCount: 1 as const,
|
||||
leafStyle: "glatt" as const,
|
||||
frameThickness: 0.05,
|
||||
frameWidth: 0.06,
|
||||
defaultWidth: 0.9,
|
||||
defaultHeight: 2.0,
|
||||
};
|
||||
const zTop = (meshes: { positions: number[] }[]): number => {
|
||||
let m = -Infinity;
|
||||
for (const mesh of meshes) for (let i = 2; i < mesh.positions.length; i += 3) m = Math.max(m, mesh.positions[i]);
|
||||
return m;
|
||||
};
|
||||
// Vollblatt (glatt): genau EIN Blatt-Panel, bis unter den Sturz (2.0 − fw).
|
||||
const solid = projectToModel3d(doorProject(baseDoor)).meshes.filter(isLeaf);
|
||||
expect(solid.length).toBe(1);
|
||||
expect(zTop(solid)).toBeCloseTo(2.0 - 0.06, 6);
|
||||
// Auch bei grob vorhanden (Füllung ist kein „fein"-Detail).
|
||||
expect(projectToModel3d(doorProject(baseDoor), { detail: "grob" }).meshes.filter(isLeaf).length).toBe(1);
|
||||
// Teilverglasung (glas, glazingRatio 0.4): Blatt nur im unteren 60%-Rest.
|
||||
const glazed = projectToModel3d(
|
||||
doorProject({ ...baseDoor, id: "door-leaf-glas", leafStyle: "glas", glazingRatio: 0.4 }),
|
||||
).meshes.filter(isLeaf);
|
||||
expect(glazed.length).toBe(1);
|
||||
const fieldTop = 2.0 - 0.06;
|
||||
expect(zTop(glazed)).toBeCloseTo(fieldTop - 0.4 * fieldTop, 6);
|
||||
// Ganzglastür (glazingRatio 1): kein blinder Rest -> kein Blatt.
|
||||
expect(
|
||||
projectToModel3d(doorProject({ ...baseDoor, id: "door-leaf-vollglas", leafStyle: "glas" })).meshes.filter(isLeaf)
|
||||
.length,
|
||||
).toBe(0);
|
||||
// Fenster: nie ein Blatt.
|
||||
expect(projectToModel3d(winProject({ ...baseWin, id: "win-no-leaf" })).meshes.filter(isLeaf).length).toBe(0);
|
||||
});
|
||||
|
||||
it("Tür-Zarge mit Umgriff: zarge -> 6 Bekleidungs-Riegel mehr als blockrahmen", () => {
|
||||
|
||||
+78
-24
@@ -417,6 +417,14 @@ const SILL_RGB: RRgb = [0.8, 0.8, 0.78];
|
||||
const SYMBOL_RGB: RRgb = [0.25, 0.27, 0.3];
|
||||
/** Halbe Breite der Symbol-Diagonalen (Meter) — ~5 mm Gesamtbreite, feine Linie. */
|
||||
const SYMBOL_HALF_WIDTH = 0.0025;
|
||||
/**
|
||||
* Warmer, heller Türblatt-Ton — bewusst wärmer/heller als der Rahmen
|
||||
* ({@link FRAME_RGB}), damit das Blatt im 3D als eigene Füllung liest
|
||||
* (s. {@link doorLeafPanels}).
|
||||
*/
|
||||
const DOOR_LEAF_RGB: RRgb = [0.93, 0.9, 0.85];
|
||||
/** Türblatt-Dicke quer zur Wand (Meter). */
|
||||
const DOOR_LEAF_THICK = 0.04;
|
||||
/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */
|
||||
const DEFAULT_FRAME_WIDTH = 0.06;
|
||||
/** Warmer Terrakotta-Grauton für Dachflächen/-giebel (s. {@link emitRoofs}) — hebt das Dach klar von Wand/Decke/Kontext ab. */
|
||||
@@ -2216,21 +2224,22 @@ function frameMeshesForOpening(
|
||||
}
|
||||
}
|
||||
// Flügelrahmen je ÖFFENBAREM Flügel: ein eigener Rahmen-Riegel-Ring (links/
|
||||
// rechts/oben/unten) INNERHALB des Blendrahmen-Felds, um `reveal` eingerückt
|
||||
// (Schachtelung Blendrahmen → Flügelrahmen → Glas). Anders als bisher sitzt der
|
||||
// Flügelrahmen in einem EIGENEN, nach aussen VORSTEHENDEN und flacheren Quer-
|
||||
// Band (`sashBackN..sashFrontN`) — dadurch liest er sich als abgesetzter,
|
||||
// verschachtelter Körper VOR dem Blendrahmen (VW „fein"), nicht mehr flach mit
|
||||
// ihm verschmolzen. Zusätzlich (nur „fein") die DIN-Öffnungssymbole auf dem
|
||||
// Glas. Feste Flügel bekommen weder Flügelrahmen noch Symbol.
|
||||
// rechts/oben/unten), der DIREKT am Blendrahmen ansetzt (kein Spalt — sonst
|
||||
// schiene die Vollfeld-Glasscheibe als Streifen zwischen Blend- und Flügel-
|
||||
// rahmen durch). Die Schachtelung Blendrahmen → Flügelrahmen → Glas liest sich
|
||||
// über die Tiefe: der Flügelrahmen sitzt in einem EIGENEN, leicht ZURÜCK-
|
||||
// GESETZTEN und flacheren Quer-Band (`sashBackN..sashFrontN`) hinter der
|
||||
// Blendrahmen-Vorderkante — eine plastische Stufe nach innen (VW „fein"),
|
||||
// ohne vor die Fassade auszukragen. Zusätzlich (nur „fein") die DIN-Öffnungs-
|
||||
// symbole auf dem Glas. Feste Flügel bekommen weder Flügelrahmen noch Symbol.
|
||||
if (sashesOn && params.sashes.length > 0 && sashTo - sashFrom > EPS && sashTop - sashBottom > EPS) {
|
||||
const segs = resolveSashSpans(params.sashes, sashFrom, sashTo);
|
||||
const reveal = fw * 0.5;
|
||||
const sfw = fw * 0.7;
|
||||
// Verschachteltes Flügelrahmen-Band: ~12 mm vor der Blendrahmen-Vorderkante
|
||||
// (nMax = Aussenseite) und nur ~50 mm tief — abgesetzte, plastische Stufe.
|
||||
const sashFrontN = params.nMax + 0.012;
|
||||
const sashBackN = Math.max(params.nMin, params.nMax - 0.05);
|
||||
// Verschachteltes Flügelrahmen-Band: ~5 mm HINTER der Blendrahmen-Vorderkante
|
||||
// (nMax = Aussenseite) und nur ~50 mm tief — abgesetzte Stufe nach innen,
|
||||
// die Glas-Vorderkante (nMax − 0.03) liegt INNERHALB dieses Bands.
|
||||
const sashFrontN = params.nMax - 0.005;
|
||||
const sashBackN = Math.max(params.nMin, params.nMax - 0.055);
|
||||
// Glas-Vorderkante (s. glassPanesForOpening: n1 = nMax − 0.03); die Symbole
|
||||
// liegen als ~2.5 mm dünne dunkle Prismen KNAPP davor (weiter aussen).
|
||||
const glassFrontN = params.nMax - 0.03;
|
||||
@@ -2245,10 +2254,12 @@ function frameMeshesForOpening(
|
||||
for (const seg of segs) {
|
||||
const s = seg.sash;
|
||||
if (s.kind !== "fluegel" || !s.opening || s.opening === "fest") continue;
|
||||
const cf = seg.from + fw + reveal;
|
||||
const ct = seg.to - fw - reveal;
|
||||
const bz = sashBottom + reveal;
|
||||
const tz = sashTop - reveal;
|
||||
// Bündig am Blendrahmen/Segmentrand ansetzen — die Stufe kommt aus der
|
||||
// Tiefe (sashBackN..sashFrontN), nicht aus einem sichtbaren Glas-Spalt.
|
||||
const cf = seg.from;
|
||||
const ct = seg.to;
|
||||
const bz = sashBottom;
|
||||
const tz = sashTop;
|
||||
if (ct - cf <= EPS || tz - bz <= EPS) continue;
|
||||
pushSash(cf, cf + sfw, bz, tz); // linker Flügelholm
|
||||
pushSash(ct - sfw, ct, bz, tz); // rechter Flügelholm
|
||||
@@ -2410,18 +2421,56 @@ function glassPanesForOpening(
|
||||
if (params.transomHeight > EPS) {
|
||||
const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTopFull);
|
||||
const mainTop = splitZ - fw / 2;
|
||||
push(glazedBottom(sashBottom, mainTop), mainTop); // Hauptglas (ggf. nur oben).
|
||||
push(splitZ + fw / 2, sashTopFull); // Oberlicht-Scheibe (immer voll).
|
||||
} else {
|
||||
if (params.glazed) push(glazedBottom(sashBottom, mainTop), mainTop); // Hauptglas (ggf. nur oben).
|
||||
push(splitZ + fw / 2, sashTopFull); // Oberlicht-Scheibe (immer voll, auch Vollblatt-Tür).
|
||||
} else if (params.glazed) {
|
||||
push(glazedBottom(sashBottom, sashTopFull), sashTopFull);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emittiert je Fensteröffnung eine Glasscheibe (Türen nur bei glasfülligem
|
||||
* Blatt, `leafStyle: "glas"`) als rohes Quader-Mesh — damit Fenster im 3D als
|
||||
* Fenster lesbar sind statt als blosse Löcher in der Wand.
|
||||
* Türblatt EINER typisierten Tür als solide Blatt-Tafel im Hauptfeld (zwischen
|
||||
* den Rahmen-Pfosten, über Schwelle bis Sturz bzw. Kämpfer): Vollblatt
|
||||
* (`leafStyle` ≠ "glas") füllt das ganze Feld, Teilverglasung
|
||||
* (`glazingRatio` < 1) nur den blinden UNTEREN Rest unter dem Glasanteil
|
||||
* (Anteil von OBEN gemessen, s. {@link glassPanesForOpening}). Das Blatt liegt
|
||||
* mittig um die Glasebene (nMax − 0.03), ~4 cm dick ({@link DOOR_LEAF_THICK}),
|
||||
* in {@link DOOR_LEAF_RGB} — damit Türen im 3D als Türen lesbar sind statt als
|
||||
* offene Löcher mit Rahmen. Fenster: nie (glazedFraction = 1 ⇒ kein Blatt).
|
||||
*/
|
||||
function doorLeafPanels(
|
||||
wall: Wall,
|
||||
iv: { from: number; to: number },
|
||||
vert: { zBottom: number; zTop: number },
|
||||
params: OpeningFrameParams,
|
||||
): RMesh[] {
|
||||
const out: RMesh[] = [];
|
||||
const fw = params.frameWidth;
|
||||
const leafFrom = iv.from + fw;
|
||||
const leafTo = iv.to - fw;
|
||||
const leafBottom = vert.zBottom + (params.hasSill ? fw : 0);
|
||||
let fieldTop = vert.zTop - fw;
|
||||
if (params.transomHeight > EPS) {
|
||||
const splitZ = clampRange(vert.zTop - params.transomHeight, leafBottom, fieldTop);
|
||||
fieldTop = splitZ - fw / 2; // Hauptfeld endet am Kämpfer, Oberlicht bleibt Glas.
|
||||
}
|
||||
// Blinder Blattanteil: alles unterhalb des (von oben gemessenen) Glasanteils.
|
||||
const frac = params.glazed ? Math.max(0.05, Math.min(1, params.glazedFraction)) : 0;
|
||||
const leafTop = fieldTop - frac * (fieldTop - leafBottom);
|
||||
if (leafTop - leafBottom <= EPS || leafTo - leafFrom <= EPS) return out;
|
||||
const n1 = params.nMax - 0.03 + DOOR_LEAF_THICK / 2;
|
||||
const n0 = Math.max(params.nMin, n1 - DOOR_LEAF_THICK);
|
||||
const mesh = openingAxisBox(wall, leafFrom, leafTo, leafBottom, leafTop, n0, n1, DOOR_LEAF_RGB);
|
||||
if (mesh) out.push(mesh);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emittiert je Fensteröffnung eine Glasscheibe und je Türöffnung die Füllung
|
||||
* (Blatt-Tafel, s. {@link doorLeafPanels}, plus Scheibe bei Glasanteil/
|
||||
* Oberlicht) als rohe Quader-Meshes — damit Fenster/Türen im 3D lesbar sind
|
||||
* statt als blosse Löcher in der Wand.
|
||||
*
|
||||
* OHNE auflösbaren Typ (`getWindowType`/`getDoorType` liefert `undefined`,
|
||||
* z. B. `Opening.typeId` fehlt): unverändertes ALT-Verhalten — EINE Vollscheibe
|
||||
@@ -2470,9 +2519,14 @@ function emitOpeningGlass(project: Project): RMesh[] {
|
||||
}
|
||||
out.push(...glassPanesForOpening(wall, iv, vert, params));
|
||||
} else {
|
||||
// Tür: nur bei glasfülligem Blatt eine Scheibe (Alt-Verhalten: offen).
|
||||
// Tür: Blatt-Tafel (Vollblatt bzw. blinder Rest unter der Teilverglasung)
|
||||
// + Scheibe(n) für Glasanteil/Oberlicht. Ohne auflösbaren Typ: nichts
|
||||
// (Alt-Verhalten: offenes Loch).
|
||||
const params = resolveOpeningFrame(project, wall, op);
|
||||
if (params?.glazed) out.push(...glassPanesForOpening(wall, iv, vert, params));
|
||||
if (params) {
|
||||
out.push(...doorLeafPanels(wall, iv, vert, params));
|
||||
out.push(...glassPanesForOpening(wall, iv, vert, params));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user