Schnittebenen 2D↔3D Phase 1+2, native Fenster, Hell/Dunkel-Theme, SWISSIMAGE-Import
- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht, unsichtbare Ebenen blenden ihre Führungslinie aus - Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/ Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste (AppMenuBar) neben der Wortmarke - Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert, flackerfrei vor erstem Render gesetzt - SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene - UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access- Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/ Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt - Neues Dachziegel-Material (RoofingTiles013A)
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region).",
|
||||
"windows": ["main"],
|
||||
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region). Menu: fuer die native macOS-Systemmenueleiste (src/native/appMenu.ts). Webview/Event: fuer die nativen Ressourcen-/Einstellungs-/Zeichnungsebenen-/Ebeneneinstellungs-Fenster + ihre Sync-Bruecken (src/native/*Window.ts) - gilt fuer alle Fensterlabels, da jedes Events senden/empfangen muss.",
|
||||
"windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
@@ -12,6 +12,10 @@
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:menu:default",
|
||||
"core:event:default",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-write-text-file"
|
||||
]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 103 B After Width: | Height: | Size: 12 KiB |
+159
-26
@@ -91,11 +91,21 @@ fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
/// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc).
|
||||
///
|
||||
/// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment.
|
||||
/// Leerer Vektor bei leerem/degeneriertem Mesh.
|
||||
pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
||||
/// Eine Kante samt ihrer deduplizierten Repraesentanten-Normalen (Rueckseiten
|
||||
/// bereits gepaart, s. `representatives`). Grundlage BEIDER Kanten-Stile: die
|
||||
/// statischen Feature-Edges (`build_mesh_edges`) UND die blickabhaengigen
|
||||
/// Silhouetten-Konturen (`build_silhouette_edges`).
|
||||
pub struct EdgeAdj {
|
||||
pub p0: [f32; 3],
|
||||
pub p1: [f32; 3],
|
||||
/// Flaechennormalen der (max. 2) angrenzenden Original-Dreiecke.
|
||||
pub reps: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
/// Baut die Kanten-Adjazenz aus dem Mesh: je eindeutiger Kante die Endpunkte +
|
||||
/// die deduplizierten Repraesentanten-Normalen. GPU-frei; einmalig bei `set_model`
|
||||
/// berechnet und gecacht (die Silhouette wird daraus je Blickwinkel neu abgeleitet).
|
||||
pub fn build_edge_adjacency(mesh: &Mesh) -> Vec<EdgeAdj> {
|
||||
if mesh.indices.len() < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -137,20 +147,83 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut verts: Vec<f32> = Vec::new();
|
||||
let mut push = |p: [f32; 3]| {
|
||||
verts.push(p[0]);
|
||||
verts.push(p[1]);
|
||||
verts.push(p[2]);
|
||||
verts.push(EDGE_COLOR[0]);
|
||||
verts.push(EDGE_COLOR[1]);
|
||||
verts.push(EDGE_COLOR[2]);
|
||||
};
|
||||
edges
|
||||
.into_values()
|
||||
.map(|rec| EdgeAdj {
|
||||
p0: rec.p0,
|
||||
p1: rec.p1,
|
||||
reps: representatives(&rec.normals),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
for rec in edges.values() {
|
||||
if should_draw(&rec.normals) {
|
||||
push(rec.p0);
|
||||
push(rec.p1);
|
||||
fn push_edge(verts: &mut Vec<f32>, p0: [f32; 3], p1: [f32; 3]) {
|
||||
for p in [p0, p1] {
|
||||
verts.extend_from_slice(&[p[0], p[1], p[2], EDGE_COLOR[0], EDGE_COLOR[1], EDGE_COLOR[2]]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc).
|
||||
///
|
||||
/// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment.
|
||||
/// Leerer Vektor bei leerem/degeneriertem Mesh.
|
||||
pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
||||
let adj = build_edge_adjacency(mesh);
|
||||
let mut verts: Vec<f32> = Vec::new();
|
||||
for a in &adj {
|
||||
if draws_crease(&a.reps) {
|
||||
push_edge(&mut verts, a.p0, a.p1);
|
||||
}
|
||||
}
|
||||
verts
|
||||
}
|
||||
|
||||
/// Blickabhaengige Silhouetten-Konturen aus der gecachten Kanten-Adjazenz: gezeichnet
|
||||
/// werden Randkanten (nur ein Dreieck) UND echte Silhouetten-Kanten — solche, deren
|
||||
/// zwei angrenzende Flaechen aus Sicht der Kamera GEGENSAETZLICH orientiert sind
|
||||
/// (eine vorder-, eine rueckseitig). Ergebnis ist der reine Umriss (Gipsmodell-/
|
||||
/// Konturen-Look) ohne die inneren Knickkanten (Dachgrate). `eye` = Kameraposition
|
||||
/// (world), `forward` = Blickrichtung (world, von der Kamera ins Bild); `perspective`
|
||||
/// waehlt die pro-Kante-Blickrichtung (Perspektive) vs. die konstante (ortho).
|
||||
pub fn build_silhouette_edges(
|
||||
adj: &[EdgeAdj],
|
||||
eye: [f32; 3],
|
||||
forward: [f32; 3],
|
||||
perspective: bool,
|
||||
) -> Vec<f32> {
|
||||
let mut verts: Vec<f32> = Vec::new();
|
||||
for a in adj {
|
||||
let draw = match a.reps.len() {
|
||||
0 => false,
|
||||
1 => true, // offene Randkante -> immer Teil der Kontur
|
||||
_ => {
|
||||
let mid = [
|
||||
(a.p0[0] + a.p1[0]) * 0.5,
|
||||
(a.p0[1] + a.p1[1]) * 0.5,
|
||||
(a.p0[2] + a.p1[2]) * 0.5,
|
||||
];
|
||||
// Blickvektor von der Flaeche ZUR Kamera. Perspektive: eye - mid;
|
||||
// ortho: entgegen der Blickrichtung (konstante Kamera).
|
||||
let view = if perspective {
|
||||
sub(eye, mid)
|
||||
} else {
|
||||
[-forward[0], -forward[1], -forward[2]]
|
||||
};
|
||||
let mut front = false;
|
||||
let mut back = false;
|
||||
for n in &a.reps {
|
||||
let d = dot(*n, view);
|
||||
if d > 0.0 {
|
||||
front = true;
|
||||
} else if d < 0.0 {
|
||||
back = true;
|
||||
}
|
||||
}
|
||||
front && back // Silhouette: eine Flaeche vorder-, eine rueckseitig
|
||||
}
|
||||
};
|
||||
if draw {
|
||||
push_edge(&mut verts, a.p0, a.p1);
|
||||
}
|
||||
}
|
||||
verts
|
||||
@@ -171,15 +244,18 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
||||
/// nur EINEN Vertreter je Original-Dreieck behalten — danach greift dieselbe
|
||||
/// Rand-/Knick-Logik wie bei einseitigen Meshes (Waende), unabhaengig davon,
|
||||
/// ob doppelseitig gerendert wurde oder nicht.
|
||||
fn should_draw(normals: &[[f32; 3]]) -> bool {
|
||||
let mut representatives: Vec<[f32; 3]> = Vec::new();
|
||||
/// Dedupliziert die angrenzenden Normalen zu Repraesentanten: paart die von der
|
||||
/// doppelseitigen Emission stammenden Rueckseiten (dot ≈ -1) und behaelt je
|
||||
/// Original-Dreieck EINEN Vertreter (s. Moduldoc `should_draw`-Historie).
|
||||
fn representatives(normals: &[[f32; 3]]) -> Vec<[f32; 3]> {
|
||||
let mut reps: Vec<[f32; 3]> = Vec::new();
|
||||
let mut used = vec![false; normals.len()];
|
||||
for i in 0..normals.len() {
|
||||
if used[i] {
|
||||
continue;
|
||||
}
|
||||
used[i] = true;
|
||||
representatives.push(normals[i]);
|
||||
reps.push(normals[i]);
|
||||
for j in (i + 1)..normals.len() {
|
||||
if used[j] {
|
||||
continue;
|
||||
@@ -190,14 +266,20 @@ fn should_draw(normals: &[[f32; 3]]) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
match representatives.len() {
|
||||
reps
|
||||
}
|
||||
|
||||
/// Entscheidet fuer die STATISCHEN Feature-Edges, ob eine Kante gezeichnet wird:
|
||||
/// Randkante (ein Repraesentant) oder Knickkante (zwei stehen ueber dem Crease-
|
||||
/// Winkel zueinander). Koplanare geteilte Kanten (Flaechendiagonalen) fallen weg.
|
||||
fn draws_crease(reps: &[[f32; 3]]) -> bool {
|
||||
match reps.len() {
|
||||
0 => false,
|
||||
1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck
|
||||
_ => {
|
||||
// Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt.
|
||||
for i in 0..representatives.len() {
|
||||
for j in (i + 1)..representatives.len() {
|
||||
if dot(representatives[i], representatives[j]) < CREASE_COS {
|
||||
for i in 0..reps.len() {
|
||||
for j in (i + 1)..reps.len() {
|
||||
if dot(reps[i], reps[j]) < CREASE_COS {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -277,6 +359,57 @@ mod tests {
|
||||
let m = Mesh::default();
|
||||
assert!(build_mesh_edges(&m).is_empty());
|
||||
}
|
||||
|
||||
/// Ein geschlossener Wuerfel: 8 Ecken, 12 Dreiecke (6 Flaechen, aussen).
|
||||
fn unit_cube() -> Mesh {
|
||||
let c = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
let idx: Vec<u32> = vec![
|
||||
0,3,2, 0,2,1, // -Z
|
||||
4,5,6, 4,6,7, // +Z
|
||||
0,1,5, 0,5,4, // -Y
|
||||
3,7,6, 3,6,2, // +Y
|
||||
0,4,7, 0,7,3, // -X
|
||||
1,2,6, 1,6,5, // +X
|
||||
];
|
||||
mesh(&c, &idx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_feature_edges_are_twelve() {
|
||||
// Statische Feature-Edges: die 12 Wuerfelkanten (Flaechendiagonalen weg).
|
||||
let e = build_mesh_edges(&unit_cube());
|
||||
assert_eq!(segment_count(&e), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_silhouette_from_generic_view_is_hexagon() {
|
||||
// Aus einer generischen Ecken-Sicht ist die Silhouette eines Wuerfels ein
|
||||
// Sechseck = 6 Kanten (nur der Umriss, KEINE inneren Knickkanten).
|
||||
let adj = build_edge_adjacency(&unit_cube());
|
||||
let eye = [5.0, 6.0, 7.0];
|
||||
let fwd = normalize(sub([0.5, 0.5, 0.5], eye)); // Blick auf die Mitte
|
||||
let sil = build_silhouette_edges(&adj, eye, fwd, true);
|
||||
assert_eq!(segment_count(&sil), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_silhouette_ortho_is_hexagon_too() {
|
||||
// Auch orthografisch (konstante Blickrichtung) ergibt sich das Sechseck.
|
||||
let adj = build_edge_adjacency(&unit_cube());
|
||||
let fwd = normalize([-1.0, -1.2, -1.4]);
|
||||
let sil = build_silhouette_edges(&adj, [0.0, 0.0, 0.0], fwd, false);
|
||||
assert_eq!(segment_count(&sil), 6);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -12,17 +12,20 @@
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
|
||||
use crate::edges::{
|
||||
build_edge_adjacency, build_mesh_edges, build_silhouette_edges, EdgeAdj, EDGE_FLOATS_PER_VERTEX,
|
||||
};
|
||||
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
||||
use crate::math::{view_projection, Mat4};
|
||||
use crate::mesh::{
|
||||
build_scene_mesh, build_scene_mesh_textured, build_walls_mesh, build_walls_mesh_textured,
|
||||
build_free_wall_end_caps, build_scene_mesh, build_scene_mesh_textured, build_terrain_mesh,
|
||||
build_walls_mesh, build_walls_mesh_textured,
|
||||
};
|
||||
use crate::section::SectionPlane;
|
||||
use crate::section_fill::{
|
||||
build_cut_cap_lines, build_cut_caps, CAP_FLOATS_PER_VERTEX, CUT_LINE_FLOATS_PER_VERTEX,
|
||||
};
|
||||
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
|
||||
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_AERIAL_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
|
||||
use crate::types::{
|
||||
Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX,
|
||||
TEXTURED_FLOATS_PER_VERTEX,
|
||||
@@ -51,6 +54,11 @@ pub enum RenderStyle {
|
||||
/// Modell-Kanten obenauf (wie bei `Hidden`, aber mit Material- statt
|
||||
/// Weiss-Flaechen).
|
||||
ShadedEdges,
|
||||
/// „Nur Konturen" (Gipsmodell-Look): flach-weisse Flaechen wie `Hidden`, aber
|
||||
/// obenauf NUR die blickabhaengige Silhouette (Umriss je Volumen), nicht die
|
||||
/// inneren Knickkanten (Dachgrate). Die Silhouette wird je Kamera aus der
|
||||
/// gecachten Kanten-Adjazenz neu abgeleitet (siehe `edges::build_silhouette_edges`).
|
||||
Contour,
|
||||
}
|
||||
|
||||
impl RenderStyle {
|
||||
@@ -62,6 +70,7 @@ impl RenderStyle {
|
||||
"wireframe" => RenderStyle::Wireframe,
|
||||
"hidden" => RenderStyle::Hidden,
|
||||
"shaded-edges" => RenderStyle::ShadedEdges,
|
||||
"contour" => RenderStyle::Contour,
|
||||
_ => RenderStyle::Shaded,
|
||||
}
|
||||
}
|
||||
@@ -72,7 +81,8 @@ impl RenderStyle {
|
||||
fn mesh_mode(self) -> f32 {
|
||||
match self {
|
||||
RenderStyle::White => 1.0,
|
||||
RenderStyle::Hidden => 2.0,
|
||||
// Contour teilt den flach-weissen Flaechen-Look von Hidden (mode 2).
|
||||
RenderStyle::Hidden | RenderStyle::Contour => 2.0,
|
||||
// Shaded, Textured, Wireframe (Flaechen werden bei Wireframe eh nicht
|
||||
// gezeichnet), ShadedEdges -> Material.
|
||||
_ => 0.0,
|
||||
@@ -98,7 +108,10 @@ impl RenderStyle {
|
||||
/// zeichnen (Flaechen + Kanten) — bislang nur `Hidden`, jetzt auch
|
||||
/// `ShadedEdges`.
|
||||
fn needs_biased_face_pipeline(self) -> bool {
|
||||
matches!(self, RenderStyle::Hidden | RenderStyle::ShadedEdges)
|
||||
matches!(
|
||||
self,
|
||||
RenderStyle::Hidden | RenderStyle::ShadedEdges | RenderStyle::Contour
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +187,19 @@ struct TexturedMeshBuffers {
|
||||
index_count: u32,
|
||||
}
|
||||
|
||||
/// GPU-Ressourcen EINES georeferenzierten Luftbilds (SWISSIMAGE), das aufs Gelaende
|
||||
/// drapiert wird (`gpu::aerial_pipeline`). Haelt Textur + View am Leben und die
|
||||
/// fertige Bind-Group (group 1: Textur + Sampler + Bbox-Uniform).
|
||||
struct AerialBuffers {
|
||||
#[allow(dead_code)]
|
||||
texture: wgpu::Texture,
|
||||
#[allow(dead_code)]
|
||||
view: wgpu::TextureView,
|
||||
#[allow(dead_code)]
|
||||
bbox: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// Kantenlaenge der prozeduralen Test-Textur (Schachbrett), in Texeln.
|
||||
const TEXTURE_SIZE: u32 = 256;
|
||||
|
||||
@@ -268,6 +294,30 @@ pub struct Renderer {
|
||||
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
|
||||
/// Shader/Layout unterscheiden sich.
|
||||
textured_pipeline: wgpu::RenderPipeline,
|
||||
/// Luftbild-Drape-Pipeline (`MESH_AERIAL_WGSL`): zeichnet das Terrain-Overlay
|
||||
/// (`terrain_overlay`) ein zweites Mal mit einer Bild-Textur (group 1). Vertex-
|
||||
/// Layout = Haupt-Mesh-Layout `[pos, normal, color]`. Leichter Depth-Bias zur
|
||||
/// Kamera hin (wie `cap_pipeline`), damit das Overlay das deckungsgleiche
|
||||
/// Terrain im Hauptpuffer ueberlagert statt zu flimmern.
|
||||
aerial_pipeline: wgpu::RenderPipeline,
|
||||
/// Bind-Group-Layout (group 1) des Luftbild-Pfades: Textur + Sampler + Bbox-
|
||||
/// Uniform. Festgehalten fuer den Bind-Group-Neubau in `set_aerial_texture`.
|
||||
aerial_bind_group_layout: wgpu::BindGroupLayout,
|
||||
/// Sampler des Luftbilds (ClampToEdge/Linear — kein Kacheln, weiche Filterung).
|
||||
aerial_sampler: wgpu::Sampler,
|
||||
/// Das aktuell drapierte Luftbild (Textur + Bind-Group). None = kein Luftbild.
|
||||
aerial: Option<AerialBuffers>,
|
||||
/// Nur die Gelaende-Dreiecke (aus `build_terrain_mesh`) als eigener Puffer —
|
||||
/// Grundlage des Luftbild-Overlays. Bei `upload_model` erzeugt; None = kein
|
||||
/// Terrain. Wird nur gezeichnet, wenn zusaetzlich `aerial` gesetzt ist.
|
||||
terrain_overlay: Option<MeshBuffers>,
|
||||
/// Gecachte Kanten-Adjazenz des Modells (bei `upload_*` erzeugt) — Grundlage
|
||||
/// der blickabhaengigen Silhouette (Stil `Contour`). Leer = kein Modell.
|
||||
edge_adj: Vec<EdgeAdj>,
|
||||
/// Zuletzt gebaute Silhouetten-LineList (Stil `Contour`) + die Kamera-Signatur,
|
||||
/// fuer die sie gilt — bei Blickwechsel neu abgeleitet (nicht je Frame blind).
|
||||
silhouette: Option<LineBuffers>,
|
||||
silhouette_key: Option<[i64; 6]>,
|
||||
/// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler.
|
||||
/// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt
|
||||
/// sie das 1-Ebenen-Array mit dem Fallback-Schachbrett.
|
||||
@@ -310,6 +360,12 @@ pub struct Renderer {
|
||||
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
|
||||
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
|
||||
caps: Option<CapBuffers>,
|
||||
/// STATISCHE Schraffur-Kappen an freien Wandenden (`mesh::build_free_wall_end_caps`,
|
||||
/// kein Live-Schnitt noetig — unabhaengig von `caps`/`cut_lines`, die nur bei
|
||||
/// aktivem Live-Schnitt existieren). Bei `upload_model` neu gebaut; None = keine
|
||||
/// Wand hat eine offene, schraffierte Kante. Immer gezeichnet, wenn Flaechen
|
||||
/// gezeichnet werden (nicht an den Live-Schnitt-Zustand gebunden).
|
||||
wall_end_caps: Option<CapBuffers>,
|
||||
/// Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) als Ribbon-TriangleList
|
||||
/// (`[pos, color]`). None = kein Schnitt aktiv / keine Kanten. Ueber
|
||||
/// `set_cut_lines` gesetzt/geloescht, gezeichnet NACH den Caps.
|
||||
@@ -733,6 +789,115 @@ impl Renderer {
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// ── Luftbild-Drape-Pfad (aerial_pipeline) ───────────────────────────────
|
||||
// group 1: EINE 2D-Bild-Textur + Sampler + Bbox-Uniform (16 Byte, vec4).
|
||||
// group 0 (Globals) bleibt geteilt. Vertex-Layout = Haupt-Mesh-Layout
|
||||
// [pos, normal, color], damit der Terrain-Overlay-Puffer direkt passt.
|
||||
let aerial_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("aerial.layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(16),
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let aerial_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("3d.aerial.layout"),
|
||||
bind_group_layouts: &[Some(&bind_group_layout), Some(&aerial_bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let aerial_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("mesh_aerial.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(MESH_AERIAL_WGSL.into()),
|
||||
});
|
||||
let aerial_vertex_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: (FLOATS_PER_VERTEX * 4) as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3],
|
||||
};
|
||||
let aerial_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("mesh.aerial.pipeline"),
|
||||
layout: Some(&aerial_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &aerial_module,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[aerial_vertex_layout],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &aerial_module,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
// Das Overlay nutzt EXAKT dieselbe Terrain-Geometrie wie der
|
||||
// Hauptpuffer -> bit-identische Tiefe. Mit LessEqual gewinnt das
|
||||
// NACH dem Terrain gezeichnete Overlay bei gleicher Tiefe
|
||||
// deterministisch (kein Bias noetig). Ein Slope-Bias wuerde in
|
||||
// ORTHO-Praesets (iso/axo) je nach Dreiecks-Neigung ungleich
|
||||
// schieben und fleckige „Abschnitte" erzeugen — daher KEIN Bias.
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: SAMPLE_COUNT,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
// ClampToEdge (Randpixel ausserhalb der Bbox) + Linear (weiche Filterung).
|
||||
let aerial_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("aerial.sampler"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Prozedurale 256x256-Schachbrett-Textur ANLEGEN (kein Asset). RGBA8-srgb,
|
||||
// damit die im Shader gesampelten Werte im linearen Raum landen (die
|
||||
// Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das
|
||||
@@ -809,6 +974,14 @@ impl Renderer {
|
||||
cap_pipeline,
|
||||
cut_line_pipeline,
|
||||
textured_pipeline,
|
||||
aerial_pipeline,
|
||||
aerial_bind_group_layout,
|
||||
aerial_sampler,
|
||||
aerial: None,
|
||||
terrain_overlay: None,
|
||||
edge_adj: Vec::new(),
|
||||
silhouette: None,
|
||||
silhouette_key: None,
|
||||
texture_bind_group,
|
||||
texture,
|
||||
texture_bind_group_layout,
|
||||
@@ -823,6 +996,7 @@ impl Renderer {
|
||||
grid: None,
|
||||
highlight: None,
|
||||
caps: None,
|
||||
wall_end_caps: None,
|
||||
cut_lines: None,
|
||||
grid_visible: false,
|
||||
style: RenderStyle::Shaded,
|
||||
@@ -859,19 +1033,63 @@ impl Renderer {
|
||||
build_scene_mesh(walls, slabs, meshes),
|
||||
build_scene_mesh_textured(walls, slabs, meshes),
|
||||
);
|
||||
// Terrain-Overlay-Puffer (nur Gelaende-Dreiecke) fuer das Luftbild-Drapieren.
|
||||
let terrain = build_terrain_mesh(meshes);
|
||||
self.terrain_overlay = if terrain.indices.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("terrain.overlay.vbo"),
|
||||
contents: bytemuck::cast_slice(&terrain.verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let ibo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("terrain.overlay.ibo"),
|
||||
contents: bytemuck::cast_slice(&terrain.indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
Some(MeshBuffers {
|
||||
vbo,
|
||||
ibo,
|
||||
index_count: terrain.indices.len() as u32,
|
||||
})
|
||||
};
|
||||
// Schraffur-Kappen an freien (unangeschlossenen) Wandenden — statisch aus
|
||||
// der Wandkonnektivitaet, unabhaengig vom Live-Schnitt-Zustand.
|
||||
let end_caps = build_free_wall_end_caps(walls);
|
||||
self.wall_end_caps = if end_caps.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("wall_end_caps.vbo"),
|
||||
contents: bytemuck::cast_slice(&end_caps),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
Some(CapBuffers {
|
||||
vbo,
|
||||
vertex_count: (end_caps.len() / CAP_FLOATS_PER_VERTEX) as u32,
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und
|
||||
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
|
||||
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
|
||||
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) {
|
||||
// Silhouette-Cache verwerfen (neues Modell) — wird bei Bedarf neu gebaut.
|
||||
self.silhouette = None;
|
||||
self.silhouette_key = None;
|
||||
if mesh.indices.is_empty() {
|
||||
self.mesh = None;
|
||||
self.textured_mesh = None;
|
||||
self.edges = None;
|
||||
self.edge_adj = Vec::new();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kanten-Adjazenz einmalig ableiten: Grundlage der statischen Feature-Edges
|
||||
// UND der blickabhaengigen Silhouette (Stil `Contour`).
|
||||
self.edge_adj = build_edge_adjacency(&mesh);
|
||||
// Kanten aus der Mesh-Geometrie ableiten und (falls vorhanden) hochladen.
|
||||
let edge_verts = build_mesh_edges(&mesh);
|
||||
self.edges = if edge_verts.is_empty() {
|
||||
@@ -1111,6 +1329,111 @@ impl Renderer {
|
||||
/// `WallInput::material_index` (1-basiert) auf seine Ebene; Baender ohne
|
||||
/// Material (`-1`) bleiben beim Schachbrett (Ebene 0). `layer_count == 0` (oder
|
||||
/// leere/zu kleine Daten) setzt auf das reine Schachbrett-Array zurueck.
|
||||
/// Setzt/ersetzt das aufs Gelaende drapierte Luftbild (SWISSIMAGE). `rgba` =
|
||||
/// dicht gepackte RGBA8-Bytes (`width*height*4`, zeilenweise von OBEN = Nord);
|
||||
/// `bbox = [min_x, min_y, max_x, max_y]` die Modell-Meter-Bounding-Box, die das
|
||||
/// Bild ausfuellt (x = Ost/world.x, y = Nord/world.z). Erzeugt Textur + Bind-
|
||||
/// Group (group 1) fuer die `aerial_pipeline`. Ungueltige/leere Eingabe loescht
|
||||
/// das Luftbild. Gezeichnet wird nur, wenn zusaetzlich Terrain vorliegt.
|
||||
pub fn set_aerial_texture(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
rgba: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
bbox: [f32; 4],
|
||||
) {
|
||||
let needed = (width as usize) * (height as usize) * 4;
|
||||
if width == 0 || height == 0 || rgba.len() < needed {
|
||||
self.aerial = None;
|
||||
return;
|
||||
}
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("aerial.texture"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
// srgb: die gesampelten Werte landen linear (Beleuchtung rechnet linear).
|
||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
// write_texture verlangt bytes_per_row als Vielfaches von 256. Bei Bedarf
|
||||
// Zeilen in einen gepolsterten Puffer kopieren (sonst 1:1 hochladen).
|
||||
let unpadded = (width * 4) as usize;
|
||||
let padded = unpadded.div_ceil(256) * 256;
|
||||
let upload: std::borrow::Cow<[u8]> = if padded == unpadded {
|
||||
std::borrow::Cow::Borrowed(&rgba[..needed])
|
||||
} else {
|
||||
let mut buf = vec![0u8; padded * height as usize];
|
||||
for row in 0..height as usize {
|
||||
let src = &rgba[row * unpadded..row * unpadded + unpadded];
|
||||
buf[row * padded..row * padded + unpadded].copy_from_slice(src);
|
||||
}
|
||||
std::borrow::Cow::Owned(buf)
|
||||
};
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
&upload,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded as u32),
|
||||
rows_per_image: Some(height),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let bbox_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("aerial.bbox"),
|
||||
contents: bytemuck::cast_slice(&bbox),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("aerial.bind"),
|
||||
layout: &self.aerial_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.aerial_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: bbox_buf.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.aerial = Some(AerialBuffers {
|
||||
texture,
|
||||
view,
|
||||
bbox: bbox_buf,
|
||||
bind_group,
|
||||
});
|
||||
}
|
||||
|
||||
/// Loescht das drapierte Luftbild (das Gelaende erscheint wieder einfarbig/shaded).
|
||||
pub fn clear_aerial(&mut self) {
|
||||
self.aerial = None;
|
||||
}
|
||||
|
||||
pub fn set_material_textures(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -1307,6 +1630,51 @@ impl Renderer {
|
||||
|
||||
self.ensure_depth(device, w, h);
|
||||
self.ensure_msaa(device, w, h);
|
||||
|
||||
// Silhouette (Stil `Contour`) je Blickwinkel neu ableiten — aber nur, wenn
|
||||
// sich die Kamera merklich bewegt hat (Signatur-Cache), nicht blind je Frame.
|
||||
if self.style == RenderStyle::Contour && !self.edge_adj.is_empty() {
|
||||
let eye = camera.eye;
|
||||
let mut fwd = [
|
||||
camera.target[0] - eye[0],
|
||||
camera.target[1] - eye[1],
|
||||
camera.target[2] - eye[2],
|
||||
];
|
||||
let fl = (fwd[0] * fwd[0] + fwd[1] * fwd[1] + fwd[2] * fwd[2]).sqrt();
|
||||
if fl > 1e-6 {
|
||||
fwd = [fwd[0] / fl, fwd[1] / fl, fwd[2] / fl];
|
||||
}
|
||||
let perspective = matches!(camera.projection, crate::types::Projection::Perspective);
|
||||
// Signatur: Auge (0.01 m) + Blickrichtung (0.001). Perspektive haengt
|
||||
// von der Auge-Position ab, Ortho nur von der Richtung — beide erfasst.
|
||||
let q = |v: f32, s: f32| (v * s).round() as i64;
|
||||
let key = [
|
||||
q(eye[0], 100.0),
|
||||
q(eye[1], 100.0),
|
||||
q(eye[2], 100.0),
|
||||
q(fwd[0], 1000.0),
|
||||
q(fwd[1], 1000.0),
|
||||
q(fwd[2], 1000.0),
|
||||
];
|
||||
if self.silhouette.is_none() || self.silhouette_key != Some(key) {
|
||||
let verts = build_silhouette_edges(&self.edge_adj, eye, fwd, perspective);
|
||||
self.silhouette = if verts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("silhouette.vbo"),
|
||||
contents: bytemuck::cast_slice(&verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
Some(LineBuffers {
|
||||
vbo,
|
||||
vertex_count: (verts.len() / EDGE_FLOATS_PER_VERTEX) as u32,
|
||||
})
|
||||
};
|
||||
self.silhouette_key = Some(key);
|
||||
}
|
||||
}
|
||||
|
||||
let depth_view = &self.depth.as_ref().unwrap().view;
|
||||
let msaa_view = &self.msaa.as_ref().unwrap().view;
|
||||
|
||||
@@ -1375,6 +1743,38 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
// 1a) Luftbild-Drape: das Terrain-Overlay ein zweites Mal texturiert
|
||||
// ueber das (deckungsgleiche) Terrain im Hauptpuffer zeichnen. Nur
|
||||
// wenn Flaechen gezeichnet werden, ein Luftbild gesetzt ist UND ein
|
||||
// Terrain-Overlay vorliegt. Der Depth-Bias der Pipeline haelt es vor
|
||||
// dem Terrain (kein Z-Fighting). group 1 = Bild-Textur + Bbox.
|
||||
if self.style.draws_faces() {
|
||||
if let (Some(a), Some(t)) = (&self.aerial, &self.terrain_overlay) {
|
||||
pass.set_pipeline(&self.aerial_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_bind_group(1, &a.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, t.vbo.slice(..));
|
||||
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..t.index_count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
// 1a2) Schraffur-Kappen an freien Wandenden (STATISCH, unabhaengig vom
|
||||
// Live-Schnitt-Zustand — `mesh::build_free_wall_end_caps`): ein
|
||||
// unangeschlossenes Wandende zeigt die Bauteil-Schraffur statt der
|
||||
// Flaechenfarbe, wie ein echter Schnitt. Dieselbe `cap_pipeline`
|
||||
// (Bias zur Kamera) wie die Live-Schnitt-Kappen — sitzt exakt auf
|
||||
// der (weiterhin vorhandenen) flach eingefaerbten End-Kappe im
|
||||
// Hauptpuffer und gewinnt den Tiefentest.
|
||||
if self.style.draws_faces() {
|
||||
if let Some(wc) = &self.wall_end_caps {
|
||||
pass.set_pipeline(&self.cap_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, wc.vbo.slice(..));
|
||||
pass.draw(0..wc.vertex_count, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
// 1b) Schnittflaechen-Kappen (Cut-Caps) NACH den Flaechen, VOR den
|
||||
// Kanten: sie fuellen die an der Schnittebene weggeschnittene Wunde
|
||||
// mit der Diagonalschraffur (CAP_WGSL). Werden selbst NICHT gekappt.
|
||||
@@ -1411,6 +1811,19 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
// 2b) „Nur Konturen" (Stil Contour): die blickabhaengige Silhouette
|
||||
// obenauf (tiefengetestet gegen die gebiasten Flaechen -> nur
|
||||
// sichtbare Umrisslinien). Gleiche LineList-Pipeline wie die
|
||||
// Feature-Edges, aber der reduzierte Kantensatz.
|
||||
if self.style == RenderStyle::Contour {
|
||||
if let Some(s) = &self.silhouette {
|
||||
pass.set_pipeline(&self.grid_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, s.vbo.slice(..));
|
||||
pass.draw(0..s.vertex_count, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Bodengitter NACH dem Modell zeichnen (gemeinsamer Tiefenpuffer ->
|
||||
// das Modell verdeckt das Gitter korrekt). Unabhaengig vom Stil, nur
|
||||
// wenn sichtbar + vorhanden.
|
||||
|
||||
@@ -55,10 +55,13 @@ pub use types::{
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mesh::{
|
||||
build_walls_mesh, build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
|
||||
build_free_wall_end_caps, build_scene_mesh_textured, build_walls_mesh,
|
||||
build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
|
||||
};
|
||||
use super::section_fill::CAP_FLOATS_PER_VERTEX;
|
||||
use super::types::{
|
||||
Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
Hatch, Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX,
|
||||
TEXTURED_FLOATS_PER_VERTEX,
|
||||
};
|
||||
|
||||
/// Bequemer Bau einer achsparallelen Wand entlang +X.
|
||||
@@ -161,6 +164,65 @@ mod tests {
|
||||
assert!((max_idx as usize) < mesh.vertex_count());
|
||||
}
|
||||
|
||||
/// `wall_x` mit gesetzter Bauteil-Schraffur (Voraussetzung fuer die Kappen).
|
||||
fn wall_x_hatched(len: f32, thickness: f32, height: f32) -> WallInput {
|
||||
WallInput {
|
||||
hatch: Some(Hatch { pattern: 3, angle: 45.0, scale: 1.0, line_weight: 0.13 }),
|
||||
..wall_x(len, thickness, height)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolierte_wand_ohne_hatch_bekommt_keine_kappen() {
|
||||
// Bestehende Test-Fixtures (kein `hatch`) bleiben unangetastet: keine
|
||||
// Kappen-Geometrie, unabhaengig davon, dass beide Enden frei sind.
|
||||
let caps = build_free_wall_end_caps(&[wall_x(3.0, 0.2, 2.5)]);
|
||||
assert!(caps.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolierte_wand_mit_hatch_bekommt_kappen_an_beiden_freien_enden() {
|
||||
// Eine einzelne Wand hat KEINE Nachbarn -> beide Achsenenden sind frei ->
|
||||
// je Ende eine Schraffur-Kappe (1 Schicht x 2 Dreiecke x 3 Vertices = 6
|
||||
// Vertices je Ende, 12 insgesamt).
|
||||
let caps = build_free_wall_end_caps(&[wall_x_hatched(3.0, 0.2, 2.5)]);
|
||||
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 12);
|
||||
// Jeder Kappen-Vertex traegt die Schraffur-Parameter (Muster 3 = crosshatch).
|
||||
for v in caps.chunks_exact(CAP_FLOATS_PER_VERTEX) {
|
||||
assert_eq!(v[5], 3.0, "pattern");
|
||||
assert!((v[6] - 45.0_f32.to_radians()).abs() < 1e-5, "angle_rad");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbundene_wandenden_bekommen_keine_kappe_dort() {
|
||||
// Zwei Waende treffen sich rechtwinklig bei (3,0) -> DIESES Ende ist bei
|
||||
// beiden Waenden NICHT frei (Gehrung/Anschluss), die beiden anderen
|
||||
// (freien) Enden bekommen weiterhin je eine Kappe -> 2 Kappen gesamt
|
||||
// (nicht 4), macht 12 Vertices statt 24.
|
||||
let a = wall_x_hatched(3.0, 0.2, 2.5); // (0,0) frei .. (3,0) verbunden
|
||||
let b = WallInput {
|
||||
start: [3.0, 0.0],
|
||||
end: [3.0, 4.0],
|
||||
..wall_x_hatched(4.0, 0.2, 2.5) // (3,0) verbunden .. (3,4) frei
|
||||
};
|
||||
let caps = build_free_wall_end_caps(&[a, b]);
|
||||
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 12, "nur die 2 freien Enden -> 2 Kappen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_stoss_bekommt_ebenfalls_keine_kappe_am_knoten() {
|
||||
// Drei Waende treffen sich am selben Punkt (T-Stoss, Gehrung bleibt
|
||||
// rechtwinklig, s. compute_wall_miters) -> trotzdem gilt der Knoten als
|
||||
// "beruehrt", KEINE der drei dortigen Enden bekommt eine Kappe.
|
||||
let a = wall_x_hatched(3.0, 0.2, 2.5); // endet bei (3,0)
|
||||
let b = WallInput { start: [3.0, 0.0], end: [3.0, 4.0], ..wall_x_hatched(4.0, 0.2, 2.5) };
|
||||
let c = WallInput { start: [3.0, 0.0], end: [6.0, 0.0], ..wall_x_hatched(3.0, 0.2, 2.5) };
|
||||
let caps = build_free_wall_end_caps(&[a, b, c]);
|
||||
// Freie Enden: a.start(0,0), b.end(3,4), c.end(6,0) -> 3 Kappen (nicht 6).
|
||||
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mehrere_waende_addieren_sich() {
|
||||
let mesh = build_walls_mesh(&[
|
||||
@@ -840,6 +902,8 @@ mod tests {
|
||||
indices: vec![0, 1, 2],
|
||||
kind,
|
||||
color,
|
||||
material_index: None,
|
||||
vertex_colors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,6 +945,73 @@ mod tests {
|
||||
assert_eq!([mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]], [0.1, 0.2, 0.3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_mesh_pro_vertex_farbe_wird_uebernommen() {
|
||||
// Luftbild-Drape: jeder Vertex traegt eine eigene Farbe. Wird 1:1 an die
|
||||
// Ausgabe-Vertices durchgereicht (statt der Einzelfarbe).
|
||||
let mut mesh = Mesh::default();
|
||||
append_context_mesh(
|
||||
&mut mesh,
|
||||
&MeshInput {
|
||||
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||
indices: vec![0, 1, 2],
|
||||
kind: MeshKind::Terrain,
|
||||
color: None,
|
||||
material_index: None,
|
||||
vertex_colors: vec![
|
||||
1.0, 0.0, 0.0, // Vertex 0 rot
|
||||
0.0, 1.0, 0.0, // Vertex 1 gruen
|
||||
0.0, 0.0, 1.0, // Vertex 2 blau
|
||||
],
|
||||
},
|
||||
);
|
||||
// Vorderseite = erste 3 Vertices (Winding a,b,c): Farben 0,1,2.
|
||||
assert_eq!([mesh.verts[6], mesh.verts[7], mesh.verts[8]], [1.0, 0.0, 0.0]);
|
||||
assert_eq!([mesh.verts[15], mesh.verts[16], mesh.verts[17]], [0.0, 1.0, 0.0]);
|
||||
assert_eq!([mesh.verts[24], mesh.verts[25], mesh.verts[26]], [0.0, 0.0, 1.0]);
|
||||
// Zu kurzes vertex_colors-Array -> Einzelfarbe (kein Panic, kein Teilbezug).
|
||||
let mut mesh2 = Mesh::default();
|
||||
append_context_mesh(
|
||||
&mut mesh2,
|
||||
&MeshInput {
|
||||
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||
indices: vec![0, 1, 2],
|
||||
kind: MeshKind::Terrain,
|
||||
color: None,
|
||||
material_index: None,
|
||||
vertex_colors: vec![1.0, 0.0, 0.0], // nur 1 Vertex -> ignoriert
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
[mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]],
|
||||
MeshKind::Terrain.default_color()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kontext_mesh_materialindex_wandert_in_die_textured_layer() {
|
||||
// Daecher (und potenziell andere Kontext-Meshes) koennen jetzt einen
|
||||
// Material-Textur-Index tragen (`MeshInput::material_index`) — vorher
|
||||
// war er fuer ALLE Kontext-Meshes hart auf -1 (Schachbrett) gesetzt, der
|
||||
// Stil „Textured" konnte also nie eine echte Dachziegel-Textur zeigen.
|
||||
let m = MeshInput {
|
||||
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||
indices: vec![0, 1, 2],
|
||||
kind: MeshKind::Extrusion,
|
||||
color: None,
|
||||
material_index: Some(2),
|
||||
vertex_colors: Vec::new(),
|
||||
};
|
||||
let tex = build_scene_mesh_textured(&[], &[], std::slice::from_ref(&m));
|
||||
assert!(!tex.layers.is_empty());
|
||||
assert!(tex.layers.iter().all(|&l| l == 2.0), "alle Vertices Ebene 2");
|
||||
|
||||
// Ohne material_index bleibt der Fallback (-1 -> Schachbrett).
|
||||
let m2 = MeshInput { material_index: None, ..m };
|
||||
let tex2 = build_scene_mesh_textured(&[], &[], std::slice::from_ref(&m2));
|
||||
assert!(tex2.layers.iter().all(|&l| l == -1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() {
|
||||
let mut mesh = Mesh::default();
|
||||
@@ -892,6 +1023,8 @@ mod tests {
|
||||
indices: vec![0, 1, 9],
|
||||
kind: MeshKind::Imported,
|
||||
color: None,
|
||||
material_index: None,
|
||||
vertex_colors: Vec::new(),
|
||||
},
|
||||
);
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
@@ -903,6 +1036,8 @@ mod tests {
|
||||
indices: vec![0, 1, 2],
|
||||
kind: MeshKind::Imported,
|
||||
color: None,
|
||||
material_index: None,
|
||||
vertex_colors: Vec::new(),
|
||||
},
|
||||
);
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
@@ -1078,5 +1213,14 @@ mod tests {
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&tex_module)
|
||||
.unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
|
||||
// Luftbild-Drape-Shader (Terrain-Overlay, Bild-Textur + Bbox-Uniform in
|
||||
// group 1) ebenso headless validieren.
|
||||
let aerial_src = super::shaders::MESH_AERIAL_WGSL;
|
||||
let aerial_module = naga::front::wgsl::parse_str(aerial_src)
|
||||
.unwrap_or_else(|e| panic!("aerial: WGSL-Parse-Fehler: {e:?}"));
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&aerial_module)
|
||||
.unwrap_or_else(|e| panic!("aerial: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,6 +835,160 @@ fn compute_wall_miters(walls: &[WallInput]) -> Vec<WallMiters> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Ermittelt je Wand, ob ihr Start-/Endpunkt ein FREIES Ende ist — d. h. KEIN
|
||||
/// anderes Wandende (auch kein T-/X-Stoss) trifft sich dort im selben
|
||||
/// Hoehenbereich. Dieselbe Punkt-Rundung + Hoehen-Ueberlappungs-Clustering wie
|
||||
/// `compute_wall_miters`, aber OHNE dessen Zwei-Wandenden-Einschraenkung: jeder
|
||||
/// Cluster der Groesse ≥ 2 (Gehrung ODER T-/X-Stoss) gilt als "beruehrt", nur
|
||||
/// ein isolierter Cluster der Groesse 1 (nur die Wand selbst) bleibt frei.
|
||||
/// Grundlage der Schraffur-Kappen an unverbundenen Wandenden
|
||||
/// (`build_free_wall_end_caps`) — ein Wandende "ohne Anschluss" (Nutzer-Begriff)
|
||||
/// zeigt dort die Bauteil-Schraffur statt der Flaechenfarbe, wie ein echter
|
||||
/// Schnitt.
|
||||
fn compute_free_wall_ends(walls: &[WallInput]) -> Vec<(bool, bool)> {
|
||||
let mut out = vec![(true, true); walls.len()];
|
||||
|
||||
let mut ends: Vec<EndRef> = Vec::with_capacity(walls.len() * 2);
|
||||
for (i, w) in walls.iter().enumerate() {
|
||||
let n = left_normal(w.start, w.end);
|
||||
if n == [0.0, 0.0] {
|
||||
continue; // entartete Wand: weder Ende gilt als "frei" (irrelevant)
|
||||
}
|
||||
let dir: Point2 = [n[1], -n[0]];
|
||||
let z0 = w.base_elevation;
|
||||
let z1 = w.base_elevation + w.height;
|
||||
ends.push(EndRef { wall: i, is_start: true, point: w.start, dir, z0, z1 });
|
||||
ends.push(EndRef { wall: i, is_start: false, point: w.end, dir, z0, z1 });
|
||||
}
|
||||
|
||||
let mut groups: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
|
||||
for (idx, e) in ends.iter().enumerate() {
|
||||
groups.entry(round_key(e.point)).or_default().push(idx);
|
||||
}
|
||||
|
||||
for members in groups.into_values() {
|
||||
if members.len() < 2 {
|
||||
continue; // einziges Ende an diesem Punkt -> bleibt frei (Default)
|
||||
}
|
||||
for cluster in cluster_by_height_overlap(&ends, &members) {
|
||||
if cluster.len() < 2 {
|
||||
continue; // eigener, hoehenmaessig isolierter Cluster -> frei
|
||||
}
|
||||
for &idx in &cluster {
|
||||
let e = &ends[idx];
|
||||
if e.is_start {
|
||||
out[e.wall].0 = false;
|
||||
} else {
|
||||
out[e.wall].1 = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Haengt EINE Schraffur-Kappe (alle Schicht-Baender gestapelt) an einem
|
||||
/// Wand-Achsenende an `verts` an — Vertex-Format identisch zu
|
||||
/// `section_fill::build_cut_caps` (`CAP_FLOATS_PER_VERTEX`), damit dieselbe
|
||||
/// `cap_pipeline`/`CAP_WGSL` sie zeichnen kann. `(u,v)` = (Quer-Versatz `s` zur
|
||||
/// Wandachse, Welt-Hoehe) — metrisch, an den Schicht-Grenzen stetig.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn push_wall_end_cap(
|
||||
verts: &mut Vec<f32>,
|
||||
point: Point2,
|
||||
n: Point2,
|
||||
layers: &[WallLayer],
|
||||
total: f32,
|
||||
y0: f32,
|
||||
y1: f32,
|
||||
pattern: f32,
|
||||
angle_rad: f32,
|
||||
scale: f32,
|
||||
line_weight: f32,
|
||||
) {
|
||||
let corner = |s: f32, y: f32| -> [f32; 9] {
|
||||
let p = [point[0] + n[0] * s, point[1] + n[1] * s];
|
||||
[p[0], y, p[1], s, y, pattern, angle_rad, scale, line_weight]
|
||||
};
|
||||
let mut off_a = total * 0.5;
|
||||
for layer in layers {
|
||||
let off_b = off_a - layer.thickness;
|
||||
let c0 = corner(off_a, y0);
|
||||
let c1 = corner(off_b, y0);
|
||||
let c2 = corner(off_b, y1);
|
||||
let c3 = corner(off_a, y1);
|
||||
// Fan-Triangulierung des Quads (Winding egal, cap_pipeline kennt kein
|
||||
// Backface-Culling — siehe section_fill.rs-Moduldoc).
|
||||
verts.extend_from_slice(&c0);
|
||||
verts.extend_from_slice(&c1);
|
||||
verts.extend_from_slice(&c2);
|
||||
verts.extend_from_slice(&c0);
|
||||
verts.extend_from_slice(&c2);
|
||||
verts.extend_from_slice(&c3);
|
||||
off_a = off_b;
|
||||
}
|
||||
}
|
||||
|
||||
/// Schraffur-Kappen an FREIEN Wandenden (kein Anschluss an eine andere Wand,
|
||||
/// s. `compute_free_wall_ends`): additive, ZWEITE Geometrie neben der
|
||||
/// (unveraendert weiterhin flach eingefaerbten) End-Kappe der normalen
|
||||
/// Extrusion — gezeichnet mit Tiefen-Bias VOR der Flaeche (analog dem
|
||||
/// Luftbild-Overlay `gpu::aerial_pipeline`), sodass sie diese optisch
|
||||
/// ueberdeckt, OHNE `extrude_layer_segment` selbst anzufassen (kein
|
||||
/// Regressionsrisiko fuer bestehende Vollkoerper-Tests). Nur Waende MIT
|
||||
/// explizit gesetzter `hatch` (Bauteil-Schraffur, `toWalls3d.ts` loest sie je
|
||||
/// Materialschicht auf) bekommen eine Kappe — ohne `hatch` (z. B. alle
|
||||
/// bestehenden Test-Fixtures) bleibt das Alt-Verhalten unangetastet. Bildet NUR
|
||||
/// die echten Achsen-Enden ab (Wandanfang/-ende), keine Oeffnungs-Laibungen —
|
||||
/// "Wand endet an Decke" (vertikale Terminierung) ist bewusst NICHT Teil dieser
|
||||
/// Kappen (separates, noch offenes Thema).
|
||||
pub fn build_free_wall_end_caps(walls: &[WallInput]) -> Vec<f32> {
|
||||
let free_ends = compute_free_wall_ends(walls);
|
||||
let mut verts: Vec<f32> = Vec::new();
|
||||
|
||||
for (wi, wall) in walls.iter().enumerate() {
|
||||
let Some(hatch) = wall.hatch else { continue };
|
||||
let (free_start, free_end) = free_ends[wi];
|
||||
if !free_start && !free_end {
|
||||
continue;
|
||||
}
|
||||
let n = left_normal(wall.start, wall.end);
|
||||
if n == [0.0, 0.0] || wall.height <= 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let y0 = wall.base_elevation;
|
||||
let y1 = wall.base_elevation + wall.height;
|
||||
|
||||
let synthetic;
|
||||
let layers: &[WallLayer] = match &wall.layers {
|
||||
Some(ls) if !ls.is_empty() => ls.as_slice(),
|
||||
_ => {
|
||||
synthetic = [WallLayer { thickness: wall.thickness, color: wall.color }];
|
||||
&synthetic
|
||||
}
|
||||
};
|
||||
let total: f32 = layers.iter().map(|l| l.thickness).sum();
|
||||
let pattern = hatch.pattern as f32;
|
||||
let angle_rad = hatch.angle.to_radians();
|
||||
let scale = hatch.scale.max(0.05);
|
||||
let line_weight = hatch.line_weight.max(0.0);
|
||||
|
||||
if free_start {
|
||||
push_wall_end_cap(
|
||||
&mut verts, wall.start, n, layers, total, y0, y1, pattern, angle_rad, scale,
|
||||
line_weight,
|
||||
);
|
||||
}
|
||||
if free_end {
|
||||
push_wall_end_cap(
|
||||
&mut verts, wall.end, n, layers, total, y0, y1, pattern, angle_rad, scale,
|
||||
line_weight,
|
||||
);
|
||||
}
|
||||
}
|
||||
verts
|
||||
}
|
||||
|
||||
/// Grundriss-Fussabdruck EINER Wand (die vier Band-Eckpunkte g0..g3 in CCW),
|
||||
/// an ihren echten Achsenenden ggf. gehrt — bitgenau dieselbe Ecken-Konstruktion
|
||||
/// wie `extrude_layer_segment` fuer das Vollstueck (Einzel-Layer, keine
|
||||
@@ -1026,7 +1180,10 @@ pub fn build_scene_mesh_textured(
|
||||
for m in meshes {
|
||||
let before = mesh.vertex_count();
|
||||
append_context_mesh(&mut mesh, m);
|
||||
fill(&mesh, &mut layers, before, -1.0);
|
||||
// Material-Ebene des Kontext-Meshes (aktuell nur Daecher setzen sie,
|
||||
// s. `MeshInput::material_index`); ohne -> Fallback-Schachbrett.
|
||||
let layer = m.material_index.map(|i| i as f32).unwrap_or(-1.0);
|
||||
fill(&mesh, &mut layers, before, layer);
|
||||
}
|
||||
|
||||
let mut tex = textured_from_mesh(&mesh);
|
||||
@@ -1063,6 +1220,20 @@ pub fn build_scene_mesh(walls: &[WallInput], slabs: &[SlabInput], meshes: &[Mesh
|
||||
mesh
|
||||
}
|
||||
|
||||
/// Baut NUR die Gelaende-Dreiecke (`MeshKind::Terrain`) zu EINEM Mesh — Grundlage
|
||||
/// des Luftbild-Overlays (`gpu::aerial_pipeline`): dieselbe Geometrie wie im
|
||||
/// Hauptpuffer, aber separat, damit sie ein zweites Mal texturiert obenauf
|
||||
/// gezeichnet werden kann. Leer, wenn kein Terrain vorliegt.
|
||||
pub fn build_terrain_mesh(meshes: &[MeshInput]) -> Mesh {
|
||||
let mut mesh = Mesh::default();
|
||||
for m in meshes {
|
||||
if matches!(m.kind, crate::types::MeshKind::Terrain) {
|
||||
append_context_mesh(&mut mesh, m);
|
||||
}
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
// ── Rohe Kontext-Meshes (Terrain / importierte Volumen) ──────────────────────
|
||||
|
||||
/// Haengt EIN rohes Dreiecks-Mesh (Terrain-TIN oder importiertes Volumen, siehe
|
||||
@@ -1085,6 +1256,21 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
|
||||
let pos = &input.positions;
|
||||
let vcount = pos.len() / 3;
|
||||
let color = input.effective_color();
|
||||
// Pro-Vertex-Farben nur nutzen, wenn genau ein RGB je Vertex vorliegt
|
||||
// (sonst greift die Einzelfarbe fuers ganze Mesh). Beispiel: Luftbild-Drape.
|
||||
let per_vertex = input.vertex_colors.len() >= vcount * 3 && vcount > 0;
|
||||
let vcol = |i: usize| -> Rgb {
|
||||
if per_vertex {
|
||||
let b = i * 3;
|
||||
[
|
||||
input.vertex_colors[b],
|
||||
input.vertex_colors[b + 1],
|
||||
input.vertex_colors[b + 2],
|
||||
]
|
||||
} else {
|
||||
color
|
||||
}
|
||||
};
|
||||
// world-Position aus Modell (x, y, z=Hoehe): (x, hoehe, y).
|
||||
let vworld = |i: usize| -> [f32; 3] {
|
||||
let b = i * 3;
|
||||
@@ -1099,6 +1285,7 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
|
||||
let a = vworld(ia);
|
||||
let b = vworld(ib);
|
||||
let c = vworld(ic);
|
||||
let (ca, cb, cc) = (vcol(ia), vcol(ib), vcol(ic));
|
||||
// Flaechen-Normale (b-a) x (c-a); entartete Dreiecke ueberspringen.
|
||||
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
||||
@@ -1113,8 +1300,8 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
|
||||
}
|
||||
let n = [gn[0] / len, gn[1] / len, gn[2] / len];
|
||||
// Vorderseite + Rueckseite (siehe Moduldoc: doppelseitig).
|
||||
push_ctx_tri(mesh, a, b, c, n, color);
|
||||
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], color);
|
||||
push_ctx_tri(mesh, a, b, c, n, [ca, cb, cc]);
|
||||
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], [ca, cc, cb]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,10 +1314,10 @@ fn push_ctx_tri(
|
||||
b: [f32; 3],
|
||||
c: [f32; 3],
|
||||
normal: [f32; 3],
|
||||
color: Rgb,
|
||||
colors: [Rgb; 3],
|
||||
) {
|
||||
let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32;
|
||||
for p in [a, b, c] {
|
||||
for (p, color) in [a, b, c].iter().zip(colors.iter()) {
|
||||
mesh.verts.extend_from_slice(&[
|
||||
p[0], p[1], p[2], normal[0], normal[1], normal[2], color[0], color[1], color[2],
|
||||
]);
|
||||
|
||||
@@ -220,6 +220,90 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
"#;
|
||||
|
||||
/// WGSL des LUFTBILD-Drapierens aufs Gelände (`gpu::aerial_pipeline`). Zeichnet die
|
||||
/// TERRAIN-Dreiecke ein zweites Mal, texturiert mit EINEM georeferenzierten Luftbild
|
||||
/// (SWISSIMAGE). Die UV kommen NICHT aus einem Vertex-Attribut, sondern werden im
|
||||
/// Fragment aus der WELT-Lage (world.x = Ost, world.z = Nord) und der Bild-Bounding-
|
||||
/// Box (group 1, Uniform `bbox = (min_x, min_y, max_x, max_y)` in Modell-Metern)
|
||||
/// berechnet — dadurch ist die Auflösung PIXELSCHARF (unabhängig von der TIN-Dichte,
|
||||
/// anders als die Pro-Vertex-Färbung). Beleuchtung identisch zu `MESH_WGSL`. Vertex-
|
||||
/// Layout = das Haupt-Mesh-Layout `[pos, normal, color]` (color wird ignoriert), damit
|
||||
/// derselbe Vertex-Puffer (Terrain-Overlay) ohne Re-Meshing genutzt werden kann.
|
||||
/// Bild-Zeile 0 liegt im NORDEN (+Y) -> die Textur-v-Achse wird gespiegelt (1 - v).
|
||||
pub const MESH_AERIAL_WGSL: &str = r#"
|
||||
struct Globals {
|
||||
view_proj : mat4x4<f32>,
|
||||
light_dir : vec4<f32>,
|
||||
sky_color : vec4<f32>,
|
||||
ground_color : vec4<f32>,
|
||||
sun_color : vec4<f32>,
|
||||
mode : vec4<f32>,
|
||||
section_plane : vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> globals : Globals;
|
||||
|
||||
// Luftbild-Textur + Sampler + Bounding-Box (group 1). `bbox = (min_x, min_y,
|
||||
// max_x, max_y)` in Modell-Metern (x = Ost/world.x, y = Nord/world.z).
|
||||
@group(1) @binding(0) var tex : texture_2d<f32>;
|
||||
@group(1) @binding(1) var samp : sampler;
|
||||
struct Aerial { bbox : vec4<f32>, };
|
||||
@group(1) @binding(2) var<uniform> aerial : Aerial;
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position : vec3<f32>,
|
||||
@location(1) normal : vec3<f32>,
|
||||
@location(2) color : vec3<f32>,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip_pos : vec4<f32>,
|
||||
@location(0) world_normal : vec3<f32>,
|
||||
@location(1) world_pos : vec3<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(in : VsIn) -> VsOut {
|
||||
var out : VsOut;
|
||||
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
|
||||
out.world_normal = in.normal;
|
||||
out.world_pos = in.position;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
// Live-Schnitt-Kappung identisch zur Mesh-Pipeline (mode.y > 0.5 = aktiv).
|
||||
if (globals.mode.y > 0.5) {
|
||||
if (dot(in.world_pos, globals.section_plane.xyz) + globals.section_plane.w > 0.0) {
|
||||
discard;
|
||||
}
|
||||
}
|
||||
let n = normalize(in.world_normal);
|
||||
let l = normalize(globals.light_dir.xyz);
|
||||
|
||||
// Hemisphaerisches Ambient (identisch zu MESH_WGSL).
|
||||
let hemi_t = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
let ambient = mix(globals.ground_color.rgb, globals.sky_color.rgb, hemi_t);
|
||||
let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb;
|
||||
let fill_l = normalize(vec3<f32>(-l.x, abs(l.y) * 0.35 + 0.15, -l.z));
|
||||
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
|
||||
* vec3<f32>(0.24, 0.27, 0.30);
|
||||
|
||||
// UV aus der Welt-Lage + Bild-Bbox. world.x = Ost, world.z = Nord.
|
||||
let span = vec2<f32>(aerial.bbox.z - aerial.bbox.x, aerial.bbox.w - aerial.bbox.y);
|
||||
let u = (in.world_pos.x - aerial.bbox.x) / span.x;
|
||||
let v = (in.world_pos.z - aerial.bbox.y) / span.y;
|
||||
// Bild-Zeile 0 = Nord (+Y) -> v spiegeln. Ausserhalb [0,1] -> Randpixel (Clamp
|
||||
// uebernimmt der Sampler mit ClampToEdge).
|
||||
let albedo = textureSample(tex, samp, vec2<f32>(u, 1.0 - v)).rgb;
|
||||
|
||||
var shaded = albedo * (ambient + diffuse + fill);
|
||||
let edge = 0.90 + 0.10 * abs(n.y);
|
||||
shaded = shaded * edge;
|
||||
return vec4<f32>(shaded, 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline:
|
||||
/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion
|
||||
/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline.
|
||||
|
||||
@@ -336,6 +336,20 @@ pub struct MeshInput {
|
||||
/// Default-Farbe je `kind` (siehe `effective_color`).
|
||||
#[serde(default)]
|
||||
pub color: Option<Rgb>,
|
||||
/// Optionaler Material-Textur-Index fuer den Stil `Textured` (1-basiert,
|
||||
/// Ebene 0 = Fallback-Schachbrett) — analog `WallInput::material_index`.
|
||||
/// Bislang nutzen das nur Daecher (`toWalls3d.ts::emitRoofs`); andere
|
||||
/// Kontext-Meshes (Terrain/Import) bleiben ohne (`None` -> Schachbrett).
|
||||
#[serde(default, rename = "materialIndex")]
|
||||
pub material_index: Option<u32>,
|
||||
/// Optionale PRO-VERTEX-Albedo (flaches `[r,g,b, r,g,b, ...]`, 0..1) — ein
|
||||
/// Wert je Positions-Vertex. Wird u. a. genutzt, um ein SWISSIMAGE-Luftbild
|
||||
/// auf das Gelaende-TIN zu „drapieren" (jeder Vertex traegt den Bildpunkt an
|
||||
/// seiner Lage). Ist das Array leer oder zu kurz, greift die Einzelfarbe
|
||||
/// (`effective_color`) fuer das ganze Mesh. Die Farben werden im Shaded-Pfad
|
||||
/// pro Vertex interpoliert (kein Textur-Sampler noetig).
|
||||
#[serde(default, rename = "vertexColors")]
|
||||
pub vertex_colors: Vec<f32>,
|
||||
}
|
||||
|
||||
impl MeshInput {
|
||||
@@ -398,8 +412,15 @@ fn default_ortho_half_height() -> f32 {
|
||||
fn default_near() -> f32 {
|
||||
0.05
|
||||
}
|
||||
fn default_far() -> f32 {
|
||||
1000.0
|
||||
// `pub(crate)`, damit `web::set_camera` denselben Basiswert als Untergrenze fuer
|
||||
// die modellgroessen-abhaengige Far-Ebene nutzen kann (s. Moduldoc dort).
|
||||
// War 1000.0 — Nutzer-Report: Teile des Modells (und das Boden-Referenzgitter)
|
||||
// verschwanden abhaengig von Kamera-Distanz/-Winkel. Grosszuegig angehoben,
|
||||
// damit im ueblichen Gebrauch (auch grosse/georeferenzierte Situationen) nie
|
||||
// etwas am Fern-Clipping haengenbleibt; `set_camera` skaliert ohnehin
|
||||
// zusaetzlich mit der tatsaechlichen Modell-Bounding-Box nach oben.
|
||||
pub(crate) fn default_far() -> f32 {
|
||||
50_000.0
|
||||
}
|
||||
|
||||
impl Default for Camera {
|
||||
|
||||
@@ -114,7 +114,11 @@ pub struct WebModelRenderer {
|
||||
/// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx):
|
||||
/// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante.
|
||||
/// `None` bei leerem Modell.
|
||||
fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [f32; 3])> {
|
||||
fn model_bounds(
|
||||
walls: &[WallInput],
|
||||
slabs: &[SlabInput],
|
||||
meshes: &[MeshInput],
|
||||
) -> Option<([f32; 3], [f32; 3])> {
|
||||
let mut min = [f32::INFINITY; 3];
|
||||
let mut max = [f32::NEG_INFINITY; 3];
|
||||
let mut grow = |x: f32, y: f32, z: f32| {
|
||||
@@ -147,6 +151,17 @@ fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [
|
||||
grow(p[0], s.z_top, p[1]);
|
||||
}
|
||||
}
|
||||
// Meshes (Daecher, Extrusionen, Stuetzen UND importierte Gebaeude/Terrain):
|
||||
// model (x,y,z=Hoehe) -> world (x, z, y). Ohne sie kann ein Praeset/Fit den
|
||||
// (evtl. weit entfernten) Import nicht rahmen.
|
||||
for m in meshes {
|
||||
let p = &m.positions;
|
||||
let mut i = 0;
|
||||
while i + 2 < p.len() {
|
||||
grow(p[i], p[i + 2], p[i + 1]);
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
if !min[0].is_finite() {
|
||||
return None;
|
||||
}
|
||||
@@ -247,7 +262,7 @@ impl WebModelRenderer {
|
||||
pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> {
|
||||
let model: ModelInput = serde_json::from_str(json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
|
||||
self.bounds = model_bounds(&model.walls, &model.slabs);
|
||||
self.bounds = model_bounds(&model.walls, &model.slabs, &model.meshes);
|
||||
self.renderer
|
||||
.upload_model(&self.device, &model.walls, &model.slabs, &model.meshes);
|
||||
// Wand-/Decken-Eingabe fuer den Live-Schnitt cachen (Klon, da der Renderer
|
||||
@@ -300,6 +315,15 @@ impl WebModelRenderer {
|
||||
/// (`ortho_half_height` = halbe Sichthoehe in Metern). Die TS-Seite berechnet
|
||||
/// `eye` aus Orbit-Winkeln/Distanz oder einem Praeset (front/top/side/iso/persp)
|
||||
/// — siehe `useWasm3dRenderer`/`Wasm3DViewport`.
|
||||
///
|
||||
/// FAR-EBENE: die feste `Camera::default()`-Far-Ebene (1000 m) reicht bei
|
||||
/// grossen georeferenzierten Importen (Suchradius bis 2000 m, Bounding-Kugel
|
||||
/// entsprechend gross) nicht mehr — Teile des Modells wurden je nach
|
||||
/// Blickwinkel/Distanz VOM FERN-CLIPPING abgeschnitten (Nutzer-Report: "Teile
|
||||
/// im 3D nicht sichtbar in einem Winkel"). Die Far-Ebene wird deshalb hier je
|
||||
/// Aufruf aus der zuletzt hochgeladenen Modell-Bounding-Box abgeleitet: der
|
||||
/// weiteste Bbox-Eckpunkt vom Auge aus + Sicherheitsmarge, mindestens der
|
||||
/// bisherige Default (kleine Modelle bleiben unveraendert).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_camera(
|
||||
&mut self,
|
||||
@@ -316,6 +340,32 @@ impl WebModelRenderer {
|
||||
fov_y: f32,
|
||||
ortho_half_height: f32,
|
||||
) {
|
||||
let far = match self.bounds {
|
||||
Some((min, max)) => {
|
||||
let corners = [
|
||||
[min[0], min[1], min[2]],
|
||||
[max[0], min[1], min[2]],
|
||||
[min[0], max[1], min[2]],
|
||||
[max[0], max[1], min[2]],
|
||||
[min[0], min[1], max[2]],
|
||||
[max[0], min[1], max[2]],
|
||||
[min[0], max[1], max[2]],
|
||||
[max[0], max[1], max[2]],
|
||||
];
|
||||
let mut max_dist = 0.0f32;
|
||||
for c in corners {
|
||||
let dx = c[0] - eye_x;
|
||||
let dy = c[1] - eye_y;
|
||||
let dz = c[2] - eye_z;
|
||||
let d = (dx * dx + dy * dy + dz * dz).sqrt();
|
||||
if d > max_dist {
|
||||
max_dist = d;
|
||||
}
|
||||
}
|
||||
(max_dist * 1.5).max(crate::types::default_far())
|
||||
}
|
||||
None => crate::types::default_far(),
|
||||
};
|
||||
self.camera = Camera {
|
||||
eye: [eye_x, eye_y, eye_z],
|
||||
target: [target_x, target_y, target_z],
|
||||
@@ -327,6 +377,7 @@ impl WebModelRenderer {
|
||||
},
|
||||
fov_y,
|
||||
ortho_half_height,
|
||||
far,
|
||||
..Camera::default()
|
||||
};
|
||||
}
|
||||
@@ -455,6 +506,39 @@ impl WebModelRenderer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setzt/ersetzt das aufs Gelaende drapierte Luftbild (SWISSIMAGE). `rgba` =
|
||||
/// dicht gepackte RGBA8-Bytes (`width*height*4`, zeilenweise von OBEN = Nord),
|
||||
/// die der JS-Aufrufer aus der Bild-Data-URL dekodiert (siehe
|
||||
/// `viewport/aerialTexture.ts`). `min_x/min_y/max_x/max_y` = Modell-Meter-
|
||||
/// Bounding-Box (x = Ost, y = Nord). Ungueltige/leere Eingabe loescht das
|
||||
/// Luftbild. Wirkt erst beim naechsten `render`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_aerial_texture(
|
||||
&mut self,
|
||||
rgba: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
min_x: f32,
|
||||
min_y: f32,
|
||||
max_x: f32,
|
||||
max_y: f32,
|
||||
) -> Result<(), JsValue> {
|
||||
self.renderer.set_aerial_texture(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
rgba,
|
||||
width,
|
||||
height,
|
||||
[min_x, min_y, max_x, max_y],
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loescht das drapierte Luftbild (Gelaende erscheint wieder einfarbig/shaded).
|
||||
pub fn clear_aerial(&mut self) {
|
||||
self.renderer.clear_aerial();
|
||||
}
|
||||
|
||||
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
let (w, h) = (width.max(1), height.max(1));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "cad",
|
||||
"productName": "Dossier",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ch.dossier.cad",
|
||||
"build": {
|
||||
@@ -11,12 +11,13 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "cad",
|
||||
"title": "Dossier",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"dragDropEnabled": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": { "x": 20, "y": 22 }
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
Reference in New Issue
Block a user