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:
2026-07-02 00:27:46 +02:00
parent 3d2d4d6321
commit f08ef13fe8
10 changed files with 4192 additions and 0 deletions
+271
View File
@@ -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");
}
+400
View File
@@ -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()));
}
}
+174
View File
@@ -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:?}"));
}
}
}
+89
View File
@@ -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
}
+103
View File
@@ -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;
}
"#;
+414
View File
@@ -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);
}
+82
View File
@@ -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 }
}
}