764 lines
30 KiB
Rust
764 lines
30 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, LengthUnit};
|
|
use femm_doc_mag::ans::MagSolution;
|
|
use femm_doc_mag::mesh::Mesh;
|
|
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;
|
|
pub const ZOOM_MIN: f32 = 0.05;
|
|
pub const ZOOM_MAX: f32 = 200.0;
|
|
const CLICK_DRAG_THRESHOLD_PX: f32 = 4.0;
|
|
const PICK_RADIUS_PX: f32 = 10.0;
|
|
const MARQUEE_FILL: Color = Color::from_rgba(0.45, 0.65, 0.95, 0.12);
|
|
const MARQUEE_STROKE_COLOR: Color = Color::from_rgba(0.45, 0.65, 0.95, 0.9);
|
|
const LABEL_COLOR: Color = Color::from_rgb(0.45, 0.65, 0.95);
|
|
const PENDING_COLOR: Color = Color::from_rgb(0.85, 0.30, 0.30);
|
|
const SELECT_COLOR: Color = Color::from_rgb(0.95, 0.20, 0.20);
|
|
const SELECT_STROKE: f32 = 2.4;
|
|
const MESH_COLOR: Color = Color::from_rgba(0.55, 0.55, 0.55, 0.55);
|
|
const MESH_STROKE: f32 = 0.5;
|
|
const FLUX_LINE_COLOR: Color = Color::from_rgba(0.0, 0.0, 0.0, 0.85);
|
|
const FLUX_LINE_STROKE: f32 = 0.6;
|
|
const BAND_COUNT: usize = 20;
|
|
const FLUX_LINE_COUNT: usize = 19;
|
|
const GRID_COLOR: Color = Color::from_rgba(0.5, 0.5, 0.5, 0.25);
|
|
const GRID_STROKE: f32 = 0.5;
|
|
const GRID_MIN_PX_SPACING: f32 = 4.0;
|
|
|
|
/// field-plot mode applied on top of the FE solution.
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RenderMode {
|
|
/// smooth |B| jet fill across every element.
|
|
Density,
|
|
/// 20-band |B| jet fill plus 19 iso-A flux lines.
|
|
#[default]
|
|
Contour,
|
|
}
|
|
|
|
/// active editing mode on the canvas.
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Tool {
|
|
#[default]
|
|
Select,
|
|
AddNode,
|
|
AddBlockLabel,
|
|
AddSegment,
|
|
}
|
|
|
|
/// composition mode for a pick action, derived from modifier keys at click/release time.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PickOp {
|
|
Replace,
|
|
Add,
|
|
Toggle,
|
|
}
|
|
|
|
/// restricts the entity-kind set considered by a pick.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PickRestrict {
|
|
Any,
|
|
NodesOnly,
|
|
}
|
|
|
|
/// messages emitted by the canvas back to the app.
|
|
#[derive(Debug, Clone)]
|
|
pub enum CanvasMessage {
|
|
/// click at a doc-world coordinate under a non-Select tool.
|
|
Click { world: (f64, f64), tool: Tool },
|
|
/// two-point segment request from the canvas.
|
|
SegmentBetween { from: (f64, f64), to: (f64, f64) },
|
|
/// left-click selection at a world point with modifier-derived op and restrict.
|
|
PickAt { world: (f64, f64), pick_radius_world: f64, op: PickOp, restrict: PickRestrict },
|
|
/// left-drag marquee selection between two world points with modifier-derived op and restrict.
|
|
PickRect { p0: (f64, f64), p1: (f64, f64), op: PickOp, restrict: PickRestrict },
|
|
/// Delete key in Select mode, removing every selected entity from the doc.
|
|
DeleteSelected,
|
|
/// drag delta in canvas pixels accumulating into app-side pan.
|
|
PanBy { dx: f32, dy: f32 },
|
|
/// wheel-zoom factor centred on a cursor offset measured from canvas centre.
|
|
ZoomAt { factor: f32, focus_rel: Point },
|
|
/// resets pan and zoom to the natural fit.
|
|
ResetView,
|
|
/// canvas reports its current pixel bounds for app-side view math.
|
|
ViewportSize { width: f32, height: f32 },
|
|
/// canvas-computed view replacement, used by zoom-window and other bounds-aware operations.
|
|
SetView { pan: Vector, zoom: f32 },
|
|
}
|
|
|
|
/// pan offset and zoom factor owned by the app shell.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct ViewState {
|
|
pub pan: Vector,
|
|
pub zoom: f32,
|
|
}
|
|
|
|
/// canvas-local click, drag, marquee, and modifier bookkeeping.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct CanvasState {
|
|
pan_drag_from: Option<Point>,
|
|
marquee_from: Option<Point>,
|
|
press_origin: Option<Point>,
|
|
dragged: bool,
|
|
pending_segment_start: Option<(f64, f64)>,
|
|
cursor_world: Option<(f64, f64)>,
|
|
cursor_canvas: Option<Point>,
|
|
modifiers: iced::keyboard::Modifiers,
|
|
last_reported_size: Option<(f32, f32)>,
|
|
}
|
|
|
|
/// constructs the canvas widget for a doc reference, optional mesh overlay, and optional solution.
|
|
pub fn view<'a>(
|
|
doc: &'a FemmDoc,
|
|
tool: Tool,
|
|
mesh: Option<&'a Mesh>,
|
|
solution: Option<&'a MagSolution>,
|
|
render_mode: RenderMode,
|
|
view_state: ViewState,
|
|
show_grid: bool,
|
|
zoom_window_active: bool,
|
|
) -> Element<'a, CanvasMessage> {
|
|
Canvas::new(DocCanvas {
|
|
doc, tool, mesh, solution, render_mode, view_state, show_grid, zoom_window_active,
|
|
})
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
struct DocCanvas<'a> {
|
|
doc: &'a FemmDoc,
|
|
tool: Tool,
|
|
mesh: Option<&'a Mesh>,
|
|
solution: Option<&'a MagSolution>,
|
|
render_mode: RenderMode,
|
|
view_state: ViewState,
|
|
show_grid: bool,
|
|
zoom_window_active: bool,
|
|
}
|
|
|
|
impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
|
|
type State = CanvasState;
|
|
|
|
fn update(
|
|
&self,
|
|
state: &mut Self::State,
|
|
event: &Event,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> Option<Action<CanvasMessage>> {
|
|
match event {
|
|
Event::Keyboard(iced::keyboard::Event::ModifiersChanged(m)) => {
|
|
state.modifiers = *m;
|
|
}
|
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
|
|
if let Some(p) = cursor.position_in(bounds) {
|
|
state.pan_drag_from = Some(p);
|
|
return Some(Action::capture());
|
|
}
|
|
}
|
|
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => {
|
|
state.pan_drag_from = None;
|
|
return Some(Action::capture());
|
|
}
|
|
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 || self.zoom_window_active {
|
|
state.marquee_from = Some(p);
|
|
}
|
|
return Some(Action::capture());
|
|
}
|
|
}
|
|
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
|
let press = state.press_origin.take();
|
|
let marquee_start = state.marquee_from.take();
|
|
let was_dragged = std::mem::take(&mut state.dragged);
|
|
let now_opt = cursor.position_in(bounds);
|
|
|
|
if self.zoom_window_active {
|
|
if let (Some(start), Some(now)) = (marquee_start, now_opt) {
|
|
if was_dragged {
|
|
let view = ViewTransform::fit(self.doc, bounds, &self.view_state);
|
|
let p0 = view.inverse_map(start);
|
|
let p1 = view.inverse_map(now);
|
|
let (xmin, xmax) = (p0.0.min(p1.0), p0.0.max(p1.0));
|
|
let (ymin, ymax) = (p0.1.min(p1.1), p0.1.max(p1.1));
|
|
if let Some((pan, zoom)) = zoom_window_view(self.doc, bounds, xmin, xmax, ymin, ymax) {
|
|
return Some(Action::publish(CanvasMessage::SetView { pan, zoom }).and_capture());
|
|
}
|
|
}
|
|
}
|
|
return Some(Action::capture());
|
|
}
|
|
|
|
if self.tool == Tool::Select {
|
|
if let (Some(start), Some(now)) = (marquee_start, now_opt) {
|
|
let view = ViewTransform::fit(self.doc, bounds, &self.view_state);
|
|
let op = pick_op_from(state.modifiers);
|
|
let restrict = pick_restrict_from(state.modifiers);
|
|
if was_dragged {
|
|
let p0 = view.inverse_map(start);
|
|
let p1 = view.inverse_map(now);
|
|
return Some(Action::publish(CanvasMessage::PickRect {
|
|
p0, p1, op, restrict,
|
|
}).and_capture());
|
|
} else {
|
|
let world = view.inverse_map(now);
|
|
let pick_radius_world = (PICK_RADIUS_PX as f64) / view.scale.max(1e-9);
|
|
return Some(Action::publish(CanvasMessage::PickAt {
|
|
world, pick_radius_world, op, restrict,
|
|
}).and_capture());
|
|
}
|
|
}
|
|
return Some(Action::capture());
|
|
}
|
|
|
|
if !was_dragged {
|
|
if let (Some(start), Some(now)) = (press, now_opt) {
|
|
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, &self.view_state);
|
|
let world = view.inverse_map(now);
|
|
if self.tool == Tool::AddSegment {
|
|
if let Some(from) = state.pending_segment_start.take() {
|
|
return Some(Action::publish(
|
|
CanvasMessage::SegmentBetween { from, to: world },
|
|
));
|
|
}
|
|
state.pending_segment_start = Some(world);
|
|
return Some(Action::request_redraw().and_capture());
|
|
}
|
|
return Some(Action::publish(CanvasMessage::Click {
|
|
world,
|
|
tool: self.tool,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
if press.is_some() {
|
|
return Some(Action::capture());
|
|
}
|
|
}
|
|
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
|
let current_size = (bounds.width, bounds.height);
|
|
if state.last_reported_size != Some(current_size) {
|
|
state.last_reported_size = Some(current_size);
|
|
return Some(Action::publish(CanvasMessage::ViewportSize {
|
|
width: bounds.width, height: bounds.height,
|
|
}));
|
|
}
|
|
if let Some(now) = cursor.position_in(bounds) {
|
|
let view = ViewTransform::fit(self.doc, bounds, &self.view_state);
|
|
state.cursor_world = Some(view.inverse_map(now));
|
|
state.cursor_canvas = Some(now);
|
|
} else {
|
|
state.cursor_world = None;
|
|
state.cursor_canvas = None;
|
|
}
|
|
if let (Some(prev), Some(now)) = (state.pan_drag_from, cursor.position_in(bounds)) {
|
|
let dx = now.x - prev.x;
|
|
let dy = now.y - prev.y;
|
|
state.pan_drag_from = Some(now);
|
|
return Some(Action::publish(CanvasMessage::PanBy { dx, dy }).and_capture());
|
|
}
|
|
if let (Some(start), Some(now)) = (state.marquee_from, 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;
|
|
return Some(Action::request_redraw());
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
if state.pending_segment_start.is_some() {
|
|
return Some(Action::request_redraw());
|
|
}
|
|
}
|
|
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,
|
|
};
|
|
let factor = ZOOM_STEP.powf(lines);
|
|
let focus_rel = Point::new(
|
|
focus.x - bounds.width * 0.5,
|
|
focus.y - bounds.height * 0.5,
|
|
);
|
|
return Some(Action::publish(CanvasMessage::ZoomAt { factor, focus_rel }).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 = CanvasState::default();
|
|
return Some(Action::publish(CanvasMessage::ResetView));
|
|
}
|
|
}
|
|
if matches!(
|
|
key,
|
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
|
) {
|
|
if state.pending_segment_start.take().is_some() {
|
|
return Some(Action::request_redraw());
|
|
}
|
|
}
|
|
if matches!(
|
|
key,
|
|
iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete)
|
|
| iced::keyboard::Key::Named(iced::keyboard::key::Named::Backspace),
|
|
) {
|
|
if self.tool == Tool::Select {
|
|
return Some(Action::publish(CanvasMessage::DeleteSelected));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn draw(
|
|
&self,
|
|
state: &Self::State,
|
|
renderer: &Renderer,
|
|
theme: &Theme,
|
|
bounds: Rectangle,
|
|
_cursor: mouse::Cursor,
|
|
) -> Vec<Geometry<Renderer>> {
|
|
let palette = theme.palette();
|
|
let bg = palette.background;
|
|
let geom = palette.text;
|
|
let mut frame = Frame::new(renderer, bounds.size());
|
|
frame.fill_rectangle(Point::ORIGIN, bounds.size(), bg);
|
|
|
|
let view = ViewTransform::fit(self.doc, bounds, &self.view_state);
|
|
|
|
if self.show_grid {
|
|
draw_grid(&mut frame, bounds, &view, self.doc.length_units);
|
|
}
|
|
|
|
if let Some(sol) = self.solution {
|
|
let (lo, hi) = sol.b_magnitude_range();
|
|
let span = if hi > lo { hi - lo } else { 1.0 };
|
|
let banded = matches!(self.render_mode, RenderMode::Contour);
|
|
for i in 0..sol.mesh_elements.len() {
|
|
let el = &sol.mesh_elements[i];
|
|
let (Some(a), Some(b), Some(c)) = (
|
|
sol.mesh_nodes.get(el.n[0] as usize),
|
|
sol.mesh_nodes.get(el.n[1] as usize),
|
|
sol.mesh_nodes.get(el.n[2] as usize),
|
|
) else { continue };
|
|
let pa = view.map(a.x, a.y);
|
|
let pb = view.map(b.x, b.y);
|
|
let pc = view.map(c.x, c.y);
|
|
let t = ((sol.b_magnitude(i) - lo) / span).clamp(0.0, 1.0);
|
|
let t_mapped = if banded {
|
|
let band = (t * BAND_COUNT as f64).floor().min(BAND_COUNT as f64 - 1.0);
|
|
(band + 0.5) / BAND_COUNT as f64
|
|
} else {
|
|
t
|
|
};
|
|
let color = jet(t_mapped as f32);
|
|
let tri = Path::new(|p| {
|
|
p.move_to(pa);
|
|
p.line_to(pb);
|
|
p.line_to(pc);
|
|
p.close();
|
|
});
|
|
frame.fill(&tri, color);
|
|
}
|
|
if matches!(self.render_mode, RenderMode::Contour) {
|
|
let (a_lo, a_hi) = sol.a_real_range();
|
|
if a_hi > a_lo {
|
|
let mut levels = Vec::with_capacity(FLUX_LINE_COUNT);
|
|
let denom = (FLUX_LINE_COUNT + 1) as f64;
|
|
for k in 1..=FLUX_LINE_COUNT {
|
|
levels.push(a_lo + (a_hi - a_lo) * (k as f64) / denom);
|
|
}
|
|
let stroke = Stroke::default()
|
|
.with_width(FLUX_LINE_STROKE)
|
|
.with_color(FLUX_LINE_COLOR);
|
|
for (p0, p1) in sol.flux_lines(&levels) {
|
|
let a = view.map(p0.0, p0.1);
|
|
let b = view.map(p1.0, p1.1);
|
|
frame.stroke(&Path::line(a, b), stroke.clone());
|
|
}
|
|
}
|
|
}
|
|
} else if let Some(mesh) = self.mesh {
|
|
for el in &mesh.elements {
|
|
let (Some(a), Some(b), Some(c)) = (
|
|
mesh.nodes.get(el.v0 as usize),
|
|
mesh.nodes.get(el.v1 as usize),
|
|
mesh.nodes.get(el.v2 as usize),
|
|
) else { continue };
|
|
let pa = view.map(a.x, a.y);
|
|
let pb = view.map(b.x, b.y);
|
|
let pc = view.map(c.x, c.y);
|
|
let tri = Path::new(|p| {
|
|
p.move_to(pa);
|
|
p.line_to(pb);
|
|
p.line_to(pc);
|
|
p.close();
|
|
});
|
|
frame.stroke(&tri,
|
|
Stroke::default().with_width(MESH_STROKE).with_color(MESH_COLOR));
|
|
}
|
|
}
|
|
|
|
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);
|
|
let (color, width) = if s.selected {
|
|
(SELECT_COLOR, SELECT_STROKE)
|
|
} else {
|
|
(geom, STROKE_WIDTH)
|
|
};
|
|
frame.stroke(&Path::line(a, b),
|
|
Stroke::default().with_width(width).with_color(color));
|
|
}
|
|
}
|
|
|
|
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))
|
|
{
|
|
let (color, width) = if a.selected {
|
|
(SELECT_COLOR, SELECT_STROKE)
|
|
} else {
|
|
(geom, STROKE_WIDTH)
|
|
};
|
|
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(width).with_color(color));
|
|
} 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(width).with_color(color));
|
|
}
|
|
}
|
|
}
|
|
|
|
for n in &self.doc.nodes {
|
|
let p = view.map(n.x, n.y);
|
|
let (color, r) = if n.selected {
|
|
(SELECT_COLOR, NODE_RADIUS + 2.0)
|
|
} else {
|
|
(geom, NODE_RADIUS)
|
|
};
|
|
frame.fill(&Path::circle(p, r), color);
|
|
}
|
|
|
|
if let Some(start_world) = state.pending_segment_start {
|
|
let sp = view.map(start_world.0, start_world.1);
|
|
let ring = Path::new(|b| {
|
|
b.circle(sp, NODE_RADIUS + 2.0);
|
|
});
|
|
frame.stroke(&ring,
|
|
Stroke::default().with_width(STROKE_WIDTH).with_color(PENDING_COLOR));
|
|
if let Some(cursor_world) = state.cursor_world {
|
|
let cp = view.map(cursor_world.0, cursor_world.1);
|
|
frame.stroke(&Path::line(sp, cp),
|
|
Stroke::default().with_width(STROKE_WIDTH).with_color(PENDING_COLOR));
|
|
}
|
|
}
|
|
|
|
for label in &self.doc.block_labels {
|
|
let p = view.map(label.x, label.y);
|
|
let (color, width) = if label.selected {
|
|
(SELECT_COLOR, SELECT_STROKE)
|
|
} else {
|
|
(LABEL_COLOR, STROKE_WIDTH)
|
|
};
|
|
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(width).with_color(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,
|
|
size: 12.0.into(),
|
|
..Text::default()
|
|
});
|
|
}
|
|
|
|
if let (Some(start), true, Some(now)) =
|
|
(state.marquee_from, state.dragged, state.cursor_canvas)
|
|
{
|
|
let xmin = start.x.min(now.x);
|
|
let ymin = start.y.min(now.y);
|
|
let w = (start.x - now.x).abs();
|
|
let h = (start.y - now.y).abs();
|
|
let rect = Path::rectangle(Point::new(xmin, ymin), iced::Size::new(w, h));
|
|
frame.fill(&rect, MARQUEE_FILL);
|
|
frame.stroke(&rect, Stroke::default().with_width(1.0).with_color(MARQUEE_STROKE_COLOR));
|
|
}
|
|
|
|
vec![frame.into_geometry()]
|
|
}
|
|
|
|
fn mouse_interaction(
|
|
&self,
|
|
state: &Self::State,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> mouse::Interaction {
|
|
if state.pan_drag_from.is_some() {
|
|
return mouse::Interaction::Grabbing;
|
|
}
|
|
if state.marquee_from.is_some() && state.dragged {
|
|
return mouse::Interaction::Crosshair;
|
|
}
|
|
if cursor.position_in(bounds).is_some() {
|
|
return match self.tool {
|
|
Tool::Select => mouse::Interaction::Grab,
|
|
Tool::AddNode | Tool::AddBlockLabel | Tool::AddSegment => {
|
|
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)
|
|
}
|
|
|
|
/// converts a canvas pixel back into 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)
|
|
}
|
|
}
|
|
|
|
/// computes the pan and zoom values that frame a world rectangle inside the canvas with default padding.
|
|
pub fn zoom_window_view(
|
|
doc: &FemmDoc,
|
|
bounds: Rectangle,
|
|
xmin: f64, xmax: f64, ymin: f64, ymax: f64,
|
|
) -> Option<(Vector, f32)> {
|
|
let (dxmin, dxmax, dymin, dymax) = doc_bounds(doc);
|
|
let dx = (dxmax - dxmin).max(1e-9);
|
|
let dy = (dymax - dymin).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 rw = (xmax - xmin).max(1e-9);
|
|
let rh = (ymax - ymin).max(1e-9);
|
|
if !rw.is_finite() || !rh.is_finite() { return None; }
|
|
let target_scale = (avail_w / rw).min(avail_h / rh);
|
|
let zoom = (target_scale / base_scale) as f32;
|
|
let zoom = zoom.clamp(ZOOM_MIN, ZOOM_MAX);
|
|
|
|
let doc_cx = (dxmin + dxmax) * 0.5;
|
|
let doc_cy = (dymin + dymax) * 0.5;
|
|
let r_cx = (xmin + xmax) * 0.5;
|
|
let r_cy = (ymin + ymax) * 0.5;
|
|
let scale_effective = base_scale * (zoom as f64);
|
|
let pan_x = (doc_cx - r_cx) * scale_effective;
|
|
let pan_y = (r_cy - doc_cy) * scale_effective;
|
|
Some((Vector::new(pan_x as f32, pan_y as f32), zoom))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// maps a normalized t in [0, 1] onto the jet colormap (blue, cyan, green, yellow, red).
|
|
/// derives the pick composition mode from a modifier snapshot.
|
|
fn pick_op_from(m: iced::keyboard::Modifiers) -> PickOp {
|
|
if m.shift() { PickOp::Add }
|
|
else if m.command() { PickOp::Toggle }
|
|
else { PickOp::Replace }
|
|
}
|
|
|
|
/// derives the pick-restrict mode from a modifier snapshot.
|
|
fn pick_restrict_from(m: iced::keyboard::Modifiers) -> PickRestrict {
|
|
if m.alt() { PickRestrict::NodesOnly } else { PickRestrict::Any }
|
|
}
|
|
|
|
fn jet(t: f32) -> Color {
|
|
let t = t.clamp(0.0, 1.0);
|
|
let (r, g, b) = if t < 0.25 {
|
|
let s = t * 4.0;
|
|
(0.0, s, 1.0)
|
|
} else if t < 0.5 {
|
|
let s = (t - 0.25) * 4.0;
|
|
(0.0, 1.0, 1.0 - s)
|
|
} else if t < 0.75 {
|
|
let s = (t - 0.5) * 4.0;
|
|
(s, 1.0, 0.0)
|
|
} else {
|
|
let s = (t - 0.75) * 4.0;
|
|
(1.0, 1.0 - s, 0.0)
|
|
};
|
|
Color::from_rgb(r, g, b)
|
|
}
|
|
|
|
/// returns the world-space distance equal to one centimetre under the given length unit.
|
|
pub fn grid_step_for_unit(unit: LengthUnit) -> f64 {
|
|
match unit {
|
|
LengthUnit::Inches => 1.0 / 2.54,
|
|
LengthUnit::Millimeters => 10.0,
|
|
LengthUnit::Centimeters => 1.0,
|
|
LengthUnit::Meters => 0.01,
|
|
LengthUnit::Mils => 1000.0 / 2.54,
|
|
LengthUnit::Microns => 10_000.0,
|
|
}
|
|
}
|
|
|
|
/// rounds a world-space coordinate to the nearest 1-cm grid step.
|
|
pub fn snap_world(x: f64, unit: LengthUnit) -> f64 {
|
|
let step = grid_step_for_unit(unit);
|
|
if step <= 0.0 { x } else { (x / step).round() * step }
|
|
}
|
|
|
|
/// renders a faint 1-cm grid under the field plot.
|
|
fn draw_grid(frame: &mut Frame, bounds: Rectangle, view: &ViewTransform, unit: LengthUnit) {
|
|
let step = grid_step_for_unit(unit);
|
|
if step <= 0.0 { return; }
|
|
let px_per_step = (step * view.scale) as f32;
|
|
if px_per_step < GRID_MIN_PX_SPACING { return; }
|
|
|
|
let (x0_world, y1_world) = view.inverse_map(Point::new(0.0, 0.0));
|
|
let (x1_world, y0_world) = view.inverse_map(Point::new(bounds.width, bounds.height));
|
|
let stroke = Stroke::default().with_width(GRID_STROKE).with_color(GRID_COLOR);
|
|
|
|
let i_start = (x0_world / step).floor() as i32;
|
|
let i_end = (x1_world / step).ceil() as i32;
|
|
for i in i_start..=i_end {
|
|
let wx = i as f64 * step;
|
|
let top = view.map(wx, y1_world);
|
|
let bot = view.map(wx, y0_world);
|
|
frame.stroke(&Path::line(top, bot), stroke.clone());
|
|
}
|
|
|
|
let j_start = (y0_world / step).floor() as i32;
|
|
let j_end = (y1_world / step).ceil() as i32;
|
|
for j in j_start..=j_end {
|
|
let wy = j as f64 * step;
|
|
let left = view.map(x0_world, wy);
|
|
let right = view.map(x1_world, wy);
|
|
frame.stroke(&Path::line(left, right), stroke.clone());
|
|
}
|
|
}
|
|
|
|
/// derives center, radius, and angular extent of an arc segment from its endpoints and degree sweep, returning None on coincident endpoints.
|
|
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)))
|
|
}
|