Nativer 2D-wgpu-Renderer (render2d): Ear-Clipping-Fuellungen, gehrte Papier-mm-Linien, GPU-Pan/Zoom
Eigenstaendige Crate (render/window-Feature-Stufung), serde-only Tessellier- schicht headless testbar. Linien als EIN gehrter Streifen (Miter-Bisektor + 1/cos-Laengenfaktor) statt Butt-Cap-Quads pro Segment -> saubere Ecken. Standalone-Spike-Fenster via winit (cargo run --features window --bin spike).
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+2605
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
[package]
|
||||
name = "render2d"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Nativer wgpu-2D-Renderer fuer den CAD-Grundriss (Tessellierung + Pipelines)"
|
||||
|
||||
# Bewusst NICHT Teil des src-tauri-Workspaces: die Tessellierungs-Bibliothek soll
|
||||
# unabhaengig von Tauri baubar/testbar bleiben (headless, ohne Webview-Toolchain).
|
||||
# Der leere [workspace]-Block schliesst die Crate aus einem uebergeordneten
|
||||
# Workspace aus, sodass `cargo test`/`cargo build` hier eigenstaendig laufen.
|
||||
[workspace]
|
||||
|
||||
[features]
|
||||
# Standard: nur die reine Tessellierung + Szene + Ortho-Matrix + WGSL-Quellen.
|
||||
# Diese Schicht ist serde-only, hat keine GPU-Abhaengigkeit und ist headless
|
||||
# vollstaendig per `cargo test` pruefbar.
|
||||
default = []
|
||||
|
||||
# GPU-Pipelines (wgpu). Baut auch headless (wgpu laedt Vulkan/GL erst zur
|
||||
# Laufzeit dynamisch), rendert aber nur mit einer aktiven Grafik-Session.
|
||||
render = ["dep:wgpu", "dep:bytemuck", "dep:pollster"]
|
||||
|
||||
# Fenster-Spike (winit) inkl. lauffaehigem Binary. Braucht Wayland/X11 + eine
|
||||
# Display-Session zur visuellen Verifikation.
|
||||
window = ["render", "dep:winit", "dep:env_logger"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
# --- GPU (nur mit Feature "render") ------------------------------------------
|
||||
# Standard-Features von wgpu (wgsl, webgpu, dx12, metal). Die Linux-Backends
|
||||
# Vulkan/GLES aktiviert wgpu selbst ueber seine target-spezifischen Defaults —
|
||||
# es gibt in wgpu 22 keine expliziten `vulkan`/`gles`-Feature-Namen.
|
||||
wgpu = { version = "22", optional = true }
|
||||
bytemuck = { version = "1", optional = true, features = ["derive"] }
|
||||
pollster = { version = "0.3", optional = true }
|
||||
|
||||
# --- Fenster (nur mit Feature "window") --------------------------------------
|
||||
winit = { version = "0.30", optional = true }
|
||||
env_logger = { version = "0.11", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
# naga validiert die WGSL-Quellen headless (Parser + Validator) im Test, ohne
|
||||
# GPU/Display. Version an wgpu 22 gekoppelt (transitiv ohnehin vorhanden).
|
||||
naga = { version = "22", features = ["wgsl-in"] }
|
||||
|
||||
# Der Fenster-Spike ist nur mit Feature "window" verfuegbar; ohne das Feature
|
||||
# wird das Binary aus dem Build ausgeschlossen (required-features).
|
||||
[[bin]]
|
||||
name = "spike"
|
||||
path = "src/bin/spike.rs"
|
||||
required-features = ["window"]
|
||||
@@ -0,0 +1,271 @@
|
||||
// Standalone-Fenster-Spike (Feature "window"): oeffnet ein winit-Fenster mit
|
||||
// eigener wgpu-Surface und zeichnet eine Demo-Szene (gefuellte Polygone + Striche
|
||||
// in echter Papier-mm-Breite). Pan (Ziehen mit linker Maustaste) und Zoom (Rad)
|
||||
// veraendern NUR die Ortho-Matrix — kein Re-Tessellieren.
|
||||
//
|
||||
// Das ist bewusst Option 2 aus dem Briefing: das Rendering entkoppelt von der
|
||||
// Tauri/Webview-Integration verifizieren. Die Anbindung unter die Webview
|
||||
// (raw-window-handle) folgt in M2 (siehe docs/design/wgpu-integration-findings.md).
|
||||
//
|
||||
// Start: cargo run --features window --bin spike
|
||||
// (braucht eine aktive Wayland-/X11-Session; headless nicht sichtbar verifizierbar).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use render2d::gpu::Renderer;
|
||||
use render2d::types::{FillPolygon, Line, Outline, Scene, ViewBox};
|
||||
use render2d::PX_PER_M;
|
||||
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||
use winit::window::{Window, WindowId};
|
||||
|
||||
/// Demo-Szene in Modell-Metern: ein L-foermiger Wand-Poche (konkav!), ein Raum
|
||||
/// und ein paar Striche — genug, um Fuellung, Umriss und Papier-mm-Linien zu sehen.
|
||||
fn demo_scene() -> Scene {
|
||||
let wall_grey: [f32; 4] = [0.55, 0.55, 0.55, 1.0];
|
||||
let room_blue: [f32; 4] = [0.20, 0.45, 0.85, 0.18];
|
||||
let ink: [f32; 4] = [0.10, 0.10, 0.10, 1.0];
|
||||
|
||||
// Konkaves L (Wandflaeche).
|
||||
let l = vec![
|
||||
[0.0, 0.0],
|
||||
[4.0, 0.0],
|
||||
[4.0, 2.0],
|
||||
[2.0, 2.0],
|
||||
[2.0, 4.0],
|
||||
[0.0, 4.0],
|
||||
];
|
||||
// Ein transluzenter Raum daneben.
|
||||
let room = vec![[5.0, 0.0], [9.0, 0.0], [9.0, 4.0], [5.0, 4.0]];
|
||||
|
||||
Scene {
|
||||
fills: vec![
|
||||
FillPolygon {
|
||||
pts: l.clone(),
|
||||
color: wall_grey,
|
||||
},
|
||||
FillPolygon {
|
||||
pts: room.clone(),
|
||||
color: room_blue,
|
||||
},
|
||||
],
|
||||
outlines: vec![
|
||||
Outline {
|
||||
pts: l,
|
||||
color: ink,
|
||||
width_mm: 0.35,
|
||||
},
|
||||
Outline {
|
||||
pts: room,
|
||||
color: ink,
|
||||
width_mm: 0.18,
|
||||
},
|
||||
],
|
||||
lines: vec![Line {
|
||||
a: [0.0, -1.0],
|
||||
b: [9.0, -1.0],
|
||||
color: ink,
|
||||
width_mm: 0.25,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// viewBox so, dass die Szene mittig einpasst (Bildschirm-Raum = Meter*PX_PER_M).
|
||||
fn initial_view_box() -> ViewBox {
|
||||
// Szene ~ x[0..9], y[-1..4] in Meter -> Bildschirm x[0..810], y[-360..90].
|
||||
// Etwas Rand drumherum.
|
||||
let pad = 90.0;
|
||||
ViewBox::new(
|
||||
-pad,
|
||||
-4.0 * PX_PER_M - pad,
|
||||
9.0 * PX_PER_M + 2.0 * pad,
|
||||
5.0 * PX_PER_M + 2.0 * pad,
|
||||
)
|
||||
}
|
||||
|
||||
struct GpuState {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
renderer: Renderer,
|
||||
window: Arc<Window>,
|
||||
}
|
||||
|
||||
impl GpuState {
|
||||
fn new(window: Arc<Window>) -> Self {
|
||||
let size = window.inner_size();
|
||||
let instance = wgpu::Instance::default();
|
||||
let surface = instance
|
||||
.create_surface(window.clone())
|
||||
.expect("Surface erstellen");
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
force_fallback_adapter: false,
|
||||
compatible_surface: Some(&surface),
|
||||
}))
|
||||
.expect("kein passender GPU-Adapter");
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(
|
||||
&wgpu::DeviceDescriptor {
|
||||
label: Some("2d.device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: wgpu::Limits::default(),
|
||||
memory_hints: wgpu::MemoryHints::Performance,
|
||||
},
|
||||
None,
|
||||
))
|
||||
.expect("Device anfordern");
|
||||
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
let format = caps
|
||||
.formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| f.is_srgb())
|
||||
.unwrap_or(caps.formats[0]);
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format,
|
||||
width: size.width.max(1),
|
||||
height: size.height.max(1),
|
||||
present_mode: caps.present_modes[0],
|
||||
alpha_mode: caps.alpha_modes[0],
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
surface.configure(&device, &config);
|
||||
|
||||
let mut renderer = Renderer::new(&device, format);
|
||||
renderer.upload_scene(&device, &demo_scene());
|
||||
|
||||
Self {
|
||||
surface,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
renderer,
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
fn resize(&mut self, w: u32, h: u32) {
|
||||
if w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
self.config.width = w;
|
||||
self.config.height = h;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
|
||||
fn render(&mut self, view_box: ViewBox) {
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
Ok(f) => f,
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Surface-Fehler: {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let view = frame
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
self.renderer.render(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&view,
|
||||
view_box,
|
||||
(self.config.width, self.config.height),
|
||||
);
|
||||
frame.present();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct App {
|
||||
state: Option<GpuState>,
|
||||
view_box: Option<ViewBox>,
|
||||
dragging: bool,
|
||||
last_cursor: (f64, f64),
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.state.is_some() {
|
||||
return;
|
||||
}
|
||||
let attrs = Window::default_attributes().with_title("render2d — Spike");
|
||||
let window = Arc::new(event_loop.create_window(attrs).expect("Fenster erstellen"));
|
||||
self.view_box.get_or_insert_with(initial_view_box);
|
||||
self.state = Some(GpuState::new(window));
|
||||
}
|
||||
|
||||
fn window_event(
|
||||
&mut self,
|
||||
event_loop: &ActiveEventLoop,
|
||||
_id: WindowId,
|
||||
event: WindowEvent,
|
||||
) {
|
||||
let Some(state) = self.state.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let vb = self.view_box.get_or_insert_with(initial_view_box);
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(size) => {
|
||||
state.resize(size.width, size.height);
|
||||
state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::MouseInput { state: s, button, .. } => {
|
||||
if button == MouseButton::Left {
|
||||
self.dragging = s == ElementState::Pressed;
|
||||
}
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
if self.dragging {
|
||||
// Pan: Cursor-Delta (Geraete-px) -> viewBox-Einheiten (meet-Skala).
|
||||
let (vw, vh) = (state.config.width as f32, state.config.height as f32);
|
||||
let meet = render2d::meet_scale(*vb, vw, vh);
|
||||
let dx = (position.x - self.last_cursor.0) as f32 / meet;
|
||||
let dy = (position.y - self.last_cursor.1) as f32 / meet;
|
||||
vb.x -= dx;
|
||||
vb.y -= dy;
|
||||
state.window.request_redraw();
|
||||
}
|
||||
self.last_cursor = (position.x, position.y);
|
||||
}
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let step = match delta {
|
||||
MouseScrollDelta::LineDelta(_, y) => y,
|
||||
MouseScrollDelta::PixelDelta(p) => (p.y as f32) / 40.0,
|
||||
};
|
||||
// Zoom um die viewBox-Mitte (Faktor pro Radschritt).
|
||||
let factor = if step > 0.0 { 0.9 } else { 1.0 / 0.9 };
|
||||
let cx = vb.x + vb.w * 0.5;
|
||||
let cy = vb.y + vb.h * 0.5;
|
||||
vb.w *= factor;
|
||||
vb.h *= factor;
|
||||
vb.x = cx - vb.w * 0.5;
|
||||
vb.y = cy - vb.h * 0.5;
|
||||
state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
let vb_copy = *vb;
|
||||
state.render(vb_copy);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let event_loop = EventLoop::new().expect("Event-Loop erstellen");
|
||||
event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait);
|
||||
let mut app = App::default();
|
||||
event_loop.run_app(&mut app).expect("App laufen lassen");
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// wgpu-Pipelines des nativen 2D-Renderers (nur mit Feature "render").
|
||||
//
|
||||
// Zwei Pipelines analog zum WebGL-Pfad:
|
||||
// - Fuell-Pipeline: gefuellte Polygone, Ortho-Matrix-Uniform.
|
||||
// - Linien-Pipeline: Quad-Expansion, Breite in echten Papier-mm (Shader).
|
||||
//
|
||||
// Der Renderer ist fenster-/surface-agnostisch: er bekommt ein `Device`, eine
|
||||
// `Queue` und ein `TextureView` (die Surface oder eine Offscreen-Textur) und
|
||||
// zeichnet dort hinein. Die Fenster-Anbindung (winit) liegt separat im Spike-Bin.
|
||||
//
|
||||
// Pro-Batch-Uniforms ueber DYNAMISCHE Offsets: alle Globals-Bloecke (je Fuell-/
|
||||
// Linien-Batch einer) liegen hintereinander in EINEM Uniform-Puffer (auf die
|
||||
// GPU-Mindest-Ausrichtung gepolstert). Vor dem Zeichnen wird der ganze Puffer
|
||||
// EINMAL geschrieben; jeder Draw-Call bindet seinen Block per dynamischem Offset.
|
||||
// Das umgeht die Regel "kein write_buffer auf gebundenen Puffer im offenen Pass".
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::ortho::{compute_ortho_matrix, mm_to_device_px, Mat4};
|
||||
use crate::shaders::{FILL_WGSL, LINE_WGSL};
|
||||
use crate::tessellate::{compile_scene, GpuGeometry};
|
||||
use crate::types::{Scene, ViewBox};
|
||||
|
||||
/// Uniform-Block, 1:1 zum WGSL-`Globals`-Struct. std140-kompatibel ausgelegt
|
||||
/// (16-Byte-Alignment der mat4/vec4, Skalare am Ende gepackt).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable)]
|
||||
struct Globals {
|
||||
view_proj: [f32; 16],
|
||||
color: [f32; 4],
|
||||
viewport_px: [f32; 2],
|
||||
stroke_px: f32,
|
||||
stroke_scale: f32,
|
||||
}
|
||||
|
||||
impl Default for Globals {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
view_proj: crate::ortho::identity(),
|
||||
color: [0.0, 0.0, 0.0, 1.0],
|
||||
viewport_px: [1.0, 1.0],
|
||||
stroke_px: 0.0,
|
||||
stroke_scale: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rundet `size` auf das naechste Vielfache von `align` (>=1) auf.
|
||||
fn align_up(size: u64, align: u64) -> u64 {
|
||||
if align <= 1 {
|
||||
return size;
|
||||
}
|
||||
((size + align - 1) / align) * align
|
||||
}
|
||||
|
||||
/// GPU-seitige Puffer + Batch-Metadaten einer tessellierten Szene.
|
||||
struct SceneBuffers {
|
||||
fill_vbo: Option<wgpu::Buffer>,
|
||||
fill_ibo: Option<wgpu::Buffer>,
|
||||
line_vbo: Option<wgpu::Buffer>,
|
||||
line_ibo: Option<wgpu::Buffer>,
|
||||
geo: GpuGeometry,
|
||||
}
|
||||
|
||||
/// Der native 2D-Renderer: haelt beide Pipelines, den (dynamischen) Uniform-
|
||||
/// Puffer und die aktuell hochgeladene Szene. Pan/Zoom aktualisiert nur die
|
||||
/// Ortho-Matrix (kein Re-Tessellieren).
|
||||
pub struct Renderer {
|
||||
fill_pipeline: wgpu::RenderPipeline,
|
||||
line_pipeline: wgpu::RenderPipeline,
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
/// Ausgerichtete Groesse eines Globals-Blocks im dynamischen Uniform-Puffer.
|
||||
uniform_stride: u64,
|
||||
/// Aktuell allozierter Uniform-Puffer + zugehoerige Bind-Group (wachsen bei Bedarf).
|
||||
uniform: Option<UniformArena>,
|
||||
scene: Option<SceneBuffers>,
|
||||
/// Loeschfarbe (Zeichenblatt), Default hell wie im WebGL-Pfad (#f0f0f0).
|
||||
pub clear_color: wgpu::Color,
|
||||
/// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten.
|
||||
pub paper_scale_n: f32,
|
||||
}
|
||||
|
||||
/// Ein Uniform-Puffer, der `capacity` Globals-Bloecke fasst, plus Bind-Group.
|
||||
struct UniformArena {
|
||||
buffer: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
capacity: u64,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Erzeugt Pipelines + Layout fuer ein gegebenes Farbformat (Surface).
|
||||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
let fill_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("fill.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(FILL_WGSL.into()),
|
||||
});
|
||||
let line_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("line.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(LINE_WGSL.into()),
|
||||
});
|
||||
|
||||
// Ein Uniform-Block (Globals), fuer beide Pipelines geteilt, mit dynamischem
|
||||
// Offset (jeder Batch bekommt seinen eigenen Block-Slice).
|
||||
let bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("globals.layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: true,
|
||||
min_binding_size: wgpu::BufferSize::new(
|
||||
std::mem::size_of::<Globals>() as u64
|
||||
),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("2d.layout"),
|
||||
bind_group_layouts: &[&bind_group_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
|
||||
// Alpha-Blending (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) wie im WebGL-Pfad.
|
||||
let blend = Some(wgpu::BlendState::ALPHA_BLENDING);
|
||||
let color_target = wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
};
|
||||
|
||||
// Fuell-Pipeline: ein vec2-Attribut (Bildschirm-Position).
|
||||
let fill_vertex_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: 2 * 4,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x2],
|
||||
};
|
||||
let fill_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("fill.pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &fill_module,
|
||||
entry_point: "vs_main",
|
||||
buffers: &[fill_vertex_layout],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fill_module,
|
||||
entry_point: "fs_main",
|
||||
targets: &[Some(color_target.clone())],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
// Ohr-Clipping liefert stets CCW; wir lassen Culling aus (robuster).
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Linien-Pipeline: [pos vec2, bisector vec2, side f32, miter f32], stride 6*4.
|
||||
let line_vertex_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: 6 * 4,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32, 3 => Float32],
|
||||
};
|
||||
let line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("line.pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &line_module,
|
||||
entry_point: "vs_main",
|
||||
buffers: &[line_vertex_layout],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &line_module,
|
||||
entry_point: "fs_main",
|
||||
targets: &[Some(color_target)],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Block-Stride = Globals auf die Dynamic-Offset-Ausrichtung des Geraets gepolstert.
|
||||
let min_align = device.limits().min_uniform_buffer_offset_alignment as u64;
|
||||
let uniform_stride = align_up(std::mem::size_of::<Globals>() as u64, min_align.max(1));
|
||||
|
||||
Self {
|
||||
fill_pipeline,
|
||||
line_pipeline,
|
||||
bind_group_layout,
|
||||
uniform_stride,
|
||||
uniform: None,
|
||||
scene: None,
|
||||
// #f0f0f0 (== --sheet). 0.941 entspricht dem WebGL-clearColor.
|
||||
clear_color: wgpu::Color {
|
||||
r: 0.941,
|
||||
g: 0.941,
|
||||
b: 0.941,
|
||||
a: 1.0,
|
||||
},
|
||||
paper_scale_n: 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tessellliert eine Szene und laedt die Puffer hoch (einmal je Szene).
|
||||
pub fn upload_scene(&mut self, device: &wgpu::Device, scene: &Scene) {
|
||||
let geo = compile_scene(scene);
|
||||
|
||||
let mk_vbo = |data: &[f32], label: &str| -> Option<wgpu::Buffer> {
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents: bytemuck::cast_slice(data),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
}))
|
||||
};
|
||||
let mk_ibo = |data: &[u32], label: &str| -> Option<wgpu::Buffer> {
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents: bytemuck::cast_slice(data),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
}))
|
||||
};
|
||||
|
||||
self.scene = Some(SceneBuffers {
|
||||
fill_vbo: mk_vbo(&geo.fill_pos, "fill.vbo"),
|
||||
fill_ibo: mk_ibo(&geo.fill_idx, "fill.ibo"),
|
||||
line_vbo: mk_vbo(&geo.line_verts, "line.vbo"),
|
||||
line_ibo: mk_ibo(&geo.line_idx, "line.ibo"),
|
||||
geo,
|
||||
});
|
||||
}
|
||||
|
||||
/// Stellt sicher, dass der dynamische Uniform-Puffer mindestens `count`
|
||||
/// Bloecke fasst; realloziert (Puffer + Bind-Group) bei Bedarf.
|
||||
fn ensure_uniform_capacity(&mut self, device: &wgpu::Device, count: u64) {
|
||||
let need = count.max(1);
|
||||
if let Some(u) = &self.uniform {
|
||||
if u.capacity >= need {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let size = self.uniform_stride * need;
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("globals.buffer"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("globals.bind"),
|
||||
layout: &self.bind_group_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &buffer,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(std::mem::size_of::<Globals>() as u64),
|
||||
}),
|
||||
}],
|
||||
});
|
||||
self.uniform = Some(UniformArena {
|
||||
buffer,
|
||||
bind_group,
|
||||
capacity: need,
|
||||
});
|
||||
}
|
||||
|
||||
/// Zeichnet einen Frame in `view` (Surface- oder Offscreen-Textur).
|
||||
/// Pan/Zoom kommt ueber `view_box`/`viewport`; nur die Ortho-Matrix aendert sich.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
view: &wgpu::TextureView,
|
||||
view_box: ViewBox,
|
||||
viewport: (u32, u32),
|
||||
) {
|
||||
let (vw, vh) = (viewport.0 as f32, viewport.1 as f32);
|
||||
let proj: Mat4 = compute_ortho_matrix(view_box, vw, vh);
|
||||
let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n);
|
||||
|
||||
// Alle Globals-Bloecke der Reihenfolge nach (erst Fuell-, dann Linien-Batches)
|
||||
// sammeln, den Puffer einmal schreiben, danach nur noch dynamisch binden.
|
||||
let mut blocks: Vec<Globals> = Vec::new();
|
||||
if let Some(scene) = &self.scene {
|
||||
for b in &scene.geo.fill_batches {
|
||||
blocks.push(Globals {
|
||||
view_proj: proj,
|
||||
color: b.color,
|
||||
viewport_px: [vw, vh],
|
||||
stroke_px: 0.0,
|
||||
stroke_scale: mm_px,
|
||||
});
|
||||
}
|
||||
for b in &scene.geo.line_batches {
|
||||
blocks.push(Globals {
|
||||
view_proj: proj,
|
||||
color: b.color,
|
||||
viewport_px: [vw, vh],
|
||||
stroke_px: b.stroke_mm,
|
||||
stroke_scale: mm_px,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.ensure_uniform_capacity(device, blocks.len() as u64);
|
||||
|
||||
// Alle Bloecke in den (auf uniform_stride gepolsterten) Puffer schreiben.
|
||||
if let Some(u) = &self.uniform {
|
||||
let stride = self.uniform_stride as usize;
|
||||
let mut bytes = vec![0u8; stride * blocks.len().max(1)];
|
||||
for (i, g) in blocks.iter().enumerate() {
|
||||
let off = i * stride;
|
||||
bytes[off..off + std::mem::size_of::<Globals>()]
|
||||
.copy_from_slice(bytemuck::bytes_of(g));
|
||||
}
|
||||
if !blocks.is_empty() {
|
||||
queue.write_buffer(&u.buffer, 0, &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("2d.encoder"),
|
||||
});
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("2d.pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(self.clear_color),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
|
||||
if let (Some(scene), Some(u)) = (&self.scene, &self.uniform) {
|
||||
let stride = self.uniform_stride as u32;
|
||||
let mut block = 0u32; // Laufindex ueber Fuell- dann Linien-Batches
|
||||
|
||||
// 1) Fuellungen.
|
||||
if let (Some(vbo), Some(ibo)) = (&scene.fill_vbo, &scene.fill_ibo) {
|
||||
pass.set_pipeline(&self.fill_pipeline);
|
||||
pass.set_vertex_buffer(0, vbo.slice(..));
|
||||
pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for b in &scene.geo.fill_batches {
|
||||
pass.set_bind_group(0, &u.bind_group, &[block * stride]);
|
||||
let start = b.start_index;
|
||||
pass.draw_indexed(start..start + b.index_count, 0, 0..1);
|
||||
block += 1;
|
||||
}
|
||||
} else {
|
||||
block += scene.geo.fill_batches.len() as u32;
|
||||
}
|
||||
|
||||
// 2) Linien (Umrisse + freie Linien).
|
||||
if let (Some(vbo), Some(ibo)) = (&scene.line_vbo, &scene.line_ibo) {
|
||||
pass.set_pipeline(&self.line_pipeline);
|
||||
pass.set_vertex_buffer(0, vbo.slice(..));
|
||||
pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for b in &scene.geo.line_batches {
|
||||
pass.set_bind_group(0, &u.bind_group, &[block * stride]);
|
||||
let start = b.start_index;
|
||||
pass.draw_indexed(start..start + b.index_count, 0, 0..1);
|
||||
block += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.submit(std::iter::once(encoder.finish()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Nativer wgpu-2D-Renderer fuer den CAD-Grundriss.
|
||||
//
|
||||
// Aufbau in Schichten (bewusst getrennt, siehe Feature-Flags in Cargo.toml):
|
||||
// - `types` : serde-only Eingabe (geflachte Primitive, Szene, ViewBox).
|
||||
// - `tessellate` : Ear-Clipping + Linien->Quads, Bildschirm-Raum. GPU-frei,
|
||||
// headless per `cargo test` pruefbar. Kern-Port des WebGL-Pfads.
|
||||
// - `ortho` : aspekt-korrekte Ortho-Matrix + Papier-mm-Strichbreiten-Formel.
|
||||
// - `shaders` : WGSL-Quellen (aus den GLSL-Vorlagen uebersetzt).
|
||||
// - `gpu` : wgpu-Pipelines (Feature "render").
|
||||
// - `bin/spike` : winit-Fenster fuer die visuelle Verifikation (Feature "window").
|
||||
//
|
||||
// Standard-Build (`cargo test`/`cargo build` ohne Features) enthaelt nur die
|
||||
// GPU-freien Schichten und ist damit unabhaengig von einer Display-Session.
|
||||
|
||||
pub mod ortho;
|
||||
pub mod shaders;
|
||||
pub mod tessellate;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub mod gpu;
|
||||
|
||||
pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4};
|
||||
pub use tessellate::{compile_scene, triangulate, GpuGeometry, PX_PER_M};
|
||||
pub use types::{FillPolygon, Line, Outline, Point, Rgba, Scene, ViewBox};
|
||||
|
||||
// --- Tests: Tessellierung (Muster wie glPlanCompile.test.ts) -----------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tessellate::{triangulate, PX_PER_M};
|
||||
use super::types::Point;
|
||||
|
||||
/// Summierte Dreiecksflaeche (Betrag) aus Indizes ueber pts.
|
||||
fn tri_area(pts: &[Point], idx: &[u32]) -> f32 {
|
||||
let mut area = 0.0f32;
|
||||
let mut i = 0;
|
||||
while i < idx.len() {
|
||||
let a = pts[idx[i] as usize];
|
||||
let b = pts[idx[i + 1] as usize];
|
||||
let c = pts[idx[i + 2] as usize];
|
||||
area += ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() / 2.0;
|
||||
i += 3;
|
||||
}
|
||||
area
|
||||
}
|
||||
|
||||
/// Polygon-Flaeche (Shoelace, Betrag).
|
||||
fn poly_area(pts: &[Point]) -> f32 {
|
||||
let mut a = 0.0f32;
|
||||
let n = pts.len();
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
a += (pts[j][0] + pts[i][0]) * (pts[j][1] - pts[i][1]);
|
||||
j = i;
|
||||
}
|
||||
a.abs() / 2.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quadrat_zwei_dreiecke_volle_flaeche() {
|
||||
let sq: Vec<Point> = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
|
||||
let idx = triangulate(&sq);
|
||||
assert_eq!(idx.len(), 6, "2 Dreiecke erwartet");
|
||||
assert!((tri_area(&sq, &idx) - poly_area(&sq)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn konkaves_l_flaechentreu_kein_fan() {
|
||||
// L-Form: konkave Ecke bei (2,2). Ein Fan wuerde Flaeche ausserhalb des L
|
||||
// erzeugen; Ear-Clipping muss flaechentreu bleiben.
|
||||
let l: Vec<Point> = vec![
|
||||
[0.0, 0.0],
|
||||
[4.0, 0.0],
|
||||
[4.0, 2.0],
|
||||
[2.0, 2.0],
|
||||
[2.0, 4.0],
|
||||
[0.0, 4.0],
|
||||
];
|
||||
let idx = triangulate(&l);
|
||||
assert_eq!(idx.len(), 12, "6 Ecken -> 4 Dreiecke");
|
||||
assert!((tri_area(&l, &idx) - poly_area(&l)).abs() < 1e-4);
|
||||
assert!((tri_area(&l, &idx) - 12.0).abs() < 1e-4, "L-Flaeche == 12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weniger_als_drei_ecken_leer() {
|
||||
assert!(triangulate(&[]).is_empty());
|
||||
assert!(triangulate(&[[0.0, 0.0]]).is_empty());
|
||||
assert!(triangulate(&[[0.0, 0.0], [1.0, 1.0]]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beide_wicklungsrichtungen_gleich() {
|
||||
// CW-Quadrat.
|
||||
let cw: Vec<Point> = vec![[0.0, 0.0], [0.0, 4.0], [4.0, 4.0], [4.0, 0.0]];
|
||||
let idx = triangulate(&cw);
|
||||
assert_eq!(idx.len(), 6);
|
||||
assert!((tri_area(&cw, &idx) - poly_area(&cw)).abs() < 1e-4);
|
||||
|
||||
// CCW-Quadrat gleicher Groesse -> gleiche Gesamtflaeche.
|
||||
let ccw: Vec<Point> = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
|
||||
let idx2 = triangulate(&ccw);
|
||||
assert_eq!(idx2.len(), 6);
|
||||
assert!((tri_area(&ccw, &idx2) - tri_area(&cw, &idx)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
// --- Zusatz: Bildschirm-Raum-Abbildung + Ortho-Matrix --------------------
|
||||
|
||||
#[test]
|
||||
fn to_screen_konvention() {
|
||||
// sx = mx*90, sy = -my*90 (Modell-Y hoch -> Bildschirm-Y runter).
|
||||
let s = super::tessellate::to_screen([2.0, 3.0]);
|
||||
assert!((s[0] - 2.0 * PX_PER_M).abs() < 1e-4);
|
||||
assert!((s[1] + 3.0 * PX_PER_M).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ortho_bildet_viewbox_ecken_auf_clip_ab() {
|
||||
use super::ortho::compute_ortho_matrix;
|
||||
use super::types::ViewBox;
|
||||
// Quadratischer viewBox + quadratisches Canvas -> keine Aspekt-Dehnung.
|
||||
let m = compute_ortho_matrix(ViewBox::new(0.0, 0.0, 100.0, 100.0), 100.0, 100.0);
|
||||
// Spalten-Major: clip = M * (x,y,0,1).
|
||||
let clip = |x: f32, y: f32| -> (f32, f32) {
|
||||
(
|
||||
m[0] * x + m[4] * y + m[12],
|
||||
m[1] * x + m[5] * y + m[13],
|
||||
)
|
||||
};
|
||||
// Bildschirm-Punkt (0,0) = obere-linke Ecke -> Clip (-1, +1).
|
||||
let tl = clip(0.0, 0.0);
|
||||
assert!((tl.0 + 1.0).abs() < 1e-4);
|
||||
assert!((tl.1 - 1.0).abs() < 1e-4);
|
||||
// (100,100) = untere-rechte Ecke -> Clip (+1, -1).
|
||||
let br = clip(100.0, 100.0);
|
||||
assert!((br.0 - 1.0).abs() < 1e-4);
|
||||
assert!((br.1 + 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mm_zu_device_px_35_bei_1zu100() {
|
||||
use super::ortho::mm_to_device_px;
|
||||
use super::types::ViewBox;
|
||||
// meet=1 (viewport == viewBox), N=100: mmToPx = N/1000 * 90 * meet = 9 px/mm.
|
||||
// 0.35 mm -> 3.15 px.
|
||||
let vb = ViewBox::new(0.0, 0.0, 100.0, 100.0);
|
||||
let mm_px = mm_to_device_px(vb, 100.0, 100.0, 100.0);
|
||||
assert!((mm_px - 9.0).abs() < 1e-4, "9 px/mm bei 1:100, meet=1");
|
||||
assert!((0.35 * mm_px - 3.15).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// Validiert die WGSL-Quellen headless ueber naga (Parser + Validator) —
|
||||
/// faengt Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render",
|
||||
/// weil naga sonst nicht mitgebaut wird.
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn wgsl_quellen_sind_valide() {
|
||||
use naga::valid::{Capabilities, ValidationFlags, Validator};
|
||||
|
||||
for (name, src) in [
|
||||
("fill", super::shaders::FILL_WGSL),
|
||||
("line", super::shaders::LINE_WGSL),
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(src)
|
||||
.unwrap_or_else(|e| panic!("{name}: WGSL-Parse-Fehler: {e:?}"));
|
||||
let mut validator =
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all());
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{name}: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Aspekt-korrekte Orthografie + Papier-mm-Strichbreiten-Formel.
|
||||
// 1:1-Port von `computeOrthoMatrix` / dem strokeScale-Pfad aus
|
||||
// `src/plan/glPlan/glPlanRender.ts`.
|
||||
//
|
||||
// WICHTIG (Konventionsunterschied WebGL vs. wgpu):
|
||||
// - WebGL-Clip-Z liegt in [-1, 1].
|
||||
// - wgpu/WebGPU-Clip-Z liegt in [0, 1].
|
||||
// Fuer den reinen 2D-Fall (z=0) spielt das keine Rolle: wir setzen z konstant auf
|
||||
// 0.0 im Shader; die XY-Abbildung ist identisch. Die Matrix hier ist daher exakt
|
||||
// die WebGL-Ortho (Spalten-Major), unveraendert uebernehmbar.
|
||||
|
||||
use crate::tessellate::PX_PER_M;
|
||||
use crate::types::ViewBox;
|
||||
|
||||
/// 4x4-Matrix, Spalten-Major (WebGL/wgpu-Konvention: `m[col*4 + row]`).
|
||||
pub type Mat4 = [f32; 16];
|
||||
|
||||
/// Einheitsmatrix.
|
||||
pub fn identity() -> Mat4 {
|
||||
let mut m = [0.0f32; 16];
|
||||
m[0] = 1.0;
|
||||
m[5] = 1.0;
|
||||
m[10] = 1.0;
|
||||
m[15] = 1.0;
|
||||
m
|
||||
}
|
||||
|
||||
/// Orthografische Projektion [left,right] x [bottom,top] -> Clip. Spalten-Major.
|
||||
/// Die Z-Zeile bildet z=0 auf 0 ab (fuer wgpu unschaedlich, siehe Modul-Kopf).
|
||||
fn ortho(left: f32, right: f32, bottom: f32, top: f32) -> Mat4 {
|
||||
let mut m = identity();
|
||||
let w = right - left;
|
||||
let h = top - bottom;
|
||||
m[0] = 2.0 / w;
|
||||
m[5] = 2.0 / h;
|
||||
m[10] = -1.0;
|
||||
m[12] = -(right + left) / w;
|
||||
m[13] = -(top + bottom) / h;
|
||||
m
|
||||
}
|
||||
|
||||
/// Projektionsmatrix fuer einen viewBox, aspekt-korrigiert wie SVG
|
||||
/// `preserveAspectRatio="xMidYMid meet"`: der viewBox wird auf das
|
||||
/// Canvas-Seitenverhaeltnis gedehnt (zentriert), damit die Skalierung in X und Y
|
||||
/// gleich ist (keine Verzerrung, Perpendikel bleiben senkrecht).
|
||||
///
|
||||
/// Eingabe-Positionen sind bereits im BILDSCHIRM-Raum (`to_screen`), daher keine
|
||||
/// zusaetzliche Meter-Skalierung. Bildschirm-Y zeigt nach unten -> top = vb.y,
|
||||
/// bottom = vb.y + vb.h (kleineres Y oben -> Clip +1).
|
||||
pub fn compute_ortho_matrix(view_box: ViewBox, canvas_w: f32, canvas_h: f32) -> Mat4 {
|
||||
let ViewBox {
|
||||
mut x,
|
||||
mut y,
|
||||
mut w,
|
||||
mut h,
|
||||
} = view_box;
|
||||
if canvas_w > 0.0 && canvas_h > 0.0 && w > 0.0 && h > 0.0 {
|
||||
let c_aspect = canvas_w / canvas_h;
|
||||
let v_aspect = w / h;
|
||||
if c_aspect > v_aspect {
|
||||
// Canvas breiter -> viewBox in der Breite dehnen, X zentrieren.
|
||||
let nw = h * c_aspect;
|
||||
x -= (nw - w) / 2.0;
|
||||
w = nw;
|
||||
} else {
|
||||
// Canvas hoeher -> viewBox in der Hoehe dehnen, Y zentrieren.
|
||||
let nh = w / c_aspect;
|
||||
y -= (nh - h) / 2.0;
|
||||
h = nh;
|
||||
}
|
||||
}
|
||||
// top = y (Bildschirm-Y oben, kleiner), bottom = y + h.
|
||||
ortho(x, x + w, y + h, y)
|
||||
}
|
||||
|
||||
/// meet-Skala: Geraete-px je viewBox-Einheit (SVG `xMidYMid meet`).
|
||||
pub fn meet_scale(view_box: ViewBox, viewport_w: f32, viewport_h: f32) -> f32 {
|
||||
(viewport_w / view_box.w).min(viewport_h / view_box.h)
|
||||
}
|
||||
|
||||
/// Umrechnung Papier-mm -> Geraete-px, EXAKT wie der SVG-`printStrokeVb`-Pfad:
|
||||
/// Breite_vb = mm * N/1000 * PX_PER_M (viewBox-Einheiten, skaliert mit Zoom)
|
||||
/// Breite_px = Breite_vb * meetSkala (Geraete-px je viewBox-Einheit)
|
||||
/// -> mm * (N/1000 * PX_PER_M * meet). So gilt: 0.35 mm bei 1:100 = echte 0.35 mm
|
||||
/// Papier (deckungsgleich zum SVG-Renderer).
|
||||
pub fn mm_to_device_px(view_box: ViewBox, viewport_w: f32, viewport_h: f32, paper_scale_n: f32) -> f32 {
|
||||
let meet = meet_scale(view_box, viewport_w, viewport_h);
|
||||
(paper_scale_n / 1000.0) * PX_PER_M * meet
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// WGSL-Shader des nativen 2D-Renderers. Uebersetzt aus den GLSL-300-es-Vorlagen
|
||||
// in `src/plan/glPlan/glPlanShaders.ts` (WGSL ~= GLSL). Als Konstanten hier, damit
|
||||
// die Quellen auch ohne aktives GPU-Feature (headless) im Repo pruefbar bleiben.
|
||||
//
|
||||
// Uniform-Layout (ein gemeinsamer Struct, group(0) binding(0)):
|
||||
// view_proj : mat4x4<f32> Bildschirm-Raum -> Clip
|
||||
// color : vec4<f32> Draw-Farbe (pro Batch gesetzt)
|
||||
// viewport_px : vec2<f32> Zeichenpuffer-Groesse [w,h] in Geraete-px
|
||||
// stroke_px : f32 ECHTE Papier-mm-Breite (Basis; == strokeMm)
|
||||
// stroke_scale : f32 mm -> Geraete-px (== mmToDevicePx)
|
||||
//
|
||||
// Fuellungen ignorieren die Linien-Felder; Linien nutzen alle.
|
||||
|
||||
/// Gemeinsame WGSL-Uniform-Deklaration (fuer beide Pipelines identisch).
|
||||
pub const UNIFORM_WGSL: &str = r#"
|
||||
struct Globals {
|
||||
view_proj : mat4x4<f32>,
|
||||
color : vec4<f32>,
|
||||
viewport_px : vec2<f32>,
|
||||
stroke_px : f32,
|
||||
stroke_scale : f32,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> globals : Globals;
|
||||
"#;
|
||||
|
||||
/// Fuell-Pipeline: bildet Bildschirm-Raum-Positionen ueber `view_proj` in den
|
||||
/// Clip-Raum ab und faerbt einheitlich (MVP; Schraffur folgt in M3).
|
||||
pub const FILL_WGSL: &str = r#"
|
||||
struct Globals {
|
||||
view_proj : mat4x4<f32>,
|
||||
color : vec4<f32>,
|
||||
viewport_px : vec2<f32>,
|
||||
stroke_px : f32,
|
||||
stroke_scale : f32,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> globals : Globals;
|
||||
|
||||
@vertex
|
||||
fn vs_main(@location(0) position : vec2<f32>) -> @builtin(position) vec4<f32> {
|
||||
return globals.view_proj * vec4<f32>(position, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main() -> @location(0) vec4<f32> {
|
||||
return globals.color;
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Linien-Pipeline mit ECHTER Papier-mm-Breite.
|
||||
///
|
||||
/// Idee (wie im GLSL-Original): Endpunkt und (Endpunkt + Normale) werden durch
|
||||
/// `view_proj` transformiert; ihre Clip-Differenz (in Pixel skaliert) ergibt die
|
||||
/// Perpendikel-Richtung. Diese wird auf `width_px/2` Pixel normiert und — je nach
|
||||
/// `side` — auf die passende Seite versetzt, dann px -> Clip zurueckgerechnet. So
|
||||
/// bleibt die Strichbreite zoom-unabhaengig echte Papier-mm (keine Re-Tessellierung).
|
||||
///
|
||||
/// width_px = max(0.6, stroke_px * stroke_scale) (== mm * mmToDevicePx)
|
||||
/// Mindestens ~0.6 px, damit feine Gewichte beim Rauszoomen sichtbar bleiben.
|
||||
pub const LINE_WGSL: &str = r#"
|
||||
struct Globals {
|
||||
view_proj : mat4x4<f32>,
|
||||
color : vec4<f32>,
|
||||
viewport_px : vec2<f32>,
|
||||
stroke_px : f32,
|
||||
stroke_scale : f32,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> globals : Globals;
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position : vec2<f32>, // Bildschirm-Raum-Endpunkt
|
||||
@location(1) bisector : vec2<f32>, // Bildschirm-Raum-Miter-Bisektor (Einheit)
|
||||
@location(2) side : f32, // +1.0 oder -1.0
|
||||
@location(3) miter : f32, // 1/cos(theta/2)-Laengenfaktor (Gehrung)
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(in : VsIn) -> @builtin(position) vec4<f32> {
|
||||
let clip_p = globals.view_proj * vec4<f32>(in.position, 0.0, 1.0);
|
||||
let clip_n = globals.view_proj * vec4<f32>(in.position + in.bisector, 0.0, 1.0);
|
||||
|
||||
// Bisektor-Richtung im Pixel-Raum (Clip-Differenz -> px).
|
||||
let dir_px = (clip_n.xy - clip_p.xy) * globals.viewport_px * 0.5;
|
||||
let len = length(dir_px);
|
||||
var unit_px = vec2<f32>(0.0, 0.0);
|
||||
if (len > 1e-6) {
|
||||
unit_px = dir_px / len;
|
||||
}
|
||||
|
||||
// Echte Papierbreite in Geraete-px = mm(stroke_px) * (mm->px)(stroke_scale),
|
||||
// entlang des Bisektors um den Miter-Faktor verlaengert (buendige Gehrung).
|
||||
let width_px = max(0.6, globals.stroke_px * globals.stroke_scale) * in.miter;
|
||||
let offset_px = unit_px * (width_px * 0.5) * in.side;
|
||||
|
||||
var out = clip_p;
|
||||
out = vec4<f32>(out.xy + offset_px / (globals.viewport_px * 0.5), out.z, out.w);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main() -> @location(0) vec4<f32> {
|
||||
return globals.color;
|
||||
}
|
||||
"#;
|
||||
@@ -0,0 +1,414 @@
|
||||
// Tessellierung: wandelt Szenen-Primitive in GPU-fertige Vertex-/Index-Puffer.
|
||||
// - Polygone -> echtes Ear-Clipping (konkav-faehig), Bildschirm-Raum-Dreiecke
|
||||
// - Linien -> Quad mit Normale + Seiten-Flag (Breite spaeter im Shader)
|
||||
//
|
||||
// 1:1-Port von `src/plan/glPlan/glPlanCompile.ts`. Koordinaten: alles wird in
|
||||
// BILDSCHIRM-Raum abgelegt (wie `to_screen`):
|
||||
// sx = mx * PX_PER_M, sy = -my * PX_PER_M.
|
||||
// Damit ist die Projektion eine reine viewBox-Orthografie (siehe `ortho`).
|
||||
//
|
||||
// WARUM Ear-Clipping selbst portiert (statt earcutr/lyon):
|
||||
// - Die Plan-Polygone haben wenige Ecken; O(n^2)-Ear-Clipping ist reichlich
|
||||
// schnell und exakt der bereits verifizierte Web-Algorithmus (gleiche Tests).
|
||||
// - Keine externe Abhaengigkeit -> die Tessellierungs-Bibliothek bleibt
|
||||
// serde-only und headless testbar (entscheidend fuer die saubere Trennung
|
||||
// vom GPU-Teil). earcutr/lyon zoegen zusaetzliche Crates in genau die
|
||||
// Schicht, die ohne Display gruen bleiben muss.
|
||||
// - Ergebnis ist byte-fuer-byte-vergleichbar mit dem WebGL-Referenzpfad.
|
||||
|
||||
use crate::types::{FillPolygon, Line, Point, Rgba, Scene};
|
||||
|
||||
/// viewBox-Einheiten je Meter (identisch zu PlanView/toScreen im Web-Pfad).
|
||||
pub const PX_PER_M: f32 = 90.0;
|
||||
|
||||
/// Modell-Meter -> Bildschirm-Raum (identisch zu `toScreen`).
|
||||
#[inline]
|
||||
pub fn to_screen(p: Point) -> Point {
|
||||
[p[0] * PX_PER_M, -p[1] * PX_PER_M]
|
||||
}
|
||||
|
||||
/// Signierte Flaeche (Shoelace); >0 = CCW (Modell-Y nach oben).
|
||||
fn signed_area(pts: &[Point]) -> f32 {
|
||||
let mut a = 0.0f32;
|
||||
let n = pts.len();
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
a += pts[j][0] * pts[i][1] - pts[i][0] * pts[j][1];
|
||||
j = i;
|
||||
}
|
||||
a / 2.0
|
||||
}
|
||||
|
||||
/// Kreuzprodukt (b-a) x (c-a).
|
||||
#[inline]
|
||||
fn cross(a: Point, b: Point, c: Point) -> f32 {
|
||||
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
||||
}
|
||||
|
||||
/// Maximaler Miter-Laengenfaktor; darueber wird geklemmt (kein Spike an spitzen Ecken).
|
||||
const MITER_LIMIT: f32 = 4.0;
|
||||
|
||||
/// Einheits-Links-Normale von `from` nach `to` (Bildschirm-Raum); None bei Nulllaenge.
|
||||
#[inline]
|
||||
fn left_normal(from: Point, to: Point) -> Option<Point> {
|
||||
let dx = to[0] - from[0];
|
||||
let dy = to[1] - from[1];
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len < 1e-6 {
|
||||
None
|
||||
} else {
|
||||
Some([-dy / len, dx / len])
|
||||
}
|
||||
}
|
||||
|
||||
/// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert).
|
||||
fn point_in_tri(a: Point, b: Point, c: Point, p: Point) -> bool {
|
||||
let d1 = cross(a, b, p);
|
||||
let d2 = cross(b, c, p);
|
||||
let d3 = cross(c, a, p);
|
||||
let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
|
||||
let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
|
||||
!(has_neg && has_pos)
|
||||
}
|
||||
|
||||
/// Ear-Clipping-Triangulierung eines einfachen (lochfreien) Polygons. Robust fuer
|
||||
/// konvexe UND konkave Ringe. O(n^2) — fuer Plan-Polygone (wenige Ecken) voellig
|
||||
/// ausreichend. Gibt Dreiecks-Indizes (0-basiert auf `pts`) zurueck; leer bei <3
|
||||
/// Ecken oder Degeneration.
|
||||
pub fn triangulate(pts: &[Point]) -> Vec<u32> {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Ohr-Test unten nutzt cross>0 = konvex, was CCW voraussetzt. Bei CW-Polygonen
|
||||
// die Index-Reihenfolge umdrehen (Triangulierung ist raum-affin-invariant, die
|
||||
// Indizes gelten danach auch fuer die Bildschirm-Raum-Vertices).
|
||||
let mut idx: Vec<usize> = (0..n).collect();
|
||||
if signed_area(pts) < 0.0 {
|
||||
idx.reverse(); // <0 = CW -> auf CCW drehen
|
||||
}
|
||||
|
||||
let mut tris: Vec<u32> = Vec::new();
|
||||
let mut guard = 0usize;
|
||||
let max_guard = n * n + 16;
|
||||
|
||||
while idx.len() > 3 && guard < max_guard {
|
||||
guard += 1;
|
||||
let mut clipped = false;
|
||||
let m = idx.len();
|
||||
for i in 0..m {
|
||||
let i_prev = idx[(i + m - 1) % m];
|
||||
let i_cur = idx[i];
|
||||
let i_next = idx[(i + 1) % m];
|
||||
let a = pts[i_prev];
|
||||
let b = pts[i_cur];
|
||||
let c = pts[i_next];
|
||||
|
||||
// Konvexe Ecke? (bei CCW: cross > 0)
|
||||
if cross(a, b, c) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kein anderer Vertex im Ohr?
|
||||
let mut contains = false;
|
||||
for &vi in &idx {
|
||||
if vi == i_prev || vi == i_cur || vi == i_next {
|
||||
continue;
|
||||
}
|
||||
if point_in_tri(a, b, c, pts[vi]) {
|
||||
contains = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if contains {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ohr abschneiden.
|
||||
tris.push(i_prev as u32);
|
||||
tris.push(i_cur as u32);
|
||||
tris.push(i_next as u32);
|
||||
idx.remove(i);
|
||||
clipped = true;
|
||||
break;
|
||||
}
|
||||
if !clipped {
|
||||
break; // Degeneriert -> abbrechen (kein Absturz).
|
||||
}
|
||||
}
|
||||
if idx.len() == 3 {
|
||||
tris.push(idx[0] as u32);
|
||||
tris.push(idx[1] as u32);
|
||||
tris.push(idx[2] as u32);
|
||||
}
|
||||
tris
|
||||
}
|
||||
|
||||
/// Ein zusammenhaengender Zeichen-Batch (Index-Bereich) mit einer Farbe.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FillBatch {
|
||||
pub start_index: u32,
|
||||
pub index_count: u32,
|
||||
pub color: Rgba,
|
||||
}
|
||||
|
||||
/// Ein Linien-Batch: Index-Bereich mit Farbe + echter Papier-Strichbreite.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LineBatch {
|
||||
pub start_index: u32,
|
||||
pub index_count: u32,
|
||||
pub color: Rgba,
|
||||
/// ECHTE Strichbreite in Papier-Millimeter.
|
||||
pub stroke_mm: f32,
|
||||
}
|
||||
|
||||
/// GPU-fertige Geometrie: getrennte Puffer fuer Flaechen (Poche) und Striche.
|
||||
/// Einmal je Szene tessellieren; danach treibt nur die Projektionsmatrix Pan/Zoom.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GpuGeometry {
|
||||
/// Fuell-Positionen, interleaved [x,y, x,y, ...] in Bildschirm-Raum.
|
||||
pub fill_pos: Vec<f32>,
|
||||
pub fill_idx: Vec<u32>,
|
||||
pub fill_batches: Vec<FillBatch>,
|
||||
/// Linien-Vertices, interleaved [x,y, bx,by, side, miter] in Bildschirm-Raum
|
||||
/// (bx,by = Miter-Bisektor, miter = 1/cos(theta/2)-Laengenfaktor).
|
||||
pub line_verts: Vec<f32>,
|
||||
pub line_idx: Vec<u32>,
|
||||
pub line_batches: Vec<LineBatch>,
|
||||
/// Modell-Bounds (Meter) fuer Debug/Einpassen; nicht render-kritisch.
|
||||
pub bounds: [f32; 4], // [min_x, min_y, max_x, max_y]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn same_rgba(a: Rgba, b: Rgba) -> bool {
|
||||
a[0] == b[0] && a[1] == b[1] && a[2] == b[2] && a[3] == b[3]
|
||||
}
|
||||
|
||||
impl GpuGeometry {
|
||||
/// Ordnungserhaltendes Batch-Merging fuer Fuellungen: aufeinanderfolgende
|
||||
/// Indizes gleicher Farbe werden zu EINEM Draw-Call zusammengefasst.
|
||||
fn add_fill_batch(&mut self, count: u32, color: Rgba) {
|
||||
if let Some(last) = self.fill_batches.last_mut() {
|
||||
if same_rgba(last.color, color) {
|
||||
last.index_count += count;
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.fill_batches.push(FillBatch {
|
||||
start_index: self.fill_idx.len() as u32 - count,
|
||||
index_count: count,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
fn add_line_batch(&mut self, count: u32, color: Rgba, stroke_mm: f32) {
|
||||
if let Some(last) = self.line_batches.last_mut() {
|
||||
if last.stroke_mm == stroke_mm && same_rgba(last.color, color) {
|
||||
last.index_count += count;
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.line_batches.push(LineBatch {
|
||||
start_index: self.line_idx.len() as u32 - count,
|
||||
index_count: count,
|
||||
color,
|
||||
stroke_mm,
|
||||
});
|
||||
}
|
||||
|
||||
/// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) als EINEN
|
||||
/// gehrten Streifen: an jedem Stuetzpunkt wird der Versatz entlang des Miter-
|
||||
/// Bisektors verlaengert (1/cos(theta/2)), sodass benachbarte Segmente buendig
|
||||
/// verschmelzen -> gehrte Ecke statt Butt-Cap-Stufe. `closed` schliesst den Ring
|
||||
/// (letzter<->erster Punkt). Vertex-Layout: [x,y, bx,by, side, miter] (6 floats).
|
||||
///
|
||||
/// 1:1-Port von `strokePolyline` in `src/plan/glPlan/glPlanCompile.ts`.
|
||||
fn stroke_polyline(
|
||||
&mut self,
|
||||
pts_m: &[Point],
|
||||
closed: bool,
|
||||
color: Rgba,
|
||||
stroke_mm: f32,
|
||||
bounds: &mut Bounds,
|
||||
) {
|
||||
// Auf Bildschirm-Raum abbilden + aufeinanderfolgende Duplikate entfernen.
|
||||
let mut s: Vec<Point> = Vec::with_capacity(pts_m.len());
|
||||
for &p in pts_m {
|
||||
let sp = to_screen(p);
|
||||
if let Some(last) = s.last() {
|
||||
if (last[0] - sp[0]).abs() < 1e-6 && (last[1] - sp[1]).abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
s.push(sp);
|
||||
bounds.track(p);
|
||||
}
|
||||
if closed && s.len() > 1 {
|
||||
let f = s[0];
|
||||
let l = s[s.len() - 1];
|
||||
if (f[0] - l[0]).abs() < 1e-6 && (f[1] - l[1]).abs() < 1e-6 {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
let k = s.len();
|
||||
if k < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let base = (self.line_verts.len() / 6) as u32;
|
||||
for i in 0..k {
|
||||
let has_in = closed || i > 0;
|
||||
let has_out = closed || i < k - 1;
|
||||
let n_in = if has_in {
|
||||
left_normal(s[(i + k - 1) % k], s[i])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let n_out = if has_out {
|
||||
left_normal(s[i], s[(i + 1) % k])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (bx, by, miter): (f32, f32, f32);
|
||||
match (n_in, n_out) {
|
||||
(Some(a), Some(b)) => {
|
||||
let sx = a[0] + b[0];
|
||||
let sy = a[1] + b[1];
|
||||
let slen = (sx * sx + sy * sy).sqrt();
|
||||
if slen < 1e-3 {
|
||||
// ~180-Grad-Umkehr -> kein sinnvoller Bisektor, gerade weiter.
|
||||
bx = b[0];
|
||||
by = b[1];
|
||||
miter = 1.0;
|
||||
} else {
|
||||
bx = sx / slen;
|
||||
by = sy / slen;
|
||||
let denom = bx * b[0] + by * b[1]; // cos(theta/2)
|
||||
miter = if denom > 1e-3 {
|
||||
(1.0 / denom).min(MITER_LIMIT)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
}
|
||||
}
|
||||
(Some(n), None) | (None, Some(n)) => {
|
||||
bx = n[0];
|
||||
by = n[1];
|
||||
miter = 1.0;
|
||||
}
|
||||
(None, None) => {
|
||||
// Bei k>=2 unerreichbar; sicherheitshalber gerade lassen.
|
||||
bx = 0.0;
|
||||
by = 0.0;
|
||||
miter = 1.0;
|
||||
}
|
||||
}
|
||||
self.line_verts
|
||||
.extend_from_slice(&[s[i][0], s[i][1], bx, by, 1.0, miter]);
|
||||
self.line_verts
|
||||
.extend_from_slice(&[s[i][0], s[i][1], bx, by, -1.0, miter]);
|
||||
}
|
||||
|
||||
let segs = if closed { k } else { k - 1 };
|
||||
for i in 0..segs {
|
||||
let a = base + 2 * i as u32;
|
||||
let b = base + 2 * (((i + 1) % k) as u32);
|
||||
self.line_idx
|
||||
.extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]);
|
||||
}
|
||||
self.add_line_batch((segs * 6) as u32, color, stroke_mm);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hilfsstruct zum Verfolgen der Modell-Bounds.
|
||||
struct Bounds {
|
||||
min_x: f32,
|
||||
min_y: f32,
|
||||
max_x: f32,
|
||||
max_y: f32,
|
||||
}
|
||||
|
||||
impl Bounds {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
min_x: f32::INFINITY,
|
||||
min_y: f32::INFINITY,
|
||||
max_x: f32::NEG_INFINITY,
|
||||
max_y: f32::NEG_INFINITY,
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn track(&mut self, p: Point) {
|
||||
if p[0] < self.min_x {
|
||||
self.min_x = p[0];
|
||||
}
|
||||
if p[1] < self.min_y {
|
||||
self.min_y = p[1];
|
||||
}
|
||||
if p[0] > self.max_x {
|
||||
self.max_x = p[0];
|
||||
}
|
||||
if p[1] > self.max_y {
|
||||
self.max_y = p[1];
|
||||
}
|
||||
}
|
||||
fn finish(self) -> [f32; 4] {
|
||||
[
|
||||
if self.min_x.is_finite() { self.min_x } else { 0.0 },
|
||||
if self.min_y.is_finite() { self.min_y } else { 0.0 },
|
||||
if self.max_x.is_finite() { self.max_x } else { 1.0 },
|
||||
if self.max_y.is_finite() { self.max_y } else { 1.0 },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Tessellliert eine ganze Szene zu GPU-Geometrie (gefuellte Polygone +
|
||||
/// Papier-mm-Striche). Reihenfolge: Fuellungen -> Umrisse -> freie Linien.
|
||||
pub fn compile_scene(scene: &Scene) -> GpuGeometry {
|
||||
let mut geo = GpuGeometry::default();
|
||||
let mut bounds = Bounds::new();
|
||||
|
||||
// 1) Gefuellte Polygone.
|
||||
for poly in &scene.fills {
|
||||
compile_fill(&mut geo, poly, &mut bounds);
|
||||
}
|
||||
// 2) Umrisse (geschlossene Ringe als EIN gehrter Streifen, inkl. Schluss-Kante).
|
||||
for o in &scene.outlines {
|
||||
if o.width_mm > 0.0 && o.pts.len() >= 2 {
|
||||
geo.stroke_polyline(&o.pts, true, o.color, o.width_mm, &mut bounds);
|
||||
}
|
||||
}
|
||||
// 3) Freie Linien.
|
||||
for l in &scene.lines {
|
||||
compile_line(&mut geo, l, &mut bounds);
|
||||
}
|
||||
|
||||
geo.bounds = bounds.finish();
|
||||
geo
|
||||
}
|
||||
|
||||
fn compile_fill(geo: &mut GpuGeometry, poly: &FillPolygon, bounds: &mut Bounds) {
|
||||
let tris = triangulate(&poly.pts);
|
||||
if tris.is_empty() {
|
||||
return;
|
||||
}
|
||||
let base = (geo.fill_pos.len() / 2) as u32;
|
||||
for &pt in &poly.pts {
|
||||
let s = to_screen(pt);
|
||||
geo.fill_pos.push(s[0]);
|
||||
geo.fill_pos.push(s[1]);
|
||||
bounds.track(pt);
|
||||
}
|
||||
let count = tris.len() as u32;
|
||||
for t in tris {
|
||||
geo.fill_idx.push(base + t);
|
||||
}
|
||||
geo.add_fill_batch(count, poly.color);
|
||||
}
|
||||
|
||||
fn compile_line(geo: &mut GpuGeometry, l: &Line, bounds: &mut Bounds) {
|
||||
let w = if l.width_mm > 0.0 { l.width_mm } else { 0.18 };
|
||||
geo.stroke_polyline(&[l.a, l.b], false, l.color, w, bounds);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Eingabe-Typen des nativen 2D-Renderers: geflachte Primitive, wie sie aus dem
|
||||
// Web-Renderer (`src/plan/generatePlan.ts` -> `Plan.primitives`) heruebergereicht
|
||||
// werden. Bewusst reduziert auf das, was die GPU zeichnet: gefuellte Polygone und
|
||||
// Linien. Text/Schraffur bleiben (wie im WebGL-Pfad) Overlay bzw. spaeteres M3.
|
||||
//
|
||||
// Die Typen sind serde-only und bewusst frei von GPU-Abhaengigkeiten, damit die
|
||||
// Tessellierung eigenstaendig (headless) getestet werden kann.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// RGBA-Farbe, Komponenten in [0,1] (deckungsgleich zu `Rgba` im WebGL-Pfad).
|
||||
pub type Rgba = [f32; 4];
|
||||
|
||||
/// Ein 2D-Punkt in MODELL-Metern (nicht Bildschirm-Raum). Die Konvertierung nach
|
||||
/// Bildschirm-Raum passiert erst in der Tessellierung (`to_screen`).
|
||||
pub type Point = [f32; 2];
|
||||
|
||||
/// Gefuelltes Polygon (Wand-Poche, Raeume, Decken). `pts` in Modell-Metern.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FillPolygon {
|
||||
/// Ring-Ecken in Modell-Metern; erste != letzte (Ring wird implizit geschlossen).
|
||||
pub pts: Vec<Point>,
|
||||
/// Fuellfarbe (RGBA 0..1).
|
||||
pub color: Rgba,
|
||||
}
|
||||
|
||||
/// Ein Liniensegment mit echter Papier-mm-Breite.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Line {
|
||||
/// Startpunkt in Modell-Metern.
|
||||
pub a: Point,
|
||||
/// Endpunkt in Modell-Metern.
|
||||
pub b: Point,
|
||||
/// Strichfarbe (RGBA 0..1).
|
||||
pub color: Rgba,
|
||||
/// Strichbreite in ECHTEN Papier-Millimetern (siehe `stroke::mm_to_device_px`).
|
||||
#[serde(rename = "widthMm")]
|
||||
pub width_mm: f32,
|
||||
}
|
||||
|
||||
/// Ein geschlossenes Polygon als Umriss (nur Kanten, keine Fuellung) — bequemer
|
||||
/// Weg, den Ring eines `FillPolygon` als Striche zu rendern (wie der WebGL-Pfad
|
||||
/// die Poche-Umrisse zeichnet).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Outline {
|
||||
/// Ring-Ecken in Modell-Metern.
|
||||
pub pts: Vec<Point>,
|
||||
/// Strichfarbe (RGBA 0..1).
|
||||
pub color: Rgba,
|
||||
/// Strichbreite in echten Papier-Millimetern.
|
||||
#[serde(rename = "widthMm")]
|
||||
pub width_mm: f32,
|
||||
}
|
||||
|
||||
/// Die vollstaendige Szene: alles, was ein Frame zeichnet. Reihenfolge ist
|
||||
/// signifikant (Z-/Alpha-Ueberlagerung): erst Fuellungen, dann Umrisse, dann
|
||||
/// freie Linien — analog zur Draw-Reihenfolge im WebGL-Pfad.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Scene {
|
||||
#[serde(default)]
|
||||
pub fills: Vec<FillPolygon>,
|
||||
#[serde(default)]
|
||||
pub outlines: Vec<Outline>,
|
||||
#[serde(default)]
|
||||
pub lines: Vec<Line>,
|
||||
}
|
||||
|
||||
/// Rechteckiger Ausschnitt in BILDSCHIRM-Einheiten (viewBox), analog `ViewBox`
|
||||
/// im WebGL-Pfad. Getrieben von Pan/Zoom in der Webview.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct ViewBox {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl ViewBox {
|
||||
pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
|
||||
Self { x, y, w, h }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user