From c02a02cffeec8228acfb9b1df97a88ff8310d5fb Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 11 Jul 2026 02:33:46 +0200 Subject: [PATCH] =?UTF-8?q?Ansicht=20als=20Linienzeichnung=20(VW-Look):=20?= =?UTF-8?q?weisse=20Fl=C3=A4chen=20+=20XOR-Silhouette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/plan/toElevation.ts | 119 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 12 deletions(-) diff --git a/src/plan/toElevation.ts b/src/plan/toElevation.ts index 134bcd1..3ae4c7d 100644 --- a/src/plan/toElevation.ts +++ b/src/plan/toElevation.ts @@ -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], [number, number]]> { + const EPS_AX = 1e-6; + const keyOf = (x: number): number => Math.round(x * 1e5); + const horiz = new Map>(); + const vert = new Map>(); + 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>, + 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(); 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).