Files
ink-notes/src/background.rs
T
haraldandClaude Opus 5.5 3e2b627d6a feat: Textmarker-Radierer, Papierfarben/Dunkelmodus und Druckkurve
- Radierer: Modus „Nur Textmarker“ (gilt auch für Stiftrückseite und Seitentaste)
- Striche speichern jetzt ihr Werkzeug; bisher wurde jeder Strich als "pen"
  gespeichert. Ältere Textmarker werden an der Halbtransparenz erkannt.
- Papierfarben Weiß/Creme/Dunkelgrau/Schwarz (nur Anzeige); auf dunklem Papier
  hellt der Shader dunkle Tinte auf (HSL-Helligkeit gespiegelt)
- Einstellbare Druckkurve (weich/hart, Mindestdruck) mit Vorschau
- Einstellungen in ui.json; ☰-Menü scrollt bei kleinen Fenstern
- Test prüft den WGSL-Shader mit naga

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:04:59 +02:00

309 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex;
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
/// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm).
pub const LINE_THICKNESS: f32 = 1.5;
/// Punktgröße für das Punktraster in Seiteneinheiten (2.5 = 0.25 mm).
pub const DOT_SIZE: f32 = 2.5;
/// Rasterabstand 5 mm (50 Einheiten).
pub const GRID_5MM_SPACING: f32 = 50.0;
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
pub const LINED_SPACING: f32 = 75.0;
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
///
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PaperTheme {
#[default]
White,
/// Cremefarben (augenschonend, wie Recyclingpapier)
Cream,
/// Dunkelgrau (Nachtmodus)
DarkGray,
/// Schwarz (OLED)
Black,
}
impl PaperTheme {
pub const ALL: [PaperTheme; 4] = [
PaperTheme::White,
PaperTheme::Cream,
PaperTheme::DarkGray,
PaperTheme::Black,
];
pub fn label(self) -> &'static str {
match self {
PaperTheme::White => "Weiß",
PaperTheme::Cream => "Creme",
PaperTheme::DarkGray => "Dunkelgrau",
PaperTheme::Black => "Schwarz",
}
}
/// Dunkles Papier: dunkle Tinte wird bei Bedarf aufgehellt.
pub fn is_dark(self) -> bool {
matches!(self, PaperTheme::DarkGray | PaperTheme::Black)
}
/// Farbe des Blatts.
pub fn paper_color(self) -> [f32; 4] {
match self {
PaperTheme::White => [1.0, 1.0, 1.0, 1.0],
// sRGB #FBF5E6
PaperTheme::Cream => [0.965, 0.913, 0.791, 1.0],
// sRGB #2B2B2E
PaperTheme::DarkGray => [0.024, 0.024, 0.027, 1.0],
PaperTheme::Black => [0.0, 0.0, 0.0, 1.0],
}
}
/// Farbe von Karos, Linien und Punkten.
pub fn pattern_color(self) -> [f32; 4] {
match self {
PaperTheme::White => [0.88, 0.88, 0.88, 1.0],
PaperTheme::Cream => [0.78, 0.72, 0.60, 1.0],
// sRGB #48484D bzw. #38383C
PaperTheme::DarkGray => [0.064, 0.064, 0.074, 1.0],
PaperTheme::Black => [0.040, 0.040, 0.045, 1.0],
}
}
/// Schreibtisch-Hintergrund um das Blatt; bei dunklem Papier noch dunkler, damit sich
/// das Blatt abhebt.
pub fn desk_color(self) -> wgpu::Color {
let (r, g, b) = match self {
PaperTheme::White | PaperTheme::Cream => (0.16, 0.16, 0.18),
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
PaperTheme::Black => (0.03, 0.03, 0.035),
};
wgpu::Color { r, g, b, a: 1.0 }
}
}
/// Hellt dunkle Tinte für dunkles Papier auf: die HSL-Helligkeit L wird zu 1 − L gespiegelt,
/// Farbton und Sättigung bleiben erhalten. Farben mit L ≥ 0.5 (Rot, Blau, Textmarker)
/// bleiben unverändert. Muss mit `fs_ink` in `shader.wgsl` übereinstimmen.
pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
let max = color[0].max(color[1]).max(color[2]);
let min = color[0].min(color[1]).min(color[2]);
let lift = (1.0 - max - min).max(0.0);
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
}
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt.
pub fn generate_paper_quad(color: [f32; 4]) -> Vec<InkVertex> {
vec![
InkVertex::new(0.0, 0.0, color),
InkVertex::new(PAGE_WIDTH, 0.0, color),
InkVertex::new(0.0, PAGE_HEIGHT, color),
InkVertex::new(0.0, PAGE_HEIGHT, color),
InkVertex::new(PAGE_WIDTH, 0.0, color),
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color),
]
}
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
#[inline]
fn push_quad(
vertices: &mut Vec<InkVertex>,
color: [f32; 4],
x_min: f32,
y_min: f32,
x_max: f32,
y_max: f32,
) {
vertices.push(InkVertex::new(x_min, y_min, color));
vertices.push(InkVertex::new(x_max, y_min, color));
vertices.push(InkVertex::new(x_min, y_max, color));
vertices.push(InkVertex::new(x_min, y_max, color));
vertices.push(InkVertex::new(x_max, y_min, color));
vertices.push(InkVertex::new(x_max, y_max, color));
}
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`.
pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec<InkVertex> {
match background {
PageBackground::Blank => Vec::new(),
PageBackground::Grid5mm => {
let mut vertices = Vec::with_capacity(1200);
let half_t = LINE_THICKNESS / 2.0;
// Vertikale Linien alle 5 mm
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
push_quad(
&mut vertices,
color,
x - half_t,
0.0,
x + half_t,
PAGE_HEIGHT,
);
x += GRID_5MM_SPACING;
}
// Horizontale Linien alle 5 mm
let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT {
push_quad(
&mut vertices,
color,
0.0,
y - half_t,
PAGE_WIDTH,
y + half_t,
);
y += GRID_5MM_SPACING;
}
vertices
}
PageBackground::Lined => {
let mut vertices = Vec::with_capacity(600);
let half_t = LINE_THICKNESS / 2.0;
// Oberer und unterer Randabstand (z. B. 15 mm)
let margin_top = 150.0;
let margin_bottom = 150.0;
let mut y = margin_top;
while y <= PAGE_HEIGHT - margin_bottom {
push_quad(
&mut vertices,
color,
0.0,
y - half_t,
PAGE_WIDTH,
y + half_t,
);
y += LINED_SPACING;
}
vertices
}
PageBackground::DotGrid => {
let mut vertices = Vec::with_capacity(15000);
let half_d = DOT_SIZE / 2.0;
let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT {
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
push_quad(
&mut vertices,
color,
x - half_d,
y - half_d,
x + half_d,
y + half_d,
);
x += GRID_5MM_SPACING;
}
y += GRID_5MM_SPACING;
}
vertices
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_paper_quad_generation() {
let quad = generate_paper_quad(PaperTheme::White.paper_color());
assert_eq!(quad.len(), 6);
assert_eq!(quad[0].position, [0.0, 0.0]);
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
}
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
#[test]
fn test_background_meshes() {
let blank = generate_background_mesh(PageBackground::Blank, PATTERN);
assert!(blank.is_empty());
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN);
assert!(!grid.is_empty());
assert_eq!(
grid.len() % 6,
0,
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
);
let lined = generate_background_mesh(PageBackground::Lined, PATTERN);
assert!(!lined.is_empty());
assert_eq!(lined.len() % 6, 0);
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN);
assert!(!dots.is_empty());
assert_eq!(dots.len() % 6, 0);
}
#[test]
fn test_background_mesh_uses_theme_color() {
let dark = PaperTheme::Black.pattern_color();
let mesh = generate_background_mesh(PageBackground::Lined, dark);
assert!(mesh.iter().all(|v| v.color == dark));
let quad = generate_paper_quad(PaperTheme::Cream.paper_color());
assert!(quad
.iter()
.all(|v| v.color == PaperTheme::Cream.paper_color()));
}
#[test]
fn test_adapt_ink_color() {
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
// Schwarz wird Weiß, Deckkraft bleibt
assert!(close(
adapt_ink_color([0.0, 0.0, 0.0, 0.7]),
[1.0, 1.0, 1.0, 0.7]
));
// Notiz-Schwarz wird hell
let ink = adapt_ink_color([0.12, 0.12, 0.14, 1.0]);
assert!(ink[0] > 0.85 && ink[2] > 0.85);
// Dunkelblau wird Hellblau: Farbton bleibt (Blau bleibt der stärkste Kanal)
let blue = adapt_ink_color([0.0, 0.0, 0.5, 1.0]);
assert!(close(blue, [0.5, 0.5, 1.0, 1.0]));
// Helle und gesättigte Farben bleiben unverändert (Rot, Universitäts-Blau, Textmarker)
for c in [
[0.85, 0.20, 0.20, 1.0],
[0.15, 0.35, 0.85, 1.0],
[1.0, 0.95, 0.1, 0.35],
[1.0, 1.0, 1.0, 1.0],
] {
assert!(close(adapt_ink_color(c), c), "{c:?} wurde verändert");
}
// Ergebnis ist nie heller als Weiß
for c in [[0.0, 0.3, 0.1, 1.0], [0.45, 0.45, 0.48, 1.0]] {
assert!(adapt_ink_color(c)[..3].iter().all(|&x| x <= 1.0 + 1e-6));
}
}
#[test]
fn test_paper_themes_contrast() {
for theme in PaperTheme::ALL {
let paper = theme.paper_color()[0];
let pattern = theme.pattern_color()[0];
assert!(
(paper - pattern).abs() > 0.03,
"{theme:?}: Muster hebt sich nicht vom Papier ab"
);
// Helles Papier ↔ dunkles Papier
assert_eq!(theme.is_dark(), paper < 0.5, "{theme:?}");
}
}
}