diff options
| author | Johannes Hofmann <Johannes.Hofmann@gmx.de> | 2026-10-26 20:05:20 +0100 |
|---|---|---|
| committer | Johannes Hofmann <Johannes.Hofmann@gmx.de> | 2026-10-26 20:05:20 +0100 |
| commit | 375d87a9b0ec1cf2fffd705adb870c86144d2400 (patch) | |
| tree | 1e8e05e7cd58686628d0f4622622456613a42e4d /crates/gipfel-app/src/viewport.rs | |
| parent | 45425dd047b791fe63f13a46a3251c529cfffc94 (diff) | |
Replace the C++ implementation with the Rust port
The Rust port (formerly gipfel-rs) is now the official successor to the
FLTK/GSL C++ code base. This squashes the port into a single commit and
removes the C++ sources (they remain in the git history below).
Layout:
crates/gipfel-core headless core: hills, panorama geometry, projections
(maxima-generated derivatives), pure-Rust LM fit,
gipfel: JPEG comment + EXIF I/O, profiles, stitching
crates/gipfel-cli command-line tools (export, info, stitch, profiles)
crates/gipfel-app Cosmic DE (libcosmic) GUI, binary name `gipfel`
codegen/ maxima scripts + regen.sh for the projection code
tests/ golden files + C++ reference harnesses (historical)
The original README is kept for now; it will be updated to describe the
Rust build. gipfel.dat, AUTHORS, ChangeLog and NEWS are unchanged.
Diffstat (limited to 'crates/gipfel-app/src/viewport.rs')
| -rw-r--r-- | crates/gipfel-app/src/viewport.rs | 659 |
1 files changed, 659 insertions, 0 deletions
diff --git a/crates/gipfel-app/src/viewport.rs b/crates/gipfel-app/src/viewport.rs new file mode 100644 index 0000000..3001fc4 --- /dev/null +++ b/crates/gipfel-app/src/viewport.rs @@ -0,0 +1,659 @@ +//! The gipfel viewport: a custom widget that draws the photo, hill markers, +//! decluttered labels, GPS track, and handles all canvas interaction. +//! +//! Marker/label placement mirrors the original GipfelWidget::draw/set_labels +//! (image-space coordinates; the screen transform is applied at draw time). + +use std::rc::Rc; +use std::time::Instant; + +use cosmic::iced::advanced::image::{FilterMethod, Handle, Image as CoreImage}; +use cosmic::iced::advanced::renderer::Style as RendererStyle; +use cosmic::iced::advanced::text::{ + self as adv_text, paragraph::Plain, LineHeight, Renderer as _, +}; +use cosmic::iced::advanced::widget::{tree, Tree, Widget as WidgetTrait}; +use cosmic::iced::advanced::{Clipboard, Layout, Shell}; +use cosmic::iced::mouse::{self, Button, Cursor, Interaction, ScrollDelta}; +use cosmic::iced::widget::canvas::{Frame, LineCap, Path, Stroke}; +use cosmic::iced::advanced::graphics::geometry::Style as GeoStyle; +use cosmic::iced::{ + alignment, Color, Event, Length, Pixels, Point, Rectangle, Radians, Size, Vector, +}; + +use cosmic::Renderer as R; +use cosmic::Theme; + +pub const DEFAULT_LABEL_FONT: f32 = 12.0; +const CROSS_SIZE: f64 = 4.0; + +#[derive(Clone, Debug)] +pub struct Marker { + /// image-space coordinates, origin at image center (like C's m->x/m->y) + pub x: f64, + pub y: f64, + pub name: Rc<str>, + pub idx: usize, + pub known: bool, + pub hidden: bool, +} + +#[derive(Clone, Debug)] +pub struct TrackPt { + pub x: f64, + pub y: f64, + pub hidden: bool, + pub width: f64, +} + +#[derive(Debug, Clone)] +pub enum VpMsg { + /// zoom by `factor`, keeping image point `img` under the cursor + ZoomAt { factor: f32, img: Point }, + PanBy { delta: Vector }, + PanTo { pan: Vector }, + MoveMarker { idx: usize, img: Point }, + DragEnd { had_marker: bool }, + /// hill index of the toggled marker (None = clicked empty space) + ToggleKnown(Option<usize>), + ContextAt { img: Point, hit: Option<usize> }, + /// hill index of the hovered marker + Hover(Option<usize>), +} + +#[derive(Clone)] +struct LabelLayout { + version: u64, + font_size: f32, + zoom: f32, + entries: Vec<(f32, f32)>, +} + +#[derive(Default)] +struct DragState { + drag: Drag, + last_click: Option<(Instant, Point)>, + label_cache: std::cell::RefCell<Option<LabelLayout>>, + /// Paragraph primitives must outlive the frame: the renderer stores only a + /// Weak reference, so keep strong refs here until the next draw. + paragraphs: std::cell::RefCell<Vec<Plain<<R as adv_text::Renderer>::Paragraph>>>, +} + +#[derive(Default, Clone)] +enum Drag { + #[default] + None, + Marker(usize), + Pan { start: Point, pan0: Vector }, +} + +pub struct ViewportWidget<M: From<VpMsg> + Send + 'static> { + pub handle: Handle, + pub img_w: f32, + pub img_h: f32, + pub zoom: f32, + pub pan: Vector, + /// hill markers in visible order; label layout depends on this order + pub markers: Rc<Vec<Marker>>, + pub track: Rc<Vec<TrackPt>>, + pub hover: Option<usize>, + pub ctrl: bool, + /// label font size in pixels (UI-tunable) + pub font_size: f32, + /// bump whenever marker positions change to invalidate the label cache + pub version: u64, + pub _msg: std::marker::PhantomData<fn() -> M>, +} + +impl<M: From<VpMsg> + Send + 'static> Clone for ViewportWidget<M> { + fn clone(&self) -> Self { + ViewportWidget { + handle: self.handle.clone(), + img_w: self.img_w, + img_h: self.img_h, + zoom: self.zoom, + pan: self.pan, + markers: self.markers.clone(), + track: self.track.clone(), + hover: self.hover, + ctrl: self.ctrl, + font_size: self.font_size, + version: self.version, + _msg: std::marker::PhantomData, + } + } +} + +impl<M: From<VpMsg> + Send + 'static> ViewportWidget<M> { + /// line height: paragraph metrics (cosmic LineHeight default = 1.3x) plus + /// a 2px gap, so stacked labels never touch + fn line_h(&self) -> f32 { + self.font_size * 1.3 + 2.0 + } + + fn center(bounds: Rectangle, pan: Vector) -> Point { + Point::new( + bounds.x + bounds.width / 2.0 + pan.x, + bounds.y + bounds.height / 2.0 + pan.y, + ) + } + + fn to_img(&self, center: Point, p: Point) -> Point { + Point::new((p.x - center.x) / self.zoom, (p.y - center.y) / self.zoom) + } + + /// hit-test in image space; tolerance scales with zoom (~6 screen px). + /// Returns the *hill index* of the hit marker. + fn hit(&self, center: Point, p: Point) -> Option<usize> { + let img = self.to_img(center, p); + let tol = (6.0 / self.zoom).clamp(2.0, 20.0); + self.markers + .iter() + .find(|m| (m.x as f32 - img.x).abs() < tol && (m.y as f32 - img.y).abs() < tol) + .map(|m| m.idx) + } +} + +impl<M: From<VpMsg> + Send + 'static> WidgetTrait<M, Theme, R> for ViewportWidget<M> { + fn size(&self) -> Size<Length> { + Size::new(Length::Fill, Length::Fill) + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::<DragState>() + } + + fn state(&self) -> tree::State { + tree::State::new(DragState::default()) + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &R, + limits: &cosmic::iced::advanced::layout::Limits, + ) -> cosmic::iced::advanced::layout::Node { + cosmic::iced::advanced::layout::Node::new(limits.max()) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut R, + _theme: &Theme, + _style: &RendererStyle, + layout: Layout<'_>, + _cursor: Cursor, + _viewport: &Rectangle, + ) { + use cosmic::iced::advanced::image::Renderer as _; + use cosmic::iced::advanced::Renderer as _; + + let bounds = layout.bounds(); + let center = Self::center(bounds, self.pan); + let z = self.zoom; + + // 1. photo + let dest = Rectangle::new( + Point::new(center.x - self.img_w * z / 2.0, center.y - self.img_h * z / 2.0), + Size::new(self.img_w * z, self.img_h * z), + ); + renderer.draw_image( + CoreImage { + handle: self.handle.clone(), + filter_method: FilterMethod::Linear, + rotation: Radians(0.0), + border_radius: 0.0.into(), + opacity: 1.0, + snap: false, + }, + dest, + bounds, + ); + + let layout_entries = self.label_layout(tree, renderer); + + // The wgpu renderer draws geometry before images within one layer; + // record the overlay in a fresh layer so it lands on top of the photo. + renderer.start_layer(bounds); + self.draw_overlay(renderer, tree, bounds, center, &layout_entries); + renderer.end_layer(); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: Cursor, + _renderer: &R, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, M>, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let center = Self::center(bounds, self.pan); + let state = tree.state.downcast_mut::<DragState>(); + + match event { + Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + let Some(p) = cursor.position_over(bounds) else { return }; + let (dx, dy) = match delta { + ScrollDelta::Pixels { x, y } => (*x, *y), + ScrollDelta::Lines { x, y } => (*x * 30.0, *y * 30.0), + }; + if self.ctrl { + shell.publish(M::from(VpMsg::ZoomAt { + factor: (1.0 + dy * 0.0015).clamp(0.5, 2.0), + img: self.to_img(center, p), + })); + } else { + shell.publish(M::from(VpMsg::PanBy { delta: Vector::new(-dx, -dy) })); + } + shell.request_redraw(); + } + Event::Mouse(mouse::Event::ButtonPressed(Button::Left)) => { + let Some(p) = cursor.position_over(bounds) else { return }; + let now = Instant::now(); + let dbl = state + .last_click + .is_some_and(|(t, q)| { + t.elapsed().as_millis() < 400 + && (q.x - p.x).abs() < 6.0 + && (q.y - p.y).abs() < 6.0 + }); + state.last_click = Some((now, p)); + + if dbl { + state.drag = Drag::None; + state.last_click = None; + shell.publish(M::from(VpMsg::ToggleKnown(self.hit(center, p)))); + return; + } + + if let Some(idx) = self.hit(center, p) { + if self.markers.iter().any(|m| m.idx == idx && m.known) { + state.drag = Drag::Marker(idx); + return; + } + } + state.drag = Drag::Pan { start: p, pan0: self.pan }; + } + Event::Mouse(mouse::Event::ButtonReleased(Button::Left)) => { + let had_marker = matches!(state.drag, Drag::Marker(_)); + state.drag = Drag::None; + shell.publish(M::from(VpMsg::DragEnd { had_marker })); + } + Event::Mouse(mouse::Event::ButtonPressed(Button::Middle)) => { + if let Some(p) = cursor.position_over(bounds) { + shell.publish(M::from(VpMsg::ToggleKnown(self.hit(center, p)))); + } + } + Event::Mouse(mouse::Event::ButtonPressed(Button::Right)) => { + if let Some(p) = cursor.position_over(bounds) { + shell.publish(M::from(VpMsg::ContextAt { + img: self.to_img(center, p), + hit: self.hit(center, p), + })); + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) => { + match state.drag.clone() { + Drag::Marker(idx) => { + if let Some(p) = cursor.position() { + shell.publish(M::from(VpMsg::MoveMarker { + idx, + img: self.to_img(center, p), + })); + shell.request_redraw(); + } + } + Drag::Pan { start, pan0 } => { + if let Some(p) = cursor.position() { + shell.publish(M::from(VpMsg::PanTo { + pan: pan0 + Vector::new(p.x - start.x, p.y - start.y), + })); + shell.request_redraw(); + } + } + Drag::None => { + match cursor.position_over(bounds) { + Some(p) => shell.publish(M::from(VpMsg::Hover(self.hit(center, p)))), + // cursor moved off the viewport (e.g. into the + // sidebar): clear any stale hover info + None => shell.publish(M::from(VpMsg::Hover(None))), + } + } + } + } + _ => {} + } + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor: Cursor, + _viewport: &Rectangle, + _renderer: &R, + ) -> Interaction { + let center = Self::center(layout.bounds(), self.pan); + match cursor.position_over(layout.bounds()) { + Some(p) if self.hit(center, p).is_some() => Interaction::Pointer, + Some(_) => Interaction::Grab, + None => Interaction::None, + } + } +} + +impl<M: From<VpMsg> + Send + 'static> ViewportWidget<M> { + fn draw_overlay( + &self, + renderer: &mut R, + tree: &Tree, + bounds: Rectangle, + center: Point, + layout_entries: &[(f32, f32)], + ) { + use cosmic::iced::advanced::graphics::geometry::Renderer as _; + use cosmic::iced::advanced::text::Renderer as _; + + let z = self.zoom; + let to_screen = |p: Point| Point::new(center.x + p.x * z, center.y + p.y * z); + let layout_entries = layout_entries; + let mut frame = Frame::with_bounds(renderer, bounds); + + // 2. GPS track + for w in self.track.windows(2) { + let (a, b) = (&w[0], &w[1]); + let pa = to_screen(Point::new(a.x as f32, a.y as f32)); + let pb = to_screen(Point::new(b.x as f32, b.y as f32)); + let color = if a.hidden { + Color::from_rgb(0.2, 0.4, 1.0) + } else { + Color::from_rgb(1.0, 0.2, 0.2) + }; + let stroke = Stroke { + width: (a.width as f32 * z).max(1.0), + style: GeoStyle::Solid(color), + line_cap: LineCap::Round, + ..Default::default() + }; + let path = Path::new(|bld| { + bld.move_to(pa); + bld.line_to(pb); + }); + frame.stroke(&path, stroke); + } + + // 3. hill markers: crosses + leaders + // C's draw() skips markers outside the image (fabs(x) > w/2 || ...); + // must match label_layout's filter, otherwise off-image markers get + // drawn (in the letterbox area) without ever being laid out. + let in_img = |m: &Marker| { + m.x.abs() * 2.0 <= self.img_w as f64 && m.y.abs() * 2.0 <= self.img_h as f64 + }; + let yellow = Color::from_rgb(1.0, 0.9, 0.2); + for (i, m) in self.markers.iter().enumerate() { + if !in_img(m) { + continue; + } + let p = to_screen(Point::new(m.x as f32, m.y as f32)); + if !bounds.contains(p) { + continue; + } + let (lx, ly) = layout_entries[i]; + let d = CROSS_SIZE as f32; + let label_line = p.y + ly - self.line_h(); + let stroke = Stroke { + width: 1.0, + style: GeoStyle::Solid(yellow), + ..Default::default() + }; + let path = Path::new(|bld| { + bld.move_to(Point::new(p.x - d, p.y)); + bld.line_to(Point::new(p.x + d, p.y)); + bld.move_to(Point::new(p.x, p.y)); + bld.line_to(Point::new(p.x, label_line)); + bld.move_to(Point::new(p.x, label_line)); + bld.line_to(Point::new(p.x + lx, label_line)); + }); + frame.stroke(&path, stroke); + } + + // known-hill flags (pole + pennant + circle) + let flag_color = Color::from_rgb(1.0, 0.25, 0.25); + for m in self.markers.iter().filter(|m| m.known) { + if !in_img(m) { + continue; + } + let p = to_screen(Point::new(m.x as f32, m.y as f32)); + if !bounds.contains(p) { + continue; + } + let stroke = Stroke { + width: 1.5, + style: GeoStyle::Solid(flag_color), + ..Default::default() + }; + let pole = Path::new(|bld| { + bld.move_to(p); + bld.line_to(Point::new(p.x, p.y - 16.0)); + bld.circle(p, 3.0); + }); + let pennant = Path::new(|bld| { + bld.move_to(Point::new(p.x, p.y - 16.0)); + bld.line_to(Point::new(p.x + 10.0, p.y - 13.0)); + bld.line_to(Point::new(p.x, p.y - 10.0)); + bld.close(); + }); + frame.stroke(&pole, stroke.clone()); + frame.fill(&pennant, flag_color); + } + + renderer.draw_geometry(frame.into_geometry()); + + // 4. labels (screen space, constant font size) + let font = renderer.default_font(); + let size = Pixels(self.font_size); + // the renderer keeps only a Weak ref to each paragraph, so hold strong + // refs in the widget state until the next frame replaces them + let state = tree.state.downcast_ref::<DragState>(); + { + let mut paras = state.paragraphs.borrow_mut(); + paras.clear(); + paras.reserve(self.markers.len()); + for m in self.markers.iter() { + paras.push(self.make_paragraph(font, size, &m.name)); + } + } + let paras = state.paragraphs.borrow(); + + for (i, m) in self.markers.iter().enumerate() { + if !in_img(m) { + continue; + } + let p = to_screen(Point::new(m.x as f32, m.y as f32)); + if !bounds.contains(p) { + continue; + } + let (_, ly) = layout_entries[i]; + let pos = Point::new(p.x + 2.0, p.y + ly - self.line_h()); + let is_hover = self.hover == Some(m.idx); + + let para = ¶s[i]; + let mb = para.min_bounds(); + + let mut bg = Frame::with_bounds(renderer, bounds); + let rect = Rectangle::new( + Point::new(p.x + 1.0, pos.y - 1.0), + Size::new(mb.width + 2.0, mb.height + 1.0), + ); + let path = Path::new(|bld| bld.rectangle(rect.position(), rect.size())); + bg.fill( + &path, + if is_hover { + Color::from_rgba(1.0, 0.9, 0.2, 0.95) + } else { + Color::from_rgba(0.0, 0.0, 0.0, 0.55) + }, + ); + renderer.draw_geometry(bg.into_geometry()); + + let color = if is_hover { + Color::BLACK + } else if m.hidden { + Color::from_rgb(0.4, 0.6, 1.0) + } else { + Color::WHITE + }; + renderer.fill_paragraph( + para.raw(), + Point::new(p.x + 2.0, pos.y), + color, + bounds, + ); + } + } + + fn make_paragraph(&self, font: <R as adv_text::Renderer>::Font, size: Pixels, text: &str) -> Plain<<R as adv_text::Renderer>::Paragraph> { + Plain::<<R as adv_text::Renderer>::Paragraph>::new(adv_text::Text { + content: text.to_string(), + bounds: Size::new(600.0, 40.0), + size, + line_height: LineHeight::default(), + font, + align_x: alignment::Horizontal::Left.into(), + align_y: alignment::Vertical::Top.into(), + shaping: adv_text::Shaping::Basic, + wrapping: adv_text::Wrapping::None, + ellipsize: adv_text::Ellipsize::None, + }) + } + + /// C set_labels port: place labels above crosses, pushing colliding ones up. + /// Markers outside the image are skipped entirely (C: fabs(x) > w/2 filter), + /// so off-image peaks never push visible labels up. + fn label_layout(&self, tree: &Tree, renderer: &mut R) -> Vec<(f32, f32)> { + let state = tree.state.downcast_ref::<DragState>(); + if let Some(l) = state.label_cache.borrow().as_ref() { + if l.version == self.version + && l.font_size == self.font_size + && l.zoom == self.zoom + && l.entries.len() == self.markers.len() + { + return l.entries.clone(); + } + } + + let font = renderer.default_font(); + let size = Pixels(self.font_size); + let line_h = self.line_h(); + + let widths: Vec<f32> = self + .markers + .iter() + .map(|m| self.make_paragraph(font, size, &m.name).min_bounds().width) + .collect(); + + // C: fabs(m->x) > img->w()/2 || fabs(m->y) > img->h()/2 -> skip + // (marker coords are center-relative, like C's m->x/m->y) + let in_img: Vec<bool> = self + .markers + .iter() + .map(|m| m.x.abs() * 2.0 <= self.img_w as f64 && m.y.abs() * 2.0 <= self.img_h as f64) + .collect(); + + // Labels are drawn at constant size in *screen* space, so collisions + // depend on the on-screen distance between markers: run the layout on + // zoom-scaled coordinates (C had no zoom, image space == screen space). + let z = self.zoom; + let sx: Vec<f32> = self.markers.iter().map(|m| m.x as f32 * z).collect(); + let sy: Vec<f32> = self.markers.iter().map(|m| m.y as f32 * z).collect(); + + let entries = layout_label_y(&sx, &sy, &widths, &in_img, line_h); + *state.label_cache.borrow_mut() = Some(LabelLayout { + version: self.version, + font_size: self.font_size, + zoom: self.zoom, + entries: entries.clone(), + }); + entries + } +} + +/// Shared label collision-avoidance layout, used for both the on-screen +/// viewport and the screen dump so the dump shows exactly the same label +/// placement (plan.md M3: same code path). +/// +/// `xs`/`ys` are marker positions (center-relative) in one uniform pixel +/// space, `widths` the label widths and `line_h` the line height, all in +/// that same space. Markers must be ordered by x (the sweep breaks on the +/// first non-overlapping predecessor). Returns `(label_width + 1, y_offset)` +/// per marker; the label is drawn at `(marker.x + 2, marker.y + y_offset - +/// line_h)`. +pub fn layout_label_y( + xs: &[f32], + ys: &[f32], + widths: &[f32], + in_img: &[bool], + line_h: f32, +) -> Vec<(f32, f32)> { + let n = xs.len(); + let mut label_x = vec![0.0f32; n]; + let mut label_y = vec![0.0f32; n]; + let cross = CROSS_SIZE as f32; + let overlap = |a1: f32, l1: f32, a2: f32, l2: f32| a1 <= a2 + l2 && a1 + l1 >= a2; + + for i in 0..n { + if !in_img[i] { + continue; + } + label_x[i] = widths[i] + 1.0; + label_y[i] = 0.0; + + let mut colliding: Vec<usize> = Vec::new(); + for j in (0..i).rev() { + if !in_img[j] { + continue; + } + if overlap(xs[i], label_x[i], xs[j] - cross, label_x[j]) { + colliding.push(j); + } else { + break; + } + } + colliding.sort_by(|&a, &b| { + let ya = ys[a] + label_y[a]; + let yb = ys[b] + label_y[b]; + yb.total_cmp(&ya) + }); + // C does a single overwrite pass here, which can leave overlaps + // when a push moves the label into a band that was already + // checked. Iterate to a fixed point instead (converges quickly: + // each round only moves the label up, bounded by the stack). + for _round in 0..64 { + let mut pushed = false; + for &j in &colliding { + if overlap( + ys[i] + label_y[i] - line_h, + line_h, + ys[j] + label_y[j] - line_h, + line_h, + ) || overlap(ys[i] + label_y[i] - line_h, line_h, ys[j] - cross, 2.0 * cross) { + let ny = (ys[j] + label_y[j] - ys[i] - line_h - 2.0).round(); + if ny < label_y[i] { + label_y[i] = ny; + pushed = true; + } + } + } + if !pushed { + break; + } + } + } + + (0..n).map(|i| (label_x[i], label_y[i])).collect() +} |
