refactor: split svg.rs & app.rs
This commit is contained in:
parent
0192226609
commit
adfbe04d30
13 changed files with 1077 additions and 1052 deletions
795
src/app.rs
795
src/app.rs
|
|
@ -1,795 +0,0 @@
|
||||||
use crate::svg::{Renderable as _, SvgContent};
|
|
||||||
use crate::text_cache::TextCache;
|
|
||||||
|
|
||||||
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
|
||||||
#[derive(serde::Deserialize, serde::Serialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct TemplateApp {
|
|
||||||
#[serde(skip)]
|
|
||||||
svg_content: Option<SvgContent>,
|
|
||||||
|
|
||||||
/// Pan offset in SVG coordinates.
|
|
||||||
pan_x: f32,
|
|
||||||
pan_y: f32,
|
|
||||||
|
|
||||||
/// Zoom factor (1.0 = 100%).
|
|
||||||
zoom: f32,
|
|
||||||
|
|
||||||
#[serde(skip)]
|
|
||||||
show_menu_bar: bool,
|
|
||||||
|
|
||||||
#[serde(skip)]
|
|
||||||
show_debug: bool,
|
|
||||||
|
|
||||||
#[serde(skip)]
|
|
||||||
render_internal_areas: bool,
|
|
||||||
|
|
||||||
/// Exponential moving average of frame time for stable FPS display.
|
|
||||||
#[serde(skip)]
|
|
||||||
fps_ema: f32,
|
|
||||||
|
|
||||||
/// Whether we've auto-fitted the start viewport.
|
|
||||||
#[serde(skip)]
|
|
||||||
did_fit_start: bool,
|
|
||||||
|
|
||||||
/// Last known cursor position (for edge scrolling even without movement).
|
|
||||||
#[serde(skip)]
|
|
||||||
last_cursor_pos: Option<egui::Pos2>,
|
|
||||||
|
|
||||||
/// Whether a reset-to-start was requested (from UI)
|
|
||||||
#[serde(skip)]
|
|
||||||
reset_view_requested: bool,
|
|
||||||
|
|
||||||
/// Smooth camera animation state.
|
|
||||||
#[serde(skip)]
|
|
||||||
view_animation: Option<ViewAnimation>,
|
|
||||||
|
|
||||||
/// Last pointer position for manual drag tracking (smoother than `Sense::drag`).
|
|
||||||
#[serde(skip)]
|
|
||||||
last_pointer_pos: Option<egui::Pos2>,
|
|
||||||
|
|
||||||
/// Whether we're currently in a drag operation.
|
|
||||||
#[serde(skip)]
|
|
||||||
is_dragging: bool,
|
|
||||||
|
|
||||||
/// Text rendering cache for smooth scaling.
|
|
||||||
#[serde(skip)]
|
|
||||||
text_cache: Option<TextCache>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TemplateApp {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
svg_content: None,
|
|
||||||
pan_x: 0.0,
|
|
||||||
pan_y: 0.0,
|
|
||||||
zoom: 1.0,
|
|
||||||
show_menu_bar: false,
|
|
||||||
show_debug: false,
|
|
||||||
render_internal_areas: false,
|
|
||||||
fps_ema: 60.0,
|
|
||||||
last_pointer_pos: None,
|
|
||||||
is_dragging: false,
|
|
||||||
text_cache: None,
|
|
||||||
did_fit_start: false,
|
|
||||||
last_cursor_pos: None,
|
|
||||||
reset_view_requested: false,
|
|
||||||
view_animation: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct ViewAnimation {
|
|
||||||
start_center_x: f32,
|
|
||||||
start_center_y: f32,
|
|
||||||
start_zoom: f32,
|
|
||||||
target_center_x: f32,
|
|
||||||
target_center_y: f32,
|
|
||||||
target_zoom: f32,
|
|
||||||
elapsed: f32,
|
|
||||||
duration: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TemplateApp {
|
|
||||||
const NAV_DURATION: f32 = 1.5;
|
|
||||||
|
|
||||||
/// Called once before the first frame.
|
|
||||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
|
||||||
log::info!("Initializing application...");
|
|
||||||
|
|
||||||
log::debug!("Installing image loaders...");
|
|
||||||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
|
||||||
|
|
||||||
log::debug!("Loading app state...");
|
|
||||||
let mut app: Self = cc
|
|
||||||
.storage
|
|
||||||
.and_then(|s| eframe::get_value(s, eframe::APP_KEY))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Load SVG content
|
|
||||||
let svg_path = "../line-and-surface/content/intro.svg";
|
|
||||||
log::info!("Loading SVG from: {svg_path}");
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
match SvgContent::from_file(svg_path) {
|
|
||||||
Ok(content) => {
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
log::info!(
|
|
||||||
"Loaded SVG in {:.2?}: {} video scrolls, {} audio areas, {} anchors, {} texts",
|
|
||||||
elapsed,
|
|
||||||
content.video_scrolls.len(),
|
|
||||||
content.audio_areas.len(),
|
|
||||||
content.anchors.len(),
|
|
||||||
content.texts.len()
|
|
||||||
);
|
|
||||||
if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox {
|
|
||||||
log::debug!("SVG viewbox: ({vb_x}, {vb_y}, {vb_w}, {vb_h})");
|
|
||||||
app.pan_x = -vb_x;
|
|
||||||
app.pan_y = -vb_y;
|
|
||||||
}
|
|
||||||
app.svg_content = Some(content);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to load SVG: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Application initialized");
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cancel_animation(&mut self) {
|
|
||||||
self.view_animation = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn begin_view_animation(
|
|
||||||
&mut self,
|
|
||||||
target_pan_x: f32,
|
|
||||||
target_pan_y: f32,
|
|
||||||
target_zoom: f32,
|
|
||||||
duration: f32,
|
|
||||||
canvas_rect: &egui::Rect,
|
|
||||||
) {
|
|
||||||
if duration <= 0.0 {
|
|
||||||
self.pan_x = target_pan_x;
|
|
||||||
self.pan_y = target_pan_y;
|
|
||||||
self.zoom = target_zoom;
|
|
||||||
self.view_animation = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (start_center_x, start_center_y) =
|
|
||||||
view_center(self.pan_x, self.pan_y, self.zoom, canvas_rect);
|
|
||||||
let (target_center_x, target_center_y) =
|
|
||||||
view_center(target_pan_x, target_pan_y, target_zoom, canvas_rect);
|
|
||||||
|
|
||||||
self.view_animation = Some(ViewAnimation {
|
|
||||||
start_center_x,
|
|
||||||
start_center_y,
|
|
||||||
start_zoom: self.zoom,
|
|
||||||
target_center_x,
|
|
||||||
target_center_y,
|
|
||||||
target_zoom,
|
|
||||||
elapsed: 0.0,
|
|
||||||
duration,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_animation(&mut self, dt: f32, canvas_rect: &egui::Rect) {
|
|
||||||
if let Some(anim) = &mut self.view_animation {
|
|
||||||
anim.elapsed += dt;
|
|
||||||
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
|
||||||
let eased = ease_in_out_cubic(t);
|
|
||||||
|
|
||||||
let zoom = lerp(
|
|
||||||
anim.start_zoom,
|
|
||||||
anim.target_zoom,
|
|
||||||
zoom_ease(t, anim.start_zoom, anim.target_zoom),
|
|
||||||
);
|
|
||||||
let center_x = lerp(anim.start_center_x, anim.target_center_x, eased);
|
|
||||||
let center_y = lerp(anim.start_center_y, anim.target_center_y, eased);
|
|
||||||
|
|
||||||
if canvas_rect.width() > 0.0 && canvas_rect.height() > 0.0 {
|
|
||||||
let visible_w = canvas_rect.width() / zoom;
|
|
||||||
let visible_h = canvas_rect.height() / zoom;
|
|
||||||
self.pan_x = -center_x + visible_w * 0.5;
|
|
||||||
self.pan_y = -center_y + visible_h * 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.zoom = zoom;
|
|
||||||
|
|
||||||
if t >= 1.0 {
|
|
||||||
self.view_animation = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_view_to_rect(
|
|
||||||
&mut self,
|
|
||||||
rect: (f32, f32, f32, f32),
|
|
||||||
canvas_rect: &egui::Rect,
|
|
||||||
duration: f32,
|
|
||||||
) {
|
|
||||||
if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, canvas_rect) {
|
|
||||||
self.begin_view_animation(pan_x, pan_y, zoom, duration, canvas_rect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle zoom towards a specific point.
|
|
||||||
fn zoom_towards(&mut self, new_zoom: f32, pos: egui::Pos2, canvas_rect: &egui::Rect) {
|
|
||||||
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
|
|
||||||
let svg_y = (pos.y - canvas_rect.top()) / self.zoom - self.pan_y;
|
|
||||||
self.pan_x = (pos.x - canvas_rect.left()) / new_zoom - svg_x;
|
|
||||||
self.pan_y = (pos.y - canvas_rect.top()) / new_zoom - svg_y;
|
|
||||||
self.zoom = new_zoom;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ease_in_out_cubic(t: f32) -> f32 {
|
|
||||||
if t < 0.5 {
|
|
||||||
4.0 * t * t * t
|
|
||||||
} else {
|
|
||||||
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ease_out_cubic(t: f32) -> f32 {
|
|
||||||
1.0 - (1.0 - t).powi(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn zoom_ease(t: f32, start: f32, target: f32) -> f32 {
|
|
||||||
if target > start {
|
|
||||||
// Zooming in: bias progress late but soften landing
|
|
||||||
let biased = t.powf(1.6);
|
|
||||||
ease_in_out_cubic(biased)
|
|
||||||
} else {
|
|
||||||
// Zooming out: shed zoom early
|
|
||||||
ease_out_cubic(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lerp(a: f32, b: f32, t: f32) -> f32 {
|
|
||||||
a + (b - a) * t
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_view_for_rect(
|
|
||||||
rect: (f32, f32, f32, f32),
|
|
||||||
canvas_rect: &egui::Rect,
|
|
||||||
) -> Option<(f32, f32, f32)> {
|
|
||||||
if canvas_rect.width() <= 0.0 || canvas_rect.height() <= 0.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (sx, sy, sw, sh) = rect;
|
|
||||||
if sw <= 0.0 || sh <= 0.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let scale_x = canvas_rect.width() / sw;
|
|
||||||
let scale_y = canvas_rect.height() / sh;
|
|
||||||
let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0);
|
|
||||||
let visible_w = canvas_rect.width() / fit_zoom;
|
|
||||||
let visible_h = canvas_rect.height() / fit_zoom;
|
|
||||||
|
|
||||||
let pan_x = -sx + (visible_w - sw) * 0.5;
|
|
||||||
let pan_y = -sy + (visible_h - sh) * 0.5;
|
|
||||||
|
|
||||||
Some((pan_x, pan_y, fit_zoom))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_center(pan_x: f32, pan_y: f32, zoom: f32, canvas_rect: &egui::Rect) -> (f32, f32) {
|
|
||||||
let visible_w = canvas_rect.width() / zoom;
|
|
||||||
let visible_h = canvas_rect.height() / zoom;
|
|
||||||
(-pan_x + visible_w * 0.5, -pan_y + visible_h * 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for TemplateApp {
|
|
||||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
|
||||||
eframe::set_value(storage, eframe::APP_KEY, self);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(clippy::too_many_lines)]
|
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
||||||
// Batch input reading
|
|
||||||
let (
|
|
||||||
escape_pressed,
|
|
||||||
f3_pressed,
|
|
||||||
f_pressed,
|
|
||||||
scroll_delta,
|
|
||||||
zoom_delta,
|
|
||||||
pointer_latest,
|
|
||||||
pointer_pos,
|
|
||||||
frame_time,
|
|
||||||
primary_down,
|
|
||||||
primary_pressed,
|
|
||||||
primary_released,
|
|
||||||
space_pressed,
|
|
||||||
) = ctx.input(|i| {
|
|
||||||
(
|
|
||||||
i.key_pressed(egui::Key::Escape),
|
|
||||||
i.key_pressed(egui::Key::F3),
|
|
||||||
i.key_pressed(egui::Key::F),
|
|
||||||
i.smooth_scroll_delta.y,
|
|
||||||
i.zoom_delta(),
|
|
||||||
i.pointer.latest_pos(),
|
|
||||||
i.pointer.hover_pos(),
|
|
||||||
i.stable_dt,
|
|
||||||
i.pointer.primary_down(),
|
|
||||||
i.pointer.primary_pressed(),
|
|
||||||
i.pointer.primary_released(),
|
|
||||||
i.key_pressed(egui::Key::Space),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
if primary_pressed || scroll_delta != 0.0 || (zoom_delta - 1.0).abs() > f32::EPSILON {
|
|
||||||
self.cancel_animation();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(pos) = pointer_latest.or(pointer_pos) {
|
|
||||||
self.last_cursor_pos = Some(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
|
|
||||||
|
|
||||||
// Update FPS EMA
|
|
||||||
if frame_time > 0.0 {
|
|
||||||
const ALPHA: f32 = 0.1;
|
|
||||||
self.fps_ema = ALPHA * (1.0 / frame_time) + (1.0 - ALPHA) * self.fps_ema;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle UI elements
|
|
||||||
if escape_pressed {
|
|
||||||
self.show_menu_bar = !self.show_menu_bar;
|
|
||||||
}
|
|
||||||
if f3_pressed {
|
|
||||||
self.show_debug = !self.show_debug;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu bar
|
|
||||||
if self.show_menu_bar {
|
|
||||||
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
|
||||||
egui::MenuBar::new().ui(ui, |ui| {
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
ui.menu_button("File", |ui| {
|
|
||||||
if ui.button("Quit").clicked() {
|
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.add_space(16.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
egui::widgets::global_theme_preference_buttons(ui);
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
ui.label(format!("Zoom: {:.0}%", self.zoom * 100.0));
|
|
||||||
if ui.button("Reset View").clicked() {
|
|
||||||
self.reset_view_requested = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
let debug_label = if self.show_debug {
|
|
||||||
"Hide Debug (F3)"
|
|
||||||
} else {
|
|
||||||
"Show Debug (F3)"
|
|
||||||
};
|
|
||||||
if ui.button(debug_label).clicked() {
|
|
||||||
self.show_debug = !self.show_debug;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rendered_count = 0u32;
|
|
||||||
|
|
||||||
let background_rgb = self
|
|
||||||
.svg_content
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|content| content.background_color)
|
|
||||||
.unwrap_or([0, 0, 0]);
|
|
||||||
let background_color =
|
|
||||||
egui::Color32::from_rgb(background_rgb[0], background_rgb[1], background_rgb[2]);
|
|
||||||
|
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
|
||||||
let (response, painter) =
|
|
||||||
ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
|
|
||||||
let canvas_rect = response.rect;
|
|
||||||
painter.rect_filled(canvas_rect, 0.0, background_color);
|
|
||||||
|
|
||||||
// Advance any pending camera animation (needs canvas size)
|
|
||||||
let anim_dt = if frame_time > 0.0 {
|
|
||||||
frame_time
|
|
||||||
} else {
|
|
||||||
1.0 / 60.0
|
|
||||||
};
|
|
||||||
self.update_animation(anim_dt, &canvas_rect);
|
|
||||||
if self.view_animation.is_some() {
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fullscreen toggle via double-click or 'F'
|
|
||||||
if response.double_clicked() || f_pressed {
|
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.did_fit_start {
|
|
||||||
if let Some(ref content) = self.svg_content {
|
|
||||||
if let Some(rect) = content.start_rect.or(content.viewbox) {
|
|
||||||
if let Some((pan_x, pan_y, zoom)) =
|
|
||||||
compute_view_for_rect(rect, &canvas_rect)
|
|
||||||
{
|
|
||||||
self.pan_x = pan_x;
|
|
||||||
self.pan_y = pan_y;
|
|
||||||
self.zoom = zoom;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.did_fit_start = true;
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
if space_pressed {
|
|
||||||
self.reset_view_requested = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.reset_view_requested {
|
|
||||||
if let Some(ref content) = self.svg_content {
|
|
||||||
if let Some(rect) = content.start_rect.or(content.viewbox) {
|
|
||||||
self.request_view_to_rect(rect, &canvas_rect, TemplateApp::NAV_DURATION);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Always clear to avoid loops even if no rect present
|
|
||||||
self.reset_view_requested = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag handling
|
|
||||||
if primary_pressed && response.hovered() {
|
|
||||||
self.is_dragging = true;
|
|
||||||
self.last_pointer_pos = pointer_pos;
|
|
||||||
}
|
|
||||||
if primary_released {
|
|
||||||
self.is_dragging = false;
|
|
||||||
self.last_pointer_pos = None;
|
|
||||||
}
|
|
||||||
if self.is_dragging && primary_down {
|
|
||||||
if let (Some(current), Some(last)) = (pointer_pos, self.last_pointer_pos) {
|
|
||||||
let delta = current - last;
|
|
||||||
self.pan_x += delta.x / self.zoom;
|
|
||||||
self.pan_y += delta.y / self.zoom;
|
|
||||||
}
|
|
||||||
self.last_pointer_pos = pointer_pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zoom handling
|
|
||||||
if response.hovered() {
|
|
||||||
if scroll_delta != 0.0 {
|
|
||||||
let factor = 1.0 + scroll_delta * 0.001;
|
|
||||||
let new_zoom = (self.zoom * factor).clamp(0.01, 100.0);
|
|
||||||
if let Some(pos) = pointer_pos {
|
|
||||||
self.zoom_towards(new_zoom, pos, &canvas_rect);
|
|
||||||
} else {
|
|
||||||
self.zoom = new_zoom;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if zoom_delta != 1.0 {
|
|
||||||
let new_zoom = (self.zoom * zoom_delta).clamp(0.01, 100.0);
|
|
||||||
if let Some(pos) = pointer_pos {
|
|
||||||
self.zoom_towards(new_zoom, pos, &canvas_rect);
|
|
||||||
} else {
|
|
||||||
self.zoom = new_zoom;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edge scrolling (only in fullscreen)
|
|
||||||
let edge_pointer = pointer_latest.or(self.last_cursor_pos);
|
|
||||||
if is_fullscreen {
|
|
||||||
if let Some(mouse_pos) = edge_pointer {
|
|
||||||
let dt = if frame_time > 0.0 {
|
|
||||||
frame_time
|
|
||||||
} else {
|
|
||||||
1.0 / 60.0
|
|
||||||
};
|
|
||||||
let nx = if canvas_rect.width() > 0.0 {
|
|
||||||
((mouse_pos.x - canvas_rect.left()) / canvas_rect.width()).clamp(0.0, 1.0)
|
|
||||||
} else {
|
|
||||||
0.5
|
|
||||||
};
|
|
||||||
let ny = if canvas_rect.height() > 0.0 {
|
|
||||||
((mouse_pos.y - canvas_rect.top()) / canvas_rect.height()).clamp(0.0, 1.0)
|
|
||||||
} else {
|
|
||||||
0.5
|
|
||||||
};
|
|
||||||
|
|
||||||
let edge_threshold = 0.15;
|
|
||||||
let mut vx = 0.0;
|
|
||||||
let mut vy = 0.0;
|
|
||||||
|
|
||||||
if nx < edge_threshold {
|
|
||||||
vx = (edge_threshold - nx) / edge_threshold;
|
|
||||||
} else if nx > 1.0 - edge_threshold {
|
|
||||||
vx = -(nx - (1.0 - edge_threshold)) / edge_threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ny < edge_threshold {
|
|
||||||
vy = (edge_threshold - ny) / edge_threshold;
|
|
||||||
} else if ny > 1.0 - edge_threshold {
|
|
||||||
vy = -(ny - (1.0 - edge_threshold)) / edge_threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
if vx != 0.0 || vy != 0.0 {
|
|
||||||
let view_w = canvas_rect.width() / self.zoom;
|
|
||||||
let view_h = canvas_rect.height() / self.zoom;
|
|
||||||
let speed_w = view_w; // per second at full intensity
|
|
||||||
let speed_h = view_h; // per second at full intensity
|
|
||||||
|
|
||||||
self.pan_x += vx * speed_w * dt;
|
|
||||||
self.pan_y += vy * speed_h * dt;
|
|
||||||
|
|
||||||
// keep scrolling even without new input events
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Element colors
|
|
||||||
let internal_alpha = (0.3_f32 * 255.0).round() as u8;
|
|
||||||
let video_scroll_color =
|
|
||||||
egui::Color32::from_rgba_unmultiplied(255, 0, 0, internal_alpha);
|
|
||||||
let audio_area_color = egui::Color32::from_rgba_unmultiplied(0, 0, 255, internal_alpha);
|
|
||||||
let anchor_color = egui::Color32::from_rgba_unmultiplied(64, 255, 64, internal_alpha);
|
|
||||||
let text_color = egui::Color32::from_rgb(255, 255, 255);
|
|
||||||
|
|
||||||
// Initialize text cache (renders in white, color applied as tint)
|
|
||||||
if self.text_cache.is_none() {
|
|
||||||
self.text_cache = Some(TextCache::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract values for closures
|
|
||||||
let pan_x = self.pan_x;
|
|
||||||
let pan_y = self.pan_y;
|
|
||||||
let zoom = self.zoom;
|
|
||||||
|
|
||||||
let svg_to_screen = |x: f32, y: f32| -> egui::Pos2 {
|
|
||||||
egui::pos2(
|
|
||||||
(x + pan_x) * zoom + canvas_rect.left(),
|
|
||||||
(y + pan_y) * zoom + canvas_rect.top(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let screen_to_svg = |pos: egui::Pos2| -> (f32, f32) {
|
|
||||||
(
|
|
||||||
(pos.x - canvas_rect.left()) / zoom - pan_x,
|
|
||||||
(pos.y - canvas_rect.top()) / zoom - pan_y,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut pending_navigation: Option<(f32, f32, f32, f32)> = None;
|
|
||||||
|
|
||||||
if let Some(ref content) = self.svg_content {
|
|
||||||
let pointer_svg_hover = pointer_latest.or(pointer_pos).and_then(|p| {
|
|
||||||
if canvas_rect.contains(p) {
|
|
||||||
Some(screen_to_svg(p))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let click_svg_pos = if primary_released && !self.is_dragging {
|
|
||||||
pointer_latest.or(pointer_pos).and_then(|p| {
|
|
||||||
if canvas_rect.contains(p) {
|
|
||||||
Some(screen_to_svg(p))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((svg_x, svg_y)) = click_svg_pos {
|
|
||||||
if let Some(link) = content.anchor_links.iter().find(|link| {
|
|
||||||
svg_x >= link.x
|
|
||||||
&& svg_x <= link.x + link.width
|
|
||||||
&& svg_y >= link.y
|
|
||||||
&& svg_y <= link.y + link.height
|
|
||||||
}) {
|
|
||||||
if let Some(anchor) =
|
|
||||||
content.anchors.iter().find(|a| a.id == link.target_id)
|
|
||||||
{
|
|
||||||
pending_navigation = Some(anchor.bounds());
|
|
||||||
} else {
|
|
||||||
log::warn!("Anchor link target not found: {}", link.target_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((svg_x, svg_y)) = pointer_svg_hover {
|
|
||||||
if content.anchor_links.iter().any(|link| {
|
|
||||||
svg_x >= link.x
|
|
||||||
&& svg_x <= link.x + link.width
|
|
||||||
&& svg_y >= link.y
|
|
||||||
&& svg_y <= link.y + link.height
|
|
||||||
}) {
|
|
||||||
ctx.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.render_internal_areas {
|
|
||||||
let mut hovered_descs: Vec<String> = Vec::new();
|
|
||||||
let pointer_svg = pointer_pos.map(screen_to_svg);
|
|
||||||
|
|
||||||
// Video scrolls
|
|
||||||
for vs in &content.video_scrolls {
|
|
||||||
let (x, y, w, h) = vs.bounds();
|
|
||||||
let rect = egui::Rect::from_min_max(
|
|
||||||
svg_to_screen(x, y),
|
|
||||||
svg_to_screen(x + w, y + h),
|
|
||||||
);
|
|
||||||
if rect.intersects(canvas_rect) {
|
|
||||||
painter.rect_filled(rect, 0.0, video_scroll_color);
|
|
||||||
rendered_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((svg_x, svg_y)) = pointer_svg {
|
|
||||||
if svg_x >= x && svg_x <= x + w && svg_y >= y && svg_y <= y + h {
|
|
||||||
hovered_descs.push(format!("Video: {}", vs.desc));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio areas
|
|
||||||
for aa in &content.audio_areas {
|
|
||||||
let center = svg_to_screen(aa.cx, aa.cy);
|
|
||||||
let radius = aa.radius * zoom;
|
|
||||||
let rect = egui::Rect::from_min_max(
|
|
||||||
egui::pos2(center.x - radius, center.y - radius),
|
|
||||||
egui::pos2(center.x + radius, center.y + radius),
|
|
||||||
);
|
|
||||||
if rect.intersects(canvas_rect) {
|
|
||||||
painter.circle_filled(center, radius, audio_area_color);
|
|
||||||
rendered_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((svg_x, svg_y)) = pointer_svg {
|
|
||||||
let dx = svg_x - aa.cx;
|
|
||||||
let dy = svg_y - aa.cy;
|
|
||||||
if dx * dx + dy * dy <= aa.radius * aa.radius {
|
|
||||||
hovered_descs.push(format!("Audio: {}", aa.desc));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anchors
|
|
||||||
for anchor in &content.anchors {
|
|
||||||
let (x, y, w, h) = anchor.bounds();
|
|
||||||
let rect = egui::Rect::from_min_max(
|
|
||||||
svg_to_screen(x, y),
|
|
||||||
svg_to_screen(x + w, y + h),
|
|
||||||
);
|
|
||||||
if rect.intersects(canvas_rect) {
|
|
||||||
painter.rect_filled(rect, 0.0, anchor_color);
|
|
||||||
rendered_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((svg_x, svg_y)) = pointer_svg {
|
|
||||||
if svg_x >= x && svg_x <= x + w && svg_y >= y && svg_y <= y + h {
|
|
||||||
hovered_descs.push(format!("Anchor: {}", anchor.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hovered_descs.is_empty() {
|
|
||||||
egui::Tooltip::always_open(
|
|
||||||
ctx.clone(),
|
|
||||||
ui.layer_id(),
|
|
||||||
egui::Id::new("internal-area-desc"),
|
|
||||||
egui::PopupAnchor::Pointer,
|
|
||||||
)
|
|
||||||
.gap(12.0)
|
|
||||||
.show(|ui| {
|
|
||||||
for desc in hovered_descs {
|
|
||||||
ui.label(desc);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text elements
|
|
||||||
let text_cache = self.text_cache.as_mut().expect("just initialized");
|
|
||||||
for text_elem in &content.texts {
|
|
||||||
let (x, y, w, h) = text_elem.bounds();
|
|
||||||
let rect =
|
|
||||||
egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
|
||||||
|
|
||||||
if !rect.intersects(canvas_rect) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let display_font_size = text_elem.font_size * zoom;
|
|
||||||
if display_font_size >= 0.5 {
|
|
||||||
for line in &text_elem.lines {
|
|
||||||
if line.content.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cached = text_cache.get_or_create(
|
|
||||||
ctx,
|
|
||||||
&line.content,
|
|
||||||
text_elem.font_size,
|
|
||||||
zoom,
|
|
||||||
);
|
|
||||||
let scale_factor = zoom / cached.render_scale;
|
|
||||||
let display_width = cached.width as f32 * scale_factor;
|
|
||||||
let display_height = cached.height as f32 * scale_factor;
|
|
||||||
|
|
||||||
let y_adjusted = line.y - text_elem.font_size * 0.8;
|
|
||||||
let pos = svg_to_screen(line.x, y_adjusted);
|
|
||||||
|
|
||||||
painter.image(
|
|
||||||
cached.texture.id(),
|
|
||||||
egui::Rect::from_min_size(
|
|
||||||
pos,
|
|
||||||
egui::vec2(display_width, display_height),
|
|
||||||
),
|
|
||||||
egui::Rect::from_min_max(
|
|
||||||
egui::pos2(0.0, 0.0),
|
|
||||||
egui::pos2(1.0, 1.0),
|
|
||||||
),
|
|
||||||
text_color, // Tint the white texture with desired color
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(rect) = pending_navigation {
|
|
||||||
self.request_view_to_rect(rect, &canvas_rect, TemplateApp::NAV_DURATION);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug window
|
|
||||||
if self.show_debug {
|
|
||||||
egui::Window::new("Debug")
|
|
||||||
.anchor(egui::Align2::RIGHT_TOP, [-10.0, 10.0])
|
|
||||||
.resizable(false)
|
|
||||||
.collapsible(false)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
ui.label(format!("FPS: {:.1}", self.fps_ema));
|
|
||||||
ui.label(format!("Pixels per point: {:.2}", ctx.pixels_per_point()));
|
|
||||||
ui.separator();
|
|
||||||
ui.label(format!("Pan: ({:.1}, {:.1})", self.pan_x, self.pan_y));
|
|
||||||
ui.label(format!("Zoom: {:.2}x", self.zoom));
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
if let Some(ref content) = self.svg_content {
|
|
||||||
let total = content.video_scrolls.len()
|
|
||||||
+ content.audio_areas.len()
|
|
||||||
+ content.anchors.len()
|
|
||||||
+ content.texts.len();
|
|
||||||
ui.label(format!("Total elements: {total}"));
|
|
||||||
ui.label(format!("Rendered: {rendered_count}"));
|
|
||||||
ui.label(format!(
|
|
||||||
"Culled: {}",
|
|
||||||
total.saturating_sub(rendered_count as usize)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
ui.checkbox(&mut self.render_internal_areas, "Render internal areas");
|
|
||||||
|
|
||||||
if let Some(ref cache) = self.text_cache {
|
|
||||||
ui.separator();
|
|
||||||
let bytes = cache.cache_memory_bytes();
|
|
||||||
let mb = bytes as f32 / (1024.0 * 1024.0);
|
|
||||||
ui.label(format!(
|
|
||||||
"Text cache: {} entries (~{mb:.2} MB)",
|
|
||||||
cache.cache_size()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
164
src/app/animation.rs
Normal file
164
src/app/animation.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
use super::LasApp;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(super) struct ViewAnimation {
|
||||||
|
start_center_x: f32,
|
||||||
|
start_center_y: f32,
|
||||||
|
start_zoom: f32,
|
||||||
|
target_center_x: f32,
|
||||||
|
target_center_y: f32,
|
||||||
|
target_zoom: f32,
|
||||||
|
elapsed: f32,
|
||||||
|
duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LasApp {
|
||||||
|
pub(super) fn cancel_animation(&mut self) {
|
||||||
|
self.view_animation = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn begin_view_animation(
|
||||||
|
&mut self,
|
||||||
|
target_pan_x: f32,
|
||||||
|
target_pan_y: f32,
|
||||||
|
target_zoom: f32,
|
||||||
|
duration: f32,
|
||||||
|
canvas_rect: &egui::Rect,
|
||||||
|
) {
|
||||||
|
if duration <= 0.0 {
|
||||||
|
self.pan_x = target_pan_x;
|
||||||
|
self.pan_y = target_pan_y;
|
||||||
|
self.zoom = target_zoom;
|
||||||
|
self.view_animation = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (start_center_x, start_center_y) =
|
||||||
|
view_center(self.pan_x, self.pan_y, self.zoom, canvas_rect);
|
||||||
|
let (target_center_x, target_center_y) =
|
||||||
|
view_center(target_pan_x, target_pan_y, target_zoom, canvas_rect);
|
||||||
|
|
||||||
|
self.view_animation = Some(ViewAnimation {
|
||||||
|
start_center_x,
|
||||||
|
start_center_y,
|
||||||
|
start_zoom: self.zoom,
|
||||||
|
target_center_x,
|
||||||
|
target_center_y,
|
||||||
|
target_zoom,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_animation(&mut self, dt: f32, canvas_rect: &egui::Rect) {
|
||||||
|
if let Some(anim) = &mut self.view_animation {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
|
||||||
|
let eased = ease_in_out_cubic(t);
|
||||||
|
|
||||||
|
let zoom = lerp(
|
||||||
|
anim.start_zoom,
|
||||||
|
anim.target_zoom,
|
||||||
|
zoom_ease(t, anim.start_zoom, anim.target_zoom),
|
||||||
|
);
|
||||||
|
let center_x = lerp(anim.start_center_x, anim.target_center_x, eased);
|
||||||
|
let center_y = lerp(anim.start_center_y, anim.target_center_y, eased);
|
||||||
|
|
||||||
|
if canvas_rect.width() > 0.0 && canvas_rect.height() > 0.0 {
|
||||||
|
let visible_w = canvas_rect.width() / zoom;
|
||||||
|
let visible_h = canvas_rect.height() / zoom;
|
||||||
|
self.pan_x = -center_x + visible_w * 0.5;
|
||||||
|
self.pan_y = -center_y + visible_h * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.zoom = zoom;
|
||||||
|
|
||||||
|
if t >= 1.0 {
|
||||||
|
self.view_animation = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn request_view_to_rect(
|
||||||
|
&mut self,
|
||||||
|
rect: (f32, f32, f32, f32),
|
||||||
|
canvas_rect: &egui::Rect,
|
||||||
|
duration: f32,
|
||||||
|
) {
|
||||||
|
if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, canvas_rect) {
|
||||||
|
self.begin_view_animation(pan_x, pan_y, zoom, duration, canvas_rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle zoom towards a specific point.
|
||||||
|
pub(super) fn zoom_towards(
|
||||||
|
&mut self,
|
||||||
|
new_zoom: f32,
|
||||||
|
pos: egui::Pos2,
|
||||||
|
canvas_rect: &egui::Rect,
|
||||||
|
) {
|
||||||
|
let svg_x = (pos.x - canvas_rect.left()) / self.zoom - self.pan_x;
|
||||||
|
let svg_y = (pos.y - canvas_rect.top()) / self.zoom - self.pan_y;
|
||||||
|
self.pan_x = (pos.x - canvas_rect.left()) / new_zoom - svg_x;
|
||||||
|
self.pan_y = (pos.y - canvas_rect.top()) / new_zoom - svg_y;
|
||||||
|
self.zoom = new_zoom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ease_in_out_cubic(t: f32) -> f32 {
|
||||||
|
if t < 0.5 {
|
||||||
|
4.0 * t * t * t
|
||||||
|
} else {
|
||||||
|
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ease_out_cubic(t: f32) -> f32 {
|
||||||
|
1.0 - (1.0 - t).powi(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zoom_ease(t: f32, start: f32, target: f32) -> f32 {
|
||||||
|
if target > start {
|
||||||
|
// Zooming in: bias progress late but soften landing
|
||||||
|
let biased = t.powf(1.6);
|
||||||
|
ease_in_out_cubic(biased)
|
||||||
|
} else {
|
||||||
|
// Zooming out: shed zoom early
|
||||||
|
ease_out_cubic(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lerp(a: f32, b: f32, t: f32) -> f32 {
|
||||||
|
a + (b - a) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn compute_view_for_rect(
|
||||||
|
rect: (f32, f32, f32, f32),
|
||||||
|
canvas_rect: &egui::Rect,
|
||||||
|
) -> Option<(f32, f32, f32)> {
|
||||||
|
if canvas_rect.width() <= 0.0 || canvas_rect.height() <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sx, sy, sw, sh) = rect;
|
||||||
|
if sw <= 0.0 || sh <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale_x = canvas_rect.width() / sw;
|
||||||
|
let scale_y = canvas_rect.height() / sh;
|
||||||
|
let fit_zoom = scale_x.min(scale_y).clamp(0.01, 100.0);
|
||||||
|
let visible_w = canvas_rect.width() / fit_zoom;
|
||||||
|
let visible_h = canvas_rect.height() / fit_zoom;
|
||||||
|
|
||||||
|
let pan_x = -sx + (visible_w - sw) * 0.5;
|
||||||
|
let pan_y = -sy + (visible_h - sh) * 0.5;
|
||||||
|
|
||||||
|
Some((pan_x, pan_y, fit_zoom))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_center(pan_x: f32, pan_y: f32, zoom: f32, canvas_rect: &egui::Rect) -> (f32, f32) {
|
||||||
|
let visible_w = canvas_rect.width() / zoom;
|
||||||
|
let visible_h = canvas_rect.height() / zoom;
|
||||||
|
(-pan_x + visible_w * 0.5, -pan_y + visible_h * 0.5)
|
||||||
|
}
|
||||||
34
src/app/input.rs
Normal file
34
src/app/input.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(super) struct InputSnapshot {
|
||||||
|
pub(super) escape_pressed: bool,
|
||||||
|
pub(super) f3_pressed: bool,
|
||||||
|
pub(super) f_pressed: bool,
|
||||||
|
pub(super) scroll_delta: f32,
|
||||||
|
pub(super) zoom_delta: f32,
|
||||||
|
pub(super) pointer_latest: Option<egui::Pos2>,
|
||||||
|
pub(super) pointer_pos: Option<egui::Pos2>,
|
||||||
|
pub(super) frame_time: f32,
|
||||||
|
pub(super) primary_down: bool,
|
||||||
|
pub(super) primary_pressed: bool,
|
||||||
|
pub(super) primary_released: bool,
|
||||||
|
pub(super) space_pressed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputSnapshot {
|
||||||
|
pub(super) fn collect(ctx: &egui::Context) -> Self {
|
||||||
|
ctx.input(|i| Self {
|
||||||
|
escape_pressed: i.key_pressed(egui::Key::Escape),
|
||||||
|
f3_pressed: i.key_pressed(egui::Key::F3),
|
||||||
|
f_pressed: i.key_pressed(egui::Key::F),
|
||||||
|
scroll_delta: i.smooth_scroll_delta.y,
|
||||||
|
zoom_delta: i.zoom_delta(),
|
||||||
|
pointer_latest: i.pointer.latest_pos(),
|
||||||
|
pointer_pos: i.pointer.hover_pos(),
|
||||||
|
frame_time: i.stable_dt,
|
||||||
|
primary_down: i.pointer.primary_down(),
|
||||||
|
primary_pressed: i.pointer.primary_pressed(),
|
||||||
|
primary_released: i.pointer.primary_released(),
|
||||||
|
space_pressed: i.key_pressed(egui::Key::Space),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/app/mod.rs
Normal file
186
src/app/mod.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
use crate::svg::SvgContent;
|
||||||
|
use crate::text_cache::TextCache;
|
||||||
|
|
||||||
|
mod animation;
|
||||||
|
mod input;
|
||||||
|
mod render;
|
||||||
|
|
||||||
|
use input::InputSnapshot;
|
||||||
|
|
||||||
|
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct LasApp {
|
||||||
|
#[serde(skip)]
|
||||||
|
svg_content: Option<SvgContent>,
|
||||||
|
|
||||||
|
/// Pan offset in SVG coordinates.
|
||||||
|
pan_x: f32,
|
||||||
|
pan_y: f32,
|
||||||
|
|
||||||
|
/// Zoom factor (1.0 = 100%).
|
||||||
|
zoom: f32,
|
||||||
|
|
||||||
|
#[serde(skip)]
|
||||||
|
show_menu_bar: bool,
|
||||||
|
|
||||||
|
#[serde(skip)]
|
||||||
|
show_debug: bool,
|
||||||
|
|
||||||
|
#[serde(skip)]
|
||||||
|
render_internal_areas: bool,
|
||||||
|
|
||||||
|
/// Exponential moving average of frame time for stable FPS display.
|
||||||
|
#[serde(skip)]
|
||||||
|
fps_ema: f32,
|
||||||
|
|
||||||
|
/// Whether we've auto-fitted the start viewport.
|
||||||
|
#[serde(skip)]
|
||||||
|
did_fit_start: bool,
|
||||||
|
|
||||||
|
/// Last known cursor position (for edge scrolling even without movement).
|
||||||
|
#[serde(skip)]
|
||||||
|
last_cursor_pos: Option<egui::Pos2>,
|
||||||
|
|
||||||
|
/// Whether a reset-to-start was requested (from UI)
|
||||||
|
#[serde(skip)]
|
||||||
|
reset_view_requested: bool,
|
||||||
|
|
||||||
|
/// Smooth camera animation state.
|
||||||
|
#[serde(skip)]
|
||||||
|
view_animation: Option<animation::ViewAnimation>,
|
||||||
|
|
||||||
|
/// Last pointer position for manual drag tracking (smoother than `Sense::drag`).
|
||||||
|
#[serde(skip)]
|
||||||
|
last_pointer_pos: Option<egui::Pos2>,
|
||||||
|
|
||||||
|
/// Whether we're currently in a drag operation.
|
||||||
|
#[serde(skip)]
|
||||||
|
is_dragging: bool,
|
||||||
|
|
||||||
|
/// Text rendering cache for smooth scaling.
|
||||||
|
#[serde(skip)]
|
||||||
|
text_cache: Option<TextCache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LasApp {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
svg_content: None,
|
||||||
|
pan_x: 0.0,
|
||||||
|
pan_y: 0.0,
|
||||||
|
zoom: 1.0,
|
||||||
|
show_menu_bar: false,
|
||||||
|
show_debug: false,
|
||||||
|
render_internal_areas: false,
|
||||||
|
fps_ema: 60.0,
|
||||||
|
last_pointer_pos: None,
|
||||||
|
is_dragging: false,
|
||||||
|
text_cache: None,
|
||||||
|
did_fit_start: false,
|
||||||
|
last_cursor_pos: None,
|
||||||
|
reset_view_requested: false,
|
||||||
|
view_animation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LasApp {
|
||||||
|
const NAV_DURATION: f32 = 1.5;
|
||||||
|
|
||||||
|
/// Called once before the first frame.
|
||||||
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
|
log::info!("Initializing application...");
|
||||||
|
|
||||||
|
log::debug!("Installing image loaders...");
|
||||||
|
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||||||
|
|
||||||
|
log::debug!("Loading app state...");
|
||||||
|
let mut app: Self = cc
|
||||||
|
.storage
|
||||||
|
.and_then(|s| eframe::get_value(s, eframe::APP_KEY))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Load SVG content
|
||||||
|
let svg_path = "../line-and-surface/content/intro.svg";
|
||||||
|
log::info!("Loading SVG from: {svg_path}");
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
match SvgContent::from_file(svg_path) {
|
||||||
|
Ok(content) => {
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
log::info!(
|
||||||
|
"Loaded SVG in {:.2?}: {} video scrolls, {} audio areas, {} anchors, {} texts",
|
||||||
|
elapsed,
|
||||||
|
content.video_scrolls.len(),
|
||||||
|
content.audio_areas.len(),
|
||||||
|
content.anchors.len(),
|
||||||
|
content.texts.len()
|
||||||
|
);
|
||||||
|
if let Some((vb_x, vb_y, vb_w, vb_h)) = content.viewbox {
|
||||||
|
log::debug!("SVG viewbox: ({vb_x}, {vb_y}, {vb_w}, {vb_h})");
|
||||||
|
app.pan_x = -vb_x;
|
||||||
|
app.pan_y = -vb_y;
|
||||||
|
}
|
||||||
|
app.svg_content = Some(content);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load SVG: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Application initialized");
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_fps(&mut self, frame_time: f32) {
|
||||||
|
if frame_time > 0.0 {
|
||||||
|
const ALPHA: f32 = 0.1;
|
||||||
|
self.fps_ema = ALPHA * (1.0 / frame_time) + (1.0 - ALPHA) * self.fps_ema;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for LasApp {
|
||||||
|
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||||
|
eframe::set_value(storage, eframe::APP_KEY, self);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
let input = InputSnapshot::collect(ctx);
|
||||||
|
|
||||||
|
if input.primary_pressed
|
||||||
|
|| input.scroll_delta != 0.0
|
||||||
|
|| (input.zoom_delta - 1.0).abs() > f32::EPSILON
|
||||||
|
{
|
||||||
|
self.cancel_animation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pos) = input.pointer_latest.or(input.pointer_pos) {
|
||||||
|
self.last_cursor_pos = Some(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_fullscreen = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
|
||||||
|
|
||||||
|
self.update_fps(input.frame_time);
|
||||||
|
|
||||||
|
if input.escape_pressed {
|
||||||
|
self.show_menu_bar = !self.show_menu_bar;
|
||||||
|
}
|
||||||
|
if input.f3_pressed {
|
||||||
|
self.show_debug = !self.show_debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.show_menu_bar {
|
||||||
|
self.render_menu_bar(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered_count = egui::CentralPanel::default()
|
||||||
|
.show(ctx, |ui| self.render_canvas(ctx, ui, &input, is_fullscreen))
|
||||||
|
.inner;
|
||||||
|
|
||||||
|
if self.show_debug {
|
||||||
|
self.render_debug_window(ctx, rendered_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
428
src/app/render.rs
Normal file
428
src/app/render.rs
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
use super::LasApp;
|
||||||
|
use super::animation::compute_view_for_rect;
|
||||||
|
use super::input::InputSnapshot;
|
||||||
|
use crate::svg::Renderable as _;
|
||||||
|
|
||||||
|
impl LasApp {
|
||||||
|
pub(super) fn render_menu_bar(&mut self, ctx: &egui::Context) {
|
||||||
|
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
||||||
|
egui::MenuBar::new().ui(ui, |ui| {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
ui.menu_button("File", |ui| {
|
||||||
|
if ui.button("Quit").clicked() {
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(16.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::widgets::global_theme_preference_buttons(ui);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
ui.label(format!("Zoom: {:.0}%", self.zoom * 100.0));
|
||||||
|
if ui.button("Reset View").clicked() {
|
||||||
|
self.reset_view_requested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
let debug_label = if self.show_debug {
|
||||||
|
"Hide Debug (F3)"
|
||||||
|
} else {
|
||||||
|
"Show Debug (F3)"
|
||||||
|
};
|
||||||
|
if ui.button(debug_label).clicked() {
|
||||||
|
self.show_debug = !self.show_debug;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_debug_window(&mut self, ctx: &egui::Context, rendered_count: u32) {
|
||||||
|
egui::Window::new("Debug")
|
||||||
|
.anchor(egui::Align2::RIGHT_TOP, [-10.0, 10.0])
|
||||||
|
.resizable(false)
|
||||||
|
.collapsible(false)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.label(format!("FPS: {:.1}", self.fps_ema));
|
||||||
|
ui.label(format!("Pixels per point: {:.2}", ctx.pixels_per_point()));
|
||||||
|
ui.separator();
|
||||||
|
ui.label(format!("Pan: ({:.1}, {:.1})", self.pan_x, self.pan_y));
|
||||||
|
ui.label(format!("Zoom: {:.2}x", self.zoom));
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if let Some(ref content) = self.svg_content {
|
||||||
|
let total = content.video_scrolls.len()
|
||||||
|
+ content.audio_areas.len()
|
||||||
|
+ content.anchors.len()
|
||||||
|
+ content.texts.len();
|
||||||
|
ui.label(format!("Total elements: {total}"));
|
||||||
|
ui.label(format!("Rendered: {rendered_count}"));
|
||||||
|
ui.label(format!(
|
||||||
|
"Culled: {}",
|
||||||
|
total.saturating_sub(rendered_count as usize)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.checkbox(&mut self.render_internal_areas, "Render internal areas");
|
||||||
|
|
||||||
|
if let Some(ref cache) = self.text_cache {
|
||||||
|
ui.separator();
|
||||||
|
let bytes = cache.cache_memory_bytes();
|
||||||
|
let mb = bytes as f32 / (1024.0 * 1024.0);
|
||||||
|
ui.label(format!(
|
||||||
|
"Text cache: {} entries (~{mb:.2} MB)",
|
||||||
|
cache.cache_size()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(clippy::too_many_lines)]
|
||||||
|
pub(super) fn render_canvas(
|
||||||
|
&mut self,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
input: &InputSnapshot,
|
||||||
|
is_fullscreen: bool,
|
||||||
|
) -> u32 {
|
||||||
|
let (response, painter) =
|
||||||
|
ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
|
||||||
|
let canvas_rect = response.rect;
|
||||||
|
|
||||||
|
let background_rgb = self
|
||||||
|
.svg_content
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|content| content.background_color)
|
||||||
|
.unwrap_or([0, 0, 0]);
|
||||||
|
let background_color =
|
||||||
|
egui::Color32::from_rgb(background_rgb[0], background_rgb[1], background_rgb[2]);
|
||||||
|
painter.rect_filled(canvas_rect, 0.0, background_color);
|
||||||
|
|
||||||
|
let anim_dt = if input.frame_time > 0.0 {
|
||||||
|
input.frame_time
|
||||||
|
} else {
|
||||||
|
1.0 / 60.0
|
||||||
|
};
|
||||||
|
self.update_animation(anim_dt, &canvas_rect);
|
||||||
|
if self.view_animation.is_some() {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.double_clicked() || input.f_pressed {
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!is_fullscreen));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.did_fit_start {
|
||||||
|
if let Some(ref content) = self.svg_content {
|
||||||
|
if let Some(rect) = content.start_rect.or(content.viewbox) {
|
||||||
|
if let Some((pan_x, pan_y, zoom)) = compute_view_for_rect(rect, &canvas_rect) {
|
||||||
|
self.pan_x = pan_x;
|
||||||
|
self.pan_y = pan_y;
|
||||||
|
self.zoom = zoom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.did_fit_start = true;
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.space_pressed {
|
||||||
|
self.reset_view_requested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.reset_view_requested {
|
||||||
|
if let Some(ref content) = self.svg_content {
|
||||||
|
if let Some(rect) = content.start_rect.or(content.viewbox) {
|
||||||
|
self.request_view_to_rect(rect, &canvas_rect, Self::NAV_DURATION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.reset_view_requested = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.primary_pressed && response.hovered() {
|
||||||
|
self.is_dragging = true;
|
||||||
|
self.last_pointer_pos = input.pointer_pos;
|
||||||
|
}
|
||||||
|
if input.primary_released {
|
||||||
|
self.is_dragging = false;
|
||||||
|
self.last_pointer_pos = None;
|
||||||
|
}
|
||||||
|
if self.is_dragging && input.primary_down {
|
||||||
|
if let (Some(current), Some(last)) = (input.pointer_pos, self.last_pointer_pos) {
|
||||||
|
let delta = current - last;
|
||||||
|
self.pan_x += delta.x / self.zoom;
|
||||||
|
self.pan_y += delta.y / self.zoom;
|
||||||
|
}
|
||||||
|
self.last_pointer_pos = input.pointer_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.hovered() {
|
||||||
|
if input.scroll_delta != 0.0 {
|
||||||
|
let factor = 1.0 + input.scroll_delta * 0.001;
|
||||||
|
let new_zoom = (self.zoom * factor).clamp(0.01, 100.0);
|
||||||
|
if let Some(pos) = input.pointer_pos {
|
||||||
|
self.zoom_towards(new_zoom, pos, &canvas_rect);
|
||||||
|
} else {
|
||||||
|
self.zoom = new_zoom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.zoom_delta != 1.0 {
|
||||||
|
let new_zoom = (self.zoom * input.zoom_delta).clamp(0.01, 100.0);
|
||||||
|
if let Some(pos) = input.pointer_pos {
|
||||||
|
self.zoom_towards(new_zoom, pos, &canvas_rect);
|
||||||
|
} else {
|
||||||
|
self.zoom = new_zoom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let edge_pointer = input.pointer_latest.or(self.last_cursor_pos);
|
||||||
|
if is_fullscreen {
|
||||||
|
if let Some(mouse_pos) = edge_pointer {
|
||||||
|
let dt = if input.frame_time > 0.0 {
|
||||||
|
input.frame_time
|
||||||
|
} else {
|
||||||
|
1.0 / 60.0
|
||||||
|
};
|
||||||
|
let nx = if canvas_rect.width() > 0.0 {
|
||||||
|
((mouse_pos.x - canvas_rect.left()) / canvas_rect.width()).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.5
|
||||||
|
};
|
||||||
|
let ny = if canvas_rect.height() > 0.0 {
|
||||||
|
((mouse_pos.y - canvas_rect.top()) / canvas_rect.height()).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.5
|
||||||
|
};
|
||||||
|
|
||||||
|
let edge_threshold = 0.15;
|
||||||
|
let mut vx = 0.0;
|
||||||
|
let mut vy = 0.0;
|
||||||
|
|
||||||
|
if nx < edge_threshold {
|
||||||
|
vx = (edge_threshold - nx) / edge_threshold;
|
||||||
|
} else if nx > 1.0 - edge_threshold {
|
||||||
|
vx = -(nx - (1.0 - edge_threshold)) / edge_threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ny < edge_threshold {
|
||||||
|
vy = (edge_threshold - ny) / edge_threshold;
|
||||||
|
} else if ny > 1.0 - edge_threshold {
|
||||||
|
vy = -(ny - (1.0 - edge_threshold)) / edge_threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
if vx != 0.0 || vy != 0.0 {
|
||||||
|
let view_w = canvas_rect.width() / self.zoom;
|
||||||
|
let view_h = canvas_rect.height() / self.zoom;
|
||||||
|
let speed_w = view_w;
|
||||||
|
let speed_h = view_h;
|
||||||
|
|
||||||
|
self.pan_x += vx * speed_w * dt;
|
||||||
|
self.pan_y += vy * speed_h * dt;
|
||||||
|
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let internal_alpha = (0.3_f32 * 255.0).round() as u8;
|
||||||
|
let video_scroll_color = egui::Color32::from_rgba_unmultiplied(255, 0, 0, internal_alpha);
|
||||||
|
let audio_area_color = egui::Color32::from_rgba_unmultiplied(0, 0, 255, internal_alpha);
|
||||||
|
let anchor_color = egui::Color32::from_rgba_unmultiplied(64, 255, 64, internal_alpha);
|
||||||
|
let text_color = egui::Color32::from_rgb(255, 255, 255);
|
||||||
|
|
||||||
|
if self.text_cache.is_none() {
|
||||||
|
self.text_cache = Some(crate::text_cache::TextCache::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let pan_x = self.pan_x;
|
||||||
|
let pan_y = self.pan_y;
|
||||||
|
let zoom = self.zoom;
|
||||||
|
|
||||||
|
let svg_to_screen = |x: f32, y: f32| -> egui::Pos2 {
|
||||||
|
egui::pos2(
|
||||||
|
(x + pan_x) * zoom + canvas_rect.left(),
|
||||||
|
(y + pan_y) * zoom + canvas_rect.top(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let screen_to_svg = |pos: egui::Pos2| -> (f32, f32) {
|
||||||
|
(
|
||||||
|
(pos.x - canvas_rect.left()) / zoom - pan_x,
|
||||||
|
(pos.y - canvas_rect.top()) / zoom - pan_y,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pending_navigation: Option<(f32, f32, f32, f32)> = None;
|
||||||
|
let mut rendered_count = 0u32;
|
||||||
|
|
||||||
|
if let Some(ref content) = self.svg_content {
|
||||||
|
let pointer_svg_hover = input.pointer_latest.or(input.pointer_pos).and_then(|p| {
|
||||||
|
if canvas_rect.contains(p) {
|
||||||
|
Some(screen_to_svg(p))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let click_svg_pos = if input.primary_released && !self.is_dragging {
|
||||||
|
input.pointer_latest.or(input.pointer_pos).and_then(|p| {
|
||||||
|
if canvas_rect.contains(p) {
|
||||||
|
Some(screen_to_svg(p))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((svg_x, svg_y)) = click_svg_pos {
|
||||||
|
if let Some(link) = content.anchor_links.iter().find(|link| {
|
||||||
|
svg_x >= link.x
|
||||||
|
&& svg_x <= link.x + link.width
|
||||||
|
&& svg_y >= link.y
|
||||||
|
&& svg_y <= link.y + link.height
|
||||||
|
}) {
|
||||||
|
if let Some(anchor) = content.anchors.iter().find(|a| a.id == link.target_id) {
|
||||||
|
pending_navigation = Some(anchor.bounds());
|
||||||
|
} else {
|
||||||
|
log::warn!("Anchor link target not found: {}", link.target_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((svg_x, svg_y)) = pointer_svg_hover {
|
||||||
|
if content.anchor_links.iter().any(|link| {
|
||||||
|
svg_x >= link.x
|
||||||
|
&& svg_x <= link.x + link.width
|
||||||
|
&& svg_y >= link.y
|
||||||
|
&& svg_y <= link.y + link.height
|
||||||
|
}) {
|
||||||
|
ctx.output_mut(|o| o.cursor_icon = egui::CursorIcon::PointingHand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.render_internal_areas {
|
||||||
|
let mut hovered_descs: Vec<String> = Vec::new();
|
||||||
|
let pointer_svg = input.pointer_pos.map(screen_to_svg);
|
||||||
|
|
||||||
|
for vs in &content.video_scrolls {
|
||||||
|
let (x, y, w, h) = vs.bounds();
|
||||||
|
let rect =
|
||||||
|
egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
||||||
|
if rect.intersects(canvas_rect) {
|
||||||
|
painter.rect_filled(rect, 0.0, video_scroll_color);
|
||||||
|
rendered_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((svg_x, svg_y)) = pointer_svg {
|
||||||
|
if svg_x >= x && svg_x <= x + w && svg_y >= y && svg_y <= y + h {
|
||||||
|
hovered_descs.push(format!("Video: {}", vs.desc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for aa in &content.audio_areas {
|
||||||
|
let center = svg_to_screen(aa.cx, aa.cy);
|
||||||
|
let radius = aa.radius * zoom;
|
||||||
|
let rect = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(center.x - radius, center.y - radius),
|
||||||
|
egui::pos2(center.x + radius, center.y + radius),
|
||||||
|
);
|
||||||
|
if rect.intersects(canvas_rect) {
|
||||||
|
painter.circle_filled(center, radius, audio_area_color);
|
||||||
|
rendered_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((svg_x, svg_y)) = pointer_svg {
|
||||||
|
let dx = svg_x - aa.cx;
|
||||||
|
let dy = svg_y - aa.cy;
|
||||||
|
if dx * dx + dy * dy <= aa.radius * aa.radius {
|
||||||
|
hovered_descs.push(format!("Audio: {}", aa.desc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for anchor in &content.anchors {
|
||||||
|
let (x, y, w, h) = anchor.bounds();
|
||||||
|
let rect =
|
||||||
|
egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
||||||
|
if rect.intersects(canvas_rect) {
|
||||||
|
painter.rect_filled(rect, 0.0, anchor_color);
|
||||||
|
rendered_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((svg_x, svg_y)) = pointer_svg {
|
||||||
|
if svg_x >= x && svg_x <= x + w && svg_y >= y && svg_y <= y + h {
|
||||||
|
hovered_descs.push(format!("Anchor: {}", anchor.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hovered_descs.is_empty() {
|
||||||
|
egui::Tooltip::always_open(
|
||||||
|
ctx.clone(),
|
||||||
|
ui.layer_id(),
|
||||||
|
egui::Id::new("internal-area-desc"),
|
||||||
|
egui::PopupAnchor::Pointer,
|
||||||
|
)
|
||||||
|
.gap(12.0)
|
||||||
|
.show(|ui| {
|
||||||
|
for desc in hovered_descs {
|
||||||
|
ui.label(desc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let text_cache = self.text_cache.as_mut().expect("just initialized");
|
||||||
|
for text_elem in &content.texts {
|
||||||
|
let (x, y, w, h) = text_elem.bounds();
|
||||||
|
let rect =
|
||||||
|
egui::Rect::from_min_max(svg_to_screen(x, y), svg_to_screen(x + w, y + h));
|
||||||
|
|
||||||
|
if !rect.intersects(canvas_rect) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let display_font_size = text_elem.font_size * zoom;
|
||||||
|
if display_font_size >= 0.5 {
|
||||||
|
for line in &text_elem.lines {
|
||||||
|
if line.content.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached =
|
||||||
|
text_cache.get_or_create(ctx, &line.content, text_elem.font_size, zoom);
|
||||||
|
let scale_factor = zoom / cached.render_scale;
|
||||||
|
let display_width = cached.width as f32 * scale_factor;
|
||||||
|
let display_height = cached.height as f32 * scale_factor;
|
||||||
|
|
||||||
|
let y_adjusted = line.y - text_elem.font_size * 0.8;
|
||||||
|
let pos = svg_to_screen(line.x, y_adjusted);
|
||||||
|
|
||||||
|
painter.image(
|
||||||
|
cached.texture.id(),
|
||||||
|
egui::Rect::from_min_size(
|
||||||
|
pos,
|
||||||
|
egui::vec2(display_width, display_height),
|
||||||
|
),
|
||||||
|
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||||
|
text_color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rect) = pending_navigation {
|
||||||
|
self.request_view_to_rect(rect, &canvas_rect, Self::NAV_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#![warn(clippy::all, rust_2018_idioms)]
|
#![warn(clippy::all)]
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
pub mod svg;
|
pub mod svg;
|
||||||
mod text_cache;
|
mod text_cache;
|
||||||
|
|
||||||
pub use app::TemplateApp;
|
pub use app::LasApp;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ fn main() -> eframe::Result {
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"Line and Surface",
|
"Line and Surface",
|
||||||
native_options,
|
native_options,
|
||||||
Box::new(|cc| Ok(Box::new(las::TemplateApp::new(cc)))),
|
Box::new(|cc| Ok(Box::new(las::LasApp::new(cc)))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ fn main() {
|
||||||
.start(
|
.start(
|
||||||
canvas,
|
canvas,
|
||||||
web_options,
|
web_options,
|
||||||
Box::new(|cc| Ok(Box::new(las::TemplateApp::new(cc)))),
|
Box::new(|cc| Ok(Box::new(las::LasApp::new(cc)))),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
|
||||||
29
src/svg/color.rs
Normal file
29
src/svg/color.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
pub(super) fn parse_background_color(style: &str) -> Option<[u8; 3]> {
|
||||||
|
for entry in style.split(';') {
|
||||||
|
let entry = entry.trim();
|
||||||
|
if let Some(value) = entry.strip_prefix("background-color:") {
|
||||||
|
let value = value.trim();
|
||||||
|
return parse_hex_color(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hex_color(value: &str) -> Option<[u8; 3]> {
|
||||||
|
let hex = value.strip_prefix('#')?.trim();
|
||||||
|
match hex.len() {
|
||||||
|
3 => {
|
||||||
|
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
|
||||||
|
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
|
||||||
|
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
|
||||||
|
Some([r, g, b])
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
|
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||||
|
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||||
|
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||||
|
Some([r, g, b])
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/svg/mod.rs
Normal file
12
src/svg/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
//! SVG parsing module for extracting special elements from SVG files.
|
||||||
|
|
||||||
|
mod color;
|
||||||
|
mod parser;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
pub use types::{
|
||||||
|
Anchor, AnchorLink, AudioArea, Renderable, SvgContent, TextElement, TextLine, VideoScroll,
|
||||||
|
};
|
||||||
|
|
@ -1,133 +1,10 @@
|
||||||
//! SVG parsing module for extracting special elements from SVG files.
|
use super::Renderable as _;
|
||||||
|
use super::color::parse_background_color;
|
||||||
use quick_xml::events::Event;
|
use super::types::{Anchor, AnchorLink, AudioArea, SvgContent, TextElement, TextLine, VideoScroll};
|
||||||
use quick_xml::Reader;
|
use quick_xml::Reader;
|
||||||
|
use quick_xml::events::Event;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
/// Trait for elements that can be rendered with a bounding box.
|
|
||||||
pub trait Renderable {
|
|
||||||
/// Returns the bounding box: (x, y, width, height) in SVG coordinates.
|
|
||||||
fn bounds(&self) -> (f32, f32, f32, f32);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An `<image>` element with a `<desc>` child (video scroll area).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct VideoScroll {
|
|
||||||
pub x: f32,
|
|
||||||
pub y: f32,
|
|
||||||
pub width: f32,
|
|
||||||
pub height: f32,
|
|
||||||
pub desc: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Renderable for VideoScroll {
|
|
||||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
|
||||||
(self.x, self.y, self.width, self.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `<circle>` or `<ellipse>` element with a `<desc>` child (audio area).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AudioArea {
|
|
||||||
pub cx: f32,
|
|
||||||
pub cy: f32,
|
|
||||||
pub radius: f32,
|
|
||||||
pub desc: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Renderable for AudioArea {
|
|
||||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
|
||||||
(
|
|
||||||
self.cx - self.radius,
|
|
||||||
self.cy - self.radius,
|
|
||||||
self.radius * 2.0,
|
|
||||||
self.radius * 2.0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `<rect>` element with id starting with "anchor".
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Anchor {
|
|
||||||
pub id: String,
|
|
||||||
pub x: f32,
|
|
||||||
pub y: f32,
|
|
||||||
pub width: f32,
|
|
||||||
pub height: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Renderable for Anchor {
|
|
||||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
|
||||||
(self.x, self.y, self.width, self.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single line of text (tspan).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TextLine {
|
|
||||||
pub x: f32,
|
|
||||||
pub y: f32,
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `<text>` element with multiple lines.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TextElement {
|
|
||||||
pub lines: Vec<TextLine>,
|
|
||||||
pub font_size: f32, // Parsed from style attribute
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A hyperlink pointing to an anchor by id.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AnchorLink {
|
|
||||||
pub target_id: String,
|
|
||||||
pub x: f32,
|
|
||||||
pub y: f32,
|
|
||||||
pub width: f32,
|
|
||||||
pub height: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Renderable for TextElement {
|
|
||||||
fn bounds(&self) -> (f32, f32, f32, f32) {
|
|
||||||
if self.lines.is_empty() {
|
|
||||||
return (0.0, 0.0, 0.0, 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let min_x = self.lines.iter().map(|l| l.x).fold(f32::INFINITY, f32::min);
|
|
||||||
let min_y = self
|
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.map(|l| l.y - self.font_size)
|
|
||||||
.fold(f32::INFINITY, f32::min);
|
|
||||||
|
|
||||||
let max_x = self
|
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.map(|l| l.x + l.content.len() as f32 * self.font_size * 0.6)
|
|
||||||
.fold(f32::NEG_INFINITY, f32::max);
|
|
||||||
let max_y = self
|
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.map(|l| l.y)
|
|
||||||
.fold(f32::NEG_INFINITY, f32::max);
|
|
||||||
|
|
||||||
(min_x, min_y, max_x - min_x, max_y - min_y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Container for all parsed SVG content.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct SvgContent {
|
|
||||||
pub video_scrolls: Vec<VideoScroll>,
|
|
||||||
pub audio_areas: Vec<AudioArea>,
|
|
||||||
pub anchors: Vec<Anchor>,
|
|
||||||
pub anchor_links: Vec<AnchorLink>,
|
|
||||||
pub texts: Vec<TextElement>,
|
|
||||||
pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height)
|
|
||||||
pub background_color: Option<[u8; 3]>,
|
|
||||||
pub start_rect: Option<(f32, f32, f32, f32)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// State for tracking current element during parsing.
|
/// State for tracking current element during parsing.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum PendingElement {
|
enum PendingElement {
|
||||||
|
|
@ -246,7 +123,7 @@ impl SvgContent {
|
||||||
let cleaned = value.trim_start_matches('#');
|
let cleaned = value.trim_start_matches('#');
|
||||||
if cleaned.starts_with("anchor") {
|
if cleaned.starts_with("anchor") {
|
||||||
current_link =
|
current_link =
|
||||||
Some(LinkAccumulator::new(cleaned.to_string()));
|
Some(LinkAccumulator::new(cleaned.to_owned()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -598,128 +475,3 @@ impl SvgContent {
|
||||||
Ok(svg_content)
|
Ok(svg_content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_background_color(style: &str) -> Option<[u8; 3]> {
|
|
||||||
for entry in style.split(';') {
|
|
||||||
let entry = entry.trim();
|
|
||||||
if let Some(value) = entry.strip_prefix("background-color:") {
|
|
||||||
let value = value.trim();
|
|
||||||
return parse_hex_color(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_hex_color(value: &str) -> Option<[u8; 3]> {
|
|
||||||
let hex = value.strip_prefix('#')?.trim();
|
|
||||||
match hex.len() {
|
|
||||||
3 => {
|
|
||||||
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
|
|
||||||
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
|
|
||||||
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
|
|
||||||
Some([r, g, b])
|
|
||||||
}
|
|
||||||
6 => {
|
|
||||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
|
||||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
|
||||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
|
||||||
Some([r, g, b])
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_video_scroll() {
|
|
||||||
let svg = r#"
|
|
||||||
<svg viewBox="0 0 1000 1000">
|
|
||||||
<image x="100" y="200" width="300" height="400">
|
|
||||||
<desc>horizontal video.mp4</desc>
|
|
||||||
</image>
|
|
||||||
</svg>
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
|
||||||
assert_eq!(content.video_scrolls.len(), 1);
|
|
||||||
let vs = &content.video_scrolls[0];
|
|
||||||
assert_eq!(vs.x, 100.0);
|
|
||||||
assert_eq!(vs.y, 200.0);
|
|
||||||
assert_eq!(vs.width, 300.0);
|
|
||||||
assert_eq!(vs.height, 400.0);
|
|
||||||
assert_eq!(vs.desc, "horizontal video.mp4");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_audio_area_circle() {
|
|
||||||
let svg = r#"
|
|
||||||
<svg viewBox="0 0 1000 1000">
|
|
||||||
<circle cx="500" cy="500" r="100">
|
|
||||||
<desc>ambient.mp3</desc>
|
|
||||||
</circle>
|
|
||||||
</svg>
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
|
||||||
assert_eq!(content.audio_areas.len(), 1);
|
|
||||||
let aa = &content.audio_areas[0];
|
|
||||||
assert_eq!(aa.cx, 500.0);
|
|
||||||
assert_eq!(aa.cy, 500.0);
|
|
||||||
assert_eq!(aa.radius, 100.0);
|
|
||||||
|
|
||||||
let (x, y, w, h) = aa.bounds();
|
|
||||||
assert_eq!(x, 400.0);
|
|
||||||
assert_eq!(y, 400.0);
|
|
||||||
assert_eq!(w, 200.0);
|
|
||||||
assert_eq!(h, 200.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_anchor() {
|
|
||||||
let svg = r#"
|
|
||||||
<svg viewBox="0 0 1000 1000">
|
|
||||||
<rect id="anchor-home" x="10" y="20" width="30" height="40" />
|
|
||||||
</svg>
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
|
||||||
assert_eq!(content.anchors.len(), 1);
|
|
||||||
let anchor = &content.anchors[0];
|
|
||||||
assert_eq!(anchor.id, "anchor-home");
|
|
||||||
assert_eq!(anchor.x, 10.0);
|
|
||||||
assert_eq!(anchor.y, 20.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_text() {
|
|
||||||
let svg = r#"
|
|
||||||
<svg viewBox="0 0 1000 1000">
|
|
||||||
<text x="100" y="200" style="font-size:12px">
|
|
||||||
<tspan x="100" y="200">Hello World</tspan>
|
|
||||||
<tspan x="100" y="220">Second Line</tspan>
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
|
||||||
assert_eq!(content.texts.len(), 1);
|
|
||||||
let text = &content.texts[0];
|
|
||||||
assert_eq!(text.lines.len(), 2);
|
|
||||||
assert_eq!(text.lines[0].x, 100.0);
|
|
||||||
assert_eq!(text.lines[0].y, 200.0);
|
|
||||||
assert_eq!(text.lines[0].content, "Hello World");
|
|
||||||
assert_eq!(text.lines[1].content, "Second Line");
|
|
||||||
assert_eq!(text.font_size, 12.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_viewbox() {
|
|
||||||
let svg = r#"<svg viewBox="0 0 1920 1080"></svg>"#;
|
|
||||||
|
|
||||||
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
|
||||||
assert_eq!(content.viewbox, Some((0.0, 0.0, 1920.0, 1080.0)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
91
src/svg/tests.rs
Normal file
91
src/svg/tests.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
use super::{Renderable, SvgContent};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_video_scroll() {
|
||||||
|
let svg = r#"
|
||||||
|
<svg viewBox="0 0 1000 1000">
|
||||||
|
<image x="100" y="200" width="300" height="400">
|
||||||
|
<desc>horizontal video.mp4</desc>
|
||||||
|
</image>
|
||||||
|
</svg>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||||
|
assert_eq!(content.video_scrolls.len(), 1);
|
||||||
|
let vs = &content.video_scrolls[0];
|
||||||
|
assert_eq!(vs.x, 100.0);
|
||||||
|
assert_eq!(vs.y, 200.0);
|
||||||
|
assert_eq!(vs.width, 300.0);
|
||||||
|
assert_eq!(vs.height, 400.0);
|
||||||
|
assert_eq!(vs.desc, "horizontal video.mp4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_audio_area_circle() {
|
||||||
|
let svg = r#"
|
||||||
|
<svg viewBox="0 0 1000 1000">
|
||||||
|
<circle cx="500" cy="500" r="100">
|
||||||
|
<desc>ambient.mp3</desc>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||||
|
assert_eq!(content.audio_areas.len(), 1);
|
||||||
|
let aa = &content.audio_areas[0];
|
||||||
|
assert_eq!(aa.cx, 500.0);
|
||||||
|
assert_eq!(aa.cy, 500.0);
|
||||||
|
assert_eq!(aa.radius, 100.0);
|
||||||
|
|
||||||
|
let (x, y, w, h) = aa.bounds();
|
||||||
|
assert_eq!(x, 400.0);
|
||||||
|
assert_eq!(y, 400.0);
|
||||||
|
assert_eq!(w, 200.0);
|
||||||
|
assert_eq!(h, 200.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_anchor() {
|
||||||
|
let svg = r#"
|
||||||
|
<svg viewBox="0 0 1000 1000">
|
||||||
|
<rect id="anchor-home" x="10" y="20" width="30" height="40" />
|
||||||
|
</svg>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||||
|
assert_eq!(content.anchors.len(), 1);
|
||||||
|
let anchor = &content.anchors[0];
|
||||||
|
assert_eq!(anchor.id, "anchor-home");
|
||||||
|
assert_eq!(anchor.x, 10.0);
|
||||||
|
assert_eq!(anchor.y, 20.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_text() {
|
||||||
|
let svg = r#"
|
||||||
|
<svg viewBox="0 0 1000 1000">
|
||||||
|
<text x="100" y="200" style="font-size:12px">
|
||||||
|
<tspan x="100" y="200">Hello World</tspan>
|
||||||
|
<tspan x="100" y="220">Second Line</tspan>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||||
|
assert_eq!(content.texts.len(), 1);
|
||||||
|
let text = &content.texts[0];
|
||||||
|
assert_eq!(text.lines.len(), 2);
|
||||||
|
assert_eq!(text.lines[0].x, 100.0);
|
||||||
|
assert_eq!(text.lines[0].y, 200.0);
|
||||||
|
assert_eq!(text.lines[0].content, "Hello World");
|
||||||
|
assert_eq!(text.lines[1].content, "Second Line");
|
||||||
|
assert_eq!(text.font_size, 12.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_viewbox() {
|
||||||
|
let svg = r#"<svg viewBox="0 0 1920 1080"></svg>"#;
|
||||||
|
|
||||||
|
let content = SvgContent::parse(svg).expect("Failed to parse SVG");
|
||||||
|
assert_eq!(content.viewbox, Some((0.0, 0.0, 1920.0, 1080.0)));
|
||||||
|
}
|
||||||
123
src/svg/types.rs
Normal file
123
src/svg/types.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
/// Trait for elements that can be rendered with a bounding box.
|
||||||
|
pub trait Renderable {
|
||||||
|
/// Returns the bounding box: (x, y, width, height) in SVG coordinates.
|
||||||
|
fn bounds(&self) -> (f32, f32, f32, f32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An `<image>` element with a `<desc>` child (video scroll area).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct VideoScroll {
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
pub desc: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderable for VideoScroll {
|
||||||
|
fn bounds(&self) -> (f32, f32, f32, f32) {
|
||||||
|
(self.x, self.y, self.width, self.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `<circle>` or `<ellipse>` element with a `<desc>` child (audio area).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AudioArea {
|
||||||
|
pub cx: f32,
|
||||||
|
pub cy: f32,
|
||||||
|
pub radius: f32,
|
||||||
|
pub desc: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderable for AudioArea {
|
||||||
|
fn bounds(&self) -> (f32, f32, f32, f32) {
|
||||||
|
(
|
||||||
|
self.cx - self.radius,
|
||||||
|
self.cy - self.radius,
|
||||||
|
self.radius * 2.0,
|
||||||
|
self.radius * 2.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `<rect>` element with id starting with "anchor".
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Anchor {
|
||||||
|
pub id: String,
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderable for Anchor {
|
||||||
|
fn bounds(&self) -> (f32, f32, f32, f32) {
|
||||||
|
(self.x, self.y, self.width, self.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single line of text (tspan).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TextLine {
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `<text>` element with multiple lines.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TextElement {
|
||||||
|
pub lines: Vec<TextLine>,
|
||||||
|
pub font_size: f32, // Parsed from style attribute
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hyperlink pointing to an anchor by id.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnchorLink {
|
||||||
|
pub target_id: String,
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderable for TextElement {
|
||||||
|
fn bounds(&self) -> (f32, f32, f32, f32) {
|
||||||
|
if self.lines.is_empty() {
|
||||||
|
return (0.0, 0.0, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let min_x = self.lines.iter().map(|l| l.x).fold(f32::INFINITY, f32::min);
|
||||||
|
let min_y = self
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| l.y - self.font_size)
|
||||||
|
.fold(f32::INFINITY, f32::min);
|
||||||
|
|
||||||
|
let max_x = self
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| l.x + l.content.len() as f32 * self.font_size * 0.6)
|
||||||
|
.fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
let max_y = self
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| l.y)
|
||||||
|
.fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
|
||||||
|
(min_x, min_y, max_x - min_x, max_y - min_y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container for all parsed SVG content.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SvgContent {
|
||||||
|
pub video_scrolls: Vec<VideoScroll>,
|
||||||
|
pub audio_areas: Vec<AudioArea>,
|
||||||
|
pub anchors: Vec<Anchor>,
|
||||||
|
pub anchor_links: Vec<AnchorLink>,
|
||||||
|
pub texts: Vec<TextElement>,
|
||||||
|
pub viewbox: Option<(f32, f32, f32, f32)>, // (min_x, min_y, width, height)
|
||||||
|
pub background_color: Option<[u8; 3]>,
|
||||||
|
pub start_rect: Option<(f32, f32, f32, f32)>,
|
||||||
|
}
|
||||||
|
|
@ -184,6 +184,7 @@ impl TextCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render text to a texture at high resolution.
|
/// Render text to a texture at high resolution.
|
||||||
|
#[expect(clippy::too_many_lines)]
|
||||||
fn render_text(
|
fn render_text(
|
||||||
&self,
|
&self,
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue