vsvg_viewer/
document_widget.rs

1use crate::engine::{DisplayMode, DisplayOptions, Engine, ViewerOptions};
2use eframe::egui_wgpu;
3use eframe::egui_wgpu::{CallbackResources, ScreenDescriptor};
4use eframe::epaint::PaintCallbackInfo;
5use egui::{Pos2, Rect, Sense, Ui};
6use std::sync::{Arc, Mutex};
7use vsvg::{Document, DocumentTrait, LayerTrait, Length};
8use wgpu::{CommandBuffer, CommandEncoder, Device, Queue, RenderPass};
9
10/// Widget to display a [`Document`] in an egui application.
11///
12/// The widget is an egui wrapper around the internal `Engine` instance. It holds the state needed
13/// for rendering, such as the scale and pan offset.
14///
15/// It supports multiple UI features:
16///  - GPU-accelerated rendering of the document, typically in the central panel
17///  - helper UI functions to act on the widget state (e.g. viewing options and layer visibility)
18#[derive(Default)]
19pub struct DocumentWidget {
20    /// document to display
21    document: Option<Arc<Document>>,
22
23    /// viewer options
24    viewer_options: Arc<Mutex<ViewerOptions>>,
25
26    /// pan offset
27    ///
28    /// The offset is expressed in SVG coordinates, not in pixels. `self.scale` can be used for
29    /// conversion.
30    offset: Pos2,
31
32    /// scale factor
33    scale: f32,
34
35    /// should fit to view flag
36    must_fit_to_view: bool,
37}
38
39static PEN_WIDTHS_MM: &[f32] = &[
40    0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.5, 2.0, 3.0,
41    4.0, 5.0,
42];
43
44static PEN_OPACITY_PERCENT: &[u8] = &[100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 5];
45
46impl DocumentWidget {
47    /// Create a document widget.
48    ///
49    /// Initially, the document widget is empty. Use [`DocumentWidget::set_document()`] to set its
50    /// content.
51    #[must_use]
52    pub(crate) fn new<'a>(cc: &'a eframe::CreationContext<'a>) -> Option<Self> {
53        let viewer_options = Arc::new(Mutex::new(ViewerOptions::default()));
54
55        // Get the WGPU render state from the eframe creation context. This can also be retrieved
56        // from `eframe::Frame` when you don't have a `CreationContext` available.
57        let wgpu_render_state = cc.wgpu_render_state.as_ref()?;
58
59        // prepare engine
60        let engine = Engine::new(wgpu_render_state, viewer_options.clone());
61
62        // Because the graphics pipeline must have the same lifetime as the egui render pass,
63        // instead of storing the pipeline in our `Custom3D` struct, we insert it into the
64        // `paint_callback_resources` type map, which is stored alongside the render pass.
65        wgpu_render_state
66            .renderer
67            .write()
68            .callback_resources
69            .insert(engine);
70
71        Some(Self {
72            document: None,
73            viewer_options,
74            offset: Pos2::ZERO,
75            scale: 1.0,
76            must_fit_to_view: true,
77        })
78    }
79
80    pub fn set_document(&mut self, doc: Arc<Document>) {
81        self.document = Some(doc);
82    }
83
84    pub fn set_tolerance(&mut self, tolerance: f64) {
85        self.viewer_options
86            .lock()
87            .unwrap()
88            .display_options
89            .tolerance = tolerance;
90    }
91
92    #[must_use]
93    pub fn antialias(&self) -> f32 {
94        self.viewer_options
95            .lock()
96            .unwrap()
97            .display_options
98            .anti_alias
99    }
100
101    pub fn set_antialias(&self, anti_alias: f32) {
102        self.viewer_options
103            .lock()
104            .unwrap()
105            .display_options
106            .anti_alias = anti_alias;
107    }
108
109    #[must_use]
110    pub fn vertex_count(&self) -> u64 {
111        self.viewer_options.lock().unwrap().vertex_count
112    }
113
114    #[allow(clippy::missing_panics_doc)]
115    pub fn ui(&mut self, ui: &mut Ui) {
116        vsvg::trace_function!();
117
118        // do not actually allocate any space, so custom viewer code may use all of the central
119        // panel
120        let rect = ui.available_rect_before_wrap();
121        let response = ui.interact(rect, ui.id(), Sense::click_and_drag());
122
123        // fit to view on double click
124        if response.double_clicked() {
125            self.must_fit_to_view = true;
126        }
127
128        // fit to view on request
129        if self.must_fit_to_view {
130            self.fit_to_view(&rect);
131        }
132
133        // handle mouse input
134        let old_offset = self.offset;
135        let old_scale = self.scale;
136
137        self.offset -= response.drag_delta() / self.scale;
138        if let Some(mut pos) = response.hover_pos() {
139            response.ctx.input(|i| {
140                self.offset -= i.raw_scroll_delta / self.scale;
141                self.scale *= i.zoom_delta();
142            });
143
144            // zoom around mouse
145            pos -= rect.min.to_vec2();
146            let dz = 1. / old_scale - 1. / self.scale;
147            self.offset += pos.to_vec2() * dz;
148        }
149
150        #[allow(clippy::float_cmp)]
151        if old_offset != self.offset || old_scale != self.scale {
152            self.must_fit_to_view = false;
153        }
154
155        // add the paint callback
156        ui.painter().add(egui_wgpu::Callback::new_paint_callback(
157            rect,
158            DocumentWidgetCallback {
159                document: self.document.clone(),
160                origin: cgmath::Point2::new(self.offset.x, self.offset.y),
161                scale: self.scale,
162                rect,
163            },
164        ));
165    }
166
167    #[allow(clippy::too_many_lines)]
168    pub fn view_menu_ui(&mut self, ui: &mut Ui) {
169        ui.menu_button("View", |ui| {
170            ui.set_min_width(200.0);
171
172            ui.menu_button("Display Mode", |ui| {
173                if ui
174                    .radio_value(
175                        &mut self.viewer_options.lock().unwrap().display_mode,
176                        DisplayMode::Preview,
177                        "Preview",
178                    )
179                    .clicked()
180                {
181                    ui.close_menu();
182                };
183                if ui
184                    .radio_value(
185                        &mut self.viewer_options.lock().unwrap().display_mode,
186                        DisplayMode::Outline,
187                        "Outline",
188                    )
189                    .clicked()
190                {
191                    ui.close_menu();
192                };
193            });
194
195            ui.separator();
196
197            {
198                let pen_width = &mut self
199                    .viewer_options
200                    .lock()
201                    .unwrap()
202                    .display_options
203                    .line_display_options
204                    .override_width;
205                ui.menu_button("Override Pen Width", |ui| {
206                    if ui.radio_value(pen_width, None, "Off").clicked() {
207                        ui.close_menu();
208                    }
209                    ui.separator();
210                    for width in PEN_WIDTHS_MM {
211                        if ui
212                            .radio_value(
213                                pen_width,
214                                Some(Length::mm(*width).into()),
215                                format!("{width:.2}mm"),
216                            )
217                            .clicked()
218                        {
219                            ui.close_menu();
220                        }
221                    }
222                });
223            }
224
225            {
226                let opacity = &mut self
227                    .viewer_options
228                    .lock()
229                    .unwrap()
230                    .display_options
231                    .line_display_options
232                    .override_opacity;
233
234                ui.menu_button("Override Pen Opacity", |ui| {
235                    if ui.radio_value(opacity, None, "Off").clicked() {
236                        ui.close_menu();
237                    }
238                    ui.separator();
239                    for opacity_value in PEN_OPACITY_PERCENT {
240                        #[allow(clippy::cast_lossless)]
241                        if ui
242                            .radio_value(
243                                opacity,
244                                Some(*opacity_value as f32 / 100.0),
245                                format!("{opacity_value}%"),
246                            )
247                            .clicked()
248                        {
249                            ui.close_menu();
250                        }
251                    }
252                });
253            }
254
255            ui.separator();
256
257            ui.checkbox(
258                &mut self
259                    .viewer_options
260                    .lock()
261                    .unwrap()
262                    .display_options
263                    .show_display_vertices,
264                "Show points",
265            );
266            ui.checkbox(
267                &mut self
268                    .viewer_options
269                    .lock()
270                    .unwrap()
271                    .display_options
272                    .show_pen_up,
273                "Show pen-up trajectories",
274            );
275            ui.checkbox(
276                &mut self
277                    .viewer_options
278                    .lock()
279                    .unwrap()
280                    .display_options
281                    .show_bezier_handles,
282                "Show control points",
283            );
284            ui.separator();
285            if ui.button("Fit to view").clicked() {
286                self.must_fit_to_view = true;
287                ui.close_menu();
288            }
289
290            ui.separator();
291
292            ui.horizontal(|ui| {
293                ui.label("AA:");
294                ui.add(egui::Slider::new(
295                    &mut self
296                        .viewer_options
297                        .lock()
298                        .unwrap()
299                        .display_options
300                        .anti_alias,
301                    0.0..=2.0,
302                ))
303                .on_hover_text("Renderer anti-aliasing (default: 0.5)");
304            });
305
306            ui.horizontal(|ui| {
307                ui.label("Tol:");
308                ui.add(
309                    egui::Slider::new(
310                        &mut self
311                            .viewer_options
312                            .lock()
313                            .unwrap()
314                            .display_options
315                            .tolerance,
316                        0.001..=10.0,
317                    )
318                    .logarithmic(true),
319                )
320                .on_hover_text("Tolerance for rendering curves (default: 0.01)");
321            });
322
323            ui.separator();
324
325            if ui
326                .button("Reset")
327                .on_hover_text("Reset all display options to the default")
328                .clicked()
329            {
330                let options = &mut self.viewer_options.lock().unwrap().display_options;
331                *options = DisplayOptions {
332                    anti_alias: options.anti_alias,
333                    ..DisplayOptions::default()
334                };
335                ui.close_menu();
336            }
337        });
338    }
339
340    #[allow(clippy::missing_panics_doc)]
341    pub fn layer_menu_ui(&mut self, ui: &mut Ui) {
342        ui.menu_button("Layer", |ui| {
343            let Some(document) = self.document.clone() else {
344                return;
345            };
346
347            for (lid, layer) in &document.layers {
348                let mut viewer_options = self.viewer_options.lock().unwrap();
349                let visibility = viewer_options.layer_visibility.entry(*lid).or_insert(true);
350                let mut label = format!("Layer {lid}");
351                if let Some(name) = &layer.metadata().name {
352                    label.push_str(&format!(": {name}"));
353                }
354
355                ui.checkbox(visibility, label);
356            }
357        });
358    }
359
360    fn fit_to_view(&mut self, viewport: &Rect) {
361        vsvg::trace_function!();
362
363        let Some(document) = self.document.clone() else {
364            return;
365        };
366
367        let bounds = if let Some(page_size) = document.metadata().page_size {
368            if page_size.w() != 0.0 && page_size.h() != 0.0 {
369                Some(kurbo::Rect::from_points(
370                    (0., 0.),
371                    (page_size.w(), page_size.h()),
372                ))
373            } else {
374                document.bounds()
375            }
376        } else {
377            document.bounds()
378        };
379
380        if bounds.is_none() {
381            return;
382        }
383        let bounds = bounds.expect("bounds is not none");
384
385        #[allow(clippy::cast_possible_truncation)]
386        {
387            let (w, h) = (bounds.width() as f32, bounds.height() as f32);
388            let (view_w, view_h) = (viewport.width(), viewport.height());
389
390            self.scale = 0.95 * f32::min(view_w / w, view_h / h);
391
392            self.offset = Pos2::new(
393                bounds.x0 as f32 - (view_w / self.scale - w) / 2.0,
394                bounds.y0 as f32 - (view_h / self.scale - h) / 2.0,
395            );
396        }
397    }
398}
399
400struct DocumentWidgetCallback {
401    document: Option<Arc<Document>>,
402    origin: cgmath::Point2<f32>,
403    scale: f32,
404    rect: Rect,
405}
406
407impl egui_wgpu::CallbackTrait for DocumentWidgetCallback {
408    fn prepare(
409        &self,
410        device: &Device,
411        queue: &Queue,
412        _screen_descriptor: &ScreenDescriptor,
413        _egui_encoder: &mut CommandEncoder,
414        callback_resources: &mut CallbackResources,
415    ) -> Vec<CommandBuffer> {
416        vsvg::trace_scope!("wgpu prepare callback");
417        let engine: &mut Engine = callback_resources.get_mut().unwrap();
418
419        if let Some(document) = self.document.clone() {
420            engine.set_document(document);
421        }
422
423        engine.prepare(device, queue, self.rect, self.scale, self.origin);
424
425        Vec::new()
426    }
427
428    fn paint<'a>(
429        &'a self,
430        _info: PaintCallbackInfo,
431        render_pass: &mut RenderPass<'a>,
432        callback_resources: &'a CallbackResources,
433    ) {
434        vsvg::trace_scope!("wgpu paint callback");
435
436        let engine: &Engine = callback_resources.get().unwrap();
437        engine.paint(render_pass);
438    }
439}