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

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)))
}