render3d: Wandverschneidung an Ecken + sichtbare Schichten

mesh.rs berechnet die Ecken-Verschneidung jetzt selbst (portiert aus dem
Web-Kern computeJoins/clippedBand): Wandenden werden per Rundungs-Key
gruppiert, ueber Hoehen-Ueberlappung geclustert (gestapelte Geschosse mit
gleichem Grundriss verschneiden sich NICHT), und Zweier-Cluster ueber die
gemeinsame Gehrungslinie geschnitten. T-/X-Stoesse und freie Enden bleiben
stumpf, wie im Web-Kern. Endkappen-Normalen geometrisch aus dem Kreuzprodukt.

WallInput bekommt optional layers (WallLayer{thickness,color}, serde-default,
rueckwaertskompatibel): jede Schicht wird als eigenes Band ueber dieselbe
gehrte Grundflaeche extrudiert und materialgefaerbt. toWalls3d.ts loest die
WallType-Schichten je Wand auf (hexToRgb) und reicht sie durch.

39/39 Rust-Tests (7 neue: Gehrung, T-Stoss, freies Ende, Schichten, Kombi).
This commit is contained in:
2026-07-03 21:45:01 +02:00
parent 5fd0161bc7
commit 1f6da09b2b
7 changed files with 681 additions and 73 deletions
+47 -7
View File
@@ -17,15 +17,29 @@
// DECKEN (Slabs): geschossgebundene Flächenbauteile werden als extrudierte
// Polygon-Platten (SlabInput) mitgegeben — render3d trianguliert den Umriss und
// zieht ihn über die Deckendicke hoch.
//
// SCHICHTEN (Putz/Dämmung/Mauerwerk/…): zusätzlich zur Gesamtdicke wird der
// aufgelöste Schichtaufbau des WallType mitgegeben (`layers`) — je Schicht
// Dicke + 3D-Farbe des zugehörigen Bauteil-Materials (Component). render3d
// extrudiert daraus je Schicht ein eigenes Band quer zur Wanddicke (siehe
// render3d::mesh); die Eck-Gehrung (Wandknoten-Verschneidung) berechnet
// render3d selbständig aus der Wandkonnektivität, braucht also keine
// zusätzlichen Felder von hier.
import type { Project, Wall } from "../model/types";
import { getWallType, wallTypeThickness, openingsOfWall } from "../model/types";
import { getComponent, 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];
/** Eine aufgelöste Wandschicht: Dicke + 3D-Albedo-Farbe (aus dem Component). */
export interface RLayer {
thickness: number;
color: RRgb;
}
export interface RWall {
start: RVec2;
end: RVec2;
@@ -33,6 +47,8 @@ export interface RWall {
height: number;
baseElevation: number;
color: RRgb;
/** Aufgelöster Schichtaufbau (leer = kein WallType auflösbar, siehe emitWall). */
layers: RLayer[];
}
/** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */
@@ -57,6 +73,18 @@ const SLAB_RGB: RRgb = [0.86, 0.86, 0.88];
/** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */
const EPS = 1e-4;
/**
* Wandelt eine Hex-Farbe ("#rrggbb", Component-Farbe im Plan) in float-RGB
* (0..1, render3d-Konvention) um. Ungültiges/fehlendes Format fällt auf den
* neutralen Wand-Grundton zurück (nie ein hartes Fehlbild).
*/
function hexToRgb(hex: string): RRgb {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex);
if (!m) return WALL_RGB;
const v = parseInt(m[1], 16);
return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255];
}
/**
* Hängt einen Wand-Teilquader an: das Achsenstück [from..to] (Meter ab
* Wand-Startpunkt) über den Höhenbereich [zBottom..zTop]. Entartete Stücke
@@ -71,6 +99,7 @@ function pushSegment(
zBottom: number,
zTop: number,
thickness: number,
layers: RLayer[],
): void {
if (to - from <= EPS) return;
if (zTop - zBottom <= EPS) return;
@@ -89,16 +118,27 @@ function pushSegment(
height: zTop - zBottom,
baseElevation: zBottom,
color: WALL_RGB,
layers,
});
}
/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */
function emitWall(out: RWall[], project: Project, wall: Wall): void {
let thickness = 0.2;
// Aufgelöster Schichtaufbau (Dicke + 3D-Farbe je Schicht des WallType) —
// leer, wenn kein WallType auflösbar ist (render3d extrudiert dann wie
// bisher einen Vollkörper aus `thickness`/`color`, siehe RWall-Moduldoc).
let layers: RLayer[] = [];
try {
thickness = wallTypeThickness(getWallType(project, wall));
const wt = getWallType(project, wall);
thickness = wallTypeThickness(wt);
layers = wt.layers.map((l) => ({
thickness: l.thickness,
color: hexToRgb(getComponent(project, l.componentId).color),
}));
} catch {
thickness = 0.2;
layers = [];
}
const { zBottom, zTop } = wallVerticalExtent(project, wall);
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
@@ -128,7 +168,7 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void {
// Ohne Aussparungen: ein durchgehender Quader (wie bisher).
if (cutouts.length === 0) {
pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness);
pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness, layers);
return;
}
@@ -140,23 +180,23 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void {
const segFrom = Math.max(cursor, from);
if (from > cursor) {
// Voller Wandpfeiler bis zur Aussparung.
pushSegment(out, wall, cursor, from, zBottom, zTop, thickness);
pushSegment(out, wall, cursor, from, zBottom, zTop, thickness, layers);
}
if (to > segFrom) {
// 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);
pushSegment(out, wall, segFrom, to, zBottom, oBottom, thickness, layers);
}
// Sturz über der Öffnung (bis zum Wandkopf).
if (oTop < zTop - EPS) {
pushSegment(out, wall, segFrom, to, oTop, zTop, thickness);
pushSegment(out, wall, segFrom, to, oTop, zTop, thickness, layers);
}
}
cursor = Math.max(cursor, to);
}
// Restlicher Wandpfeiler bis zum Achsenende.
if (cursor < axisLen) {
pushSegment(out, wall, cursor, axisLen, zBottom, zTop, thickness);
pushSegment(out, wall, cursor, axisLen, zBottom, zTop, thickness, layers);
}
}