diff --git a/crates/femm-app/src/doc_canvas.rs b/crates/femm-app/src/doc_canvas.rs index d1fba08..7b8065f 100644 --- a/crates/femm-app/src/doc_canvas.rs +++ b/crates/femm-app/src/doc_canvas.rs @@ -17,6 +17,7 @@ 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); +const PENDING_COLOR: Color = Color::from_rgb(0.85, 0.30, 0.30); /// active editing mode on the canvas. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -25,16 +26,19 @@ pub enum Tool { Select, AddNode, AddBlockLabel, + AddSegment, } /// 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 at a doc-world coordinate. Click { world: (f64, f64), tool: Tool }, + /// two-point segment request from the canvas. + SegmentBetween { from: (f64, f64), to: (f64, f64) }, } -/// pan offset and zoom factor applied on top of fit-to-view. +/// pan offset, zoom factor, and click-gesture bookkeeping for the canvas. #[derive(Debug, Default, Clone, Copy)] pub struct ViewState { pan: Vector, @@ -42,6 +46,8 @@ pub struct ViewState { drag_origin: Option, press_origin: Option, dragged: bool, + pending_segment_start: Option<(f64, f64)>, + cursor_world: Option<(f64, f64)>, } /// constructs the canvas widget for a doc reference. @@ -89,6 +95,15 @@ impl<'a> canvas::Program for DocCanvas<'a> { { let view = ViewTransform::fit(self.doc, bounds, 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, @@ -101,6 +116,12 @@ impl<'a> canvas::Program for DocCanvas<'a> { } } Event::Mouse(mouse::Event::CursorMoved { .. }) => { + if let Some(now) = cursor.position_in(bounds) { + let view = ViewTransform::fit(self.doc, bounds, state); + state.cursor_world = Some(view.inverse_map(now)); + } else { + state.cursor_world = None; + } 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); @@ -114,6 +135,9 @@ impl<'a> canvas::Program for DocCanvas<'a> { 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) { @@ -138,6 +162,11 @@ impl<'a> canvas::Program for DocCanvas<'a> { return Some(Action::request_redraw()); } } + 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()); + } + } } _ => {} } @@ -200,6 +229,20 @@ impl<'a> canvas::Program for DocCanvas<'a> { frame.fill(&Path::circle(p, NODE_RADIUS), GEOM); } + 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 cross = Path::new(|b| { @@ -234,7 +277,9 @@ impl<'a> canvas::Program for DocCanvas<'a> { if cursor.position_in(bounds).is_some() { return match self.tool { Tool::Select => mouse::Interaction::Grab, - Tool::AddNode | Tool::AddBlockLabel => mouse::Interaction::Crosshair, + Tool::AddNode | Tool::AddBlockLabel | Tool::AddSegment => { + mouse::Interaction::Crosshair + } }; } mouse::Interaction::default() diff --git a/crates/femm-app/src/main.rs b/crates/femm-app/src/main.rs index 58c002b..a5179c3 100644 --- a/crates/femm-app/src/main.rs +++ b/crates/femm-app/src/main.rs @@ -79,7 +79,19 @@ impl App { let idx = self.doc.add_block_label(world.0, world.1, ADD_TOLERANCE); self.status = format!("block label {idx} at ({:.3}, {:.3})", world.0, world.1); } - Tool::Select => {} + Tool::Select | Tool::AddSegment => {} + } + } + Message::Canvas(CanvasMessage::SegmentBetween { from, to }) => { + let n0 = self.doc.add_node(from.0, from.1, ADD_TOLERANCE) as i32; + let n1 = self.doc.add_node(to.0, to.1, ADD_TOLERANCE) as i32; + if self.doc.add_segment(n0, n1) { + self.status = format!( + "segment {n0} -> {n1} ({} total)", + self.doc.segments.len(), + ); + } else { + self.status = format!("rejected segment {n0} -> {n1}"); } } } @@ -98,9 +110,10 @@ impl App { let toolbar = row![ button("Open .fem...").on_press(Message::OpenFem), - tool_button("Select", Tool::Select, self.tool), - tool_button("Add Node", Tool::AddNode, self.tool), - tool_button("Add Label", Tool::AddBlockLabel, self.tool), + tool_button("Select", Tool::Select, self.tool), + tool_button("Add Node", Tool::AddNode, self.tool), + tool_button("Add Segment", Tool::AddSegment, self.tool), + tool_button("Add Label", Tool::AddBlockLabel, self.tool), text(&self.source_label).size(13), stats, ] diff --git a/crates/femm-doc-mag/src/edit.rs b/crates/femm-doc-mag/src/edit.rs index 336bfec..0fd5b3f 100644 --- a/crates/femm-doc-mag/src/edit.rs +++ b/crates/femm-doc-mag/src/edit.rs @@ -186,7 +186,7 @@ impl FemmDoc { self.closest_node(p0.0, p0.1), self.closest_node(p1.0, p1.1), ) else { continue }; - self.add_arc_segment(n0 as i32, n1 as i32, a.arc_length); + self.add_arc_segment_with_template(n0 as i32, n1 as i32, a.arc_length, &a); } self.block_labels = old_block_labels; @@ -508,6 +508,37 @@ mod tests { assert_eq!(d.segments[0].boundary_marker, "outer"); } + #[test] + fn enforce_pslg_preserves_arc_metadata() { + // arc carrying non-default marker, side length, group, and direction. + // enforce_pslg round-trips every field intact. + let mut d = FemmDoc::default(); + d.add_node(1.0, 0.0, 0.0); + d.add_node(0.0, 1.0, 0.0); + let template = ArcSegment { + n0: 0, + n1: 1, + arc_length: 90.0, + max_side_length: 5.0, + boundary_marker: String::from("outer"), + hidden: true, + in_group: 7, + normal_direction: false, + selected: false, + }; + assert!(d.add_arc_segment_with_template(0, 1, 90.0, &template)); + + d.enforce_pslg(); + + assert_eq!(d.arcs.len(), 1); + let a = &d.arcs[0]; + assert_eq!(a.boundary_marker, "outer"); + assert!((a.max_side_length - 5.0).abs() < 1e-12); + assert!(a.hidden); + assert_eq!(a.in_group, 7); + assert!(!a.normal_direction); + } + #[test] fn duplicate_arc_rejected() { let mut d = FemmDoc::default();