Ansicht als Linienzeichnung (VW-Look): weisse Flächen + XOR-Silhouette

Nutzer-Referenz ('so sieht es ordentlich aus'): Architektur-Ansichten sind
Linienzeichnungen, keine Grauflächen. Umsetzung:
- Alle Flächen weiss (Fassade/Dach/Rahmen/Glas/Türblatt); Kanten tragen.
- Frontale Wand-/Deckenflächen je Tiefen-Gruppe: Flächen ohne Kontur, dann
  der Vereinigungs-UMRISS der Gruppe über Kanten-XOR (xorOutlineEdges) —
  Innenkanten der Pfeiler-/Sturz-/Geschoss-Teilboxen heben sich paarweise
  auf, übrig bleibt exakt die Silhouette inklusive der Öffnungslöcher.
- Dach + schräg angeschnittene Flächen behalten ihre eigene Kontur.
Painter-Verdeckung bleibt (weisse Füllung nah überdeckt fern). 737/737 grün;
headless verifiziert — deckungsgleich mit der VW-Referenz.
This commit is contained in:
2026-07-11 02:33:46 +02:00
parent 983d061a0c
commit c02a02cffe
+107 -12
View File
@@ -69,7 +69,7 @@ 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 = "#7f8b99";
const GLASS_FILL = "#ffffff";
/** Glas im Mono-Modus (neutrales Grau statt Blaugrau). */
const GLASS_FILL_MONO = "#c4c8cd";
/** Festes Schattengrau (über der Fassade, unter den Kanten). */
@@ -82,12 +82,12 @@ const GROUND_MM = 0.5;
* 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. */
const WALL_FILL = "#ecebe8";
const ROOF_FILL = "#d9d6d1";
/** Blendrahmen-Fläche der Öffnungen (heller als die Fassade). */
const FRAME_FILL = "#f8f7f4";
/** Türblatt-Fläche (zwischen Fassade und Glas). */
const DOOR_LEAF_FILL = "#d4d2cd";
const WALL_FILL = "#ffffff";
const ROOF_FILL = "#ffffff";
/** Blendrahmen-Fläche der Öffnungen. */
const FRAME_FILL = "#ffffff";
/** Türblatt-Fläche. */
const DOOR_LEAF_FILL = "#ffffff";
/** Öffnungssymbol (DIN-Andeutung): dezente Annotation, nicht Bauteilkante. */
const SYMBOL_INK = "#7d858f";
@@ -292,6 +292,8 @@ export interface ProjectedFace {
depth: number;
/** Kleinste Punkt-Tiefe (nächste Kante — z. B. die auskragende Traufe). */
depthMin: number;
/** Grösste Punkt-Tiefe — depthMax≈depthMin ⇒ frontale (ebene) Fläche. */
depthMax: number;
role: FaceRole;
wallId?: string;
ceilingId?: string;
@@ -310,15 +312,17 @@ export function projectFace(frame: ElevationFrame, face: WorldFace): ProjectedFa
const pts: Array<[number, number]> = [];
let depthSum = 0;
let depthMin = Infinity;
let depthMax = -Infinity;
for (const w of face.poly) {
const p = projectPoint(frame, w);
pts.push([p.u, p.v]);
depthSum += p.depth;
depthMin = Math.min(depthMin, p.depth);
depthMax = Math.max(depthMax, p.depth);
}
const depth = depthSum / face.poly.length;
if (depth <= EPS) return null; // vor der Ebene → ignorieren
return { pts, depth, depthMin, 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 };
}
/**
@@ -732,6 +736,69 @@ export function buildShadows(walls: ProjectedFace[], slabs: ProjectedFace[]): Sh
return out;
}
// ── Umriss-Vereinigung frontaler Flächengruppen ─────────────────────────────
/**
* Vereinigungs-UMRISS einer Gruppe achsparalleler (u,v)-Rechteckflächen über
* Kanten-XOR: jede Kante wird als Intervall auf ihrer Trägerlinie gesammelt;
* Bereiche mit GERADER Überdeckung (geteilte Innenkanten zweier Teilboxen)
* heben sich auf, ungerade Bereiche bilden die Silhouette — inklusive der
* Fensterlöcher (die Teilboxen um eine Öffnung lassen genau deren Rand übrig).
* Nicht-achsparallele Kanten werden unverändert durchgereicht.
*/
export function xorOutlineEdges(
polys: Array<Array<[number, number]>>,
): Array<[[number, number], [number, number]]> {
const EPS_AX = 1e-6;
const keyOf = (x: number): number => Math.round(x * 1e5);
const horiz = new Map<number, Array<[number, number]>>();
const vert = new Map<number, Array<[number, number]>>();
const out: Array<[[number, number], [number, number]]> = [];
for (const poly of polys) {
for (let i = 0; i < poly.length; i++) {
const a = poly[i];
const b = poly[(i + 1) % poly.length];
if (Math.abs(a[1] - b[1]) < EPS_AX) {
const k = keyOf(a[1]);
if (!horiz.has(k)) horiz.set(k, []);
horiz.get(k)!.push([Math.min(a[0], b[0]), Math.max(a[0], b[0])]);
} else if (Math.abs(a[0] - b[0]) < EPS_AX) {
const k = keyOf(a[0]);
if (!vert.has(k)) vert.set(k, []);
vert.get(k)!.push([Math.min(a[1], b[1]), Math.max(a[1], b[1])]);
} else {
out.push([a, b]);
}
}
}
const sweep = (
m: Map<number, Array<[number, number]>>,
emit: (coord: number, s: number, e: number) => void,
): void => {
for (const [k, ivs] of m) {
const evs: Array<[number, number]> = [];
for (const [s, e] of ivs) {
if (e - s < 1e-9) continue;
evs.push([s, 1], [e, -1]);
}
evs.sort((p, q) => p[0] - q[0] || q[1] - p[1]);
let count = 0;
let start = 0;
const coord = k / 1e5;
for (const [x, d] of evs) {
const wasOdd = count % 2 === 1;
count += d;
const isOdd = count % 2 === 1;
if (!wasOdd && isOdd) start = x;
else if (wasOdd && !isOdd && x - start > 1e-9) emit(coord, start, x);
}
}
};
sweep(horiz, (v, s, e) => out.push([[s, v], [e, v]]));
sweep(vert, (u, s, e) => out.push([[u, s], [u, e]]));
return out;
}
// ── Plan-Zusammenbau ────────────────────────────────────────────────────────
/** Optionen des Ansichts-Generators. */
@@ -799,12 +866,24 @@ export function generateElevationPlan(
const items: Item[] = [];
let seq = 0;
// Frontale Wand-/Deckenflächen (alle Ecken auf gleicher Tiefe) werden je
// Tiefen-Gruppe gesammelt: die Flächen selbst weiss OHNE Kontur, dann der
// XOR-Vereinigungs-Umriss der Gruppe als Linienzug — Innenkanten der
// Teilboxen (Pfeiler/Sturz/Geschossfugen) heben sich auf, übrig bleibt die
// Silhouette samt Öffnungslöchern (VW-Linienzeichnungs-Look).
const frontalGroups = new Map<number, { faces: ProjectedFace[]; depth: number }>();
for (const f of faces) {
const pts: Vec2[] = f.pts.map(([u, v]) => {
acc(u, v);
return { x: u, y: v };
});
const isRoof = f.role === "roof";
const frontal = !isRoof && f.depthMax - f.depthMin < 1e-4;
if (frontal) {
const gk = Math.round(f.depth * 1e4);
if (!frontalGroups.has(gk)) frontalGroups.set(gk, { faces: [], depth: f.depth });
frontalGroups.get(gk)!.faces.push(f);
}
items.push({
depth: f.depth,
seq: seq++,
@@ -816,10 +895,10 @@ export function generateElevationPlan(
kind: "polygon",
pts,
fill: fillFor(f.role),
// Nur das Dach trägt eine Kontur — die Fassade bleibt EINE ruhige
// Fläche (ihr Rand liest sich über den Kontrast zum Papier).
stroke: isRoof ? EDGE_INK : "none",
strokeWidthMm: isRoof ? EDGE_MM : 0,
// 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,
hatch: NO_HATCH,
...(f.wallId ? { wallId: f.wallId } : {}),
...(f.ceilingId ? { ceilingId: f.ceilingId } : {}),
@@ -827,6 +906,22 @@ export function generateElevationPlan(
},
});
}
for (const g of frontalGroups.values()) {
for (const [a, b] of xorOutlineEdges(g.faces.map((f) => f.pts))) {
items.push({
depth: g.depth - EPS * 5,
seq: seq++,
prim: {
kind: "line",
a: { x: a[0], y: a[1] },
b: { x: b[0], y: b[1] },
cls: "elevation-outline",
weightMm: EDGE_MM,
color: EDGE_INK,
},
});
}
}
// Schatten (leicht VOR der Empfängertiefe, damit sie direkt nach der Fassade,
// aber unter den Öffnungen/Kanten näherer Flächen liegen).