feat: durchgehendes Scrollen
Seiten hängen untereinander (8 mm Abstand) und werden mit Mausrad, Finger oder Touchpad fließend gescrollt; Strg+Mausrad zoomt. Sichtbare Nachbarseiten werden bei Bedarf geladen und mit eigener Kamera gezeichnet. Die Seite in der Fenstermitte wird nach dem Scrollen zur aktuellen; Zeichnen, Radieren und Auswählen auf einer Nachbarseite wechseln zuerst dorthin. Wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Scrollen bleibt auf das Notizbuch begrenzt. Abschaltbar im ☰-Menü (Ansicht). Nie gespeicherte, angehängte Seiten laden jetzt als leere Seite statt als schreibgeschützte Fehlerseite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+229
-21
@@ -112,6 +112,44 @@ pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::Pres
|
||||
.or_else(|| available.first().copied())
|
||||
}
|
||||
|
||||
/// Erkennt, ob die GPU-Daten einer Nachbarseite noch zur Seite passen. Änderungen an der
|
||||
/// aktuellen Seite verwerfen deren Eintrag ohnehin (siehe [`CanvasState::sync_page`]).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct NeighborFingerprint {
|
||||
page_id: u64,
|
||||
background: PageBackground,
|
||||
paper_theme: PaperTheme,
|
||||
vertex_count: usize,
|
||||
stroke_count: usize,
|
||||
last_stroke_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl NeighborFingerprint {
|
||||
fn of(page: &Page, paper_theme: PaperTheme) -> Self {
|
||||
Self {
|
||||
page_id: page.id,
|
||||
background: page.background,
|
||||
paper_theme,
|
||||
vertex_count: page.vertex_cache.len(),
|
||||
stroke_count: page.strokes.len(),
|
||||
last_stroke_id: page.strokes.last().map(|s| s.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU-Daten einer in der durchgehenden Ansicht sichtbaren Nachbarseite.
|
||||
struct NeighborPage {
|
||||
fingerprint: NeighborFingerprint,
|
||||
/// Lage relativ zur aktuellen Seite (in Seiten, negativ = darüber)
|
||||
offset: i64,
|
||||
camera_buffer: wgpu::Buffer,
|
||||
camera_bind_group: wgpu::BindGroup,
|
||||
background_buffer: Option<wgpu::Buffer>,
|
||||
background_vertex_count: u32,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
stroke_ranges: Vec<Range<u32>>,
|
||||
}
|
||||
|
||||
pub struct CanvasState {
|
||||
pub surface: wgpu::Surface<'static>,
|
||||
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||
@@ -168,6 +206,10 @@ pub struct CanvasState {
|
||||
// Eingefügte Bilder der aktuellen Seite
|
||||
image_layer: crate::image_layer::ImageLayer,
|
||||
|
||||
/// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite
|
||||
neighbors: Vec<NeighborPage>,
|
||||
camera_layout: wgpu::BindGroupLayout,
|
||||
|
||||
/// Eingeblendetes Lineal (Striche nahe einer Kante rasten ein)
|
||||
pub ruler: Option<crate::ruler::Ruler>,
|
||||
/// Kante, an der der aktuelle Live-Strich eingerastet ist
|
||||
@@ -466,6 +508,8 @@ impl CanvasState {
|
||||
committed_vertex_count: 0,
|
||||
committed_strokes: Vec::with_capacity(128),
|
||||
image_layer,
|
||||
neighbors: Vec::new(),
|
||||
camera_layout: bind_group_layout,
|
||||
ruler: None,
|
||||
ruler_lock: None,
|
||||
egui_renderer,
|
||||
@@ -475,8 +519,26 @@ impl CanvasState {
|
||||
|
||||
/// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera.
|
||||
pub fn update_uniforms(&mut self) {
|
||||
let (scale, offset) = self.camera.ndc_transform(&self.viewport);
|
||||
let camera_uniforms = CameraUniforms {
|
||||
let camera_uniforms = self.camera_uniforms(0);
|
||||
self.queue.write_buffer(
|
||||
&self.camera_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[camera_uniforms]),
|
||||
);
|
||||
for neighbor in &self.neighbors {
|
||||
self.queue.write_buffer(
|
||||
&neighbor.camera_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[self.camera_uniforms(neighbor.offset)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kamera für die Seite `page_offset` Seiten unter (negativ: über) der aktuellen.
|
||||
fn camera_uniforms(&self, page_offset: i64) -> CameraUniforms {
|
||||
let (scale, mut offset) = self.camera.ndc_transform(&self.viewport);
|
||||
offset[1] += page_offset as f32 * crate::viewport::PAGE_PITCH * scale[1];
|
||||
CameraUniforms {
|
||||
transform_scale: scale,
|
||||
transform_offset: offset,
|
||||
ink_params: [
|
||||
@@ -489,12 +551,7 @@ impl CanvasState {
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
self.queue.write_buffer(
|
||||
&self.camera_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[camera_uniforms]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
|
||||
@@ -585,6 +642,63 @@ impl CanvasState {
|
||||
self.camera.page_to_screen_vec2(page_pt, &self.viewport)
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Versatz (in Seiten) der Seite unter dem Bildschirmpunkt,
|
||||
/// `None` neben den Seiten und zwischen zwei Seiten.
|
||||
pub fn page_offset_at_screen(&self, screen_x: f32, screen_y: f32) -> Option<i64> {
|
||||
let p = self
|
||||
.camera
|
||||
.screen_to_page_unclamped(screen_x, screen_y, &self.viewport);
|
||||
if !(0.0..=crate::document::PAGE_WIDTH).contains(&p.x) {
|
||||
return None;
|
||||
}
|
||||
crate::viewport::page_offset_at(p.y)
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Versatz (in Seiten) der Seite in der Mitte des Fensters.
|
||||
pub fn page_offset_at_view_center(&self) -> Option<i64> {
|
||||
let p = self.camera.screen_to_page_unclamped(
|
||||
0.0,
|
||||
self.size.height as f32 / 2.0,
|
||||
&self.viewport,
|
||||
);
|
||||
crate::viewport::page_offset_at(p.y)
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Versätze der sichtbaren Nachbarseiten der aktuellen Seite.
|
||||
pub fn visible_neighbor_offsets(&self, current: usize, page_count: usize) -> Vec<i64> {
|
||||
let top = self
|
||||
.camera
|
||||
.screen_to_page_unclamped(0.0, 0.0, &self.viewport);
|
||||
let bottom =
|
||||
self.camera
|
||||
.screen_to_page_unclamped(0.0, self.size.height as f32, &self.viewport);
|
||||
crate::viewport::visible_neighbor_offsets(top.y, bottom.y, current, page_count)
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der
|
||||
/// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seitenbreite), damit
|
||||
/// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde.
|
||||
pub fn clamp_scroll(&mut self, current: usize, page_count: usize) -> bool {
|
||||
let center = [
|
||||
self.viewport.page_x + self.viewport.page_width / 2.0,
|
||||
self.size.height as f32 / 2.0,
|
||||
];
|
||||
let changed =
|
||||
self.camera
|
||||
.clamp_continuous_center(center, current, page_count, &self.viewport);
|
||||
if changed {
|
||||
self.update_uniforms();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich
|
||||
/// der Bildausschnitt bewegt. Die Nachbarseiten sind danach neu zu setzen.
|
||||
pub fn shift_reference_page(&mut self, k: i64) {
|
||||
self.camera.shift_reference_page(k);
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Ersetzt die Live-Vertices des aktiven Strichs durch ein bereinigtes Form-Mesh (für Draw-and-Hold).
|
||||
pub fn set_live_shape_mesh(&mut self, samples: Vec<StylusSample>, vertices: Vec<InkVertex>) {
|
||||
self.live_samples = samples;
|
||||
@@ -672,19 +786,10 @@ impl CanvasState {
|
||||
/// Synchronisiert die GPU-Puffer mit den Daten der aktiven Seite.
|
||||
pub fn sync_page(&mut self, page: &mut Page) {
|
||||
page.rebuild_cache_if_dirty();
|
||||
// Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen
|
||||
self.neighbors.retain(|n| n.fingerprint.page_id != page.id);
|
||||
self.set_background(page.background);
|
||||
// PDF-Seite zuerst, darüber die eingefügten Bilder
|
||||
self.image_layer.set_quads(
|
||||
page.pdf_background
|
||||
.iter()
|
||||
.map(|pdf| (pdf.key(), pdf.corners()))
|
||||
.chain(
|
||||
page.strokes
|
||||
.iter()
|
||||
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?))),
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
self.image_layer.set_quads(page_image_quads(page));
|
||||
|
||||
self.committed_vertex_count = page.vertex_cache.len() as u32;
|
||||
self.committed_strokes = page.stroke_ranges.clone();
|
||||
@@ -711,6 +816,74 @@ impl CanvasState {
|
||||
);
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: übernimmt die sichtbaren Nachbarseiten mit ihrem Versatz zur
|
||||
/// aktuellen Seite (in Seiten). Die Vertex-Caches der Seiten müssen aktuell sein
|
||||
/// ([`Page::rebuild_cache_if_dirty`]); unveränderte Seiten werden nicht erneut hochgeladen.
|
||||
/// Eine leere Liste schaltet die Nachbarseiten ab.
|
||||
pub fn set_neighbor_pages(&mut self, pages: &[(i64, &Page)]) {
|
||||
let mut old = std::mem::take(&mut self.neighbors);
|
||||
let mut image_groups = Vec::with_capacity(pages.len());
|
||||
for &(offset, page) in pages {
|
||||
let fingerprint = NeighborFingerprint::of(page, self.paper_theme);
|
||||
let mut neighbor = match old.iter().position(|n| n.fingerprint == fingerprint) {
|
||||
Some(i) => old.swap_remove(i),
|
||||
None => self.upload_neighbor(page, fingerprint),
|
||||
};
|
||||
neighbor.offset = offset;
|
||||
self.queue.write_buffer(
|
||||
&neighbor.camera_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[self.camera_uniforms(offset)]),
|
||||
);
|
||||
self.neighbors.push(neighbor);
|
||||
image_groups.push(page_image_quads(page));
|
||||
}
|
||||
self.image_layer.set_neighbor_quads(image_groups);
|
||||
}
|
||||
|
||||
/// Lädt Raster und Striche einer Nachbarseite auf die GPU.
|
||||
fn upload_neighbor(&self, page: &Page, fingerprint: NeighborFingerprint) -> NeighborPage {
|
||||
let camera_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Neighbor Camera Uniform Buffer"),
|
||||
size: std::mem::size_of::<CameraUniforms>() as wgpu::BufferAddress,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let camera_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Neighbor Camera Bind Group"),
|
||||
layout: &self.camera_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: camera_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let vertex_buffer_with = |label: &str, vertices: &[InkVertex]| {
|
||||
(!vertices.is_empty()).then(|| {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: std::mem::size_of_val(vertices) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.queue
|
||||
.write_buffer(&buffer, 0, bytemuck::cast_slice(vertices));
|
||||
buffer
|
||||
})
|
||||
};
|
||||
let background =
|
||||
generate_background_mesh(page.background, self.paper_theme.pattern_color());
|
||||
NeighborPage {
|
||||
fingerprint,
|
||||
offset: 0,
|
||||
camera_buffer,
|
||||
camera_bind_group,
|
||||
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
|
||||
background_vertex_count: background.len() as u32,
|
||||
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
||||
stroke_ranges: page.stroke_ranges.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt Bilder und PDF-Seiten der aktuellen Seite, die noch nicht auf der GPU liegen.
|
||||
/// Vor jedem Zeichnen aufrufen; ohne Änderungen kostet das fast nichts.
|
||||
pub fn prepare_images(
|
||||
@@ -931,6 +1104,28 @@ impl CanvasState {
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
|
||||
// 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera)
|
||||
for (i, neighbor) in self.neighbors.iter().enumerate() {
|
||||
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
|
||||
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..1);
|
||||
if let Some(buffer) = &neighbor.background_buffer {
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
render_pass.draw(0..neighbor.background_vertex_count, 0..1);
|
||||
}
|
||||
self.image_layer.draw(&mut render_pass, i + 1);
|
||||
if let Some(buffer) = &neighbor.vertex_buffer {
|
||||
render_pass.set_pipeline(&self.strip_pipeline);
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
for range in &neighbor.stroke_ranges {
|
||||
if range.end > range.start {
|
||||
render_pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2D-Kamera Uniforms (Scale & Offset) binden
|
||||
render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
|
||||
|
||||
@@ -946,7 +1141,7 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
// 3. Eingefügte Bilder (unter der Tinte)
|
||||
self.image_layer.draw(&mut render_pass);
|
||||
self.image_layer.draw(&mut render_pass, 0);
|
||||
|
||||
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
|
||||
render_pass.set_pipeline(&self.strip_pipeline);
|
||||
@@ -1010,6 +1205,19 @@ impl CanvasState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten Bilder.
|
||||
fn page_image_quads(page: &Page) -> Vec<(String, [glam::Vec2; 4])> {
|
||||
page.pdf_background
|
||||
.iter()
|
||||
.map(|pdf| (pdf.key(), pdf.corners()))
|
||||
.chain(
|
||||
page.strokes
|
||||
.iter()
|
||||
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?))),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user