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:
2026-07-20 10:51:37 +02:00
parent 0ab0361e3d
commit 68a0459d0e
90 changed files with 7730 additions and 1849 deletions
+159 -26
View File
@@ -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)]
+418 -5
View File
@@ -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.
+146 -2
View File
@@ -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:?}"));
}
}
+192 -5
View File
@@ -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],
]);
+84
View File
@@ -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.
+23 -2
View File
@@ -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 {
+86 -2
View File
@@ -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));