feat: Zoom bis 1000 %, PDF-Seiten beim Hineinzoomen schärfer
Höchster Zoom 1000 % statt 500 % (Issue #1). Importierte PDF-Seiten werden mit doppelter Auflösung nachgerendert, sobald die Standardauflösung sichtbar vergrößert würde; bis dahin bleibt sie als Ersatz sichtbar. Texte werden bis 8 Pixel je Einheit gerastert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
||||
### 🔍 Stufenloser Zoom & Pan (Kamera)
|
||||
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader.
|
||||
- **Durchgehendes Scrollen:** Seiten hängen untereinander und werden mit Mausrad, Finger oder Touchpad fließend gescrollt; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite angehängt. Abschaltbar im ☰-Menü unter „Ansicht“ (dann Seite für Seite blättern).
|
||||
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 500 %** zentriert auf die Cursor-Position via `Strg + Mausrad` (ohne durchgehende Ansicht: Mausrad), Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`).
|
||||
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 1000 %** (importierte PDF-Seiten werden beim Hineinzoomen schärfer nachgerendert) zentriert auf die Cursor-Position via `Strg + Mausrad` (ohne durchgehende Ansicht: Mausrad), Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`).
|
||||
- **Pan:** Verschieben des Canvas per mittlerer Maustaste, `Leertaste + Drag` oder Trackpad-Pan.
|
||||
- **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`).
|
||||
|
||||
|
||||
+12
-3
@@ -911,6 +911,15 @@ impl CanvasState {
|
||||
self.image_layer
|
||||
.forget_textures(|key| key.starts_with(TEXT_KEY_PREFIX));
|
||||
}
|
||||
// Importierte PDF-Seiten schärfer rendern, sobald die Standardauflösung vergrößert würde
|
||||
let page_long_edge = crate::document::PAGE_HEIGHT.max(crate::document::PAGE_WIDTH)
|
||||
* self.viewport.page_width
|
||||
/ crate::document::PAGE_WIDTH
|
||||
* self.camera.zoom;
|
||||
self.image_layer.set_pdf_pixels(
|
||||
(page_long_edge > crate::pdf_doc::DISPLAY_RENDER_PIXELS as f32 * 1.25)
|
||||
.then_some(crate::pdf_doc::ZOOMED_RENDER_PIXELS),
|
||||
);
|
||||
let specs = &self.text_specs;
|
||||
self.image_layer
|
||||
.prepare(&self.device, &self.queue, &mut |key| match specs.get(key) {
|
||||
@@ -1309,12 +1318,12 @@ fn text_key(text: &crate::text::TextBox, color: [f32; 4]) -> String {
|
||||
}
|
||||
|
||||
/// Auflösung der Texttexturen für `screen_px_per_unit` Bildschirmpixel je Seiteneinheit:
|
||||
/// auf Viertel-Zweierpotenzen aufgerundet (⅛ bis 4). So wird nicht bei jedem Zoomschritt neu
|
||||
/// auf Viertel-Zweierpotenzen aufgerundet (⅛ bis 8). So wird nicht bei jedem Zoomschritt neu
|
||||
/// gerastert, und die Textur ist höchstens ein Fünftel größer als dargestellt – stärker
|
||||
/// verkleinert wirkt die dünne Schrift blass.
|
||||
fn text_resolution(screen_px_per_unit: f32) -> f32 {
|
||||
let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil();
|
||||
2.0_f32.powf(steps / 4.0).clamp(0.125, 4.0)
|
||||
2.0_f32.powf(steps / 4.0).clamp(0.125, 8.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1328,7 +1337,7 @@ mod tests {
|
||||
assert!(r >= px - 1e-4 && r < px * 1.2 + 1e-4, "{px} → {r}");
|
||||
}
|
||||
assert_eq!(text_resolution(0.27), text_resolution(0.28));
|
||||
assert_eq!(text_resolution(100.0), 4.0);
|
||||
assert_eq!(text_resolution(100.0), 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+30
-6
@@ -91,6 +91,9 @@ pub struct ImageLayer {
|
||||
groups: Vec<Vec<(String, [glam::Vec2; 4])>>,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
vertices_dirty: bool,
|
||||
/// Importierte PDF-Seiten in höherer Auflösung (längste Kante in Pixeln) anzeigen;
|
||||
/// bis sie gerendert ist, bleibt die Standardauflösung sichtbar
|
||||
pdf_pixels: Option<u32>,
|
||||
}
|
||||
|
||||
impl ImageLayer {
|
||||
@@ -170,6 +173,7 @@ impl ImageLayer {
|
||||
groups: vec![Vec::new()],
|
||||
vertex_buffer: None,
|
||||
vertices_dirty: false,
|
||||
pdf_pixels: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +198,19 @@ impl ImageLayer {
|
||||
self.groups.iter().flatten()
|
||||
}
|
||||
|
||||
/// Auflösung importierter PDF-Seiten (`None`: Standard), z. B. beim Hineinzoomen.
|
||||
pub fn set_pdf_pixels(&mut self, pixels: Option<u32>) {
|
||||
self.pdf_pixels = pixels;
|
||||
}
|
||||
|
||||
/// Schlüssel der anzuzeigenden Textur: PDF-Seiten ggf. in höherer Auflösung.
|
||||
fn texture_key(&self, key: &str) -> String {
|
||||
match self.pdf_pixels {
|
||||
Some(pixels) if key.contains('#') => format!("{key}@{pixels}"),
|
||||
_ => key.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
|
||||
/// ohne Änderungen kostet das nichts.
|
||||
pub fn prepare(
|
||||
@@ -204,9 +221,8 @@ impl ImageLayer {
|
||||
) {
|
||||
let missing: Vec<String> = self
|
||||
.all_quads()
|
||||
.map(|(key, _)| key)
|
||||
.filter(|key| !self.textures.contains_key(*key))
|
||||
.cloned()
|
||||
.map(|(key, _)| self.texture_key(key))
|
||||
.filter(|key| !self.textures.contains_key(key))
|
||||
.collect();
|
||||
for key in missing {
|
||||
if self.textures.contains_key(&key) {
|
||||
@@ -230,7 +246,11 @@ impl ImageLayer {
|
||||
|
||||
// Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird
|
||||
if self.texture_bytes > TEXTURE_CACHE_BYTES {
|
||||
let used: HashSet<&String> = self.groups.iter().flatten().map(|(a, _)| a).collect();
|
||||
// Die Standardauflösung bleibt als Ersatz, bis die höhere fertig ist
|
||||
let used: HashSet<String> = self
|
||||
.all_quads()
|
||||
.flat_map(|(key, _)| [key.clone(), self.texture_key(key)])
|
||||
.collect();
|
||||
self.textures.retain(|name, _| used.contains(name));
|
||||
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
|
||||
}
|
||||
@@ -279,7 +299,7 @@ impl ImageLayer {
|
||||
/// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)?
|
||||
pub fn is_waiting(&self) -> bool {
|
||||
self.all_quads()
|
||||
.any(|(key, _)| !self.textures.contains_key(key))
|
||||
.any(|(key, _)| !self.textures.contains_key(&self.texture_key(key)))
|
||||
}
|
||||
|
||||
/// Zeichnet die Bilder einer Seite (Gruppe 0: aktuelle Seite). Bind Group 0 (Kamera der
|
||||
@@ -298,7 +318,11 @@ impl ImageLayer {
|
||||
render_pass.set_pipeline(&self.pipeline);
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
for (i, (asset, _)) in quads.iter().enumerate() {
|
||||
let Some((bind_group, _)) = self.textures.get(asset) else {
|
||||
let Some((bind_group, _)) = self
|
||||
.textures
|
||||
.get(&self.texture_key(asset))
|
||||
.or_else(|| self.textures.get(asset))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
render_pass.set_bind_group(1, bind_group, &[]);
|
||||
|
||||
+7
-5
@@ -507,17 +507,19 @@ fn texture_for(
|
||||
assets: &AssetStore,
|
||||
pdf_renderer: &mut PdfRenderService,
|
||||
) -> TextureSource {
|
||||
let result = match key.split_once('#') {
|
||||
Some((asset, page)) => {
|
||||
let (Ok(path), Ok(page)) = (assets.path_of(asset), page.parse::<usize>()) else {
|
||||
let result = match key.contains('#') {
|
||||
true => {
|
||||
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
|
||||
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
|
||||
else {
|
||||
return TextureSource::Failed(format!("Ungültiger PDF-Verweis '{key}'"));
|
||||
};
|
||||
match pdf_renderer.take(key, path, page) {
|
||||
match pdf_renderer.take(key, path, page, max_pixels) {
|
||||
Some(result) => result,
|
||||
None => return TextureSource::Pending,
|
||||
}
|
||||
}
|
||||
None => assets
|
||||
false => assets
|
||||
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
|
||||
.map_err(|e| e.to_string()),
|
||||
};
|
||||
|
||||
+43
-9
@@ -16,6 +16,19 @@ use crate::assets::RgbaImage;
|
||||
|
||||
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
|
||||
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
|
||||
/// Längste Kante beim starken Hineinzoomen (A4 ≈ 440 dpi).
|
||||
pub const ZOOMED_RENDER_PIXELS: u32 = 5120;
|
||||
|
||||
/// Texturschlüssel einer PDF-Seite: `<asset>#<seite>`, gerendert mit mehr als
|
||||
/// [`DISPLAY_RENDER_PIXELS`] als `<asset>#<seite>@<pixel>`. Liefert Asset, Seite und Pixel.
|
||||
pub fn parse_page_key(key: &str) -> Option<(&str, usize, u32)> {
|
||||
let (asset, rest) = key.split_once('#')?;
|
||||
let (page, pixels) = match rest.split_once('@') {
|
||||
Some((page, pixels)) => (page, pixels.parse().ok()?),
|
||||
None => (rest, DISPLAY_RENDER_PIXELS),
|
||||
};
|
||||
Some((asset, page.parse().ok()?, pixels))
|
||||
}
|
||||
/// Größte Seitenzahl, die importiert wird.
|
||||
pub const MAX_IMPORT_PAGES: usize = 2000;
|
||||
|
||||
@@ -88,6 +101,7 @@ struct RenderRequest {
|
||||
key: String,
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
max_pixels: u32,
|
||||
}
|
||||
|
||||
/// Ergebnis eines Render-Auftrags.
|
||||
@@ -121,7 +135,7 @@ impl PdfRenderService {
|
||||
.map(|pdf| (request.path.clone(), pdf));
|
||||
}
|
||||
let result = match &open_pdf {
|
||||
Some((_, pdf)) => render_page(pdf, request.page, DISPLAY_RENDER_PIXELS),
|
||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
|
||||
None => Err("PDF kann nicht geöffnet werden".into()),
|
||||
};
|
||||
if res_tx.send((request.key, result)).is_err() {
|
||||
@@ -150,7 +164,7 @@ impl PdfRenderService {
|
||||
if self.ready.len() >= 2 {
|
||||
self.ready.clear();
|
||||
}
|
||||
self.request(key, path, page);
|
||||
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
|
||||
}
|
||||
|
||||
fn collect(&mut self) {
|
||||
@@ -161,7 +175,7 @@ impl PdfRenderService {
|
||||
}
|
||||
|
||||
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
|
||||
fn request(&mut self, key: &str, path: PathBuf, page: usize) -> bool {
|
||||
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
|
||||
if !self.pending.insert(key.to_string()) {
|
||||
return true;
|
||||
}
|
||||
@@ -169,6 +183,7 @@ impl PdfRenderService {
|
||||
key: key.to_string(),
|
||||
path,
|
||||
page,
|
||||
max_pixels,
|
||||
};
|
||||
if self.requests.send(request).is_err() {
|
||||
self.pending.remove(key);
|
||||
@@ -178,12 +193,19 @@ impl PdfRenderService {
|
||||
}
|
||||
|
||||
/// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`.
|
||||
pub fn take(&mut self, key: &str, path: PathBuf, page: usize) -> Option<RenderResult> {
|
||||
/// Die Seite wird so gerendert, dass die längste Kante `max_pixels` lang ist.
|
||||
pub fn take(
|
||||
&mut self,
|
||||
key: &str,
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
max_pixels: u32,
|
||||
) -> Option<RenderResult> {
|
||||
self.collect();
|
||||
if let Some(result) = self.ready.remove(key) {
|
||||
return Some(result);
|
||||
}
|
||||
if !self.request(key, path, page) {
|
||||
if !self.request(key, path, page, max_pixels) {
|
||||
return Some(Err("PDF-Render-Thread beendet".into()));
|
||||
}
|
||||
None
|
||||
@@ -272,13 +294,14 @@ pub(crate) mod tests {
|
||||
let mut service = PdfRenderService::new(move || {
|
||||
let _ = wake_tx.send(());
|
||||
});
|
||||
assert!(service.take("a#0", path.clone(), 0).is_none());
|
||||
let px = DISPLAY_RENDER_PIXELS;
|
||||
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||
// Zweite Anfrage erteilt keinen weiteren Auftrag
|
||||
assert!(service.take("a#0", path.clone(), 0).is_none());
|
||||
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
let image = service.take("a#0", path.clone(), 0).unwrap().unwrap();
|
||||
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
|
||||
|
||||
// Vorab rendern: danach sofort verfügbar
|
||||
@@ -286,6 +309,17 @@ pub(crate) mod tests {
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
assert!(service.take("a#0", path, 0).unwrap().is_ok());
|
||||
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_page_key() {
|
||||
assert_eq!(
|
||||
parse_page_key("abc.pdf#3"),
|
||||
Some(("abc.pdf", 3, DISPLAY_RENDER_PIXELS))
|
||||
);
|
||||
assert_eq!(parse_page_key("abc.pdf#3@5120"), Some(("abc.pdf", 3, 5120)));
|
||||
assert_eq!(parse_page_key("abc.png"), None);
|
||||
assert_eq!(parse_page_key("abc.pdf#x"), None);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ use glam::{Mat4, Vec2, Vec4};
|
||||
|
||||
/// Minimaler und maximaler Zoomfaktor.
|
||||
pub const MIN_ZOOM: f32 = 0.25;
|
||||
pub const MAX_ZOOM: f32 = 5.0;
|
||||
pub const MAX_ZOOM: f32 = 10.0;
|
||||
|
||||
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
|
||||
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
|
||||
|
||||
Reference in New Issue
Block a user