Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
@@ -33,6 +33,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
}],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
WallInput {
start: [0.0, 3.0],
@@ -44,6 +47,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
// also groessere Tiefe von der Betrachter-Ebene aus), genau im
@@ -60,6 +66,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
];
let slabs = vec![SlabInput {
@@ -67,6 +76,8 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
z_bottom: -0.2,
z_top: 0.0,
color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}];
(walls, slabs)
}
+3
View File
@@ -40,6 +40,9 @@ fn demo_walls() -> Vec<WallInput> {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
// Raum 6 x 4 m.
vec![
+278 -27
View File
@@ -15,12 +15,17 @@ use wgpu::util::DeviceExt;
use crate::edges::{build_mesh_edges, 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_walls_mesh, textured_from_mesh};
use crate::mesh::{
build_scene_mesh, build_scene_mesh_textured, build_walls_mesh, build_walls_mesh_textured,
};
use crate::section::SectionPlane;
use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX};
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::types::{
Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX,
TEXTURED_FLOATS_PER_VERTEX,
};
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
@@ -142,6 +147,10 @@ struct MeshBuffers {
/// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet.
struct TexturedMeshBuffers {
vbo: wgpu::Buffer,
/// Zweiter Vertex-Buffer: EIN f32 (Material-Textur-Ebene) je Vertex,
/// @location(3) im `MESH_TEXTURED_WGSL`. Immer gesetzt (Fallback: `-1` je
/// Vertex), damit das Pipeline-Layout stets zwei Vertex-Buffer erwartet.
layer_vbo: wgpu::Buffer,
ibo: wgpu::Buffer,
index_count: u32,
}
@@ -225,21 +234,39 @@ pub struct Renderer {
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
cap_pipeline: wgpu::RenderPipeline,
/// Schnitt-Umrisslinien-Pipeline (Cut-Cap-Kanten, P2): TriangleList von
/// Ribbon-Quads (`section_fill::build_cut_cap_lines`), Vertex-Layout [pos vec3,
/// color vec3] wie die Grid/Highlight-LineList — teilt darum das `grid_module`
/// (GRID_WGSL). KEIN Tiefentest (`depth_compare: Always`, kein Write) wie das
/// Highlight: die Fugenlinien liegen (leicht zur Kamera versetzt) sichtbar auf
/// den Cut-Caps. Sie werden NICHT an der Schnittebene weggeclippt (sie SIND die
/// Schnittflaeche) — der Kamera-Lift in `build_cut_cap_lines` haelt sie vor der
/// `depth > 0`-Kappung von GRID_WGSL.
cut_line_pipeline: wgpu::RenderPipeline,
/// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList,
/// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet
/// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
/// Shader/Layout unterscheiden sich.
textured_pipeline: wgpu::RenderPipeline,
/// Bind-Group der prozeduralen Test-Textur (group 1): Texture-View + Sampler.
/// Konstant ueber die Lebensdauer des Renderers (eine Textur fuer alle Waende).
/// 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.
texture_bind_group: wgpu::BindGroup,
/// Die (prozedurale) Textur selbst — nur festgehalten, um ihre Texel beim
/// ersten `render` einmalig hochzuladen (`texture_uploaded`), da `new` keine
/// Queue bekommt.
/// Das Textur-Array selbst. Start: 1 Ebene (Schachbrett). `set_material_textures`
/// ersetzt es durch ein Array `[Schachbrett, Material0, Material1, …]`.
texture: wgpu::Texture,
/// Ob die Textur-Texel schon per `queue.write_texture` geladen wurden. Beim
/// ersten `render` einmalig gesetzt (lazy Upload).
/// Bind-Group-Layout (group 1), festgehalten fuer den Neubau der Bind-Group
/// beim Hochladen von Material-Texturen.
texture_bind_group_layout: wgpu::BindGroupLayout,
/// Sampler (Repeat/Linear), festgehalten fuer den Bind-Group-Neubau.
texture_sampler: wgpu::Sampler,
/// Anzahl gueltiger Ebenen im Textur-Array (>= 1). 1 = nur Schachbrett; nach
/// `set_material_textures` = 1 + Anzahl Material-Karten. Wandert je Frame nach
/// `mode.z` (Klemmung der Ebenen-Auswahl im Shader).
material_layer_count: u32,
/// Ob die Schachbrett-Texel (Ebene 0) schon hochgeladen wurden. Beim ersten
/// `render` einmalig gesetzt (lazy Upload, weil `new` keine Queue bekommt).
texture_uploaded: bool,
bind_group: wgpu::BindGroup,
uniform: wgpu::Buffer,
@@ -264,6 +291,10 @@ pub struct Renderer {
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
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.
cut_lines: Option<LineBuffers>,
/// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false —
/// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter.
grid_visible: bool,
@@ -471,6 +502,52 @@ impl Renderer {
},
);
// Schnitt-Umrisslinien-Pipeline (Cut-Cap-Kanten, P2): dasselbe schlanke
// LineList-Vertex-Layout [pos vec3, color vec3] und `grid_module` (GRID_WGSL)
// wie Grid/Highlight, aber als TriangleList (die Kanten sind Ribbon-Quads,
// damit die Strichstaerke als Welt-Breite durchkommt). Depth wie Highlight
// (Always, kein Write) -> immer sichtbar auf den Caps.
let cut_line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("cut_line.pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &grid_module,
entry_point: Some("vs_main"),
buffers: &[grid_vertex_layout.clone()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &grid_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,
// Ribbons sind flach in der Schnittebene, aus beiden Seiten sichtbar.
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
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,
});
// Schnittflaechen-Kappen-Pipeline: eigener Shader (CAP_WGSL), eigenes
// schlankes Vertex-Layout [pos vec3, uv vec2], TriangleList. Dieselbe
// Bind-Group (Globals) und dasselbe Depth-/MSAA-/Farbziel wie oben.
@@ -478,10 +555,14 @@ impl Renderer {
label: Some("cap.wgsl"),
source: wgpu::ShaderSource::Wgsl(CAP_WGSL.into()),
});
// Vertex-Layout: [pos vec3, uv vec2, hatch vec4(pattern, angle_rad, scale,
// line_weight_mm)], stride 9*4 (`CAP_FLOATS_PER_VERTEX`). Das dritte Attribut
// treibt die musterspezifische Schraffur im Cap-Fragment-Shader (CAP_WGSL);
// `line_weight_mm` setzt die Musterlinien-Breite je Schraffur.
let cap_vertex_layout = wgpu::VertexBufferLayout {
array_stride: (CAP_FLOATS_PER_VERTEX * 4) as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2],
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4],
};
let cap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("cap.pipeline"),
@@ -547,7 +628,7 @@ impl Renderer {
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
@@ -579,6 +660,14 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2],
};
// ZWEITER Vertex-Buffer: die Material-Textur-Ebene je Vertex (@location(3),
// 1 f32). Separat gehalten, damit das Haupt-`[pos,normal,uv]`-Layout (und
// die Geometrie-Paritaet zum Shaded-Pfad) unveraendert bleibt.
let textured_layer_layout = wgpu::VertexBufferLayout {
array_stride: 4,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![3 => Float32],
};
// WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format,
// Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline)
// — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der
@@ -590,7 +679,7 @@ impl Renderer {
vertex: wgpu::VertexState {
module: &textured_module,
entry_point: Some("vs_main"),
buffers: &[textured_vertex_layout],
buffers: &[textured_vertex_layout, textured_layer_layout],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
@@ -632,8 +721,10 @@ impl Renderer {
// `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue
// bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs,
// spike3d) unveraendert.
// Textur-ARRAY (D2Array). Start: EINE Ebene (Fallback-Schachbrett).
// `set_material_textures` ersetzt das Array spaeter durch mehrere Ebenen.
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("checker.texture"),
label: Some("materials.texture_array"),
size: wgpu::Extent3d {
width: TEXTURE_SIZE,
height: TEXTURE_SIZE,
@@ -646,10 +737,13 @@ impl Renderer {
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
// Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung).
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("checker.sampler"),
let texture_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("materials.sampler"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
address_mode_w: wgpu::AddressMode::Repeat,
@@ -659,7 +753,7 @@ impl Renderer {
..Default::default()
});
let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("texture.bind"),
label: Some("materials.bind"),
layout: &texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
@@ -668,7 +762,7 @@ impl Renderer {
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
resource: wgpu::BindingResource::Sampler(&texture_sampler),
},
],
});
@@ -694,9 +788,13 @@ impl Renderer {
grid_pipeline,
highlight_pipeline,
cap_pipeline,
cut_line_pipeline,
textured_pipeline,
texture_bind_group,
texture,
texture_bind_group_layout,
texture_sampler,
material_layer_count: 1,
texture_uploaded: false,
bind_group,
uniform,
@@ -706,6 +804,7 @@ impl Renderer {
grid: None,
highlight: None,
caps: None,
cut_lines: None,
grid_visible: false,
style: RenderStyle::Shaded,
depth: None,
@@ -724,7 +823,7 @@ impl Renderer {
/// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch.
pub fn upload_walls(&mut self, device: &wgpu::Device, walls: &[WallInput]) {
self.upload_mesh(device, build_walls_mesh(walls));
self.upload_mesh(device, build_walls_mesh(walls), build_walls_mesh_textured(walls));
}
/// Erzeugt das Mesh aus Waenden, Deckenplatten UND rohen Kontext-Meshes
@@ -736,13 +835,17 @@ impl Renderer {
slabs: &[SlabInput],
meshes: &[MeshInput],
) {
self.upload_mesh(device, build_scene_mesh(walls, slabs, meshes));
self.upload_mesh(
device,
build_scene_mesh(walls, slabs, meshes),
build_scene_mesh_textured(walls, slabs, meshes),
);
}
/// 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) {
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) {
if mesh.indices.is_empty() {
self.mesh = None;
self.textured_mesh = None;
@@ -782,11 +885,10 @@ impl Renderer {
index_count: mesh.indices.len() as u32,
});
// Texturiertes Pendant AUS DEMSELBEN `Mesh` ableiten (planare Welt-UV je
// Vertex statt Farbe, siehe `textured_from_mesh`) und in einen zweiten
// Vertex-Puffer hochladen. So bleibt die Geometrie bitgleich zum Alt-Pfad,
// und der Stil `Textured` hat ohne Re-Meshing seine Puffer bereit.
let tex_mesh = textured_from_mesh(&mesh);
// Texturiertes Pendant (planare Welt-UV je Vertex + Material-Ebenen-Puffer,
// siehe `build_scene_mesh_textured`) in zwei Vertex-Puffer hochladen. Die
// Geometrie bleibt bitgleich zum Alt-Pfad; der Stil `Textured` hat ohne
// Re-Meshing seine Puffer bereit.
let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.textured.vbo"),
contents: bytemuck::cast_slice(&tex_mesh.verts),
@@ -797,8 +899,22 @@ impl Renderer {
contents: bytemuck::cast_slice(&tex_mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
// Material-Ebenen-Puffer (1 f32 je Vertex). Fehlt er (kein Material-Bau),
// mit `-1` je Vertex fuellen -> alle Vertices auf das Fallback-Schachbrett.
let vertex_count = tex_mesh.verts.len() / TEXTURED_FLOATS_PER_VERTEX;
let layer_data: Vec<f32> = if tex_mesh.layers.len() == vertex_count {
tex_mesh.layers
} else {
vec![-1.0; vertex_count]
};
let tlvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.textured.layer.vbo"),
contents: bytemuck::cast_slice(&layer_data),
usage: wgpu::BufferUsages::VERTEX,
});
self.textured_mesh = Some(TexturedMeshBuffers {
vbo: tvbo,
layer_vbo: tlvbo,
ibo: tibo,
index_count: tex_mesh.indices.len() as u32,
});
@@ -918,6 +1034,27 @@ impl Renderer {
});
}
/// Setzt/loescht die Schnitt-Umrisslinien (Cut-Cap-Kanten, P2). `verts` =
/// interleaved `[px,py,pz, r,g,b, ...]` (world-Meter + Farbe, TriangleList von
/// Ribbon-Quads, `CUT_LINE_FLOATS_PER_VERTEX`), wie `section_fill::
/// build_cut_cap_lines` liefert. Leeres Slice loescht die Linien. Wirkt erst
/// beim naechsten `render`.
pub fn set_cut_lines(&mut self, device: &wgpu::Device, verts: &[f32]) {
if verts.is_empty() {
self.cut_lines = None;
return;
}
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("cut_lines.vbo"),
contents: bytemuck::cast_slice(verts),
usage: wgpu::BufferUsages::VERTEX,
});
self.cut_lines = Some(LineBuffers {
vbo,
vertex_count: (verts.len() / CUT_LINE_FLOATS_PER_VERTEX) as u32,
});
}
/// Komfort: Schnittebene setzen UND die Cap-Geometrie aus den (gecachten)
/// Waenden/Decken neu bauen — bei `active == false` werden Uniform-Flag und
/// Caps geloescht. Buendelt `set_section_plane` + `build_cut_caps` +
@@ -936,11 +1073,110 @@ impl Renderer {
let plane = SectionPlane::new(point, normal);
let caps = build_cut_caps(&plane, walls, slabs);
self.set_cut_caps(device, &caps);
// P2: die Schnitt-Umrisslinien (Fugen-/Materiallinien) je ueberlebendem
// Cut-Cap-Rechteck aus DERSELBEN, dominanz-verschnittenen Rechteck-Menge.
let lines = build_cut_cap_lines(&plane, walls, slabs);
self.set_cut_lines(device, &lines);
} else {
self.set_cut_caps(device, &[]);
self.set_cut_lines(device, &[]);
}
}
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-ARRAY hoch.
/// `rgba` = dicht gepackte RGBA8-Bytes von `layer_count` Ebenen, jede exakt
/// `TEXTURE_SIZE × TEXTURE_SIZE` (der Aufrufer skaliert/dekodiert die Bilder
/// JS-seitig, siehe `web::set_material_textures`/`Wasm3DViewport`). Baut ein
/// neues Array `[Schachbrett (Ebene 0), Material0 (Ebene 1), …]`, laedt es hoch
/// und ersetzt die group-1-Bind-Group. Ein Wand-Band verweist per
/// `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.
pub fn set_material_textures(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
rgba: &[u8],
layer_count: u32,
) {
let bytes_per_layer = (TEXTURE_SIZE * TEXTURE_SIZE * 4) as usize;
// Ungueltige/leere Eingabe -> reines Schachbrett-Array (1 Ebene).
let valid = layer_count > 0 && rgba.len() >= layer_count as usize * bytes_per_layer;
let material_layers = if valid { layer_count } else { 0 };
let total_layers = material_layers + 1; // Ebene 0 = Schachbrett
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("materials.texture_array"),
size: wgpu::Extent3d {
width: TEXTURE_SIZE,
height: TEXTURE_SIZE,
depth_or_array_layers: total_layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
// Ebene 0: Schachbrett-Fallback.
let checker = build_checker_texture();
let write_layer = |layer: u32, data: &[u8]| {
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: layer,
},
aspect: wgpu::TextureAspect::All,
},
data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * TEXTURE_SIZE),
rows_per_image: Some(TEXTURE_SIZE),
},
wgpu::Extent3d {
width: TEXTURE_SIZE,
height: TEXTURE_SIZE,
depth_or_array_layers: 1,
},
);
};
write_layer(0, &checker);
for i in 0..material_layers {
let start = i as usize * bytes_per_layer;
write_layer(i + 1, &rgba[start..start + bytes_per_layer]);
}
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
self.texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("materials.bind"),
layout: &self.texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.texture_sampler),
},
],
});
self.texture = texture;
self.material_layer_count = total_layers;
// Ebene 0 (Schachbrett) ist bereits geladen -> den Lazy-Upload ueberspringen.
self.texture_uploaded = true;
}
/// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) {
let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h);
@@ -1019,6 +1255,8 @@ impl Renderer {
// `mode.x` fuer den Mesh-Shader aus dem aktuellen Stil ableiten (Material/
// Clay/Hidden-Flaechen). Wireframe zeichnet keine Flaechen -> Wert egal.
self.globals.mode[0] = self.style.mesh_mode();
// `mode.z` = Anzahl gueltiger Textur-Array-Ebenen (Klemmung im Textur-Shader).
self.globals.mode[2] = self.material_layer_count as f32;
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
// Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine
@@ -1100,6 +1338,7 @@ impl Renderer {
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_bind_group(1, &self.texture_bind_group, &[]);
pass.set_vertex_buffer(0, t.vbo.slice(..));
pass.set_vertex_buffer(1, t.layer_vbo.slice(..));
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..t.index_count, 0, 0..1);
}
@@ -1128,6 +1367,18 @@ impl Renderer {
pass.draw(0..c.vertex_count, 0..1);
}
// 1c) Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) NACH den Caps: die
// Material-/Fugenlinien je ueberlebendem Cut-Rechteck (Ribbon-
// TriangleList, eigener Stil je Schicht). Ohne Tiefentest (Always)
// liegen sie sichtbar auf den Caps; der Kamera-Lift in
// `build_cut_cap_lines` haelt sie vor der Ebenen-Kappung.
if let Some(cl) = &self.cut_lines {
pass.set_pipeline(&self.cut_line_pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, cl.vbo.slice(..));
pass.draw(0..cl.vertex_count, 0..1);
}
// 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid).
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten)
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne
+78 -1
View File
@@ -20,6 +20,7 @@ pub mod math;
pub mod mesh;
pub(crate) mod openings;
pub mod section;
pub mod section_boolean;
pub mod section_fill;
pub mod shaders;
pub mod types;
@@ -72,6 +73,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
}
}
@@ -171,6 +175,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
@@ -250,6 +257,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mut mesh = Mesh::default();
extrude_wall(&mut mesh, &w);
@@ -272,6 +282,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 0);
@@ -486,6 +499,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let b = WallInput {
start: [0.0, 0.0],
@@ -497,6 +513,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mesh = build_walls_mesh(&[a, b]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
@@ -557,6 +576,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let walls = vec![
mk([0.0, 0.0], [3.0, 0.0]),
@@ -586,6 +608,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let walls = vec![
mk([0.0, 0.0], [3.0, 0.0], 0.0),
@@ -675,6 +700,9 @@ mod tests {
openings: vec![],
layers: layers(),
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mut b = a.clone();
b.end = [0.0, 3.0];
@@ -705,6 +733,8 @@ mod tests {
z_bottom,
z_top,
color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}
}
@@ -761,6 +791,8 @@ mod tests {
z_bottom: 0.0,
z_top: 0.3,
color: [0.8, 0.8, 0.8],
hatch: None,
cut: None,
};
let mut mesh = Mesh::default();
extrude_slab(&mut mesh, &slab);
@@ -789,7 +821,7 @@ mod tests {
fn entarteter_slab_erzeugt_nichts() {
let mut mesh = Mesh::default();
// <3 Ecken.
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8] });
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8], hatch: None, cut: None });
assert_eq!(mesh.vertex_count(), 0);
// Nullhoehe.
extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6));
@@ -962,6 +994,51 @@ mod tests {
assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel");
}
#[test]
fn presets_kardinal_back_left_sind_orthografisch_und_gegenrichtung() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
let front = preset_camera(CameraPreset::Front, t, 10.0);
let back = preset_camera(CameraPreset::Back, t, 10.0);
let side = preset_camera(CameraPreset::Side, t, 10.0);
let left = preset_camera(CameraPreset::Left, t, 10.0);
assert_eq!(back.projection, Projection::Orthographic);
assert_eq!(left.projection, Projection::Orthographic);
// Back/Left sind exakt die Gegenrichtung von Front/Side (gespiegelte Achse).
assert!((back.eye[2] - (-front.eye[2])).abs() < 1e-4, "Back == -Front (Z)");
assert!((left.eye[0] - (-side.eye[0])).abs() < 1e-4, "Left == -Side (X)");
}
#[test]
fn presets_iso_oktanten_sind_orthografisch_gleich_gross() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
let dist = 10.0;
let iso = preset_camera(CameraPreset::Iso, t, dist);
let fl = preset_camera(CameraPreset::IsoFrontLeft, t, dist);
let br = preset_camera(CameraPreset::IsoBackRight, t, dist);
let bl = preset_camera(CameraPreset::IsoBackLeft, t, dist);
for cam in [&iso, &fl, &br, &bl] {
assert_eq!(cam.projection, Projection::Orthographic);
// Alle vier oberen Iso-Oktanten haben denselben eye-target-Abstand
// (Modell erscheint in jedem Oktanten gleich gross).
let d = ((cam.eye[0] - t[0]).powi(2)
+ (cam.eye[1] - t[1]).powi(2)
+ (cam.eye[2] - t[2]).powi(2))
.sqrt();
assert!((d - dist).abs() < 1e-3, "Iso-Oktant-Abstand == dist");
// Alle vier blicken von OBEN (Y > 0) — untere Oktanten bleiben ungenutzt.
assert!(cam.eye[1] > t[1], "Iso-Oktant ueber dem Ziel");
}
// Vier unterschiedliche horizontale Vorzeichen-Kombinationen (X,Z).
assert!(iso.eye[0] > t[0] && iso.eye[2] > t[2]); // vorne-rechts
assert!(fl.eye[0] < t[0] && fl.eye[2] > t[2]); // vorne-links
assert!(br.eye[0] > t[0] && br.eye[2] < t[2]); // hinten-rechts
assert!(bl.eye[0] < t[0] && bl.eye[2] < t[2]); // hinten-links
}
/// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt
/// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga
/// sonst nicht mitgebaut wird (Muster: render2d).
+53 -11
View File
@@ -185,15 +185,26 @@ pub fn orbit_eye(target: [f32; 3], yaw: f32, pitch: f32, dist: f32) -> [f32; 3]
]
}
/// Baut eine `Camera` fuer eines der fuenf Presets der three.js-Sicht. `target`
/// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side/Iso
/// sind orthografisch (Front/Top/Side achsparallel, Iso diagonal); nur Persp
/// ist perspektivisch.
/// Baut eine `Camera` fuer eines der Kardinal-/Iso-Presets (ROADMAP §11).
/// `target` ist das Blickziel (Modell-Mitte), `dist` der Abstand. Alle Presets
/// ausser `Persp` sind orthografisch (Front/Back/Top/Side/Left achsparallel,
/// die Iso-Varianten diagonal); nur Persp ist perspektivisch.
///
/// Achs-Konvention (wie die three.js-Sicht, s. `applyView3d` in Viewport3D.tsx):
/// +Z = vorne (Front blickt von +Z entlang -Z), +X = rechts (Side blickt von
/// +X entlang -X). Back/Left sind exakt die Gegenrichtungen (-Z/-X); es gibt
/// noch KEINEN Nordwinkel — die Kardinalrichtungen sind rein modellrelativ
/// (Vorne/Rechts/Hinten/Links im UI, keine Himmelsrichtungen).
pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera {
let mut cam = Camera {
target,
..Camera::default()
};
// Iso-Distanz-Helfer: Augenabstand-Komponente je Achse, sodass der
// tatsaechliche eye-target-Abstand exakt `dist` bleibt (d*sqrt(3) == dist,
// wie bei den bisherigen Front/Top/Side-Presets), damit alle Iso-Varianten
// das Modell gleich gross zeigen.
let iso_d = dist / 3.0_f32.sqrt();
match preset {
// Blick entlang -Z (von vorne), orthografisch.
CameraPreset::Front => {
@@ -202,6 +213,13 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick entlang +Z (von hinten) — Gegenrichtung von Front.
CameraPreset::Back => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0], target[1], target[2] - dist];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick von oben entlang -Y (Grundriss), orthografisch. Up = -Z, damit
// Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht).
CameraPreset::Top => {
@@ -210,24 +228,48 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
cam.up = [0.0, 0.0, -1.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick entlang -X (von der Seite), orthografisch.
// Blick entlang -X (von der rechten Seite), orthografisch.
CameraPreset::Side => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] + dist, target[1], target[2]];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick (orthografisch, Blick von schraeg oben).
// Blick entlang +X (von der linken Seite) — Gegenrichtung von Side.
CameraPreset::Left => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - dist, target[1], target[2]];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von vorne-oben-rechts (Default-Iso).
// Echte Isometrie ist per Definition parallelprojiziert (keine
// perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu).
CameraPreset::Iso => {
cam.projection = Projection::Orthographic;
let d = dist / 3.0_f32.sqrt();
cam.eye = [target[0] + d, target[1] + d, target[2] + d];
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] + iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von vorne-oben-links.
CameraPreset::IsoFrontLeft => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] + iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von hinten-oben-rechts.
CameraPreset::IsoBackRight => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] - iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von hinten-oben-links.
CameraPreset::IsoBackLeft => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] - iso_d];
cam.up = [0.0, 1.0, 0.0];
// eye-target-Abstand ist hier (wie bei Front/Top/Side) exakt
// `dist` (d*sqrt(3) == dist), darum dieselbe Skalierung wie dort,
// damit das Modell im Bild aehnlich gross erscheint.
cam.ortho_half_height = dist * 0.5;
}
// Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts).
+57 -1
View File
@@ -940,7 +940,10 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh {
textured_from_mesh(&build_walls_mesh(walls))
// Ueber den Szenen-Builder (ohne Decken/Kontext) — so traegt der Puffer auch
// die Material-Ebenen je Wand (`layers`), waehrend Geometrie/UV bitgleich zu
// `textured_from_mesh(&build_walls_mesh(walls))` bleiben.
build_scene_mesh_textured(walls, &[], &[])
}
/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh`
@@ -980,9 +983,62 @@ pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh {
TexturedMesh {
verts,
indices: mesh.indices.clone(),
// Ohne Material-Zuordnung: leer -> die GPU-Schicht bindet einen konstanten
// `-1`-Ebenenpuffer (Fallback-Schachbrett fuer alle Vertices).
layers: Vec::new(),
}
}
/// TEXTURIERTES Szenen-Mesh MIT Material-Ebenen: baut dieselbe Szene wie
/// `build_scene_mesh` (Waende -> Decken -> Kontext-Meshes, gleiche Reihenfolge/
/// Geometrie) und fuellt zusaetzlich den parallelen `layers`-Puffer (ein Eintrag
/// je Vertex): fuer jede Wand ihre `material_index`-Textur-Ebene (`None` -> `-1`),
/// fuer Decken und Kontext-Meshes `-1` (Fallback-Schachbrett). Die Vertex-Ranges
/// werden waehrend des Baus je Bauteil aus `Mesh::vertex_count()` abgegriffen —
/// dieselben `extrude_*`/`append_*`-Funktionen wie der Shaded-Pfad bleiben die
/// EINE Geometrie-Quelle (Paritaet garantiert), es kommt nur die Ebenen-Zuordnung
/// dazu. Genutzt vom `Textured`-Stil (`gpu::upload_*`).
pub fn build_scene_mesh_textured(
walls: &[WallInput],
slabs: &[SlabInput],
meshes: &[MeshInput],
) -> TexturedMesh {
let mut mesh = Mesh::default();
let mut layers: Vec<f32> = Vec::new();
let fill = |mesh: &Mesh, layers: &mut Vec<f32>, before: usize, layer: f32| {
let added = mesh.vertex_count().saturating_sub(before);
layers.extend(std::iter::repeat(layer).take(added));
};
let miters = compute_wall_miters(walls);
for (w, m) in walls.iter().zip(miters.iter()) {
let before = mesh.vertex_count();
extrude_wall_with_miters(&mut mesh, w, *m);
// Material-Ebene der Wand (1-basiert; 0 ist das Fallback-Schachbrett).
let layer = w.material_index.map(|i| i as f32).unwrap_or(-1.0);
fill(&mesh, &mut layers, before, layer);
}
for s in slabs {
let before = mesh.vertex_count();
extrude_slab(&mut mesh, s);
fill(&mesh, &mut layers, before, -1.0);
}
for m in meshes {
let before = mesh.vertex_count();
append_context_mesh(&mut mesh, m);
fill(&mesh, &mut layers, before, -1.0);
}
let mut tex = textured_from_mesh(&mesh);
debug_assert_eq!(
layers.len(),
tex.vertex_count(),
"ein Ebenen-Eintrag je Vertex"
);
tex.layers = layers;
tex
}
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
+39 -1
View File
@@ -133,7 +133,7 @@
use serde::{Deserialize, Serialize};
use crate::math;
use crate::types::{Opening, Point2, Rgb, SlabInput, WallInput};
use crate::types::{CutBandMeta, Hatch, Opening, Point2, Rgb, SlabInput, WallInput};
/// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe).
const EPS: f32 = 1e-4;
@@ -268,8 +268,19 @@ pub struct ComponentRef {
pub struct CutPolygon {
pub component: ComponentRef,
pub color: Rgb,
/// Schnitt-Schraffur des Ursprungs-Bauteils (siehe `WallInput::hatch`). `None`
/// -> der Cap-Shader zeichnet das Fallback-Diagonalmuster (Alt-Verhalten).
#[serde(default)]
pub hatch: Option<Hatch>,
/// Ring-Ecken (u, v); erste != letzte (Ring implizit geschlossen).
pub pts: Vec<[f32; 2]>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten des Ursprungs-Bandes (siehe
/// `WallInput::cut`). Treibt die Boolean-Dominanz in `section_boolean::
/// subtract_dominant_bands` (die staerkere Schicht schneidet die schwaechere)
/// und die Schnitt-Umrisslinien (`section_fill::build_cut_cap_lines`). `None`
/// -> das Band nimmt an keiner Subtraktion teil (Alt-Verhalten).
#[serde(default)]
pub cut: Option<CutBandMeta>,
}
/// Ein Liniensegment in Schnittkoordinaten (u, v), Meter.
@@ -314,9 +325,16 @@ struct Prism {
z0: f32,
z1: f32,
color: Rgb,
/// Schnitt-Schraffur des Bauteils (siehe `WallInput::hatch`). Wandert in die
/// Cut-Polygone, damit `section_fill`/`CAP_WGSL` das richtige Muster zeichnen.
hatch: Option<Hatch>,
/// Nur fuer Waende: Achse + Oeffnungen (siehe `WallAxis`). `None` fuer
/// Decken (Slabs kennen keine Oeffnungen).
wall_axis: Option<WallAxis>,
/// Schnitt-Metadaten (Prioritaet/Identitaet/Linienstil), 1:1 aus der
/// `WallInput`/`SlabInput`-Eingabe (`cut`) uebernommen und an jedes erzeugte
/// `CutPolygon` weitergereicht.
cut: Option<CutBandMeta>,
}
/// Wandachse + Oeffnungen, wie sie fuer die Oeffnungs-Zuordnung bei Cut-
@@ -399,12 +417,14 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option<Prism> {
z0: wall.base_elevation,
z1: wall.base_elevation + wall.height,
color: wall.color,
hatch: wall.hatch,
wall_axis: Some(WallAxis {
start: wall.start,
dir,
length,
openings: wall.openings.clone(),
}),
cut: wall.cut.clone(),
})
}
@@ -425,7 +445,9 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option<Prism> {
z0,
z1,
color: slab.color,
hatch: slab.hatch,
wall_axis: None,
cut: slab.cut.clone(),
})
}
@@ -974,7 +996,9 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput
index: prism.index,
},
color: prism.color,
hatch: prism.hatch,
pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]],
cut: prism.cut.clone(),
});
}
}
@@ -1017,6 +1041,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
WallInput {
start: [0.0, 3.0],
@@ -1028,6 +1055,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
];
let slabs = vec![SlabInput {
@@ -1035,6 +1065,8 @@ mod tests {
z_bottom: -0.2,
z_top: 0.0,
color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}];
(walls, slabs)
}
@@ -1285,6 +1317,9 @@ mod tests {
}],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
}
}
@@ -1371,6 +1406,9 @@ mod tests {
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
let plane = SectionPlane::looking_plus_y(-2.0);
+516
View File
@@ -0,0 +1,516 @@
//! Schnitt-Boolean-Dominanz fuer den 3D-Live-Schnitt — die 1:1-Portierung von
//! `src/plan/toSection.ts::subtractDominantBands`/`subtractRect` nach Rust.
//!
//! GRUND: der 2D-Schnitt (`toSection.ts`) ist die Referenz-Implementierung. Er
//! sammelt alle Cut-Baender (Wand- und Deckenschichten) als achsparallele
//! (u,v)-Rechtecke, taggt jedes mit seiner `Component.joinPriority` und laesst
//! dann die STAERKERE Schicht die schwaechere per Rechteck-Subtraktion wegschneiden
//! (elementuebergreifend). Der 3D-Rust-Cut (`section.rs`) produziert bereits genau
//! dasselbe Format — achsparallele (u,v)-Rechteck-`CutPolygon`s — also laesst sich
//! die Dominanz-Zerlegung hier identisch nachbauen, damit der 3D-Live-Schnitt fuer
//! Wand+Decken-Schichten EXAKT wie der 2D-Schnitt aussieht.
//!
//! Die Prioritaet/Identitaet je Band kommt aus `CutPolygon::cut` (`CutBandMeta`,
//! aus der TS-Emission `toWalls3d`). Baender ohne `join_priority` (unaufloesbares
//! Bauteil) nehmen weder als Basis noch als Cutter teil und bleiben unveraendert.
use crate::section::CutPolygon;
/// Toleranz fuer Ueberlappungs-/Rest-Rechtecke (Meter): Null-/Negativflaechen und
/// Rest-Streifen duenner als EPS werden verworfen (Float-Rauschen der (u,v)-Meter).
/// Identisch zu `RECT_EPS` in `toSection.ts`.
const RECT_EPS: f32 = 1e-6;
/// Achsparalleles Rechteck in (u, v)-Metern — das Rust-Gegenstueck zu `Rect` in
/// `toSection.ts`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub u_min: f32,
pub u_max: f32,
pub v_min: f32,
pub v_max: f32,
}
/// Bounding-Box eines (achsparallelen) Cut-Bands als `Rect`, oder `None` bei <3
/// Ecken (analog `rectOfBand`).
pub fn rect_of_poly(poly: &CutPolygon) -> Option<Rect> {
if poly.pts.len() < 3 {
return None;
}
let mut u_min = f32::INFINITY;
let mut u_max = f32::NEG_INFINITY;
let mut v_min = f32::INFINITY;
let mut v_max = f32::NEG_INFINITY;
for p in &poly.pts {
u_min = u_min.min(p[0]);
u_max = u_max.max(p[0]);
v_min = v_min.min(p[1]);
v_max = v_max.max(p[1]);
}
Some(Rect {
u_min,
u_max,
v_min,
v_max,
})
}
/// Rechteck-minus-Rechteck (beide achsparallel): `base` ohne die Ueberlappung mit
/// `cutter`, zerlegt in 04 disjunkte Rest-Rechtecke (links/rechts ueber die volle
/// Hoehe, dann oben/unten im mittleren u-Streifen — ueberlappungsfrei). Kein echter
/// Ueberlapp (Flaeche ≤ EPS in u oder v) ⇒ `base` bleibt ganz. Vollstaendige
/// Ueberdeckung ⇒ leere Liste. Rest-Streifen duenner als EPS werden verworfen.
/// 1:1 aus `toSection.ts::subtractRect`.
pub fn subtract_rect(base: Rect, cutter: Rect) -> Vec<Rect> {
let o_min_u = base.u_min.max(cutter.u_min);
let o_max_u = base.u_max.min(cutter.u_max);
let o_min_v = base.v_min.max(cutter.v_min);
let o_max_v = base.v_max.min(cutter.v_max);
// Kein (nennenswerter) Ueberlapp — `base` unveraendert.
if o_max_u - o_min_u <= RECT_EPS || o_max_v - o_min_v <= RECT_EPS {
return vec![base];
}
let mut out = Vec::new();
let mut push = |r: Rect| {
if r.u_max - r.u_min > RECT_EPS && r.v_max - r.v_min > RECT_EPS {
out.push(r);
}
};
// Links / rechts der Ueberlappung, jeweils ueber die volle Basis-Hoehe.
push(Rect {
u_min: base.u_min,
u_max: o_min_u,
v_min: base.v_min,
v_max: base.v_max,
});
push(Rect {
u_min: o_max_u,
u_max: base.u_max,
v_min: base.v_min,
v_max: base.v_max,
});
// Unten / oben im mittleren u-Streifen (nur der von der Ueberlappung befreite Rest).
push(Rect {
u_min: o_min_u,
u_max: o_max_u,
v_min: base.v_min,
v_max: o_min_v,
});
push(Rect {
u_min: o_min_u,
u_max: o_max_u,
v_min: o_max_v,
v_max: base.v_max,
});
out
}
/// `join_priority` eines Bands (aus `CutBandMeta`), oder `None`.
fn priority_of(poly: &CutPolygon) -> Option<i32> {
poly.cut.as_ref().and_then(|c| c.join_priority)
}
/// `component_id` eines Bands (aus `CutBandMeta`), oder `None`.
fn component_of(poly: &CutPolygon) -> Option<&str> {
poly.cut.as_ref().and_then(|c| c.component_id.as_deref())
}
/// Erzeugt aus einem Rest-`Rect` ein Cut-Band, das Stil/Referenz/Metadaten von
/// `src` erbt (analog `bandFromRect`). Ecken-Reihenfolge wie in `section.rs`
/// (CCW-analog, hier v_max oben zuerst — fuer die Fan-Triangulierung irrelevant).
fn poly_from_rect(src: &CutPolygon, r: Rect) -> CutPolygon {
CutPolygon {
component: src.component,
color: src.color,
hatch: src.hatch,
cut: src.cut.clone(),
pts: vec![
[r.u_min, r.v_max],
[r.u_max, r.v_max],
[r.u_max, r.v_min],
[r.u_min, r.v_min],
],
}
}
/// Globale Schnitt-Dominanz: fuer jedes Band wird die Rechteck-Ueberlappung ALLER
/// Baender mit STRIKT hoeherer `join_priority` subtrahiert (elementuebergreifend —
/// z. B. schneidet ein Decken-Beton-Band 100 ein ueberlappendes Wand-Putz-Band 10
/// weg). Gleiche Prioritaet schneidet NICHT (koexistiert → durchgehende Flaeche).
/// Baender ohne `join_priority` nehmen weder als Basis noch als Cutter teil und
/// bleiben unveraendert erhalten. 1:1 aus `toSection.ts::subtractDominantBands`
/// (inklusive der abschliessenden MERGE-Phase).
pub fn subtract_dominant_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
let rects: Vec<Option<Rect>> = bands.iter().map(rect_of_poly).collect();
let prios: Vec<Option<i32>> = bands.iter().map(priority_of).collect();
let mut out: Vec<CutPolygon> = Vec::new();
for i in 0..bands.len() {
// Nicht-rechteckig oder ohne Prioritaet: unveraendert durchreichen.
let (base, prio) = match (rects[i], prios[i]) {
(Some(b), Some(p)) => (b, p),
_ => {
out.push(bands[i].clone());
continue;
}
};
let mut pieces = vec![base];
for j in 0..bands.len() {
if pieces.is_empty() {
break;
}
if j == i {
continue;
}
let cutter = match (rects[j], prios[j]) {
(Some(c), Some(op)) if op > prio => c,
_ => continue,
};
let mut next = Vec::new();
for p in &pieces {
next.extend(subtract_rect(*p, cutter));
}
pieces = next;
}
for p in pieces {
out.push(poly_from_rect(&bands[i], p));
}
}
merge_same_component_bands(out)
}
/// Union zweier achsparalleler Rechtecke, ABER NUR wenn das Ergebnis EXAKT wieder
/// ein Rechteck ist — sonst `None`. Enthaltensein oder Achs-Deckung + Beruehrung
/// liefern eine rechteckige Union; Teil-/Eck-Ueberlappungen (L-Form) und Baender
/// mit Spalt liefern `None`. 1:1 aus `toSection.ts::rectUnionIfRect`.
fn rect_union_if_rect(a: Rect, b: Rect) -> Option<Rect> {
const E: f32 = RECT_EPS;
// Enthaltensein (inkl. Deckungsgleichheit): Union = umschliessendes Rechteck.
let a_in_b = b.u_min - a.u_min <= E
&& a.u_max - b.u_max <= E
&& b.v_min - a.v_min <= E
&& a.v_max - b.v_max <= E;
if a_in_b {
return Some(b);
}
let b_in_a = a.u_min - b.u_min <= E
&& b.u_max - a.u_max <= E
&& a.v_min - b.v_min <= E
&& b.v_max - a.v_max <= E;
if b_in_a {
return Some(a);
}
let u_aligned = (a.u_min - b.u_min).abs() <= E && (a.u_max - b.u_max).abs() <= E;
let v_aligned = (a.v_min - b.v_min).abs() <= E && (a.v_max - b.v_max).abs() <= E;
// Beruehrung/Ueberlappung in der jeweils ANDEREN Achse (kein Spalt groesser EPS).
let v_touch = a.v_max >= b.v_min - E && b.v_max >= a.v_min - E;
let u_touch = a.u_max >= b.u_min - E && b.u_max >= a.u_min - E;
if (u_aligned && v_touch) || (v_aligned && u_touch) {
return Some(Rect {
u_min: a.u_min.min(b.u_min),
u_max: a.u_max.max(b.u_max),
v_min: a.v_min.min(b.v_min),
v_max: a.v_max.max(b.v_max),
});
}
None
}
/// MERGE-Phase der Schnitt-Dominanz: Baender mit GLEICHER `component_id` UND
/// gleicher `join_priority`, deren achsparallele Rechtecke sich beruehren/ueberlappen
/// und eine rechteckige Union bilden (`rect_union_if_rect`), werden zu EINEM Band
/// zusammengefasst — so entsteht an der Beruehrkante keine innere Trennlinie durch
/// gleichartiges Material. Baender ohne `component_id`/`join_priority` oder ohne
/// Rechteck-Form nehmen nicht teil. Iterativ bis zum Fixpunkt. 1:1 aus
/// `toSection.ts::mergeSameComponentBands`.
fn merge_same_component_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
let mut out = bands;
let mut merged = true;
while merged {
merged = false;
'outer: for i in 0..out.len() {
let (ci, pi) = match (component_of(&out[i]), priority_of(&out[i])) {
(Some(c), Some(p)) => (c.to_string(), p),
_ => continue,
};
let ri = match rect_of_poly(&out[i]) {
Some(r) => r,
None => continue,
};
for j in (i + 1)..out.len() {
if component_of(&out[j]) != Some(ci.as_str()) || priority_of(&out[j]) != Some(pi) {
continue;
}
let rj = match rect_of_poly(&out[j]) {
Some(r) => r,
None => continue,
};
if let Some(u) = rect_union_if_rect(ri, rj) {
out[i] = poly_from_rect(&out[i], u);
out.remove(j);
merged = true; // Fixpunkt-Neustart
break 'outer;
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::section::{ComponentKind, ComponentRef, CutPolygon};
use crate::types::CutBandMeta;
/// Achsparalleles Cut-Band mit Prioritaet (und optionaler Komponenten-Id) —
/// Ecken wie `section.rs` (Rechteck).
fn band(
u_min: f32,
u_max: f32,
v_min: f32,
v_max: f32,
join_priority: i32,
component_id: Option<&str>,
) -> CutPolygon {
CutPolygon {
component: ComponentRef {
kind: ComponentKind::Wall,
index: 0,
},
color: [0.5, 0.5, 0.5],
hatch: None,
pts: vec![
[u_min, v_max],
[u_max, v_max],
[u_max, v_min],
[u_min, v_min],
],
cut: Some(CutBandMeta {
join_priority: Some(join_priority),
component_id: component_id.map(|s| s.to_string()),
line_color: None,
line_weight: None,
}),
}
}
fn rect_of(p: &CutPolygon) -> Rect {
rect_of_poly(p).unwrap()
}
fn area(bands: &[CutPolygon]) -> f32 {
bands
.iter()
.map(|b| {
let r = rect_of(b);
(r.u_max - r.u_min) * (r.v_max - r.v_min)
})
.sum()
}
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-5
}
// --- subtractRect (Referenz: toSection.boolean.test.ts) ------------------
#[test]
fn subtract_rect_kein_overlap_base_unveraendert() {
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
let cut = Rect { u_min: 2.0, u_max: 3.0, v_min: 2.0, v_max: 3.0 };
assert_eq!(subtract_rect(base, cut), vec![base]);
}
#[test]
fn subtract_rect_kantenberuehrung_base_unveraendert() {
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 0.0, v_max: 1.0 };
assert_eq!(subtract_rect(base, cut), vec![base]);
}
#[test]
fn subtract_rect_cutter_enthaelt_base_leer() {
let base = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
let cut = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
assert!(subtract_rect(base, cut).is_empty());
}
#[test]
fn subtract_rect_cutter_mittig_vier_reste() {
let base = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
let rest = subtract_rect(base, cut);
assert_eq!(rest.len(), 4);
let a: f32 = rest
.iter()
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
.sum();
assert!(approx(a, 8.0), "9 (base) - 1 (overlap) = 8, ist {a}");
}
#[test]
fn subtract_rect_eck_ueberlappung_zwei_reste() {
// cutter deckt die obere-rechte Ecke von base: overlap [1,2]x[1,2].
let base = Rect { u_min: 0.0, u_max: 2.0, v_min: 0.0, v_max: 2.0 };
let cut = Rect { u_min: 1.0, u_max: 3.0, v_min: 1.0, v_max: 3.0 };
let rest = subtract_rect(base, cut);
let a: f32 = rest
.iter()
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
.sum();
assert!(approx(a, 3.0), "4 - 1 = 3, ist {a}");
}
// --- subtractDominantBands: die geometrische Kernaussage ------------------
#[test]
fn decke_100_schneidet_wand_50_im_ueberlappungs_z_intervall_weg() {
// Nutzer-Sample-analog: eine Wandschicht (Backstein, Prio 50, u in [0,0.2],
// volle Hoehe v in [0,3]) und ein Decken-Beton-Band (Prio 100, u in [0,0.2],
// v in [2.6,2.8]) ueberlappen in (u,v). Nach der Subtraktion darf die Wand
// im Decken-z-Intervall KEINE Flaeche mehr haben; Beton bleibt voll.
let brick = band(0.0, 0.2, 0.0, 3.0, 50, Some("brick"));
let concrete = band(0.0, 0.2, 2.6, 2.8, 100, Some("concrete"));
let out = subtract_dominant_bands(vec![brick, concrete]);
// Beton unveraendert (kein staerkeres Band).
let concrete_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("concrete"))
.collect();
assert_eq!(concrete_out.len(), 1);
assert!(approx(
(rect_of(concrete_out[0]).v_max - rect_of(concrete_out[0]).v_min) as f32,
0.2
));
// Backstein: in v in [2.6,2.8] KEINE Flaeche mehr -> zwei Reste [0,2.6] und
// [2.8,3.0]. Kein Rest-Rechteck ragt ins Beton-Intervall.
let brick_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("brick"))
.collect();
assert!(!brick_out.is_empty());
for b in &brick_out {
let r = rect_of(b);
// Kein Backstein-Rest ueberlappt (2.6, 2.8) echt.
let overlaps = r.v_min < 2.8 - 1e-4 && r.v_max > 2.6 + 1e-4;
assert!(!overlaps, "Backstein-Rest {r:?} ragt ins Beton-z-Intervall");
}
// Flaechenbilanz: Backstein 0.2*3 = 0.6, minus Overlap 0.2*0.2 = 0.04 -> 0.56.
assert!(
approx(area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()), 0.56),
"Backstein-Restflaeche"
);
}
#[test]
fn wand_50_schneidet_estrich_30() {
// Estrich (Decke, Prio 30) verliert die Ueberlappung mit der Wand
// (Backstein, Prio 50). Referenz-Zahlen wie im 2D-Test.
let screed = band(0.0, 2.0, 0.0, 3.0, 30, Some("screed"));
let brick = band(0.0, 2.0, 2.0, 4.0, 50, Some("brick")); // Overlap v in [2,3]
let out = subtract_dominant_bands(vec![screed, brick]);
// Brick voll.
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
4.0
));
// Estrich: [0,3] minus Overlap [2,3] = [0,2] -> Rest genau v in [0,2].
let screed_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("screed"))
.collect();
assert_eq!(screed_out.len(), 1);
let r = rect_of(screed_out[0]);
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 2.0), "Estrich-Rest {r:?}");
}
/// SAMPLE-ANALOG (Nutzer-Bug): der Schnitt durch Aussenwand + Geschossdecke.
/// Deckenschichten (u-breit, gestapelt in v nahe OK): Estrich (30, [2.54,2.6]),
/// Dämmung (20, [2.50,2.54]), Beton (100, [2.30,2.50]). Wandschichten (schmal in
/// u, an ihrer Dicken-Position): Backstein (50) läuft als ZWEI Boxen [0,2.3] +
/// [2.5,2.6] durch (Beton kappt das Mittelstück — genau die per-Schicht-Decken-
/// Dominanz aus emitWall). ERWARTUNG: nach der Verschneidung haben Estrich UND
/// Dämmung im u-Bereich des Backsteins KEINE Fläche mehr (Backstein 50 > 30, 20);
/// der Beton bleibt voll und läuft durch. Das ist die Richtung, die der Nutzer als
/// kaputt meldete ("dämmung und zementboden laufen raus").
#[test]
fn sample_analog_backstein_schneidet_estrich_und_daemmung() {
let brick_u = (0.0075_f32, 0.1575_f32); // Backstein-Dickenposition (W4-analog)
let ceil_u = (0.0_f32, 5.0_f32); // Deckenschichten über die volle Raumbreite
let bands = vec![
band(ceil_u.0, ceil_u.1, 2.54, 2.6, 30, Some("screed")),
band(ceil_u.0, ceil_u.1, 2.50, 2.54, 20, Some("insulation-ceiling")),
band(ceil_u.0, ceil_u.1, 2.30, 2.50, 100, Some("concrete")),
band(brick_u.0, brick_u.1, 0.0, 2.3, 50, Some("brick")),
band(brick_u.0, brick_u.1, 2.5, 2.6, 50, Some("brick")),
];
let out = subtract_dominant_bands(bands);
// Kein Estrich-/Dämmung-Rest überlappt den Backstein-u-Bereich ECHT.
for name in ["screed", "insulation-ceiling"] {
for b in out.iter().filter(|b| component_of(b) == Some(name)) {
let r = rect_of(b);
let u_overlap = r.u_min < brick_u.1 - 1e-4 && r.u_max > brick_u.0 + 1e-4;
assert!(
!u_overlap,
"{name}-Rest {r:?} ragt noch in den Backstein-u-Bereich (müsste weg sein)"
);
}
}
// Beton bleibt voll (kein stärkeres Band): Fläche 5*0.2 = 1.0.
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("concrete")).cloned().collect::<Vec<_>>()),
1.0
));
// Backstein bleibt in beiden Boxen erhalten (nichts Stärkeres überlappt).
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
0.15 * 2.3 + 0.15 * 0.1
));
}
#[test]
fn gleiche_prioritaet_koexistiert() {
// Beton-Wand + Beton-Decke, gleiche Prio 100, VERSCHIEDENE Komponenten:
// schneiden sich NICHT und verschmelzen NICHT (bleiben beide voll).
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton-wand"));
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton-decke")); // Overlap v[2,3]
let out = subtract_dominant_bands(vec![wand, decke]);
assert_eq!(out.len(), 2);
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("beton-wand")).cloned().collect::<Vec<_>>()),
3.0
));
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("beton-decke")).cloned().collect::<Vec<_>>()),
2.0
));
}
#[test]
fn ohne_prioritaet_keine_teilnahme() {
let stark = band(0.0, 2.0, 0.0, 2.0, 100, None);
let mut ohne = band(0.0, 2.0, 0.0, 2.0, 0, None);
ohne.cut = None; // keine Prioritaet
let out = subtract_dominant_bands(vec![stark, ohne]);
// Beide unveraendert (4 + 4).
assert_eq!(out.len(), 2);
assert!(approx(area(&out), 8.0));
}
#[test]
fn merge_gleiche_komponente_und_prioritaet() {
// Beton-Wand trifft Beton-Decke (u-deckungsgleich), gleiche componentId +
// Prio -> verschmelzen zu EINEM Rechteck v in [0,4] (keine innere Kante).
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton"));
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton"));
let out = subtract_dominant_bands(vec![wand, decke]);
assert_eq!(out.len(), 1);
let r = rect_of(&out[0]);
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 4.0), "verschmolzen {r:?}");
}
}
+513 -13
View File
@@ -21,19 +21,68 @@
//!
//! ## Ausgabeformat
//!
//! Interleaved `[world_x, world_y, world_z, u, v]` je Vertex (5 f32,
//! `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern wandern als
//! Attribut mit, damit die Schraffur im Fragment-Shader ein durchgaengiges,
//! blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet (nicht im
//! Bildschirmraum). Ein Cut-Polygon ist stets ein konvexes Rechteck (4 Ecken,
//! siehe `section.rs`) — Fan-Triangulierung genuegt.
//! Interleaved `[world_x, world_y, world_z, u, v, pattern, angle_rad, scale]` je
//! Vertex (8 f32, `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern
//! wandern als Attribut mit, damit die Schraffur im Fragment-Shader ein
//! durchgaengiges, blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet
//! (nicht im Bildschirmraum). `pattern`/`angle_rad`/`scale` kommen aus der
//! Bauteil-Schraffur (`CutPolygon::hatch` -> `types::Hatch`) und waehlen im Shader
//! das MUSTER je geschnittener Schicht (statt eines fixen 45°-Musters). Sie sind je
//! Cut-Polygon konstant, wandern aber als (redundantes) Vertex-Attribut mit — das
//! haelt die Cap-Pipeline ohne zusaetzliche per-Draw-Uniform-Verwaltung (die Caps
//! werden in EINEM `draw` gezeichnet). Ein Cut-Polygon ist stets ein konvexes
//! Rechteck (4 Ecken, siehe `section.rs`) — Fan-Triangulierung genuegt.
use crate::section::{cut_section, SectionPlane};
use crate::section::{cut_section, CutPolygon, SectionPlane};
use crate::section_boolean::{rect_of_poly, subtract_dominant_bands};
use crate::types::{SlabInput, WallInput};
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v]. Spiegel des Vertex-Layouts
/// der `cap_pipeline` (gpu.rs) und des `VsIn` in `CAP_WGSL` (shaders.rs).
pub const CAP_FLOATS_PER_VERTEX: usize = 5;
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v, pattern, angle_rad, scale,
/// line_weight_mm]. Spiegel des Vertex-Layouts der `cap_pipeline` (gpu.rs) und des
/// `VsIn` in `CAP_WGSL` (shaders.rs). `line_weight_mm` steuert die Musterlinien-
/// Breite (mm-Papier -> Welt) je Schraffur.
pub const CAP_FLOATS_PER_VERTEX: usize = 9;
/// Default-Musterlinien-Staerke (mm-Papier), wenn ein Cut-Polygon keine `hatch`
/// traegt (Fallback-Diagonale) — Haarlinie, wie `HatchRender.lineWeight`-Default.
const FALLBACK_HATCH_LINE_WEIGHT_MM: f32 = 0.13;
/// f32 pro Cut-Linien-Vertex: [pos.x, pos.y, pos.z, r, g, b] — dasselbe Layout wie
/// die Grid-/Highlight-LineList (`GRID_WGSL`), damit die Schnitt-Umrisslinien ohne
/// eigenes Vertex-Format ueber eine (Dreiecks-)Ribbon-Pipeline laufen.
pub const CUT_LINE_FLOATS_PER_VERTEX: usize = 6;
/// Default-Schnittkanten-Farbe (dunkle Tinte), wenn ein Band keinen eigenen
/// `line_color` traegt — passend zur 2D-Schnittkonvention (dunkle Fugenlinie).
const DEFAULT_CUT_LINE_COLOR: [f32; 3] = [0.10, 0.10, 0.10];
/// Default-Strichstaerke der Schnitt-Umrisslinie in mm-Papier (Haarlinie), wenn
/// ein Band keinen eigenen `line_weight` traegt.
const DEFAULT_CUT_LINE_WEIGHT_MM: f32 = 0.35;
/// mm-Papier -> Welt-Meter fuer die Ribbon-BREITE der Schnitt-Umrisslinien.
///
/// EHRLICHE EINSCHRAENKUNG: die 2D-Strichstaerke ist in mm-Papier (masstabsabhaengig).
/// Der 3D-Schnitt kennt keinen Papiermasstab; die Linie wird hier als flaches Ribbon
/// mit KONSTANTER Welt-Breite in die Schnittebene gelegt (blickwinkel-unabhaengig,
/// skaliert also mit der Kamera-Distanz wie gemalte Geometrie — NICHT bildschirmfest).
/// Der Faktor ist bewusst grob gewaehlt (0.35 mm -> ~3.5 mm Welt), damit die Fuge
/// ueberhaupt lesbar bleibt; eine bildschirmfeste Strichstaerke braeuchte eine
/// Screen-Space-Expansion in einer eigenen Pipeline (bekannte Luecke, siehe
/// Schlussbericht/Moduldoc).
const CUT_LINE_MM_TO_WORLD: f32 = 0.01;
/// Kleiner Versatz (Meter) der Umrisslinien-Ribbons ZUR KAMERA (entgegen der
/// Ebenennormale), damit sie (a) nicht durch die Schnittebenen-Kappung von
/// `GRID_WGSL` (`depth > 0` = hinter der Ebene) weggeschnitten werden und (b)
/// sichtbar VOR den Cut-Caps liegen (die exakt auf der Ebene sitzen).
const CUT_LINE_LIFT: f32 = 0.002;
/// Fallback-Muster-Id, wenn ein Cut-Polygon keine `hatch` traegt: 2 = diagonal
/// (45°) — reproduziert mit `angle_rad = 0`, `scale = 1` exakt das fruehere fest
/// verdrahtete 45°-Muster (Rueckwaertskompatibilitaet fuer Waende/Decken ohne
/// aufgeloeste Bauteil-Schraffur).
const FALLBACK_PATTERN: f32 = 2.0;
/// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale
/// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die
@@ -44,12 +93,23 @@ pub const CAP_FLOATS_PER_VERTEX: usize = 5;
/// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die
/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt
/// nicht von der Normale ab.
/// Berechnet die (u,v)-Cut-Polygone der Schnittebene MIT angewandter Boolean-
/// Dominanz (`section_boolean::subtract_dominant_bands`): die staerkere Schicht
/// schneidet die schwaechere weg — elementuebergreifend, exakt wie der 2D-Schnitt
/// (`toSection.ts::attachCutStyles` Phase 2). Gemeinsame Basis der Cut-Cap-Flaechen
/// (`build_cut_caps`) UND der Cut-Umrisslinien (`build_cut_cap_lines`), damit beide
/// auf DERSELBEN, ueberlappungsfreien Rechteck-Menge arbeiten.
fn cut_cap_polys(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> Vec<CutPolygon> {
let out = cut_section(plane, walls, slabs);
subtract_dominant_bands(out.cut_polygons)
}
pub fn build_cut_caps(
plane: &SectionPlane,
walls: &[WallInput],
slabs: &[SlabInput],
) -> Vec<f32> {
let out = cut_section(plane, walls, slabs);
let polys = cut_cap_polys(plane, walls, slabs);
let point = plane.point;
let u_axis = plane.u_axis();
// (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v.
@@ -62,14 +122,28 @@ pub fn build_cut_caps(
};
let mut verts: Vec<f32> = Vec::new();
for poly in &out.cut_polygons {
for poly in &polys {
let pts = &poly.pts;
if pts.len() < 3 {
continue;
}
// Muster/Winkel/Skalierung aus der Bauteil-Schraffur (oder Fallback-
// Diagonale). Winkel von Grad in Radiant, damit der Shader direkt
// cos/sin rechnen kann.
let (pattern, angle_rad, scale, line_weight) = match poly.hatch {
Some(h) => (
h.pattern as f32,
h.angle.to_radians(),
h.scale.max(0.05),
h.line_weight.max(0.0),
),
None => (FALLBACK_PATTERN, 0.0, 1.0, FALLBACK_HATCH_LINE_WEIGHT_MM),
};
let mut push_vertex = |uv: [f32; 2]| {
let w = to_world(uv);
verts.extend_from_slice(&[w[0], w[1], w[2], uv[0], uv[1]]);
verts.extend_from_slice(&[
w[0], w[1], w[2], uv[0], uv[1], pattern, angle_rad, scale, line_weight,
]);
};
// Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1).
for i in 1..pts.len() - 1 {
@@ -80,3 +154,429 @@ pub fn build_cut_caps(
}
verts
}
/// Erzeugt die Schnitt-UMRISSLINIEN (P2): fuer JEDES ueberlebende Cut-Cap-Rechteck
/// seine vier Kanten als flache Ribbon-Dreiecke auf der Schnittebene, in Welt-
/// Koordinaten (dieselbe (u,v)->Welt-Ruecktransformation wie `build_cut_caps`).
/// Rueckgabe = interleaved `[px,py,pz, r,g,b]` (`CUT_LINE_FLOATS_PER_VERTEX`,
/// TriangleList), leer wenn die Ebene das Modell nicht schneidet.
///
/// STIL je Kante: Farbe/Strichstaerke aus dem `line_color`/`line_weight` des Bandes
/// (aus dem `jointLineStyleId`-LineStyle, TS-seitig aufgeloest), sonst der Default-
/// Schnittkanten-Stil. Die Strichstaerke (mm-Papier) wird ueber `CUT_LINE_MM_TO_WORLD`
/// in eine konstante Welt-Breite umgesetzt (siehe dortige Doku — bewusste
/// Vereinfachung, keine bildschirmfeste Strichstaerke). Jede Kante wird als Quad
/// (2 Dreiecke) um ihre Mittellinie gelegt, mit der Ribbon-Breite senkrecht zur
/// Kante IN der Ebene; alle Ribbons um `CUT_LINE_LIFT` zur Kamera versetzt, damit
/// sie nicht an der Ebene weggeclippt werden und vor den Caps liegen.
pub fn build_cut_cap_lines(
plane: &SectionPlane,
walls: &[WallInput],
slabs: &[SlabInput],
) -> Vec<f32> {
let polys = cut_cap_polys(plane, walls, slabs);
let point = plane.point;
let u_axis = plane.u_axis();
let normal = plane.normal;
// Kamera-waertiger Lift entlang -normal (normal zeigt vom Betrachter ins Modell).
let lift = [
-normal[0] * CUT_LINE_LIFT,
-normal[1] * CUT_LINE_LIFT,
-normal[2] * CUT_LINE_LIFT,
];
let to_world = |uv: [f32; 2]| -> [f32; 3] {
[
point[0] + uv[0] * u_axis[0] + lift[0],
uv[1] + lift[1],
point[2] + uv[0] * u_axis[2] + lift[2],
]
};
let mut verts: Vec<f32> = Vec::new();
for poly in &polys {
let Some(r) = rect_of_poly(poly) else {
continue;
};
// Stil aus den Band-Metadaten (oder Default).
let (color, weight_mm) = match &poly.cut {
Some(meta) => (
meta.line_color.unwrap_or(DEFAULT_CUT_LINE_COLOR),
meta.line_weight.unwrap_or(DEFAULT_CUT_LINE_WEIGHT_MM),
),
None => (DEFAULT_CUT_LINE_COLOR, DEFAULT_CUT_LINE_WEIGHT_MM),
};
let half_w = (weight_mm.max(0.0) * CUT_LINE_MM_TO_WORLD * 0.5).max(1e-4);
// Die vier Rechteck-Ecken (u,v) im Umlauf; je Kante ein Ribbon.
let corners = [
[r.u_min, r.v_min],
[r.u_max, r.v_min],
[r.u_max, r.v_max],
[r.u_min, r.v_max],
];
for k in 0..4 {
let a = to_world(corners[k]);
let b = to_world(corners[(k + 1) % 4]);
push_line_ribbon(&mut verts, a, b, normal, half_w, color);
}
}
verts
}
/// Legt ein flaches Ribbon-Quad (2 Dreiecke) der halben Breite `half_w` um die
/// Kante `a`->`b`, verbreitert IN der Schnittebene (senkrecht zur Kante ueber
/// `cross(normal, dir)`), und schreibt es als 6 Vertices `[pos, color]` in `out`.
fn push_line_ribbon(
out: &mut Vec<f32>,
a: [f32; 3],
b: [f32; 3],
normal: [f32; 3],
half_w: f32,
color: [f32; 3],
) {
let dir = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
if len < 1e-9 {
return;
}
let d = [dir[0] / len, dir[1] / len, dir[2] / len];
// In-Ebene-Senkrechte zur Kante: cross(normal, dir) liegt in der Ebene und
// steht senkrecht auf der Kante.
let mut perp = [
normal[1] * d[2] - normal[2] * d[1],
normal[2] * d[0] - normal[0] * d[2],
normal[0] * d[1] - normal[1] * d[0],
];
let plen = (perp[0] * perp[0] + perp[1] * perp[1] + perp[2] * perp[2]).sqrt();
if plen < 1e-9 {
return;
}
perp = [perp[0] / plen * half_w, perp[1] / plen * half_w, perp[2] / plen * half_w];
let p0 = [a[0] - perp[0], a[1] - perp[1], a[2] - perp[2]];
let p1 = [a[0] + perp[0], a[1] + perp[1], a[2] + perp[2]];
let p2 = [b[0] + perp[0], b[1] + perp[1], b[2] + perp[2]];
let p3 = [b[0] - perp[0], b[1] - perp[1], b[2] - perp[2]];
let mut push = |p: [f32; 3]| {
out.extend_from_slice(&[p[0], p[1], p[2], color[0], color[1], color[2]]);
};
// Zwei Dreiecke: p0,p1,p2 und p0,p2,p3.
push(p0);
push(p1);
push(p2);
push(p0);
push(p2);
push(p3);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Hatch;
/// Eine Wand entlang der X-Achse, senkrecht von einer +X-Ebene geschnitten:
/// die Muster-Id/Winkel/Skalierung der Bauteil-Schraffur muessen in JEDEM
/// Cap-Vertex an den Positionen 5/6/7 landen (Vertex-Stride = 8).
#[test]
fn hatch_muster_id_landet_in_den_cap_vertices() {
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch {
pattern: 3, // crosshatch
angle: 90.0,
scale: 2.0,
line_weight: 0.5,
}),
cut: None,
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[]);
assert!(!verts.is_empty(), "Schnitt liefert Cap-Geometrie");
assert_eq!(
verts.len() % CAP_FLOATS_PER_VERTEX,
0,
"Vertexpuffer ist ein Vielfaches des Strides ({CAP_FLOATS_PER_VERTEX})"
);
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
assert_eq!(verts[base + 5], 3.0, "pattern-Id je Vertex");
assert!(
(verts[base + 6] - 90.0_f32.to_radians()).abs() < 1e-5,
"angle in Radiant"
);
assert_eq!(verts[base + 7], 2.0, "scale je Vertex");
assert_eq!(verts[base + 8], 0.5, "line_weight (mm) je Vertex");
}
}
/// Ohne `hatch` faellt jedes Cap-Polygon auf das Diagonalmuster (Id 2,
/// angle 0, scale 1) zurueck — das reproduziert das fruehere fixe 45°-Muster.
#[test]
fn ohne_hatch_fallback_auf_diagonale() {
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[]);
assert!(!verts.is_empty());
assert_eq!(verts[5], FALLBACK_PATTERN, "Fallback = Diagonale (2)");
assert_eq!(verts[6], 0.0, "Fallback-Winkel 0");
assert_eq!(verts[7], 1.0, "Fallback-Skalierung 1");
assert_eq!(verts[8], FALLBACK_HATCH_LINE_WEIGHT_MM, "Fallback-Musterlinienstaerke 0.13 mm");
}
use crate::types::CutBandMeta;
/// Ein Wand-Band (Prio 50, volle Hoehe) und ein u-deckungsgleiches Decken-Band
/// (Prio 100) ueberlappen in einem z-Intervall. Nach `build_cut_caps` (das die
/// Boolean-Dominanz anwendet) darf im Ueberlappungs-z-Intervall KEINE Wand-
/// Cap-Geometrie mehr liegen — die geometrische Kernaussage von P1, direkt auf
/// den erzeugten Welt-Vertices geprueft (nicht bloss „laeuft").
#[test]
fn build_cut_caps_wendet_dominanz_an_wand_verliert_ueberlappung() {
// Wand entlang +X bei x in [0,3], Dicke 0.4, volle Hoehe [0,2.5]; Prio 50.
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: None,
line_weight: None,
}),
};
// Decken-Slab, deren Umriss die Wand an der Schnittstelle (x=1.5) ueberdeckt,
// z in [2.0, 2.4]; Prio 100 (Beton laeuft durch, schneidet die Wand).
let slab = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 2.0,
z_top: 2.4,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 1, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(100),
component_id: Some("concrete".into()),
line_color: None,
line_weight: None,
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[slab]);
assert!(!verts.is_empty());
// Die Wand-Caps tragen pattern==2 (diagonal), die Beton-Caps pattern==1
// (solid). Fuer JEDES Wand-Vertex (pattern 2) muss die Hoehe (v = pos.y,
// Index 1) AUSSERHALB des Beton-Intervalls [2.0,2.4] liegen.
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
let mut wall_verts = 0;
let mut concrete_verts = 0;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
let y = verts[base + 1];
let pattern = verts[base + 5];
if (pattern - 2.0).abs() < 0.5 {
wall_verts += 1;
assert!(
y <= 2.0 + 1e-3 || y >= 2.4 - 1e-3,
"Wand-Cap-Vertex bei v={y} liegt im Beton-z-Intervall (muesste weggeschnitten sein)"
);
} else if (pattern - 1.0).abs() < 0.5 {
concrete_verts += 1;
}
}
assert!(wall_verts > 0, "Wand-Caps vorhanden (ober-/unterhalb des Betons)");
assert!(concrete_verts > 0, "Beton-Caps vorhanden (voll durchlaufend)");
}
/// Reziprok zum vorigen Test (Nutzer-Report 2026-07-08): eine Backstein-WAND
/// (Prio 50) muss die SCHWAECHEREN Decken-Schichten Estrich (30) und Daemmung
/// (20) in der u/v-Ueberlappung wegschneiden — wie im 2D-Schnitt. Wir bauen die
/// Wand als EIN Backstein-Band (Prio 50) und den Boden als zwei duenne Slabs
/// (Estrich oben, Daemmung darunter), deren z-Intervall die Wand ueberlappt.
/// Nach der Dominanz darf im Wand-u-Intervall KEINE Estrich-/Daemmung-Cap mehr
/// liegen (die Wand-Cap dagegen bleibt — Backstein ist dort dominant).
#[test]
fn wand_schneidet_schwaechere_decken_schichten() {
// Backstein-Wand entlang +X, x in [0,3], Dicke 0.4 → u (=y) in [-0.2,0.2],
// volle Hoehe [0,2.5]; Prio 50, Muster 2 (diagonal).
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: None,
line_weight: None,
}),
};
// Estrich-Slab (Prio 30, Muster 3 crosshatch), z in [1.0,1.06], deckt die
// Wand in u ab (Umriss ueber y in [-1,1]).
let screed = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 1.0,
z_top: 1.06,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 3, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(30),
component_id: Some("screed".into()),
line_color: None,
line_weight: None,
}),
};
// Daemmung-Slab (Prio 20, Muster 4), z in [0.96,1.0], gleiche u-Deckung.
let insulation = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 0.96,
z_top: 1.0,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 4, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(20),
component_id: Some("insulation".into()),
line_color: None,
line_weight: None,
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[screed, insulation]);
assert!(!verts.is_empty());
// Wand-u-Intervall (=y) ist [-0.2,0.2]. Fuer JEDES Estrich- (Muster 3) oder
// Daemmung-Vertex (Muster 4) muss u AUSSERHALB [-0.2,0.2] liegen — sonst hat
// der Backstein die schwaechere Schicht dort NICHT weggeschnitten.
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
let mut weak_verts = 0;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
let u = verts[base]; // pos.x = y-Welt (Schnitt entlang +X)
let pattern = verts[base + 5];
if (pattern - 3.0).abs() < 0.5 || (pattern - 4.0).abs() < 0.5 {
weak_verts += 1;
assert!(
u <= -0.2 + 1e-3 || u >= 0.2 - 1e-3,
"Estrich/Daemmung-Vertex bei u={u} liegt im Backstein-u-Intervall \
[-0.2,0.2] (muesste weggeschnitten sein)"
);
}
}
assert!(weak_verts > 0, "Estrich/Daemmung-Caps existieren (ausserhalb der Wand)");
}
/// P2: je ueberlebendem Cut-Cap-Rechteck entstehen 4 Umriss-Kanten als Ribbon-
/// Dreiecke (4 Kanten * 6 Vertices = 24 Vertices je Rechteck), und Farbe +
/// Strichstaerke kommen aus dem Band-Stil durch (Farbe je Vertex, Staerke ueber
/// die Ribbon-Breite messbar).
#[test]
fn build_cut_cap_lines_kantenzahl_und_stil() {
let color = [0.2, 0.4, 0.9];
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: Some(color),
line_weight: Some(2.0),
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_cap_lines(&plane, &[wall.clone()], &[]);
assert!(!verts.is_empty());
assert_eq!(
verts.len() % CUT_LINE_FLOATS_PER_VERTEX,
0,
"Vielfaches des Cut-Linien-Strides"
);
// EIN Rechteck (keine Dominanz-Subtraktion, lone band) -> 4 Kanten * 6 = 24 Vertices.
assert_eq!(
verts.len() / CUT_LINE_FLOATS_PER_VERTEX,
24,
"4 Kanten * 6 Vertices je Ribbon"
);
// Farbe je Vertex aus dem Stil (Index 3/4/5).
let n = verts.len() / CUT_LINE_FLOATS_PER_VERTEX;
for i in 0..n {
let base = i * CUT_LINE_FLOATS_PER_VERTEX;
assert!((verts[base + 3] - color[0]).abs() < 1e-6);
assert!((verts[base + 4] - color[1]).abs() < 1e-6);
assert!((verts[base + 5] - color[2]).abs() < 1e-6);
}
// Strichstaerke kommt durch: eine DICKERE Linie erzeugt ein breiteres Ribbon.
// Messbar an der (u,v)-Ausdehnung senkrecht zur Kante — hier ueber die
// world-Bounding-Box-Hoehe der beiden horizontalen Kanten (v-Ausdehnung der
// gesamten Geometrie) im Vergleich duenn vs. dick.
let bbox_y = |vs: &[f32]| -> f32 {
let n = vs.len() / CUT_LINE_FLOATS_PER_VERTEX;
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for i in 0..n {
let y = vs[i * CUT_LINE_FLOATS_PER_VERTEX + 1];
lo = lo.min(y);
hi = hi.max(y);
}
hi - lo
};
let mut thin = wall.clone();
thin.cut = Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: Some(color),
line_weight: Some(0.2),
});
let thin_verts = build_cut_cap_lines(&plane, &[thin], &[]);
assert!(
bbox_y(&verts) > bbox_y(&thin_verts) + 1e-4,
"dickere Strichstaerke -> groessere v-Ausdehnung der Ribbons"
);
}
}
+104 -22
View File
@@ -144,15 +144,20 @@ struct Globals {
};
@group(0) @binding(0) var<uniform> globals : Globals;
// Bild-Textur + Sampler (group 1). Sampler mit Repeat/Linear (siehe gpu.rs) ->
// das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
@group(1) @binding(0) var tex : texture_2d<f32>;
// Material-Textur-ARRAY + Sampler (group 1). Sampler mit Repeat/Linear (siehe
// gpu.rs) -> das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
// Ebene 0 = Fallback-Schachbrett; Ebenen 1..N = per `set_material_textures`
// hochgeladene Material-Farbkarten. Die Ebene je Vertex kommt aus einem ZWEITEN
// Vertex-Buffer (@location(3)); `mode.z` traegt die Anzahl gueltiger Ebenen.
@group(1) @binding(0) var tex : texture_2d_array<f32>;
@group(1) @binding(1) var samp : sampler;
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) normal : vec3<f32>,
@location(2) uv : vec2<f32>,
// Material-Textur-Ebene dieses Vertex (`-1` = kein Material -> Schachbrett).
@location(3) layer : f32,
};
struct VsOut {
@@ -160,6 +165,7 @@ struct VsOut {
@location(0) world_normal : vec3<f32>,
@location(1) uv : vec2<f32>,
@location(2) world_pos : vec3<f32>,
@location(3) @interpolate(flat) layer : f32,
};
@vertex
@@ -169,6 +175,7 @@ fn vs_main(in : VsIn) -> VsOut {
out.world_normal = in.normal;
out.uv = in.uv;
out.world_pos = in.position;
out.layer = in.layer;
return out;
}
@@ -195,8 +202,13 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
* vec3<f32>(0.24, 0.27, 0.30);
// EINZIGER Unterschied zu MESH_WGSL: Albedo aus der Bild-Textur statt Vertexfarbe.
let albedo = textureSample(tex, samp, in.uv).rgb;
// Albedo aus dem Material-Textur-Array statt Vertexfarbe. Ebene je Vertex:
// `layer < 0` (kein Material) -> Ebene 0 (Schachbrett). Sonst auf den
// gueltigen Bereich [0, mode.z-1] geklemmt (Sicherheit gegen ueberzaehlige
// Indizes, falls die Material-Menge kleiner ist als erwartet).
let max_layer = max(i32(globals.mode.z) - 1, 0);
let li = clamp(i32(round(in.layer)), 0, max_layer);
let albedo = textureSample(tex, samp, in.uv, li).rgb;
var shaded = albedo * (ambient + diffuse + fill);
@@ -261,11 +273,16 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
"#;
/// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline).
/// Eigene TriangleList-Pipeline: schlankes Vertex-Layout [pos vec3, uv vec2],
/// Eigene TriangleList-Pipeline: Vertex-Layout [pos vec3, uv vec2, hatch vec3],
/// dieselbe `Globals`-Bind-Group (group(0) binding(0)) wie Mesh/Grid. Die Caps
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: prozedurale
/// 45-Grad-Diagonalschraffur aus den Ebenen-Koordinaten (u,v) in Modell-Metern —
/// dunkle Tinte auf hellem Papier, passend zur 2D-Schnittkonvention.
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: PROZEDURALE
/// Bauteil-Schraffur je geschnittener Schicht — Muster/Winkel/Skalierung kommen aus
/// dem `hatch`-Vertex-Attribut (pattern, angle_rad, scale; siehe
/// `section_fill.rs`/`types::Hatch`). MONOCHROM (dunkle Tinte auf hellem Papier,
/// passend zur 2D-Schnittkonvention `HATCH_INK`): es variiert das MUSTER, nicht die
/// Farbe. Muster-Ids: 0 = none (leer/weiss), 1 = solid (Vollton),
/// 2 = diagonal (Linienschar, `angle` steuert die Neigung — 0° = horizontal, wie
/// im 2D), 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung).
pub const CAP_WGSL: &str = r#"
struct Globals {
view_proj : mat4x4<f32>,
@@ -281,11 +298,18 @@ struct Globals {
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) uv : vec2<f32>,
// hatch: x = Muster-Id, y = Winkel (Radiant), z = Skalierung, w = Musterlinien-
// Staerke (mm-Papier). Je Cut-Polygon konstant (redundant je Vertex, siehe
// section_fill.rs).
@location(2) hatch : vec4<f32>,
};
struct VsOut {
@builtin(position) clip_pos : vec4<f32>,
@location(0) uv : vec2<f32>,
// flat: die Muster-Parameter sind je Flaeche konstant und duerfen nicht
// interpoliert werden (Muster-Id ist eine Ganzzahl-Auswahl).
@location(1) @interpolate(flat) hatch : vec4<f32>,
};
@vertex
@@ -293,27 +317,85 @@ fn vs_main(in : VsIn) -> VsOut {
var out : VsOut;
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
out.uv = in.uv;
out.hatch = in.hatch;
return out;
}
// Ein AA-gekantetes Linienband entlang der Koordinate `s` (in Einheiten von
// `spacing`): 1 auf der Linie, 0 dazwischen. `fwidth` glaettet blickwinkelrichtig.
fn line_band(s : f32, half_s : f32) -> f32 {
let frac = fract(s);
let dist = min(frac, 1.0 - frac);
let aa = max(fwidth(s), 1e-5);
return 1.0 - smoothstep(half_s, half_s + aa, dist);
}
@fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
// 45-Grad-Diagonalschraffur in Modell-Metern: Linien bei ganzzahligen
// Vielfachen von `spacing` entlang (u + v). `dist` = Abstand (in Einheiten
// von `spacing`) zur naechsten Linie; `fwidth` liefert eine bildschirm-
// groessenrichtige Kantenglaettung (kein Aliasing bei flachem Blickwinkel).
let spacing = 0.08; // Linienabstand ~8 cm
let half = 0.004; // halbe Linienbreite ~4 mm (Strich ~8 mm)
let ink = vec3<f32>(0.06, 0.06, 0.06);
let paper = vec3<f32>(0.94, 0.94, 0.94);
let s = (in.uv.x + in.uv.y) / spacing;
let frac = fract(s);
let dist = min(frac, 1.0 - frac); // 0 auf der Linie
let half_s = half / spacing;
let aa = max(fwidth(s), 1e-5);
let line = 1.0 - smoothstep(half_s, half_s + aa, dist);
let color = mix(paper, ink, line);
let pattern = i32(round(in.hatch.x));
let angle = in.hatch.y;
let scale = max(in.hatch.z, 0.05);
// none (0): leere (weisse) Schnittflaeche — wie „keine Schraffur" im 2D.
if (pattern == 0) {
return vec4<f32>(paper, 1.0);
}
// solid (1): Vollton (Poché) — z. B. Beton.
if (pattern == 1) {
return vec4<f32>(ink, 1.0);
}
// Muster-Koordinaten: (u,v) in Modell-Metern, um `angle` gedreht. Konvention
// wie die 2D-Schraffur (`hatchPreview.parallelLines`): `angle == 0` ergibt
// HORIZONTALE Linien (Phasen-Achse = dv), `+angle` dreht die Schar gegen den
// Uhrzeigersinn wie im 2D-Swatch. `du` laeuft ENTLANG der Linienrichtung, `dv`
// ist die Quer-/Phasen-Achse. (Frueher lag hier ein fixer 45°-Versatz, weil das
// Diagonalmuster ueber `du+dv` lief — dadurch war alles 45° verdreht.)
let ca = cos(angle);
let sa = sin(angle);
let du = in.uv.x * ca + in.uv.y * sa; // entlang der Linienrichtung
let dv = -in.uv.x * sa + in.uv.y * ca; // quer (Phase)
let spacing = 0.08 * scale; // Grund-Linienabstand ~8 cm * Skalierung
// Musterlinien-Staerke PRO Schraffur aus `hatch.w` (mm-Papier, aus dem LineStyle
// des Hatch — wie die 2D-Schnitt-Schraffur: SIA `hatch-hair` 0.02 mm vs. `thin`
// 0.13 mm). Dieselbe mm→Welt-Kalibrierung wie die Schicht-Trennlinien
// (`section_fill::CUT_LINE_MM_TO_WORLD` = 0.01, halbe Breite ×0.5 → Faktor 0.005),
// damit Schraffur- und Trennlinien-Staerke konsistent skalieren. Kleiner Floor,
// damit eine Haarlinie nicht ganz verschwindet.
let half = max(in.hatch.w * 0.005, 0.0001);
let half_s = half / spacing;
var line = 0.0;
if (pattern == 2) {
// diagonal: EINE Linienschar quer zur Phasen-Achse (dv). angle==0 →
// horizontal; `hatch.angle` (z. B. 45° bei Backstein) dreht sie wie im 2D.
line = line_band(dv / spacing, half_s);
} else if (pattern == 3) {
// crosshatch: zwei Scharen ueber Kreuz (Phase dv + Phase du = +90°). Bei
// angle==0 horizontal+vertikal, bei 45° (Beton) das gekippte Kreuz — wie 2D.
line = max(
line_band(dv / spacing, half_s),
line_band(du / spacing, half_s)
);
} else {
// insulation (4, Daemmung): Zickzack. Die Quer-Position (dv) folgt einer
// Dreieckswelle entlang der Lauf-Achse (du) — ein durchgehender Zickzack.
let cell = spacing * 2.0;
let a = du / cell;
let b = dv / cell;
let tri = abs(fract(a) - 0.5) * 2.0; // 0..1 Dreieckswelle
let fb = fract(b);
let d = min(abs(fb - tri), min(abs(fb - tri + 1.0), abs(fb - tri - 1.0)));
let aa = max(fwidth(fb) + fwidth(tri), 1e-5);
let hw = half / cell;
line = 1.0 - smoothstep(hw, hw + aa, d);
}
let color = mix(paper, ink, clamp(line, 0.0, 1.0));
return vec4<f32>(color, 1.0);
}
"#;
+129 -2
View File
@@ -20,6 +20,79 @@ pub type Point2 = [f32; 2];
/// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht.
pub type Rgb = [f32; 3];
/// Schnitt-Schraffur eines Bauteils (fuer den 3D-Live-Schnitt). Rust-seitiges
/// Minimal-Aequivalent zu `HatchStyle` (src/model/types.ts): nur die drei Felder,
/// die der prozedurale Cap-Shader (`shaders::CAP_WGSL`) auswertet — Muster-Id,
/// Winkel und Skalierung. Die Muster-Farbe bleibt MONOCHROM (Tinte auf Papier,
/// siehe 2D-Direktive `HATCH_INK`) und wandert deshalb bewusst NICHT mit.
///
/// `pattern`: 0 = none (leer/weiss), 1 = solid (Vollton), 2 = diagonal (45°),
/// 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung). Die Zuordnung ist
/// mit der TS-Seite (`toWalls3d::hatchPatternId`) und dem Shader synchron zu halten.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Hatch {
/// Muster-Id (siehe oben). Default 0 (none).
#[serde(default)]
pub pattern: u32,
/// Musterdrehung in Grad (Bildschirm-/Schnittebenen-Konvention). Default 0.
#[serde(default)]
pub angle: f32,
/// Grundmassstab (1 = Standardteilung). Default 1.
#[serde(default = "default_hatch_scale")]
pub scale: f32,
/// Strichstaerke der Musterlinien in mm-Papier (aus dem LineStyle des Hatch,
/// `HatchRender.lineWeight`). Der Cap-Shader (`CAP_WGSL`) setzt die Muster-
/// linienbreite daraus (mm->Welt wie die Trennlinien), sodass die Schraffur-
/// Staerke pro Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs.
/// `thin` 0.13 mm). Default 0.13 (Alt-Verhalten/Haarlinie).
#[serde(default = "default_hatch_line_weight", rename = "lineWeight")]
pub line_weight: f32,
}
fn default_hatch_scale() -> f32 {
1.0
}
fn default_hatch_line_weight() -> f32 {
0.13
}
/// Verschneidungs-/Stil-Metadaten EINES geschnittenen Bauteil-Bandes fuer den
/// 3D-Live-Schnitt — das Rust-Gegenstueck zu den Feldern, mit denen `toSection.ts`
/// die Schnitt-Boolean-Dominanz (`subtractDominantBands`) rechnet. Rein additiv
/// (`#[serde(default)]` an jedem Feld); fehlt der ganze Block (`WallInput::cut ==
/// None`), verhaelt sich der Schnitt wie zuvor (Band nimmt an keiner Subtraktion
/// teil).
///
/// Da der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`/`SlabInput`-Box
/// emittiert (`toWalls3d`, layered), traegt jede geschnittene Schicht hier IHRE
/// eigene Prioritaet/Identitaet/Linienstil — die Dominanz-Verschneidung
/// (`section_boolean.rs`) laeuft dann elementuebergreifend auf den erzeugten
/// (u,v)-Rechtecken, exakt wie im 2D-Schnitt.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CutBandMeta {
/// `Component.joinPriority` der Schicht: ein Band mit STRIKT hoeherer
/// Prioritaet schneidet ein ueberlappendes schwaecheres per Rechteck-
/// Subtraktion weg (elementuebergreifend). `None` -> keine Teilnahme.
#[serde(default)]
pub join_priority: Option<i32>,
/// `Component.id` der Schicht — steuert die MERGE-Regel (gleiche Komponente +
/// Prioritaet verschmelzen zu einem Koerper, keine innere Trennlinie). `None`
/// -> keine Verschmelzung.
#[serde(default)]
pub component_id: Option<String>,
/// Farbe (RGB 0..1) der Schnitt-Umrisslinie dieses Bandes (P2, aus dem
/// `jointLineStyleId`-LineStyle). `None` -> Default-Schnittkanten-Stil.
#[serde(default)]
pub line_color: Option<Rgb>,
/// Strichstaerke der Schnitt-Umrisslinie in mm-Papier (aus dem LineStyle).
/// `None` -> Default. Siehe `section_fill::build_cut_cap_lines` fuer die (ehrlich
/// dokumentierte) mm->Welt-Umsetzung.
#[serde(default)]
pub line_weight: Option<f32>,
}
/// Eine geflachte Wand: Achse (Mittellinie) Start->Ende im Grundriss, plus Dicke,
/// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh
/// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`).
@@ -82,6 +155,30 @@ pub struct WallInput {
/// extrudiert den vollen Achsen-Abschnitt mit Loechern.
#[serde(default)]
pub holes: Vec<Hole>,
/// Optionaler Material-Textur-Index fuer den Stil `Textured`: verweist auf eine
/// per `Renderer::set_material_textures` hochgeladene Material-Farbkarte
/// (Textur-Array-Ebene, 1-basiert; Ebene 0 ist das Fallback-Schachbrett). Da
/// der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
/// (`toWalls3d`, `layeredWalls:true`), traegt jede Schicht so IHRE Material-
/// Textur. `None` -> Fallback-Schachbrett (bisheriges Verhalten). Vom
/// Schnitt/Serde-Pfad (`section.rs`) ignoriert.
#[serde(default, rename = "materialIndex")]
pub material_index: Option<u32>,
/// Optionale Schnitt-Schraffur dieses Wand-Bandes (siehe `Hatch`). Nur der
/// 3D-Live-Schnitt (`section.rs`/`section_fill.rs`) wertet sie aus, um die
/// Schnittflaeche im MUSTER des Bauteils zu schraffieren (statt eines fixen
/// 45°-Musters). `None` -> Fallback-Diagonalmuster (Alt-Verhalten). Da der
/// 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
/// (`toWalls3d`, `layeredWalls:true`), traegt jede geschnittene Schicht so
/// ihre eigene Schraffur.
#[serde(default)]
pub hatch: Option<Hatch>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe
/// `CutBandMeta`). Nur der 3D-Live-Schnitt (`section_fill`/`section_boolean`)
/// wertet sie aus; fehlt sie, bleibt das Alt-Verhalten (keine Dominanz-
/// Subtraktion, Default-Schnittkante).
#[serde(default)]
pub cut: Option<CutBandMeta>,
}
/// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe
@@ -162,6 +259,13 @@ pub struct SlabInput {
/// Albedo-Farbe (RGB 0..1). Default heller Deckenton.
#[serde(default = "default_slab_color")]
pub color: Rgb,
/// Optionale Schnitt-Schraffur der Decke (siehe `Hatch`/`WallInput::hatch`).
#[serde(default)]
pub hatch: Option<Hatch>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe
/// `CutBandMeta`/`WallInput::cut`).
#[serde(default)]
pub cut: Option<CutBandMeta>,
}
fn default_slab_color() -> Rgb {
@@ -179,6 +283,9 @@ pub enum MeshKind {
/// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau.
#[default]
Imported,
/// Per truck (Profil-Extrusion, `src-tauri/trucksolid`) erzeugter Koerper —
/// warmes Orange, damit er sich klar von Wand/Decke/Kontext abhebt.
Extrusion,
}
impl MeshKind {
@@ -191,6 +298,8 @@ impl MeshKind {
MeshKind::Terrain => [0.663, 0.690, 0.635],
// 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes.
MeshKind::Imported => [0.624, 0.651, 0.682],
// Warmes Orange fuer truck-Profil-Extrusionen (Phase-1-Viewer-Beweis).
MeshKind::Extrusion => [0.85, 0.55, 0.25],
}
}
}
@@ -308,14 +417,24 @@ impl Default for Camera {
}
}
/// Die fuenf Kamera-Presets der three.js-Sicht. `Iso` und `Persp` sind
/// perspektivisch/frei; `Front`/`Top`/`Side` sind achsparallel (orthografisch).
/// Die Kamera-Presets der Kardinal-/Iso-Sicht (ROADMAP §11). `Persp` ist frei
/// perspektivisch; alle anderen sind achsparallel bzw. diagonal orthografisch.
/// `Front`/`Back`/`Side`/`Left` sind die vier Kardinalrichtungen (Vorne/Hinten/
/// Rechts/Links im UI, s. `View3d` in TopBar.tsx — keine Himmelsrichtungen, da
/// es noch keinen Nordwinkel gibt); `Iso`/`IsoFrontLeft`/`IsoBackRight`/
/// `IsoBackLeft` sind die vier OBEREN Iso-Oktanten (untere vier bleiben im
/// Hochbau ungenutzt) — `Iso` (vorne-oben-rechts) ist der bestehende Default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CameraPreset {
Front,
Back,
Top,
Side,
Left,
Iso,
IsoFrontLeft,
IsoBackRight,
IsoBackLeft,
Persp,
}
@@ -345,6 +464,14 @@ pub struct TexturedMesh {
pub verts: Vec<f32>,
/// Dreiecks-Indizes (0-basiert auf die Vertices).
pub indices: Vec<u32>,
/// Optionaler PARALLELER Puffer: EIN f32 je Vertex = die Material-Textur-
/// Ebene (Textur-Array-Index) dieses Vertex, oder `-1` fuer „kein Material"
/// (Fallback-Schachbrett, Ebene 0). Leer, wenn ohne Material-Zuordnung gebaut
/// (`textured_from_mesh`) — dann bindet die GPU-Schicht einen konstanten
/// `-1`-Puffer. Als ZWEITER Vertex-Buffer gehalten (nicht in `verts`
/// interleaved), damit das bestehende `[pos,normal,uv]`-Layout (und die
/// Geometrie-Paritaets-Tests) bitgleich bleiben.
pub layers: Vec<f32>,
}
/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV.
+33 -12
View File
@@ -15,12 +15,13 @@
// (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion
// (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention),
// damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt.
// `set_view_preset` wendet stattdessen eines der fuenf `View3d`-Praesete
// (front/top/side/iso/perspective) direkt ueber `math::preset_camera` an — Front/
// Top/Side/Iso werden dabei ECHT orthografisch (`preset_camera`; Iso = echte
// Isometrie ohne perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung
// nicht leisten kann. Nach einem Praeset-Sprung bleibt die
// weitere Navigation frei ueber `set_camera` (kein Lock).
// `set_view_preset` wendet stattdessen eines der `View3d`-Praesete (front/back/
// top/side/left/iso/isoFrontLeft/isoBackRight/isoBackLeft/perspective) direkt
// ueber `math::preset_camera` an — alle ausser Perspective werden dabei ECHT
// orthografisch (`preset_camera`; die Iso-Varianten = echte Isometrie ohne
// perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung nicht leisten
// kann. Nach einem Praeset-Sprung bleibt die weitere Navigation frei ueber
// `set_camera` (kein Lock).
//
// MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch
// zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts).
@@ -330,19 +331,25 @@ impl WebModelRenderer {
};
}
/// Wendet eines der fuenf Kamera-Praesets der three.js-Sicht
/// (`front`/`top`/`side`/`iso`/`perspective`, s. `View3d` in TopBar.tsx) auf
/// das aktuelle Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend
/// (mirror der TS-`fitTargetDist`); Front/Top/Side/Iso werden orthografisch,
/// Wendet eines der Kardinal-/Iso-Kamera-Praesets der three.js-Sicht
/// (`front`/`back`/`top`/`side`/`left`/`iso`/`isoFrontLeft`/`isoBackRight`/
/// `isoBackLeft`/`perspective`, s. `View3d` in TopBar.tsx) auf das aktuelle
/// Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend (mirror der
/// TS-`fitTargetDist`); alle Presets ausser Perspective werden orthografisch,
/// nur Perspective ist perspektivisch (`math::preset_camera`). Wirkt erst beim
/// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles
/// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx).
pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> {
let preset = match preset {
"front" => CameraPreset::Front,
"back" => CameraPreset::Back,
"top" => CameraPreset::Top,
"side" => CameraPreset::Side,
"left" => CameraPreset::Left,
"iso" => CameraPreset::Iso,
"isoFrontLeft" => CameraPreset::IsoFrontLeft,
"isoBackRight" => CameraPreset::IsoBackRight,
"isoBackLeft" => CameraPreset::IsoBackLeft,
"perspective" => CameraPreset::Persp,
other => {
return Err(JsValue::from_str(&format!(
@@ -377,8 +384,8 @@ impl WebModelRenderer {
/// - `"shaded"` — beleuchtete Bauteilfarben (Default).
/// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet
/// (identisch zum bisherigen `set_render_mode_white(true)`).
/// - `"textured"` — mangels Textur-Pipeline VORERST identisch zu `"shaded"`
/// behandelt (KEIN Fake-Stub); Platzhalter fuer eine spaetere Textur-Schicht.
/// - `"textured"` — Material-Farbkarten je Wand-Band aus dem Textur-Array
/// (`set_material_textures`), Fallback-Schachbrett fuer Baender ohne Material.
/// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen.
/// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth-
/// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare).
@@ -431,6 +438,20 @@ impl WebModelRenderer {
Ok(())
}
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-Array hoch
/// (`Renderer::set_material_textures`). `rgba` = dicht gepackte RGBA8-Bytes von
/// `layer_count` Ebenen, JEDE exakt 256×256 (der JS-Aufrufer dekodiert/skaliert
/// die Material-Bilder, siehe `viewport/materialTextures.ts`). Ebene 0 bleibt
/// intern das Fallback-Schachbrett; die hochgeladenen Karten belegen die Ebenen
/// 1..N, auf die `WallInput::materialIndex` (1-basiert) zeigt. `layer_count == 0`
/// oder zu kleine Daten setzen auf das reine Schachbrett zurueck. Wirkt erst beim
/// naechsten `render`.
pub fn set_material_textures(&mut self, rgba: &[u8], layer_count: u32) -> Result<(), JsValue> {
self.renderer
.set_material_textures(&self.device, &self.queue, rgba, layer_count);
Ok(())
}
/// 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));