perf(storage): kompaktes JSON, Stroke-Treue und Snapshot-Optimierung

This commit is contained in:
2026-10-06 19:14:52 +02:00
parent 1afe4a0f9d
commit 510035e807
7 changed files with 425 additions and 98 deletions
+61
View File
@@ -1017,4 +1017,65 @@ pub mod tests {
// Aufräumen
let _ = std::fs::remove_dir_all(&temp_recovery_dir);
}
#[test]
fn test_snapshot_performance() {
let store = Arc::new(FakePageStore::new());
let clock = Arc::new(MockClock::new(Instant::now()));
let mut controller = AutosaveController::with_options(
PathBuf::from("/tmp/test_perf"),
Duration::from_millis(1500),
Duration::from_secs(10),
store,
Arc::clone(&clock) as Arc<dyn Clock>,
);
let manifest = NotebookManifest::new("perf_nb", "Perf Test");
fn make_page(num_samples: usize) -> Page {
let mut page = Page::new(1, PageBackground::Blank);
let samples_per_stroke = 100;
let num_strokes = num_samples / samples_per_stroke;
for s in 0..num_strokes {
let mut samples = Vec::with_capacity(samples_per_stroke);
for i in 0..samples_per_stroke {
samples.push(StylusSample {
position: glam::Vec2::new(s as f32 * 10.0 + i as f32, i as f32),
pressure: 0.5,
timestamp: i as f64 * 0.01,
});
}
page.add_stroke(Stroke::new(
(s + 1) as u64,
samples,
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
));
}
page
}
// 1. 1 000 Samples
let p_1k = make_page(1_000);
let start = Instant::now();
controller.mark_page_dirty(&p_1k, &manifest);
let dur_1k = start.elapsed();
// 2. 10 000 Samples
let p_10k = make_page(10_000);
let start = Instant::now();
controller.mark_page_dirty(&p_10k, &manifest);
let dur_10k = start.elapsed();
// 3. 50 000 Samples
let p_50k = make_page(50_000);
let start = Instant::now();
controller.mark_page_dirty(&p_50k, &manifest);
let dur_50k = start.elapsed();
println!(
"\n[Benchmark Snapshot] 1k Samples: {:?} | 10k Samples: {:?} | 50k Samples: {:?}",
dur_1k, dur_10k, dur_50k
);
}
}
+16 -4
View File
@@ -104,6 +104,8 @@ pub struct CanvasState {
pub camera_buffer: wgpu::Buffer,
pub camera_bind_group: wgpu::BindGroup,
pub current_ink_color: [f32; 4],
pub current_stroke_width_mm: f32,
pub current_tool: crate::document::StrokeTool,
// Statische Hintergrund-Geometrie
pub paper_buffer: wgpu::Buffer,
@@ -374,6 +376,8 @@ impl CanvasState {
camera_buffer,
camera_bind_group,
current_ink_color: default_ink_color,
current_stroke_width_mm: 0.5,
current_tool: crate::document::StrokeTool::Pen,
paper_buffer,
background_buffer,
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
@@ -502,10 +506,14 @@ impl CanvasState {
/// Konfiguriert die Strichbreite in Millimetern.
pub fn set_stroke_width_mm(&mut self, width_mm: f32) {
// 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0.
let base_radius = (width_mm * 5.0).max(0.25);
self.spline_config.min_radius = (base_radius * 0.5).max(0.15);
self.spline_config.max_radius = (base_radius * 1.5).max(0.35);
self.current_stroke_width_mm = width_mm;
self.spline_config =
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
}
/// Setzt das aktive Zeichenwerkzeug für den Stift.
pub fn set_tool(&mut self, tool: crate::document::StrokeTool) {
self.current_tool = tool;
}
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
@@ -643,6 +651,10 @@ impl CanvasState {
self.current_ink_color,
&self.spline_config,
);
let mut stroke = stroke;
stroke.tool = self.current_tool;
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
stroke.finalized = true;
// Temporären GPU-Live-Buffer vollständig leeren
self.live_samples.clear();
+32
View File
@@ -103,6 +103,16 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
(p - projection).length()
}
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StrokeTool {
#[default]
Pen,
Highlighter,
Shape,
}
/// Ein einzelner Vektor-Strich auf einer Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
@@ -112,6 +122,9 @@ pub struct Stroke {
pub aabb: Aabb,
pub color: [f32; 4],
pub shape: Option<crate::geometry::RecognizedShape>,
pub tool: StrokeTool,
pub width: f32,
pub finalized: bool,
}
impl Stroke {
@@ -141,6 +154,9 @@ impl Stroke {
aabb,
color,
shape: None,
tool: StrokeTool::Pen,
width: 5.0,
finalized: true,
}
}
@@ -157,6 +173,22 @@ impl Stroke {
/// Verknüpft diesen Strich mit einer mathematisch erkannten Form.
pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self {
self.shape = Some(shape);
self.tool = StrokeTool::Shape;
self
}
pub fn with_tool(mut self, tool: StrokeTool) -> Self {
self.tool = tool;
self
}
pub fn with_width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn with_finalized(mut self, finalized: bool) -> Self {
self.finalized = finalized;
self
}
+3
View File
@@ -1411,6 +1411,9 @@ pub fn shape_to_stroke(
let (samples, vertices) = shape_to_mesh(&shape, width_units, color);
let mut stroke = Stroke::new(stroke_id, samples, vertices, color);
stroke.shape = Some(shape);
stroke.width = width_units;
stroke.tool = crate::document::StrokeTool::Shape;
stroke.finalized = true;
stroke
}
+16 -1
View File
@@ -105,6 +105,18 @@ impl Default for SplineConfig {
}
}
impl SplineConfig {
/// Erzeugt eine Spline-Konfiguration aus der Strichbreite in internen Einheiten (10 Einheiten = 1 mm).
pub fn from_width_units(width_units: f32) -> Self {
let base_radius = (width_units / 2.0).max(0.25);
Self {
min_radius: (base_radius * 0.5).max(0.15),
max_radius: (base_radius * 1.5).max(0.35),
samples_per_segment: 16,
}
}
}
/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r).
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
let p = pressure.clamp(0.0, 1.0);
@@ -653,7 +665,10 @@ pub fn finalize_stroke_with_config(
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
.collect();
Stroke::new(stroke_id, decimated, ink_vertices, color)
let mut stroke = Stroke::new(stroke_id, decimated, ink_vertices, color);
stroke.width = config.min_radius + config.max_radius;
stroke.finalized = true;
stroke
}
#[cfg(test)]
+267 -48
View File
@@ -46,6 +46,16 @@ impl From<SampleData> for StylusSample {
}
}
/// Standardwert für die Strichbreite bei Migration älterer Dateien (0.5 mm in Einheiten).
pub fn default_stroke_width() -> f32 {
5.0
}
/// Standardwert für den Finalisierungsstatus bei Migration älterer Dateien.
pub fn default_finalized() -> bool {
true
}
/// Serialisierbare semantische Strichdaten (ohne GPU-Vertizes).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StrokeData {
@@ -54,6 +64,12 @@ pub struct StrokeData {
pub samples: Vec<SampleData>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<crate::geometry::RecognizedShape>,
#[serde(default)]
pub tool: crate::document::StrokeTool,
#[serde(default = "default_stroke_width")]
pub width: f32,
#[serde(default = "default_finalized")]
pub finalized: bool,
}
impl From<&Stroke> for StrokeData {
@@ -63,6 +79,9 @@ impl From<&Stroke> for StrokeData {
color: stroke.color,
samples: stroke.points.iter().map(SampleData::from).collect(),
shape: stroke.shape.clone(),
tool: stroke.tool,
width: stroke.width,
finalized: stroke.finalized,
}
}
}
@@ -77,20 +96,36 @@ impl StrokeData {
.copied()
.map(StylusSample::from)
.collect();
let width = if self.width > 0.0 {
self.width
} else if self.shape.is_some() {
20.0
} else {
5.0
};
let vertices = if let Some(ref shape) = self.shape {
let (_, shape_verts) = crate::geometry::shape_to_mesh(shape, 20.0, self.color);
let (_, shape_verts) = crate::geometry::shape_to_mesh(shape, width, self.color);
shape_verts
} else {
reconstruct_stroke_vertices(&stylus_samples, self.color)
reconstruct_stroke_vertices(
&stylus_samples,
self.color,
width,
self.tool,
self.finalized,
)
};
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
stroke.shape = self.shape.clone();
stroke.tool = self.tool;
stroke.width = width;
stroke.finalized = self.finalized;
stroke
}
}
/// Aktuelle Dateiformat-Version von ink-notes.
pub const CURRENT_FORMAT_VERSION: u32 = 1;
/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized).
pub const CURRENT_FORMAT_VERSION: u32 = 2;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 {
@@ -276,53 +311,66 @@ impl From<serde_json::Error> for StorageError {
}
/// Rekonstruiert deterministisch die GPU-Triangle-Strip-Vertizes aus Stylus-Samples.
pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) -> Vec<InkVertex> {
pub fn reconstruct_stroke_vertices(
samples: &[StylusSample],
color: [f32; 4],
width: f32,
_tool: crate::document::StrokeTool,
finalized: bool,
) -> Vec<InkVertex> {
if samples.is_empty() {
return Vec::new();
}
if samples.len() == 1 {
let pt = samples[0];
let radius = spline::calculate_radius(pt.pressure, 2.5, 10.0);
let segments = 8;
let mut vertices = Vec::with_capacity((segments + 1) * 2);
for i in 0..=segments {
let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius);
vertices.push(InkVertex::new(pt.position.x, pt.position.y, color));
vertices.push(InkVertex::new(
pt.position.x + offset.x,
pt.position.y + offset.y,
color,
));
}
return vertices;
}
let config = SplineConfig::from_width_units(width);
let mut filter = OneEuroFilter::default();
let mut control_points: Vec<ControlPoint> = Vec::with_capacity(samples.len());
for s in samples {
let (fx, fy, fp) = filter.filter(s.position.x, s.position.y, s.pressure, s.timestamp);
let pt = ControlPoint::new(fx, fy, fp);
if let Some(last) = control_points.last() {
if (pt.position - last.position).length_squared() < 1.0 {
continue;
if finalized {
let corners = spline::detect_corners(samples, spline::CORNER_ANGLE_THRESHOLD_DEG);
let strip = spline::generate_finalized_triangle_strip(samples, &corners, &config);
strip
.into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
.collect()
} else {
if samples.len() == 1 {
let pt = samples[0];
let radius =
spline::calculate_radius(pt.pressure, config.min_radius, config.max_radius);
let segments = 8;
let mut vertices = Vec::with_capacity((segments + 1) * 2);
for i in 0..=segments {
let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius);
vertices.push(InkVertex::new(pt.position.x, pt.position.y, color));
vertices.push(InkVertex::new(
pt.position.x + offset.x,
pt.position.y + offset.y,
color,
));
}
return vertices;
}
control_points.push(pt);
}
let config = SplineConfig {
min_radius: 2.5,
max_radius: 10.0,
samples_per_segment: 16,
};
let raw_strip = spline::generate_triangle_strip(&control_points, &config);
raw_strip
.into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
.collect()
let mut filter = OneEuroFilter::default();
let mut control_points: Vec<ControlPoint> = Vec::with_capacity(samples.len());
for s in samples {
let (fx, fy, fp) = filter.filter(s.position.x, s.position.y, s.pressure, s.timestamp);
let pt = ControlPoint::new(fx, fy, fp);
if let Some(last) = control_points.last() {
if (pt.position - last.position).length_squared() < 1.0 {
continue;
}
}
control_points.push(pt);
}
let raw_strip = spline::generate_triangle_strip(&control_points, &config);
raw_strip
.into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
.collect()
}
}
/// Erstellt ein Verzeichnis mit gesicherten Rechten (0700 unter Unix).
@@ -385,9 +433,9 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
Ok(())
}
/// Serialisiert einen Wert als formatiertes JSON und schreibt ihn atomar.
/// Serialisiert einen Wert als kompaktes JSON (`serde_json::to_vec`) und schreibt ihn atomar.
pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StorageError> {
let bytes = serde_json::to_vec_pretty(value)?;
let bytes = serde_json::to_vec(value)?;
atomic_write_file(path, &bytes)?;
Ok(())
}
@@ -1570,11 +1618,11 @@ mod tests {
"page_1.json.v0.bak must be created before upgrading legacy format"
);
// Reload to verify format_version is now 1
// Reload to verify format_version is now CURRENT_FORMAT_VERSION (2)
let (_nb_upgraded, m_upgraded) = load_notebook(path).expect("Upgraded notebook must load");
assert_eq!(m_upgraded.format_version, 1);
assert_eq!(m_upgraded.format_version, CURRENT_FORMAT_VERSION);
let p1_upgraded = load_page(path, 1).expect("Upgraded page must load");
assert_eq!(p1_upgraded.format_version, 1);
assert_eq!(p1_upgraded.format_version, CURRENT_FORMAT_VERSION);
}
#[test]
@@ -1860,4 +1908,175 @@ mod tests {
"Nach Drop muss Lock wieder erworben werden können"
);
}
#[test]
fn test_round_trip_fidelity() {
use crate::geometry::RecognizedShape;
use crate::spline::{finalize_stroke_with_config, SplineConfig};
let mut page = Page::new(1, PageBackground::Blank);
// 1. Dünner Stift (0.3 mm = 3.0 Einheiten)
let samples1 = vec![
StylusSample {
position: glam::Vec2::new(100.0, 100.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(150.0, 100.0),
pressure: 0.6,
timestamp: 0.1,
},
StylusSample {
position: glam::Vec2::new(200.0, 150.0),
pressure: 0.7,
timestamp: 0.2,
},
];
let cfg_thin = SplineConfig {
min_radius: 0.75,
max_radius: 2.25,
samples_per_segment: 16,
};
let stroke_thin =
finalize_stroke_with_config(1, &samples1, [0.0, 0.0, 0.0, 1.0], &cfg_thin);
page.add_stroke(stroke_thin);
// 2. Breiter Stift (1.5 mm = 15.0 Einheiten)
let samples2 = vec![
StylusSample {
position: glam::Vec2::new(200.0, 200.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(250.0, 200.0),
pressure: 0.5,
timestamp: 0.1,
},
StylusSample {
position: glam::Vec2::new(300.0, 250.0),
pressure: 0.5,
timestamp: 0.2,
},
];
let cfg_thick = SplineConfig {
min_radius: 3.75,
max_radius: 11.25,
samples_per_segment: 16,
};
let stroke_thick =
finalize_stroke_with_config(2, &samples2, [0.1, 0.2, 0.8, 1.0], &cfg_thick);
page.add_stroke(stroke_thick);
// 3. Textmarker (6.0 mm = 60.0 Einheiten)
let samples3 = vec![
StylusSample {
position: glam::Vec2::new(100.0, 300.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(200.0, 300.0),
pressure: 0.5,
timestamp: 0.1,
},
StylusSample {
position: glam::Vec2::new(300.0, 300.0),
pressure: 0.5,
timestamp: 0.2,
},
];
let cfg_highlighter = SplineConfig {
min_radius: 15.0,
max_radius: 45.0,
samples_per_segment: 16,
};
let stroke_highlighter =
finalize_stroke_with_config(3, &samples3, [1.0, 1.0, 0.0, 0.4], &cfg_highlighter);
page.add_stroke(stroke_highlighter);
// 4. Erkannte Form (Kreis mit Breite 10.0 Einheiten)
let circle = RecognizedShape::Circle {
center: glam::Vec2::new(500.0, 500.0),
radius: 50.0,
};
let circle_stroke = crate::geometry::shape_to_stroke(circle, 4, [0.0, 0.5, 0.0, 1.0], 10.0);
page.add_stroke(circle_stroke);
// Serialisieren & Deserialisieren
let page_data = PageData::from(&page);
let restored_page = page_data.to_page();
assert_eq!(page.strokes.len(), restored_page.strokes.len());
for (i, (orig, restored)) in page
.strokes
.iter()
.zip(restored_page.strokes.iter())
.enumerate()
{
assert_eq!(
orig.vertices.len(),
restored.vertices.len(),
"Strich {i} Vertices-Anzahl weicht ab: orig {}, restored {}",
orig.vertices.len(),
restored.vertices.len()
);
for (v_idx, (v1, v2)) in orig
.vertices
.iter()
.zip(restored.vertices.iter())
.enumerate()
{
assert!(
(v1.position[0] - v2.position[0]).abs() < 1e-3
&& (v1.position[1] - v2.position[1]).abs() < 1e-3,
"Strich {i} Vertex {v_idx} Position weicht ab: orig {:?}, restored {:?}",
v1.position,
v2.position
);
}
}
}
#[test]
fn test_compact_json_size_reduction() {
use crate::spline::{finalize_stroke_with_config, SplineConfig};
let mut page = Page::new(1, PageBackground::Blank);
let samples: Vec<StylusSample> = (0..500)
.map(|i| StylusSample {
position: glam::Vec2::new(i as f32, (i * 2) as f32),
pressure: 0.5,
timestamp: i as f64 * 0.01,
})
.collect();
let stroke = finalize_stroke_with_config(
1,
&samples,
[0.0, 0.0, 0.0, 1.0],
&SplineConfig::default(),
);
page.add_stroke(stroke);
let page_data = PageData::from(&page);
let pretty_bytes = serde_json::to_vec_pretty(&page_data).unwrap();
let compact_bytes = serde_json::to_vec(&page_data).unwrap();
println!(
"\n[Dateigrößen-Vergleich] Pretty: {} Bytes | Kompakt: {} Bytes | Ersparnis: {:.1}%",
pretty_bytes.len(),
compact_bytes.len(),
(1.0 - compact_bytes.len() as f64 / pretty_bytes.len() as f64) * 100.0
);
assert!(
compact_bytes.len() < pretty_bytes.len(),
"Kompaktes JSON muss kleiner sein"
);
assert!(
compact_bytes.len() * 2 <= pretty_bytes.len(),
"Kompaktes JSON spart mind. 50% Whitespace"
);
}
}