Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47300f2ca2 | ||
|
|
27f84ff1b1 |
+100
-7
@@ -160,6 +160,9 @@ pub struct CanvasState {
|
|||||||
pub surface: wgpu::Surface<'static>,
|
pub surface: wgpu::Surface<'static>,
|
||||||
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||||
adapter: wgpu::Adapter,
|
adapter: wgpu::Adapter,
|
||||||
|
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
|
||||||
|
msaa_samples: u32,
|
||||||
|
msaa_view: Option<wgpu::TextureView>,
|
||||||
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
||||||
presentation_warned: bool,
|
presentation_warned: bool,
|
||||||
pub device: wgpu::Device,
|
pub device: wgpu::Device,
|
||||||
@@ -210,6 +213,9 @@ pub struct CanvasState {
|
|||||||
pub live_vertex_capacity: usize,
|
pub live_vertex_capacity: usize,
|
||||||
pub live_vertex_count: u32,
|
pub live_vertex_count: u32,
|
||||||
pub live_samples: Vec<StylusSample>,
|
pub live_samples: Vec<StylusSample>,
|
||||||
|
/// Ungefilterte Eingabepunkte des Live-Strichs (nach dem Lineal): Grundlage des fertigen
|
||||||
|
/// Strichs, damit kleine Schleifen nicht durch die Filterverzögerung schrumpfen
|
||||||
|
live_raw_samples: Vec<StylusSample>,
|
||||||
pub live_control_points: Vec<ControlPoint>,
|
pub live_control_points: Vec<ControlPoint>,
|
||||||
pub live_vertices: Vec<InkVertex>,
|
pub live_vertices: Vec<InkVertex>,
|
||||||
pub filter: OneEuroFilter,
|
pub filter: OneEuroFilter,
|
||||||
@@ -323,6 +329,18 @@ impl CanvasState {
|
|||||||
};
|
};
|
||||||
surface.configure(&device, &config);
|
surface.configure(&device, &config);
|
||||||
|
|
||||||
|
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
|
||||||
|
let msaa_samples = if adapter
|
||||||
|
.get_texture_format_features(config.format)
|
||||||
|
.flags
|
||||||
|
.sample_count_supported(MSAA_SAMPLES)
|
||||||
|
{
|
||||||
|
MSAA_SAMPLES
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
let msaa_view = create_msaa_view(&device, &config, msaa_samples);
|
||||||
|
|
||||||
// 5. Shader & Bind Group Layout
|
// 5. Shader & Bind Group Layout
|
||||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
label: Some("Ink WGSL Shader"),
|
label: Some("Ink WGSL Shader"),
|
||||||
@@ -403,7 +421,10 @@ impl CanvasState {
|
|||||||
conservative: false,
|
conservative: false,
|
||||||
},
|
},
|
||||||
depth_stencil: None,
|
depth_stencil: None,
|
||||||
multisample: wgpu::MultisampleState::default(),
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: msaa_samples,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
multiview: None,
|
multiview: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,7 +456,10 @@ impl CanvasState {
|
|||||||
conservative: false,
|
conservative: false,
|
||||||
},
|
},
|
||||||
depth_stencil: None,
|
depth_stencil: None,
|
||||||
multisample: wgpu::MultisampleState::default(),
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: msaa_samples,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
multiview: None,
|
multiview: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -484,6 +508,7 @@ impl CanvasState {
|
|||||||
&shader,
|
&shader,
|
||||||
&bind_group_layout,
|
&bind_group_layout,
|
||||||
config.format,
|
config.format,
|
||||||
|
msaa_samples,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 8. egui Renderer für das schwebende UI initialisieren
|
// 8. egui Renderer für das schwebende UI initialisieren
|
||||||
@@ -492,6 +517,8 @@ impl CanvasState {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
surface,
|
surface,
|
||||||
adapter,
|
adapter,
|
||||||
|
msaa_samples,
|
||||||
|
msaa_view,
|
||||||
presentation_warned: false,
|
presentation_warned: false,
|
||||||
device,
|
device,
|
||||||
queue,
|
queue,
|
||||||
@@ -523,6 +550,7 @@ impl CanvasState {
|
|||||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||||
live_vertex_count: 0,
|
live_vertex_count: 0,
|
||||||
live_samples: Vec::with_capacity(256),
|
live_samples: Vec::with_capacity(256),
|
||||||
|
live_raw_samples: Vec::with_capacity(256),
|
||||||
live_control_points: Vec::with_capacity(256),
|
live_control_points: Vec::with_capacity(256),
|
||||||
live_vertices: Vec::with_capacity(1024),
|
live_vertices: Vec::with_capacity(1024),
|
||||||
filter: OneEuroFilter::default(),
|
filter: OneEuroFilter::default(),
|
||||||
@@ -639,6 +667,7 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
|
||||||
self.recalculate_viewport();
|
self.recalculate_viewport();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1080,8 @@ impl CanvasState {
|
|||||||
.ruler
|
.ruler
|
||||||
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
|
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
|
||||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||||
|
self.live_raw_samples.clear();
|
||||||
|
self.push_raw_sample(page_x, page_y, pressure, timestamp);
|
||||||
|
|
||||||
// 1. Zwingend durch den 1€-Filter leiten
|
// 1. Zwingend durch den 1€-Filter leiten
|
||||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||||
@@ -1076,6 +1107,7 @@ impl CanvasState {
|
|||||||
|
|
||||||
// 1. Zwingend durch den 1€-Filter leiten
|
// 1. Zwingend durch den 1€-Filter leiten
|
||||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||||
|
self.push_raw_sample(page_x, page_y, pressure, timestamp);
|
||||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||||
let new_pt = ControlPoint::new(fx, fy, fp);
|
let new_pt = ControlPoint::new(fx, fy, fp);
|
||||||
|
|
||||||
@@ -1098,6 +1130,25 @@ impl CanvasState {
|
|||||||
self.update_live_mesh();
|
self.update_live_mesh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merkt sich einen ungefilterten Eingabepunkt (Punkte näher als 0,03 mm am vorigen
|
||||||
|
/// entfallen).
|
||||||
|
fn push_raw_sample(&mut self, x: f32, y: f32, pressure: f32, timestamp: f64) {
|
||||||
|
let position = glam::Vec2::new(x, y);
|
||||||
|
if !position.is_finite() || !pressure.is_finite() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(last) = self.live_raw_samples.last() {
|
||||||
|
if (position - last.position).length_squared() < 0.09 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.live_raw_samples.push(StylusSample {
|
||||||
|
position,
|
||||||
|
pressure: pressure.clamp(0.0, 1.0),
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
|
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
|
||||||
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
|
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
|
||||||
match self.ruler_lock {
|
match self.ruler_lock {
|
||||||
@@ -1114,6 +1165,7 @@ impl CanvasState {
|
|||||||
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
||||||
pub fn cancel_stroke(&mut self) {
|
pub fn cancel_stroke(&mut self) {
|
||||||
self.live_samples.clear();
|
self.live_samples.clear();
|
||||||
|
self.live_raw_samples.clear();
|
||||||
self.live_control_points.clear();
|
self.live_control_points.clear();
|
||||||
self.live_vertices.clear();
|
self.live_vertices.clear();
|
||||||
self.live_vertex_count = 0;
|
self.live_vertex_count = 0;
|
||||||
@@ -1131,10 +1183,16 @@ impl CanvasState {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-Stroke Finalisierung (Corner Preservation, Druckglättung, RDP, runde Kappen)
|
// Post-Stroke Finalisierung (Glättung, RDP, Ecken, runde Kappen) aus den ungefilterten
|
||||||
|
// Punkten: Der 1€-Filter dient nur der Anzeige während des Schreibens
|
||||||
|
let source = if self.live_raw_samples.is_empty() {
|
||||||
|
&self.live_samples
|
||||||
|
} else {
|
||||||
|
&self.live_raw_samples
|
||||||
|
};
|
||||||
let stroke = spline::finalize_stroke_with_config(
|
let stroke = spline::finalize_stroke_with_config(
|
||||||
stroke_id,
|
stroke_id,
|
||||||
&self.live_samples,
|
source,
|
||||||
self.current_ink_color,
|
self.current_ink_color,
|
||||||
&self.spline_config,
|
&self.spline_config,
|
||||||
);
|
);
|
||||||
@@ -1144,6 +1202,7 @@ impl CanvasState {
|
|||||||
|
|
||||||
// Temporären GPU-Live-Buffer vollständig leeren
|
// Temporären GPU-Live-Buffer vollständig leeren
|
||||||
self.live_samples.clear();
|
self.live_samples.clear();
|
||||||
|
self.live_raw_samples.clear();
|
||||||
self.live_control_points.clear();
|
self.live_control_points.clear();
|
||||||
self.live_vertices.clear();
|
self.live_vertices.clear();
|
||||||
self.live_vertex_count = 0;
|
self.live_vertex_count = 0;
|
||||||
@@ -1230,12 +1289,17 @@ impl CanvasState {
|
|||||||
{
|
{
|
||||||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
label: Some("Ink Canvas Render Pass"),
|
label: Some("Ink Canvas Render Pass"),
|
||||||
|
// Mit Multisampling ins Zwischenbild zeichnen und in die Fensterfläche auflösen
|
||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
view: &view,
|
view: self.msaa_view.as_ref().unwrap_or(&view),
|
||||||
resolve_target: None,
|
resolve_target: self.msaa_view.as_ref().map(|_| &view),
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
|
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
|
||||||
store: wgpu::StoreOp::Store,
|
store: if self.msaa_view.is_some() {
|
||||||
|
wgpu::StoreOp::Discard
|
||||||
|
} else {
|
||||||
|
wgpu::StoreOp::Store
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})],
|
})],
|
||||||
depth_stencil_attachment: None,
|
depth_stencil_attachment: None,
|
||||||
@@ -1342,6 +1406,35 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
|
||||||
|
const MSAA_SAMPLES: u32 = 4;
|
||||||
|
|
||||||
|
/// Zwischenbild für das Multisampling in Fenstergröße; `None` ohne Multisampling.
|
||||||
|
fn create_msaa_view(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
config: &wgpu::SurfaceConfiguration,
|
||||||
|
samples: u32,
|
||||||
|
) -> Option<wgpu::TextureView> {
|
||||||
|
(samples > 1).then(|| {
|
||||||
|
device
|
||||||
|
.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("MSAA Color Target"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: config.width.max(1),
|
||||||
|
height: config.height.max(1),
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: samples,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: config.format,
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
|
view_formats: &[],
|
||||||
|
})
|
||||||
|
.create_view(&wgpu::TextureViewDescriptor::default())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Zu rasterndes Textfeld.
|
/// Zu rasterndes Textfeld.
|
||||||
struct TextSpec {
|
struct TextSpec {
|
||||||
text: crate::text::TextBox,
|
text: crate::text::TextBox,
|
||||||
|
|||||||
+91
-15
@@ -10,10 +10,13 @@ use std::f32::consts::PI;
|
|||||||
|
|
||||||
/// Standard-Minimal-Grenzfrequenz in Hz (reduziert Jitter bei niedrigen Geschwindigkeiten)
|
/// Standard-Minimal-Grenzfrequenz in Hz (reduziert Jitter bei niedrigen Geschwindigkeiten)
|
||||||
pub const DEFAULT_FC_MIN: f32 = 4.5;
|
pub const DEFAULT_FC_MIN: f32 = 4.5;
|
||||||
/// Standard-Geschwindigkeitskoeffizient (reduziert Latenz bei schnellen Bewegungen)
|
/// Standard-Geschwindigkeitskoeffizient (Hz je Seiteneinheit/s): Bei 400 Einheiten/s
|
||||||
pub const DEFAULT_BETA: f32 = 0.05;
|
/// (4 cm/s, zügige Handschrift) liegt die Grenzfrequenz bei rund 65 Hz, kleine Schleifen
|
||||||
/// Standard-Grenzfrequenz für die Ableitung in Hz
|
/// werden kaum noch verkleinert
|
||||||
pub const DEFAULT_D_CUTOFF: f32 = 1.0;
|
pub const DEFAULT_BETA: f32 = 0.15;
|
||||||
|
/// Standard-Grenzfrequenz für die Geschwindigkeitsschätzung in Hz (reagiert nach wenigen
|
||||||
|
/// Millisekunden auf schnellere Bewegung)
|
||||||
|
pub const DEFAULT_D_CUTOFF: f32 = 8.0;
|
||||||
|
|
||||||
/// Berechnet den Glättungsfaktor alpha = 1 / (1 + tau / dt) = 2*pi*fc*dt / (2*pi*fc*dt + 1).
|
/// Berechnet den Glättungsfaktor alpha = 1 / (1 + tau / dt) = 2*pi*fc*dt / (2*pi*fc*dt + 1).
|
||||||
///
|
///
|
||||||
@@ -157,13 +160,18 @@ pub struct FilteredPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Adaptiver 1€-Filter für 2D-Position (x, y) und Stylus-Druck p ∈ [0.0, 1.0].
|
/// Adaptiver 1€-Filter für 2D-Position (x, y) und Stylus-Druck p ∈ [0.0, 1.0].
|
||||||
|
///
|
||||||
|
/// Die Position wird mit der Geschwindigkeit in der Ebene gefiltert (nicht je Achse):
|
||||||
|
/// Auf einer Kreisbahn ist eine Achse an den Umkehrpunkten in Ruhe, eine Filterung je
|
||||||
|
/// Achse würde dort stark glätten und Schleifen zusammenziehen.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OneEuroFilter {
|
pub struct OneEuroFilter {
|
||||||
pub fc_min: f32,
|
pub fc_min: f32,
|
||||||
pub beta: f32,
|
pub beta: f32,
|
||||||
pub d_cutoff: f32,
|
pub d_cutoff: f32,
|
||||||
x_filter: OneEuroFilter1D,
|
pos_prev: Option<glam::Vec2>,
|
||||||
y_filter: OneEuroFilter1D,
|
speed_prev: f32,
|
||||||
|
t_prev: Option<f64>,
|
||||||
p_filter: OneEuroFilter1D,
|
p_filter: OneEuroFilter1D,
|
||||||
last_output: Option<(f32, f32, f32)>,
|
last_output: Option<(f32, f32, f32)>,
|
||||||
}
|
}
|
||||||
@@ -175,8 +183,9 @@ impl OneEuroFilter {
|
|||||||
fc_min,
|
fc_min,
|
||||||
beta,
|
beta,
|
||||||
d_cutoff,
|
d_cutoff,
|
||||||
x_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
pos_prev: None,
|
||||||
y_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
speed_prev: 0.0,
|
||||||
|
t_prev: None,
|
||||||
p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
||||||
last_output: None,
|
last_output: None,
|
||||||
}
|
}
|
||||||
@@ -184,8 +193,9 @@ impl OneEuroFilter {
|
|||||||
|
|
||||||
/// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke).
|
/// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke).
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.x_filter.reset();
|
self.pos_prev = None;
|
||||||
self.y_filter.reset();
|
self.speed_prev = 0.0;
|
||||||
|
self.t_prev = None;
|
||||||
self.p_filter.reset();
|
self.p_filter.reset();
|
||||||
self.last_output = None;
|
self.last_output = None;
|
||||||
}
|
}
|
||||||
@@ -202,8 +212,8 @@ impl OneEuroFilter {
|
|||||||
// Druck immer auf [0.0, 1.0] klemmen
|
// Druck immer auf [0.0, 1.0] klemmen
|
||||||
let p_in = pressure.clamp(0.0, 1.0);
|
let p_in = pressure.clamp(0.0, 1.0);
|
||||||
|
|
||||||
let x_out = self.x_filter.filter(x, t);
|
let pos = self.filter_position(glam::Vec2::new(x, y), t);
|
||||||
let y_out = self.y_filter.filter(y, t);
|
let (x_out, y_out) = (pos.x, pos.y);
|
||||||
let p_out = self.p_filter.filter(p_in, t).clamp(0.0, 1.0);
|
let p_out = self.p_filter.filter(p_in, t).clamp(0.0, 1.0);
|
||||||
|
|
||||||
let out = (x_out, y_out, p_out);
|
let out = (x_out, y_out, p_out);
|
||||||
@@ -211,6 +221,51 @@ impl OneEuroFilter {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 1€-Filter der Position mit der Geschwindigkeit in der Ebene.
|
||||||
|
fn filter_position(&mut self, x: glam::Vec2, t: f64) -> glam::Vec2 {
|
||||||
|
let (Some(prev), Some(prev_t)) = (self.pos_prev, self.t_prev) else {
|
||||||
|
self.pos_prev = Some(x);
|
||||||
|
self.speed_prev = 0.0;
|
||||||
|
self.t_prev = Some(t);
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
let raw_dt = t - prev_t;
|
||||||
|
// Lange Pause: neu beginnen statt über die Pause hinweg zu glätten
|
||||||
|
if raw_dt > 1.0 {
|
||||||
|
self.pos_prev = Some(x);
|
||||||
|
self.speed_prev = 0.0;
|
||||||
|
self.t_prev = Some(t);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
let dt = if raw_dt <= 0.0 || !raw_dt.is_finite() {
|
||||||
|
1e-4_f32
|
||||||
|
} else {
|
||||||
|
raw_dt as f32
|
||||||
|
};
|
||||||
|
self.t_prev = Some(t);
|
||||||
|
|
||||||
|
let mut speed = (x - prev).length() / dt;
|
||||||
|
if !speed.is_finite() {
|
||||||
|
speed = 0.0;
|
||||||
|
}
|
||||||
|
let a_d = smoothing_factor(dt, self.d_cutoff);
|
||||||
|
let speed_hat = a_d * speed + (1.0 - a_d) * self.speed_prev;
|
||||||
|
|
||||||
|
let fc = (self.fc_min + self.beta * speed_hat).max(self.fc_min);
|
||||||
|
let a = smoothing_factor(dt, fc);
|
||||||
|
let mut out = prev + (x - prev) * a;
|
||||||
|
if !out.is_finite() {
|
||||||
|
out = prev;
|
||||||
|
}
|
||||||
|
self.pos_prev = Some(out);
|
||||||
|
self.speed_prev = if speed_hat.is_finite() {
|
||||||
|
speed_hat
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Filtert einen 2D-Punkt `pos` und Druck `pressure` zum Zeitstempel `t`.
|
/// Filtert einen 2D-Punkt `pos` und Druck `pressure` zum Zeitstempel `t`.
|
||||||
pub fn filter_point(&mut self, pos: glam::Vec2, pressure: f32, t: f64) -> (glam::Vec2, f32) {
|
pub fn filter_point(&mut self, pos: glam::Vec2, pressure: f32, t: f64) -> (glam::Vec2, f32) {
|
||||||
let (x, y, p) = self.filter(pos.x, pos.y, pressure, t);
|
let (x, y, p) = self.filter(pos.x, pos.y, pressure, t);
|
||||||
@@ -279,6 +334,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_small_loops_do_not_shrink() {
|
||||||
|
// Kreis mit 0,8 mm Radius in 0,1 s: die Filterung in der Ebene folgt ihm
|
||||||
|
let mut filter = OneEuroFilter::default();
|
||||||
|
let (r, period, hz) = (8.0_f32, 0.1_f64, 200.0);
|
||||||
|
let mut radii = Vec::new();
|
||||||
|
for i in 0..=(period * hz) as usize * 2 {
|
||||||
|
let t = i as f64 / hz;
|
||||||
|
let a = std::f32::consts::TAU * (t / period) as f32;
|
||||||
|
let p = glam::Vec2::new(500.0 + r * a.sin(), 500.0 - r * a.cos());
|
||||||
|
let (out, _) = filter.filter_point(p, 0.5, t);
|
||||||
|
if t > period {
|
||||||
|
radii.push((out - glam::Vec2::new(500.0, 500.0)).length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mean = radii.iter().sum::<f32>() / radii.len() as f32;
|
||||||
|
assert!(mean > r * 0.9, "Schleife geschrumpft: {mean}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_high_frequency_jitter() {
|
fn test_high_frequency_jitter() {
|
||||||
let mut filter = OneEuroFilter::default();
|
let mut filter = OneEuroFilter::default();
|
||||||
@@ -286,7 +360,8 @@ mod tests {
|
|||||||
let base_x = 100.0_f32;
|
let base_x = 100.0_f32;
|
||||||
let base_y = 50.0_f32;
|
let base_y = 50.0_f32;
|
||||||
let base_p = 0.5_f32;
|
let base_p = 0.5_f32;
|
||||||
let jitter_amplitude = 2.0_f32;
|
// Realistisches Digitizer-Rauschen (±0,05 mm) bei ruhendem Stift
|
||||||
|
let jitter_amplitude = 0.5_f32;
|
||||||
|
|
||||||
let num_samples = 120;
|
let num_samples = 120;
|
||||||
let dt = 1.0 / 120.0; // 120 Hz Abtastrate
|
let dt = 1.0 / 120.0; // 120 Hz Abtastrate
|
||||||
@@ -331,9 +406,10 @@ mod tests {
|
|||||||
.sum::<f32>()
|
.sum::<f32>()
|
||||||
/ count;
|
/ count;
|
||||||
|
|
||||||
// Der 1€-Filter muss das hochfrequente Jitter massiv dämpfen
|
// Der 1€-Filter muss das hochfrequente Jitter deutlich dämpfen (der fertige Strich
|
||||||
|
// wird zusätzlich symmetrisch geglättet)
|
||||||
assert!(
|
assert!(
|
||||||
filtered_variance < raw_variance * 0.05,
|
filtered_variance < raw_variance * 0.2,
|
||||||
"Gefilterte Varianz ({filtered_variance}) muss deutlich kleiner sein als Raw-Varianz ({raw_variance})"
|
"Gefilterte Varianz ({filtered_variance}) muss deutlich kleiner sein als Raw-Varianz ({raw_variance})"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -102,6 +102,7 @@ impl ImageLayer {
|
|||||||
shader: &wgpu::ShaderModule,
|
shader: &wgpu::ShaderModule,
|
||||||
camera_layout: &wgpu::BindGroupLayout,
|
camera_layout: &wgpu::BindGroupLayout,
|
||||||
format: wgpu::TextureFormat,
|
format: wgpu::TextureFormat,
|
||||||
|
sample_count: u32,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
label: Some("Image Texture Bind Group Layout"),
|
label: Some("Image Texture Bind Group Layout"),
|
||||||
@@ -151,7 +152,10 @@ impl ImageLayer {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
depth_stencil: None,
|
depth_stencil: None,
|
||||||
multisample: wgpu::MultisampleState::default(),
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: sample_count,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
multiview: None,
|
multiview: None,
|
||||||
});
|
});
|
||||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
|||||||
+66
-7
@@ -17,6 +17,10 @@ pub const RDP_TOLERANCE_UNITS: f32 = 0.75;
|
|||||||
|
|
||||||
/// Schwellenwert für Ecken-Erkennung (Richtungsänderung > 65 Grad).
|
/// Schwellenwert für Ecken-Erkennung (Richtungsänderung > 65 Grad).
|
||||||
pub const CORNER_ANGLE_THRESHOLD_DEG: f32 = 65.0;
|
pub const CORNER_ANGLE_THRESHOLD_DEG: f32 = 65.0;
|
||||||
|
/// Ecken nur zwischen Abschnitten, die nach der Ausdünnung mindestens so lang sind (0,6 mm).
|
||||||
|
/// Kleine Schleifen („o“, „a“, „e“) bestehen aus kurzen Abschnitten mit großem Knickwinkel;
|
||||||
|
/// würden sie als Ecken getrennt, würden sie zu spitzen Zacken statt Rundungen.
|
||||||
|
pub const CORNER_MIN_SEGMENT_UNITS: f32 = 6.0;
|
||||||
|
|
||||||
/// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern).
|
/// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern).
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -270,7 +274,7 @@ pub fn generate_triangle_strip(
|
|||||||
// Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad
|
// Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad
|
||||||
let pt = control_points[0];
|
let pt = control_points[0];
|
||||||
let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius);
|
let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius);
|
||||||
let segments = 8;
|
let segments = 20;
|
||||||
let mut vertices = Vec::with_capacity((segments + 1) * 2);
|
let mut vertices = Vec::with_capacity((segments + 1) * 2);
|
||||||
|
|
||||||
for i in 0..=segments {
|
for i in 0..=segments {
|
||||||
@@ -357,6 +361,34 @@ pub fn smooth_pressure(samples: &[StylusSample]) -> Vec<StylusSample> {
|
|||||||
smoothed
|
smoothed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Glättet die Positionen mit dem Fenster [0.25, 0.5, 0.25] gegen Digitizer-Rauschen.
|
||||||
|
/// Symmetrisch (ohne Verzögerung), daher bleiben auch kleine Schleifen rund. Anfang, Ende
|
||||||
|
/// und Punkte mit scharfem Knick (über [`CORNER_ANGLE_THRESHOLD_DEG`]) bleiben unverändert,
|
||||||
|
/// damit Ecken spitz bleiben.
|
||||||
|
pub fn smooth_positions(samples: &[StylusSample]) -> Vec<StylusSample> {
|
||||||
|
if samples.len() < 3 {
|
||||||
|
return samples.to_vec();
|
||||||
|
}
|
||||||
|
let cos_limit = CORNER_ANGLE_THRESHOLD_DEG.to_radians().cos();
|
||||||
|
let mut smoothed = samples.to_vec();
|
||||||
|
for i in 1..samples.len() - 1 {
|
||||||
|
let (a, b, c) = (
|
||||||
|
samples[i - 1].position,
|
||||||
|
samples[i].position,
|
||||||
|
samples[i + 1].position,
|
||||||
|
);
|
||||||
|
let (d_in, d_out) = (b - a, c - b);
|
||||||
|
if d_in.length_squared() > 1e-8 && d_out.length_squared() > 1e-8 {
|
||||||
|
let cos = d_in.normalize().dot(d_out.normalize());
|
||||||
|
if cos < cos_limit {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
smoothed[i].position = 0.25 * a + 0.5 * b + 0.25 * c;
|
||||||
|
}
|
||||||
|
smoothed
|
||||||
|
}
|
||||||
|
|
||||||
/// Rekursives RDP-Markieren zur Ausdünnung kollinearer Zwischenpunkte.
|
/// Rekursives RDP-Markieren zur Ausdünnung kollinearer Zwischenpunkte.
|
||||||
fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, keep: &mut [bool]) {
|
fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, keep: &mut [bool]) {
|
||||||
if end <= start + 1 {
|
if end <= start + 1 {
|
||||||
@@ -402,7 +434,8 @@ pub fn rdp_filter_samples(samples: &[StylusSample], epsilon: f32) -> Vec<StylusS
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`.
|
/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`
|
||||||
|
/// (nur zwischen Segmenten von mindestens [`CORNER_MIN_SEGMENT_UNITS`] Länge).
|
||||||
pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize> {
|
pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize> {
|
||||||
if samples.len() < 3 {
|
if samples.len() < 3 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -415,7 +448,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
|
|||||||
let l_in = v_in.length();
|
let l_in = v_in.length();
|
||||||
let l_out = v_out.length();
|
let l_out = v_out.length();
|
||||||
|
|
||||||
if l_in > 1e-4 && l_out > 1e-4 {
|
if l_in >= CORNER_MIN_SEGMENT_UNITS && l_out >= CORNER_MIN_SEGMENT_UNITS {
|
||||||
let d_in = v_in / l_in;
|
let d_in = v_in / l_in;
|
||||||
let d_out = v_out / l_out;
|
let d_out = v_out / l_out;
|
||||||
let dot = d_in.dot(d_out).clamp(-1.0, 1.0);
|
let dot = d_in.dot(d_out).clamp(-1.0, 1.0);
|
||||||
@@ -566,7 +599,8 @@ pub fn generate_finalized_triangle_strip(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
const CAP_STEPS: usize = 4;
|
// Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken
|
||||||
|
const CAP_STEPS: usize = 10;
|
||||||
let mut vertices = Vec::new();
|
let mut vertices = Vec::new();
|
||||||
|
|
||||||
// 3. Runde Start-Kappe vor dem ersten Run
|
// 3. Runde Start-Kappe vor dem ersten Run
|
||||||
@@ -626,7 +660,7 @@ pub fn generate_finalized_triangle_strip(
|
|||||||
|
|
||||||
/// Finalisiert einen Strich nach dem Zwei-Stufen-Modell:
|
/// Finalisiert einen Strich nach dem Zwei-Stufen-Modell:
|
||||||
/// 1. Ecken-Erkennung (Corner Preservation bei Richtungsänderung > 65°)
|
/// 1. Ecken-Erkennung (Corner Preservation bei Richtungsänderung > 65°)
|
||||||
/// 2. Druckglättung über gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
/// 2. Druck- und Positionsglättung über gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||||
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
|
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
|
||||||
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
|
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
|
||||||
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
|
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
|
||||||
@@ -649,8 +683,8 @@ pub fn finalize_stroke_with_config(
|
|||||||
return Stroke::new(stroke_id, Vec::new(), Vec::new(), color);
|
return Stroke::new(stroke_id, Vec::new(), Vec::new(), color);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Druckglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
// 1. Druck- und Positionsglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||||
let smoothed = smooth_pressure(raw_samples);
|
let smoothed = smooth_positions(&smooth_pressure(raw_samples));
|
||||||
|
|
||||||
// 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten)
|
// 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten)
|
||||||
let decimated = rdp_filter_samples(&smoothed, RDP_TOLERANCE_UNITS);
|
let decimated = rdp_filter_samples(&smoothed, RDP_TOLERANCE_UNITS);
|
||||||
@@ -866,6 +900,31 @@ mod tests {
|
|||||||
assert!((corner_pt.position.y - 0.0).abs() < 1e-3);
|
assert!((corner_pt.position.y - 0.0).abs() < 1e-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_small_loops_stay_round() {
|
||||||
|
// Kleines „o“ (Radius 0,8 mm) in 0,1 s mit 200 Hz, mit Vorschub wie im Wort
|
||||||
|
let (r, period, hz) = (8.0_f32, 0.1_f64, 200.0);
|
||||||
|
let center = |t: f64| glam::Vec2::new(500.0 + t as f32 * 40.0, 500.0);
|
||||||
|
let raw: Vec<StylusSample> = (0..=((period * hz) as usize))
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f64 / hz;
|
||||||
|
let a = std::f32::consts::TAU * (t / period) as f32;
|
||||||
|
StylusSample {
|
||||||
|
position: center(t) + glam::Vec2::new(r * a.sin(), -r * a.cos()),
|
||||||
|
pressure: 0.6,
|
||||||
|
timestamp: t,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let stroke =
|
||||||
|
finalize_stroke_with_config(1, &raw, [0.0; 4], &SplineConfig::from_width_units(5.0));
|
||||||
|
for p in &stroke.points {
|
||||||
|
let d = (p.position - center(p.timestamp)).length();
|
||||||
|
assert!((d - r).abs() < 0.6, "Schleife verformt: Abstand {d}");
|
||||||
|
}
|
||||||
|
assert!(detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pressure_smoothing() {
|
fn test_pressure_smoothing() {
|
||||||
let samples = vec![
|
let samples = vec![
|
||||||
|
|||||||
Reference in New Issue
Block a user