From 45f9d0c3815bf01c946eb142b3e9db860db57d45 Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 2 Jul 2026 21:00:19 +0200 Subject: [PATCH] =?UTF-8?q?3D-W=C3=A4nde=20mit=20=C3=96ffnungen:=20T=C3=BC?= =?UTF-8?q?ren/Fenster=20als=20Teilquader=20(Pfeiler+Br=C3=BCstung+Sturz)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plan/nativeSync.ts | 5 +- src/plan/toWalls3d.ts | 171 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 155 insertions(+), 21 deletions(-) diff --git a/src/plan/nativeSync.ts b/src/plan/nativeSync.ts index dd9a3ac..bd48ca8 100644 --- a/src/plan/nativeSync.ts +++ b/src/plan/nativeSync.ts @@ -50,8 +50,9 @@ export function pushNativeScene(plan: Plan): void { } /** - * Debounced: die Wände des Projekts (alle Geschosse, gestapelt) als - * render3d-WallInputs ans native 3D-Fenster schieben. No-op ohne Tauri. + * Debounced: das 3D-Modell des Projekts (alle Geschosse, gestapelt) — Wände mit + * Öffnungs-Teilquadern plus Deckenplatten — ans native 3D-Fenster schieben. + * No-op ohne Tauri. */ export function pushNativeWalls(project: Project): void { if (!isTauri()) return; diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 8cbd361..290f5a5 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -6,10 +6,22 @@ // Alle Geschosse werden einbezogen und über wallVerticalExtent korrekt // gestapelt (EG baseElevation 0, OG 2.6 …), sodass das 3D-Fenster das ganze // Gebäude zeigt. +// +// ÖFFNUNGEN (Türen/Fenster): eine Wand mit Öffnungen wird NICHT als ein Quader +// emittiert, sondern in Teilquader entlang der Achse zerlegt: +// • volle Wandhöhe links/rechts der Öffnung (Pfeiler), +// • unter dem Fenster die Brüstung (UK..Sill), +// • über Tür/Fenster der Sturz (OpeningTop..Wandkopf). +// So entstehen sichtbare Aussparungen ohne dass render3d etwas davon wissen muss. +// +// DECKEN (Slabs): geschossgebundene Flächenbauteile werden als extrudierte +// Polygon-Platten (SlabInput) mitgegeben — render3d trianguliert den Umriss und +// zieht ihn über die Deckendicke hoch. -import type { Project } from "../model/types"; -import { getWallType, wallTypeThickness } from "../model/types"; -import { wallVerticalExtent } from "../model/wall"; +import type { Project, Wall, Opening } from "../model/types"; +import { getWallType, wallTypeThickness, openingsOfWall } from "../model/types"; +import { wallVerticalExtent, ceilingVerticalExtent } from "../model/wall"; +import { openingInterval, openingVerticalExtent } from "../geometry/opening"; export type RVec2 = [number, number]; export type RRgb = [number, number, number]; @@ -23,28 +35,149 @@ export interface RWall { color: RRgb; } +/** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */ +export interface RSlab { + outline: RVec2[]; + zBottom: number; + zTop: number; + color: RRgb; +} + +/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken). */ +export interface RModel3d { + walls: RWall[]; + slabs: RSlab[]; +} + /** Warmer, neutraler Wand-Grundton (wie render3d default). */ const WALL_RGB: RRgb = [0.82, 0.8, 0.76]; +/** Etwas hellerer, kühlerer Ton für Deckenplatten (heben sich von Wänden ab). */ +const SLAB_RGB: RRgb = [0.86, 0.86, 0.88]; -export function projectToWalls3d(project: Project): RWall[] { - const out: RWall[] = []; - for (const w of project.walls) { - let thickness = 0.2; - try { - thickness = wallTypeThickness(getWallType(project, w)); - } catch { - thickness = 0.2; +/** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */ +const EPS = 1e-4; + +/** + * Hängt einen Wand-Teilquader an: das Achsenstück [from..to] (Meter ab + * Wand-Startpunkt) über den Höhenbereich [zBottom..zTop]. Entartete Stücke + * (Länge ≤ 0 oder Höhe ≤ 0) werden übersprungen — so überleben Öffnungen dicht + * an den Wandenden ohne Nullquader. + */ +function pushSegment( + out: RWall[], + wall: Wall, + from: number, + to: number, + zBottom: number, + zTop: number, + thickness: number, +): void { + if (to - from <= EPS) return; + if (zTop - zBottom <= EPS) return; + const dx = wall.end.x - wall.start.x; + const dy = wall.end.y - wall.start.y; + const len = Math.hypot(dx, dy); + if (len < 1e-9) return; + const ux = dx / len; + const uy = dy / len; + const p1: RVec2 = [wall.start.x + ux * from, wall.start.y + uy * from]; + const p2: RVec2 = [wall.start.x + ux * to, wall.start.y + uy * to]; + out.push({ + start: p1, + end: p2, + thickness, + height: zTop - zBottom, + baseElevation: zBottom, + color: WALL_RGB, + }); +} + +/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */ +function emitWall(out: RWall[], project: Project, wall: Wall): void { + let thickness = 0.2; + try { + thickness = wallTypeThickness(getWallType(project, wall)); + } catch { + thickness = 0.2; + } + const { zBottom, zTop } = wallVerticalExtent(project, wall); + const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); + if (axisLen < 1e-9 || zTop - zBottom <= EPS) return; + + // Öffnungen der Wand als [from..to]-Intervalle (auf die Achse geklemmt), + // nach Startlage sortiert. + const openings = openingsOfWall(project, wall.id); + const items: Array<{ from: number; to: number; op: Opening }> = []; + for (const op of openings) { + const iv = openingInterval(wall, op); + if (iv) items.push({ from: iv.from, to: iv.to, op }); + } + items.sort((a, b) => a.from - b.from); + + // Ohne Öffnungen: ein durchgehender Quader (wie bisher). + if (items.length === 0) { + pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness); + return; + } + + // Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Öffnungen, + // Brüstung/Sturz im Öffnungsbereich. + let cursor = 0; + for (const { from, to, op } of items) { + const segFrom = Math.max(cursor, from); + if (from > cursor) { + // Voller Wandpfeiler bis zur Öffnung. + pushSegment(out, wall, cursor, from, zBottom, zTop, thickness); } - const { zBottom, zTop } = wallVerticalExtent(project, w); - const height = Math.max(0.01, zTop - zBottom); + if (to > segFrom) { + const { zBottom: oBottom, zTop: oTop } = openingVerticalExtent(project, wall, op); + // Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom). + if (oBottom > zBottom + EPS) { + pushSegment(out, wall, segFrom, to, zBottom, oBottom, thickness); + } + // Sturz über der Öffnung (bis zum Wandkopf). + if (oTop < zTop - EPS) { + pushSegment(out, wall, segFrom, to, oTop, zTop, thickness); + } + } + cursor = Math.max(cursor, to); + } + // Restlicher Wandpfeiler bis zum Achsenende. + if (cursor < axisLen) { + pushSegment(out, wall, cursor, axisLen, zBottom, zTop, thickness); + } +} + +/** Emittiert die Deckenplatten eines Projekts (falls vorhanden). */ +function emitSlabs(project: Project): RSlab[] { + const out: RSlab[] = []; + for (const c of project.ceilings ?? []) { + if (!c.outline || c.outline.length < 3) continue; + // zBottom/zTop (und damit die Deckendicke) berechnet ceilingVerticalExtent + // bereits über ceilingThickness (respektiert Typ-Dicke + Übersteuerung). + const { zBottom, zTop } = ceilingVerticalExtent(project, c); + if (zTop - zBottom <= EPS) continue; out.push({ - start: [w.start.x, w.start.y], - end: [w.end.x, w.end.y], - thickness, - height, - baseElevation: zBottom, - color: WALL_RGB, + outline: c.outline.map((p) => [p.x, p.y] as RVec2), + zBottom, + zTop, + color: SLAB_RGB, }); } return out; } + +/** + * Nur die Wände (Rückwärts-Kompatibilität / bestehende Aufrufer). Zerlegt Wände + * mit Öffnungen in Teilquader. + */ +export function projectToWalls3d(project: Project): RWall[] { + const out: RWall[] = []; + for (const w of project.walls) emitWall(out, project, w); + return out; +} + +/** Das volle 3D-Modell: Wände (mit Öffnungen) + Deckenplatten. */ +export function projectToModel3d(project: Project): RModel3d { + return { walls: projectToWalls3d(project), slabs: emitSlabs(project) }; +}