- 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>
309 lines
10 KiB
Rust
309 lines
10 KiB
Rust
//! 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:?}");
|
||
}
|
||
}
|
||
}
|