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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 0–4 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user