FEMM/crates/femm-app/src/doc_canvas.rs

339 lines
12 KiB
Rust

//! draws a FemmDoc on an iced canvas: nodes, segments, arcs, block labels, with pan/zoom and click-to-add.
use femm_doc_mag::FemmDoc;
use iced::widget::canvas::{
self, Action, Canvas, Event, Frame, Geometry, Path, Stroke, Text, path::Builder,
};
use iced::{Color, Element, Length, Point, Radians, Rectangle, Renderer, Theme, Vector, mouse};
const PADDING_PX: f32 = 24.0;
const NODE_RADIUS: f32 = 3.0;
const STROKE_WIDTH: f32 = 1.2;
const LABEL_TICK_PX: f32 = 6.0;
const ZOOM_STEP: f32 = 1.1;
const ZOOM_MIN: f32 = 0.05;
const ZOOM_MAX: f32 = 200.0;
const CLICK_DRAG_THRESHOLD_PX: f32 = 4.0;
const BG: Color = Color::WHITE;
const GEOM: Color = Color::BLACK;
const LABEL_COLOR: Color = Color::from_rgb(0.25, 0.45, 0.85);
/// active editing mode on the canvas.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
#[default]
Select,
AddNode,
AddBlockLabel,
}
/// messages emitted by the canvas back to the app.
#[derive(Debug, Clone)]
pub enum CanvasMessage {
/// left-click at the given doc-world coordinate, intent depends on the active tool.
Click { world: (f64, f64), tool: Tool },
}
/// pan offset and zoom factor applied on top of fit-to-view.
#[derive(Debug, Default, Clone, Copy)]
pub struct ViewState {
pan: Vector,
zoom: f32,
drag_origin: Option<Point>,
press_origin: Option<Point>,
dragged: bool,
}
/// constructs the canvas widget for a doc reference.
pub fn view<'a>(doc: &'a FemmDoc, tool: Tool) -> Element<'a, CanvasMessage> {
Canvas::new(DocCanvas { doc, tool })
.width(Length::Fill)
.height(Length::Fill)
.into()
}
struct DocCanvas<'a> {
doc: &'a FemmDoc,
tool: Tool,
}
impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
type State = ViewState;
fn update(
&self,
state: &mut Self::State,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<CanvasMessage>> {
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(p) = cursor.position_in(bounds) {
state.press_origin = Some(p);
state.dragged = false;
if self.tool == Tool::Select {
state.drag_origin = Some(p);
}
return Some(Action::capture());
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let press = state.press_origin.take();
state.drag_origin = None;
let was_dragged = std::mem::take(&mut state.dragged);
if !was_dragged && self.tool != Tool::Select {
if let (Some(start), Some(now)) = (press, cursor.position_in(bounds)) {
if (now.x - start.x).abs() < CLICK_DRAG_THRESHOLD_PX
&& (now.y - start.y).abs() < CLICK_DRAG_THRESHOLD_PX
{
let view = ViewTransform::fit(self.doc, bounds, state);
let world = view.inverse_map(now);
return Some(Action::publish(CanvasMessage::Click {
world,
tool: self.tool,
}));
}
}
}
if press.is_some() {
return Some(Action::capture());
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
if let (Some(prev), Some(now)) = (state.drag_origin, cursor.position_in(bounds)) {
state.pan = state.pan + Vector::new(now.x - prev.x, now.y - prev.y);
state.drag_origin = Some(now);
state.dragged = true;
return Some(Action::request_redraw().and_capture());
}
if let (Some(start), Some(now)) = (state.press_origin, cursor.position_in(bounds)) {
if (now.x - start.x).abs() > CLICK_DRAG_THRESHOLD_PX
|| (now.y - start.y).abs() > CLICK_DRAG_THRESHOLD_PX
{
state.dragged = true;
}
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
if let Some(focus) = cursor.position_in(bounds) {
let lines = match delta {
mouse::ScrollDelta::Lines { y, .. } => *y,
mouse::ScrollDelta::Pixels { y, .. } => *y / 40.0,
};
if state.zoom == 0.0 { state.zoom = 1.0; }
let prev = state.zoom;
let next = (prev * ZOOM_STEP.powf(lines)).clamp(ZOOM_MIN, ZOOM_MAX);
let factor = next / prev;
state.pan.x = focus.x - factor * (focus.x - state.pan.x);
state.pan.y = focus.y - factor * (focus.y - state.pan.y);
state.zoom = next;
return Some(Action::request_redraw().and_capture());
}
}
Event::Keyboard(iced::keyboard::Event::KeyPressed { key, .. }) => {
if let iced::keyboard::Key::Character(c) = key {
if c.as_str().eq_ignore_ascii_case("r") {
*state = ViewState::default();
return Some(Action::request_redraw());
}
}
}
_ => {}
}
None
}
fn draw(
&self,
state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry<Renderer>> {
let mut frame = Frame::new(renderer, bounds.size());
frame.fill_rectangle(Point::ORIGIN, bounds.size(), BG);
let view = ViewTransform::fit(self.doc, bounds, state);
for s in &self.doc.segments {
if let (Some(p0), Some(p1)) =
(self.doc.nodes.get(s.n0 as usize), self.doc.nodes.get(s.n1 as usize))
{
let a = view.map(p0.x, p0.y);
let b = view.map(p1.x, p1.y);
frame.stroke(&Path::line(a, b),
Stroke::default().with_width(STROKE_WIDTH).with_color(GEOM));
}
}
for a in &self.doc.arcs {
if let (Some(p0), Some(p1)) =
(self.doc.nodes.get(a.n0 as usize), self.doc.nodes.get(a.n1 as usize))
{
if let Some((center, radius, start_angle, end_angle)) =
arc_geometry(p0.x, p0.y, p1.x, p1.y, a.arc_length, a.normal_direction)
{
let cx = view.map(center.0, center.1);
let r_px = (radius * view.scale) as f32;
let mut b = Builder::new();
b.arc(canvas::path::Arc {
center: cx,
radius: r_px,
start_angle,
end_angle,
});
frame.stroke(&b.build(),
Stroke::default().with_width(STROKE_WIDTH).with_color(GEOM));
} else {
let a_px = view.map(p0.x, p0.y);
let b_px = view.map(p1.x, p1.y);
frame.stroke(&Path::line(a_px, b_px),
Stroke::default().with_width(STROKE_WIDTH).with_color(GEOM));
}
}
}
for n in &self.doc.nodes {
let p = view.map(n.x, n.y);
frame.fill(&Path::circle(p, NODE_RADIUS), GEOM);
}
for label in &self.doc.block_labels {
let p = view.map(label.x, label.y);
let cross = Path::new(|b| {
b.move_to(Point::new(p.x - LABEL_TICK_PX, p.y));
b.line_to(Point::new(p.x + LABEL_TICK_PX, p.y));
b.move_to(Point::new(p.x, p.y - LABEL_TICK_PX));
b.line_to(Point::new(p.x, p.y + LABEL_TICK_PX));
});
frame.stroke(&cross,
Stroke::default().with_width(STROKE_WIDTH).with_color(LABEL_COLOR));
frame.fill_text(Text {
content: label.block_type.clone(),
position: Point::new(p.x + LABEL_TICK_PX + 4.0, p.y - 8.0),
color: LABEL_COLOR,
size: 12.0.into(),
..Text::default()
});
}
vec![frame.into_geometry()]
}
fn mouse_interaction(
&self,
state: &Self::State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if state.drag_origin.is_some() {
return mouse::Interaction::Grabbing;
}
if cursor.position_in(bounds).is_some() {
return match self.tool {
Tool::Select => mouse::Interaction::Grab,
Tool::AddNode | Tool::AddBlockLabel => mouse::Interaction::Crosshair,
};
}
mouse::Interaction::default()
}
}
/// affine map from doc coordinates to canvas pixels, composing fit-to-view with user pan/zoom.
struct ViewTransform {
scale: f64,
offset: Vector,
y_max: f64,
}
impl ViewTransform {
fn fit(doc: &FemmDoc, bounds: Rectangle, view: &ViewState) -> Self {
let (xmin, xmax, ymin, ymax) = doc_bounds(doc);
let dx = (xmax - xmin).max(1e-9);
let dy = (ymax - ymin).max(1e-9);
let avail_w = (bounds.width as f64 - 2.0 * PADDING_PX as f64).max(1.0);
let avail_h = (bounds.height as f64 - 2.0 * PADDING_PX as f64).max(1.0);
let base_scale = (avail_w / dx).min(avail_h / dy);
let user_zoom = if view.zoom <= 0.0 { 1.0 } else { view.zoom };
let scale = base_scale * user_zoom as f64;
let drawn_w = dx * scale;
let drawn_h = dy * scale;
let pad_x = (bounds.width as f64 - drawn_w) / 2.0 - xmin * scale + view.pan.x as f64;
let pad_y = (bounds.height as f64 - drawn_h) / 2.0 + view.pan.y as f64;
let _ = ymin;
Self {
scale,
offset: Vector::new(pad_x as f32, pad_y as f32),
y_max: ymax,
}
}
fn map(&self, x: f64, y: f64) -> Point {
let px = (x * self.scale) as f32 + self.offset.x;
let py = ((self.y_max - y) * self.scale) as f32 + self.offset.y;
Point::new(px, py)
}
/// inverse of [`map`]: converts a canvas pixel back to a doc-world coordinate.
fn inverse_map(&self, p: Point) -> (f64, f64) {
let x = (p.x - self.offset.x) as f64 / self.scale;
let y = self.y_max - (p.y - self.offset.y) as f64 / self.scale;
(x, y)
}
}
fn doc_bounds(doc: &FemmDoc) -> (f64, f64, f64, f64) {
let mut xmin = f64::INFINITY;
let mut xmax = f64::NEG_INFINITY;
let mut ymin = f64::INFINITY;
let mut ymax = f64::NEG_INFINITY;
let mut had_point = false;
for n in &doc.nodes {
xmin = xmin.min(n.x); xmax = xmax.max(n.x);
ymin = ymin.min(n.y); ymax = ymax.max(n.y);
had_point = true;
}
for l in &doc.block_labels {
xmin = xmin.min(l.x); xmax = xmax.max(l.x);
ymin = ymin.min(l.y); ymax = ymax.max(l.y);
had_point = true;
}
if !had_point { return (-1.0, 1.0, -1.0, 1.0); }
if (xmax - xmin).abs() < 1e-9 { xmin -= 0.5; xmax += 0.5; }
if (ymax - ymin).abs() < 1e-9 { ymin -= 0.5; ymax += 0.5; }
(xmin, xmax, ymin, ymax)
}
/// derives center, radius, and angular extent of an arc segment from its endpoints
/// and degree sweep. returns None when endpoints coincide.
fn arc_geometry(
x0: f64, y0: f64,
x1: f64, y1: f64,
arc_length_deg: f64,
_normal_direction: bool,
) -> Option<((f64, f64), f64, Radians, Radians)> {
let theta = arc_length_deg.to_radians();
if theta.abs() < 1e-9 { return None; }
let dx = x1 - x0;
let dy = y1 - y0;
let chord = (dx * dx + dy * dy).sqrt();
if chord < 1e-12 { return None; }
let radius = chord / (2.0 * (theta / 2.0).sin().abs());
let mid_x = (x0 + x1) / 2.0;
let mid_y = (y0 + y1) / 2.0;
let perp_x = -dy / chord;
let perp_y = dx / chord;
let h = radius * (theta / 2.0).cos();
let sign = if theta > 0.0 { 1.0 } else { -1.0 };
let cx = mid_x + sign * perp_x * h;
let cy = mid_y + sign * perp_y * h;
let a0 = (-(y0 - cy)).atan2(x0 - cx);
let a1 = (-(y1 - cy)).atan2(x1 - cx);
Some(((cx, cy), radius, Radians(a0 as f32), Radians(a1 as f32)))
}