From a4b1b3893c2af79358886381b12df467fca925cd Mon Sep 17 00:00:00 2001 From: Ellen Emilia Anna Zscheile Date: Mon, 16 Jun 2025 20:39:04 +0200 Subject: [PATCH] feat: Add interaction stepper for route building --- committed.toml | 1 + crates/topola-egui/src/actions.rs | 4 + crates/topola-egui/src/app.rs | 65 +++-------- crates/topola-egui/src/menu_bar.rs | 23 ++-- crates/topola-egui/src/overlay.rs | 4 +- crates/topola-egui/src/painter.rs | 19 ++- crates/topola-egui/src/viewport.rs | 166 ++++++++++++++++++++------- crates/topola-egui/src/workspace.rs | 8 +- locales/en-US/main.ftl | 1 + src/autorouter/execution.rs | 9 +- src/board/mod.rs | 12 ++ src/interactor/activity.rs | 64 +++++++++-- src/interactor/interaction.rs | 80 ++++++------- src/interactor/interactor.rs | 67 +++++++---- src/interactor/mod.rs | 1 + src/interactor/route_plan.rs | 172 ++++++++++++++++++++++++++++ src/layout/layout.rs | 89 ++++++++++---- src/router/router.rs | 7 +- src/stepper.rs | 9 +- 19 files changed, 586 insertions(+), 215 deletions(-) create mode 100644 src/interactor/route_plan.rs diff --git a/committed.toml b/committed.toml index 3d12836..7212cae 100644 --- a/committed.toml +++ b/committed.toml @@ -54,6 +54,7 @@ allowed_scopes = [ "interactor/activity", "interactor/interaction", "interactor/interactor", + "interactor/route_plan", "layout/layout", "layout/poly", "layout/via", diff --git a/crates/topola-egui/src/actions.rs b/crates/topola-egui/src/actions.rs index 3fa88e5..f0069f6 100644 --- a/crates/topola-egui/src/actions.rs +++ b/crates/topola-egui/src/actions.rs @@ -235,6 +235,7 @@ impl ViewActions { pub struct PlaceActions { pub place_via: Switch, + pub place_route_plan: Trigger, } impl PlaceActions { @@ -246,6 +247,8 @@ impl PlaceActions { egui::Key::P, ) .into_switch(), + place_route_plan: Action::new_keyless(tr.text("tr-menu-place-place-route-plan")) + .into_trigger(), } } @@ -258,6 +261,7 @@ impl PlaceActions { ) -> egui::InnerResponse<()> { ui.add_enabled_ui(have_workspace, |ui| { self.place_via.toggle_widget(ui, is_placing_via); + self.place_route_plan.button(ctx, ui); }) } } diff --git a/crates/topola-egui/src/app.rs b/crates/topola-egui/src/app.rs index c99361a..ce0d900 100644 --- a/crates/topola-egui/src/app.rs +++ b/crates/topola-egui/src/app.rs @@ -2,20 +2,15 @@ // // SPDX-License-Identifier: MIT -use geo::point; use std::{ future::Future, io, - ops::ControlFlow, path::Path, sync::mpsc::{channel, Receiver, Sender}, }; use unic_langid::{langid, LanguageIdentifier}; -use topola::{ - interactor::activity::InteractiveInput, - specctra::{design::SpecctraDesign, ParseErrorContext as SpecctraLoadingError}, -}; +use topola::specctra::{design::SpecctraDesign, ParseErrorContext as SpecctraLoadingError}; use crate::{ config::Config, error_dialog::ErrorDialog, menu_bar::MenuBar, status_bar::StatusBar, @@ -37,8 +32,6 @@ pub struct App { error_dialog: ErrorDialog, maybe_workspace: Option, - - update_counter: f32, } impl Default for App { @@ -52,7 +45,6 @@ impl Default for App { status_bar: StatusBar::new(), error_dialog: ErrorDialog::new(), maybe_workspace: None, - update_counter: 0.0, } } } @@ -72,23 +64,8 @@ impl App { this } - /// Advances the app's state by the delta time `dt`. May call - /// `.update_state()` more than once if the delta time is more than a multiple of - /// the timestep. - fn advance_state_by_dt(&mut self, interactive_input: &InteractiveInput) { - self.update_counter += interactive_input.dt; - - while self.update_counter >= self.menu_bar.frame_timestep { - self.update_counter -= self.menu_bar.frame_timestep; - - if let ControlFlow::Break(()) = self.update_state(interactive_input) { - return; - } - } - } - /// Advance the app's state by a single step. - fn update_state(&mut self, interactive_input: &InteractiveInput) -> ControlFlow<()> { + fn update_state(&mut self) { // If a new design has been loaded from a file, create a new workspace // with the design. Or handle the error if there was a failure to do so. if let Ok(data) = self.content_channel.1.try_recv() { @@ -128,16 +105,6 @@ impl App { }, } } - - if let Some(workspace) = &mut self.maybe_workspace { - return workspace.update_state( - &self.translator, - &mut self.error_dialog, - interactive_input, - ); - } - - ControlFlow::Break(()) } /// Update the title displayed on the application window's frame to show the @@ -213,22 +180,7 @@ impl eframe::App for App { self.maybe_workspace.as_mut(), ); - let pointer_pos = self.viewport.transform.inverse() - * ctx.input(|i| i.pointer.latest_pos().unwrap_or_default()); - - self.advance_state_by_dt(&InteractiveInput { - pointer_pos: point! {x: pointer_pos.x as f64, y: pointer_pos.y as f64}, - dt: ctx.input(|i| i.stable_dt), - }); - - self.status_bar.update( - ctx, - &self.translator, - &self.viewport, - self.maybe_workspace - .as_ref() - .and_then(|w| w.interactor.maybe_activity().as_ref()), - ); + self.update_state(); if self.menu_bar.show_appearance_panel { if let Some(workspace) = &mut self.maybe_workspace { @@ -241,10 +193,21 @@ impl eframe::App for App { let _viewport_rect = self.viewport.update( &self.config, ctx, + &self.translator, &self.menu_bar, + &mut self.error_dialog, self.maybe_workspace.as_mut(), ); + self.status_bar.update( + ctx, + &self.translator, + &self.viewport, + self.maybe_workspace + .as_ref() + .and_then(|w| w.interactor.maybe_activity().as_ref()), + ); + self.update_locale(); self.update_title(ctx); diff --git a/crates/topola-egui/src/menu_bar.rs b/crates/topola-egui/src/menu_bar.rs index 225725d..a00e623 100644 --- a/crates/topola-egui/src/menu_bar.rs +++ b/crates/topola-egui/src/menu_bar.rs @@ -9,6 +9,7 @@ use topola::{ execution::Command, invoker::InvokerError, selection::Selection, AutorouterOptions, }, board::AccessMesadata, + interactor::{interaction::InteractionStepper, route_plan::RoutePlan}, router::RouterOptions, specctra::{design::SpecctraDesign, ParseError, ParseErrorContext as SpecctraLoadingError}, }; @@ -303,16 +304,13 @@ impl MenuBar { workspace .interactor .schedule(op(selection, self.autorouter_options)); - Ok::<(), InvokerError>(()) }; if actions.edit.remove_bands.consume_key_triggered(ctx, ui) { - schedule(|selection, _| { - Command::RemoveBands(selection.band_selection) - })?; + schedule(|selection, _| Command::RemoveBands(selection.band_selection)); } else if actions.route.autoroute.consume_key_triggered(ctx, ui) { schedule(|selection, opts| { Command::Autoroute(selection.pin_selection, opts) - })?; + }); } else if actions .inspect .compare_detours @@ -320,7 +318,7 @@ impl MenuBar { { schedule(|selection, opts| { Command::CompareDetours(selection.pin_selection, opts) - })?; + }); } else if actions .inspect .measure_length @@ -328,7 +326,18 @@ impl MenuBar { { schedule(|selection, _| { Command::MeasureLength(selection.band_selection) - })?; + }); + } else if actions + .place + .place_route_plan + .consume_key_triggered(ctx, ui) + { + if let Some(active_layer) = workspace.appearance_panel.active_layer { + self.is_placing_via = false; + workspace.interactor.interact(InteractionStepper::RoutePlan( + RoutePlan::new(active_layer), + )); + } } } } diff --git a/crates/topola-egui/src/overlay.rs b/crates/topola-egui/src/overlay.rs index 75afc87..80ea35d 100644 --- a/crates/topola-egui/src/overlay.rs +++ b/crates/topola-egui/src/overlay.rs @@ -104,8 +104,8 @@ impl Overlay { .filter_map(|node| { board .layout() - .center_of_compoundless_node(node) - .map(|pos| (node, pos)) + .apex_of_compoundless_node(node, active_layer) + .map(|(_, pos)| (node, pos)) }) .map(|(idx, pos)| TrianVertex { idx, diff --git a/crates/topola-egui/src/painter.rs b/crates/topola-egui/src/painter.rs index 2fc54e4..e1a61b5 100644 --- a/crates/topola-egui/src/painter.rs +++ b/crates/topola-egui/src/painter.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -use geo::{CoordsIter, Point, Polygon}; +use geo::{CoordsIter, LineString, Point, Polygon}; use rstar::AABB; use topola::{ geometry::{primitive::PrimitiveShape, shape::AccessShape}, @@ -89,6 +89,23 @@ impl<'a> Painter<'a> { ) } + pub fn paint_linestring(&mut self, linestring: &LineString, color: egui::epaint::Color32) { + self.ui.painter().add(egui::Shape::line( + linestring + .exterior_coords_iter() + .map(|coords| { + self.transform + .mul_pos([coords.x as f32, -coords.y as f32].into()) + }) + .collect(), + egui::epaint::PathStroke { + width: 5.0, + color: egui::epaint::ColorMode::Solid(color), + kind: egui::epaint::StrokeKind::Inside, + }, + )); + } + pub fn paint_polygon(&mut self, polygon: &Polygon, color: egui::epaint::Color32) { self.ui.painter().add(egui::Shape::convex_polygon( polygon diff --git a/crates/topola-egui/src/viewport.rs b/crates/topola-egui/src/viewport.rs index 2c06b8a..9bb2e5b 100644 --- a/crates/topola-egui/src/viewport.rs +++ b/crates/topola-egui/src/viewport.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: MIT +use core::ops::ControlFlow; use geo::point; use petgraph::{ data::DataMap, @@ -23,18 +24,27 @@ use topola::{ }, geometry::{shape::AccessShape, GenericNode}, graph::MakeRef, + interactor::{ + activity::{ActivityStepper, InteractiveEvent, InteractiveInput}, + interaction::InteractionStepper, + }, layout::{poly::MakePolygon, via::ViaWeight}, math::{Circle, RotationSense}, router::navmesh::NavnodeIndex, }; -use crate::{config::Config, menu_bar::MenuBar, painter::Painter, workspace::Workspace}; +use crate::{ + config::Config, error_dialog::ErrorDialog, menu_bar::MenuBar, painter::Painter, + translator::Translator, workspace::Workspace, +}; pub struct Viewport { pub transform: egui::emath::TSTransform, /// how much should a single arrow key press scroll pub kbd_scroll_delta_factor: f32, pub scheduled_zoom_to_fit: bool, + + update_counter: f32, } impl Viewport { @@ -43,6 +53,7 @@ impl Viewport { transform: egui::emath::TSTransform::new([0.0, 0.0].into(), 0.01), kbd_scroll_delta_factor: 5.0, scheduled_zoom_to_fit: false, + update_counter: 0.0, } } @@ -50,7 +61,9 @@ impl Viewport { &mut self, config: &Config, ctx: &egui::Context, + tr: &Translator, menu_bar: &MenuBar, + error_dialog: &mut ErrorDialog, maybe_workspace: Option<&mut Workspace>, ) -> egui::Rect { egui::CentralPanel::default() @@ -123,51 +136,120 @@ impl Viewport { let mut painter = Painter::new(ui, self.transform, menu_bar.show_bboxes); if let Some(workspace) = maybe_workspace { - let layers = &mut workspace.appearance_panel; - let overlay = &mut workspace.overlay; let latest_point = point! {x: latest_pos.x as f64, y: -latest_pos.y as f64}; - let board = workspace.interactor.invoker().autorouter().board(); - if response.clicked_by(egui::PointerButton::Primary) { - if menu_bar.is_placing_via { - workspace.interactor.execute(Command::PlaceVia(ViaWeight { - from_layer: 0, - to_layer: 0, - circle: Circle { - pos: latest_point, - r: menu_bar - .autorouter_options - .router_options - .routed_band_width - / 2.0, - }, - maybe_net: Some(1234), - })); + if !workspace.interactor.maybe_activity().as_ref().map_or(true, |activity| { + matches!(activity.maybe_status(), Some(ControlFlow::Break(..))) + }) { + // there is currently some activity + let interactive_event = if response.clicked_by(egui::PointerButton::Primary) { + Some(InteractiveEvent::PointerPrimaryButtonClicked) + } else if response.clicked_by(egui::PointerButton::Secondary) { + Some(InteractiveEvent::PointerSecondaryButtonClicked) } else { - overlay.click(board, layers, latest_point); + None + }; + if let Some(event) = interactive_event { + log::debug!("got {:?}", event); + } + // Advances the app's state by the delta time `dt`. May call + // `.update_state()` more than once if the delta time is more than a multiple of + // the timestep. + let dt = ctx.input(|i| i.stable_dt); + let active_layer = workspace.appearance_panel.active_layer; + self.update_counter += if interactive_event.is_some() { + // make sure we run the loop below at least once on clicks + let mut dtx = menu_bar.frame_timestep; + if dt > dtx { + dtx = dt; + } + dtx + } else { + dt + }; + while self.update_counter >= menu_bar.frame_timestep { + self.update_counter -= menu_bar.frame_timestep; + if let ControlFlow::Break(()) = workspace.update_state( + tr, + error_dialog, + &InteractiveInput { + active_layer, + pointer_pos: latest_point, + dt, + }, + interactive_event, + ) { + break; + } + } + } else { + // Advances the app's state by the delta time `dt`. May call + // `.update_state()` more than once if the delta time is more than a multiple of + // the timestep. + let dt = ctx.input(|i| i.stable_dt); + let active_layer = workspace.appearance_panel.active_layer; + self.update_counter += dt; + while self.update_counter >= menu_bar.frame_timestep { + self.update_counter -= menu_bar.frame_timestep; + if let ControlFlow::Break(()) = workspace.update_state( + tr, + error_dialog, + &InteractiveInput { + active_layer, + pointer_pos: point! {x: latest_pos.x as f64, y: latest_pos.y as f64}, + dt, + }, + None, + ) { + break; + } + } + let layers = &mut workspace.appearance_panel; + let overlay = &mut workspace.overlay; + let board = workspace.interactor.invoker().autorouter().board(); + if response.clicked_by(egui::PointerButton::Primary) { + if menu_bar.is_placing_via { + workspace.interactor.execute(Command::PlaceVia(ViaWeight { + from_layer: 0, + to_layer: 0, + circle: Circle { + pos: latest_point, + r: menu_bar + .autorouter_options + .router_options + .routed_band_width + / 2.0, + }, + maybe_net: Some(1234), + })); + } else { + overlay.click(board, layers, latest_point); + } + } else if response.drag_started_by(egui::PointerButton::Primary) { + overlay.drag_start( + board, + layers, + latest_point, + &response.ctx.input(|i| i.modifiers), + ); + } else if response.drag_stopped_by(egui::PointerButton::Primary) { + overlay.drag_stop(board, layers, latest_point); + } else if let Some((_, bsk, cur_bbox)) = + overlay.get_bbox_reselect(latest_point) + { + use topola::autorouter::selection::BboxSelectionKind; + painter.paint_bbox_with_color( + cur_bbox, + match bsk { + BboxSelectionKind::CompletelyInside => egui::Color32::YELLOW, + BboxSelectionKind::MerelyIntersects => egui::Color32::BLUE, + }, + ); } - } else if response.drag_started_by(egui::PointerButton::Primary) { - overlay.drag_start( - board, - layers, - latest_point, - &response.ctx.input(|i| i.modifiers), - ); - } else if response.drag_stopped_by(egui::PointerButton::Primary) { - overlay.drag_stop(board, layers, latest_point); - } else if let Some((_, bsk, cur_bbox)) = - overlay.get_bbox_reselect(latest_point) - { - use topola::autorouter::selection::BboxSelectionKind; - painter.paint_bbox_with_color( - cur_bbox, - match bsk { - BboxSelectionKind::CompletelyInside => egui::Color32::YELLOW, - BboxSelectionKind::MerelyIntersects => egui::Color32::BLUE, - }, - ); } + let layers = &mut workspace.appearance_panel; + let overlay = &mut workspace.overlay; let board = workspace.interactor.invoker().autorouter().board(); for i in (0..layers.visible.len()).rev() { @@ -466,6 +548,10 @@ impl Viewport { .paint_primitive(ghost, egui::Color32::from_rgb(75, 75, 150)); } + if let ActivityStepper::Interaction(InteractionStepper::RoutePlan(rp)) = activity.activity() { + painter.paint_linestring(&rp.lines, egui::Color32::from_rgb(245, 182, 66)); + } + if let Some(ref navmesh) = activity.maybe_thetastar().map(|astar| astar.graph()) { diff --git a/crates/topola-egui/src/workspace.rs b/crates/topola-egui/src/workspace.rs index 0020760..0b292ba 100644 --- a/crates/topola-egui/src/workspace.rs +++ b/crates/topola-egui/src/workspace.rs @@ -9,7 +9,10 @@ use std::{ use topola::{ autorouter::history::History, - interactor::{activity::InteractiveInput, Interactor}, + interactor::{ + activity::{InteractiveEvent, InteractiveInput}, + Interactor, + }, layout::LayoutEdit, specctra::{design::SpecctraDesign, mesadata::SpecctraMesadata}, }; @@ -63,6 +66,7 @@ impl Workspace { tr: &Translator, error_dialog: &mut ErrorDialog, interactive_input: &InteractiveInput, + interactive_event: Option, ) -> ControlFlow<()> { if let Ok(data) = self.history_channel.1.try_recv() { match data { @@ -88,7 +92,7 @@ impl Workspace { } } - match self.interactor.update(interactive_input) { + match self.interactor.update(interactive_input, interactive_event) { ControlFlow::Continue(()) => ControlFlow::Continue(()), ControlFlow::Break(Ok(())) => ControlFlow::Break(()), ControlFlow::Break(Err(err)) => { diff --git a/locales/en-US/main.ftl b/locales/en-US/main.ftl index 338ee62..ad30c41 100644 --- a/locales/en-US/main.ftl +++ b/locales/en-US/main.ftl @@ -32,6 +32,7 @@ tr-menu-view-frame-timestep = Frame Timestep tr-menu-place = Place tr-menu-place-place-via = Place Via +tr-menu-place-place-route-plan = Place Route Plan tr-menu-route = Route tr-menu-route-autoroute = Autoroute diff --git a/src/autorouter/execution.rs b/src/autorouter/execution.rs index 9c43e2e..7cb41c5 100644 --- a/src/autorouter/execution.rs +++ b/src/autorouter/execution.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::{ board::AccessMesadata, layout::{via::ViaWeight, LayoutEdit}, - stepper::Step, + stepper::{Abort, Step}, }; use super::{ @@ -110,3 +110,10 @@ impl Step, String> for ExecutionStepper { } } } + +impl Abort> for ExecutionStepper { + fn abort(&mut self, context: &mut Invoker) { + // TODO: fix this + self.finish(context); + } +} diff --git a/src/board/mod.rs b/src/board/mod.rs index 0470865..e3dcd18 100644 --- a/src/board/mod.rs +++ b/src/board/mod.rs @@ -10,6 +10,7 @@ pub use specctra_core::mesadata::AccessMesadata; use bimap::BiBTreeMap; use derive_getters::Getters; +use geo::Point; use std::collections::BTreeMap; use crate::{ @@ -175,6 +176,17 @@ impl Board { self.node_to_pinname.get(node) } + /// Returns the apex belonging to a given pin, if any + /// + /// Warning: this is very slow. + pub fn pin_apex(&self, pin: &str, layer: usize) -> Option<(FixedDotIndex, Point)> { + self.node_to_pinname + .iter() + .filter(|(_, node_pin)| *node_pin == pin) + // this should only ever return one result + .find_map(|(node, _)| self.layout().apex_of_compoundless_node(*node, layer)) + } + /// Returns the band name associated with a given band. pub fn band_bandname(&self, band: &BandUid) -> Option<&BandName> { self.band_bandname.get_by_left(band) diff --git a/src/interactor/activity.rs b/src/interactor/activity.rs index 1d1f2cc..26a7670 100644 --- a/src/interactor/activity.rs +++ b/src/interactor/activity.rs @@ -25,15 +25,23 @@ use crate::{ navmesh::{Navmesh, NavnodeIndex}, thetastar::ThetastarStepper, }, - stepper::{Abort, Step}, + stepper::{Abort, OnEvent, Step}, }; /// Stores the interactive input data from the user pub struct InteractiveInput { + pub active_layer: Option, pub pointer_pos: Point, pub dt: f32, } +/// An event received from the user +#[derive(Clone, Copy, Debug)] +pub enum InteractiveEvent { + PointerPrimaryButtonClicked, + PointerSecondaryButtonClicked, +} + /// This is the execution context passed to the stepper on each step pub struct ActivityContext<'a, M> { pub interactive_input: &'a InteractiveInput, @@ -75,14 +83,27 @@ impl Step, String> for ActivityStepper } } -impl Abort> for ActivityStepper { - fn abort(&mut self, context: &mut ActivityContext) { +impl Abort> for ActivityStepper { + fn abort(&mut self, context: &mut Invoker) { match self { ActivityStepper::Interaction(interaction) => interaction.abort(context), - ActivityStepper::Execution(execution) => { - execution.finish(context.invoker); - } // TODO. - }; + ActivityStepper::Execution(execution) => execution.abort(context), + } + } +} + +impl OnEvent, InteractiveEvent> for ActivityStepper { + type Output = Result<(), InteractionError>; + + fn on_event( + &mut self, + context: &mut ActivityContext, + event: InteractiveEvent, + ) -> Result<(), InteractionError> { + match self { + ActivityStepper::Interaction(interaction) => interaction.on_event(context, event), + ActivityStepper::Execution(_) => Ok(()), + } } } @@ -100,6 +121,17 @@ impl ActivityStepperWithStatus { } } + pub fn new_interaction(interaction: InteractionStepper) -> ActivityStepperWithStatus { + Self { + activity: ActivityStepper::Interaction(interaction), + maybe_status: None, + } + } + + pub fn activity(&self) -> &ActivityStepper { + &self.activity + } + pub fn maybe_status(&self) -> Option> { self.maybe_status.clone() } @@ -118,13 +150,27 @@ impl Step, String> for ActivityStepper } } -impl Abort> for ActivityStepperWithStatus { - fn abort(&mut self, context: &mut ActivityContext) { +impl Abort> for ActivityStepperWithStatus { + fn abort(&mut self, context: &mut Invoker) { self.maybe_status = Some(ControlFlow::Break(String::from("aborted"))); self.activity.abort(context); } } +impl OnEvent, InteractiveEvent> + for ActivityStepperWithStatus +{ + type Output = Result<(), InteractionError>; + + fn on_event( + &mut self, + context: &mut ActivityContext, + event: InteractiveEvent, + ) -> Result<(), InteractionError> { + self.activity.on_event(context, event) + } +} + impl GetMaybeThetastarStepper for ActivityStepperWithStatus { fn maybe_thetastar(&self) -> Option<&ThetastarStepper> { self.activity.maybe_thetastar() diff --git a/src/interactor/interaction.rs b/src/interactor/interaction.rs index c182367..25dd3a5 100644 --- a/src/interactor/interaction.rs +++ b/src/interactor/interaction.rs @@ -2,26 +2,22 @@ // // SPDX-License-Identifier: MIT -use std::ops::ControlFlow; - +use core::ops::ControlFlow; use thiserror::Error; use crate::{ autorouter::invoker::{ GetGhosts, GetMaybeNavcord, GetMaybeThetastarStepper, GetNavmeshDebugTexts, GetObstacles, + Invoker, }, board::AccessMesadata, - drawing::graph::PrimitiveIndex, - geometry::primitive::PrimitiveShape, - router::{ - navcord::Navcord, - navmesh::{Navmesh, NavnodeIndex}, - thetastar::ThetastarStepper, - }, - stepper::{Abort, Step}, + stepper::{Abort, OnEvent, Step}, }; -use super::activity::ActivityContext; +use super::{ + activity::{ActivityContext, InteractiveEvent}, + route_plan::RoutePlan, +}; #[derive(Error, Debug, Clone)] pub enum InteractionError { @@ -30,10 +26,10 @@ pub enum InteractionError { } pub enum InteractionStepper { - // No interactions yet. This is only an empty skeleton for now. // Examples of interactions: // - interactively routing a track // - interactively moving a footprint. + RoutePlan(RoutePlan), } impl Step, String> for InteractionStepper { @@ -41,48 +37,38 @@ impl Step, String> for InteractionStep fn step( &mut self, - _context: &mut ActivityContext, + context: &mut ActivityContext, ) -> Result, InteractionError> { - Ok(ControlFlow::Break(String::from(""))) + match self { + Self::RoutePlan(rp) => rp.step(context), + } } } -impl Abort> for InteractionStepper { - fn abort(&mut self, _context: &mut ActivityContext) { - todo!(); +impl Abort> for InteractionStepper { + fn abort(&mut self, context: &mut Invoker) { + match self { + Self::RoutePlan(rp) => rp.abort(context), + } } } -impl GetMaybeThetastarStepper for InteractionStepper { - fn maybe_thetastar(&self) -> Option<&ThetastarStepper> { - todo!() +impl OnEvent, InteractiveEvent> for InteractionStepper { + type Output = Result<(), InteractionError>; + + fn on_event( + &mut self, + context: &mut ActivityContext, + event: InteractiveEvent, + ) -> Result<(), InteractionError> { + match self { + Self::RoutePlan(rp) => rp.on_event(context, event), + } } } -impl GetMaybeNavcord for InteractionStepper { - fn maybe_navcord(&self) -> Option<&Navcord> { - todo!() - } -} - -impl GetGhosts for InteractionStepper { - fn ghosts(&self) -> &[PrimitiveShape] { - todo!() - } -} - -impl GetObstacles for InteractionStepper { - fn obstacles(&self) -> &[PrimitiveIndex] { - todo!() - } -} - -impl GetNavmeshDebugTexts for InteractionStepper { - fn navnode_debug_text(&self, _navnode: NavnodeIndex) -> Option<&str> { - todo!() - } - - fn navedge_debug_text(&self, _navedge: (NavnodeIndex, NavnodeIndex)) -> Option<&str> { - todo!() - } -} +impl GetGhosts for InteractionStepper {} +impl GetMaybeThetastarStepper for InteractionStepper {} +impl GetMaybeNavcord for InteractionStepper {} +impl GetNavmeshDebugTexts for InteractionStepper {} +impl GetObstacles for InteractionStepper {} diff --git a/src/interactor/interactor.rs b/src/interactor/interactor.rs index 527915b..a0a54ef 100644 --- a/src/interactor/interactor.rs +++ b/src/interactor/interactor.rs @@ -14,10 +14,14 @@ use crate::{ Autorouter, }, board::{AccessMesadata, Board}, - interactor::activity::{ - ActivityContext, ActivityError, ActivityStepperWithStatus, InteractiveInput, + interactor::{ + activity::{ + ActivityContext, ActivityError, ActivityStepperWithStatus, InteractiveEvent, + InteractiveInput, + }, + interaction::InteractionStepper, }, - stepper::{Abort, Step}, + stepper::{Abort, OnEvent, Step}, }; /// Structure that manages the invoker and activities @@ -48,6 +52,11 @@ impl Interactor { Ok(()) } + /// Start an interaction activity + pub fn interact(&mut self, interaction: InteractionStepper) { + self.activity = Some(ActivityStepperWithStatus::new_interaction(interaction)); + } + /// Undo last command pub fn undo(&mut self) -> Result<(), InvokerError> { self.invoker.undo() @@ -61,13 +70,7 @@ impl Interactor { /// Abort the currently running execution or activity pub fn abort(&mut self) { if let Some(ref mut activity) = self.activity.take() { - activity.abort(&mut ActivityContext:: { - interactive_input: &InteractiveInput { - pointer_pos: [0.0, 0.0].into(), - dt: 0.0, - }, - invoker: &mut self.invoker, - }); + activity.abort(&mut self.invoker); } } @@ -80,24 +83,42 @@ impl Interactor { pub fn update( &mut self, interactive_input: &InteractiveInput, + interactive_event: Option, ) -> ControlFlow> { if let Some(ref mut activity) = self.activity { - return match activity.step(&mut ActivityContext { - interactive_input, - invoker: &mut self.invoker, - }) { - Ok(ControlFlow::Continue(())) => ControlFlow::Continue(()), - Ok(ControlFlow::Break(_msg)) => { - self.activity = None; - ControlFlow::Break(Ok(())) + if let Some(event) = interactive_event { + match activity.on_event( + &mut ActivityContext { + interactive_input, + invoker: &mut self.invoker, + }, + event, + ) { + Ok(()) => ControlFlow::Continue(()), + Err(err) => { + self.activity = None; + ControlFlow::Break(Err(err.into())) + } } - Err(err) => { - self.activity = None; - ControlFlow::Break(Err(err)) + } else { + match activity.step(&mut ActivityContext { + interactive_input, + invoker: &mut self.invoker, + }) { + Ok(ControlFlow::Continue(())) => ControlFlow::Continue(()), + Ok(ControlFlow::Break(_msg)) => { + self.activity = None; + ControlFlow::Break(Ok(())) + } + Err(err) => { + self.activity = None; + ControlFlow::Break(Err(err)) + } } - }; + } + } else { + ControlFlow::Break(Ok(())) } - ControlFlow::Break(Ok(())) } /// Returns the invoker diff --git a/src/interactor/mod.rs b/src/interactor/mod.rs index b0f308e..4ddb29c 100644 --- a/src/interactor/mod.rs +++ b/src/interactor/mod.rs @@ -5,5 +5,6 @@ pub mod activity; pub mod interaction; mod interactor; +pub mod route_plan; pub use interactor::*; diff --git a/src/interactor/route_plan.rs b/src/interactor/route_plan.rs new file mode 100644 index 0000000..b82cb45 --- /dev/null +++ b/src/interactor/route_plan.rs @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2025 Topola contributors +// +// SPDX-License-Identifier: MIT + +use core::ops::ControlFlow; +use geo::{LineString, Point}; +use rstar::AABB; + +use crate::{ + autorouter::invoker::Invoker, + board::AccessMesadata, + drawing::dot::FixedDotIndex, + geometry::shape::AccessShape as _, + stepper::{Abort, OnEvent, Step}, +}; + +use super::{ + activity::{ActivityContext, InteractiveEvent}, + interaction::InteractionError, +}; + +/// An interactive collector for a route +#[derive(Debug)] +pub struct RoutePlan { + pub active_layer: usize, + pub start_pin: Option<(FixedDotIndex, Point)>, + pub end_pin: Option<(FixedDotIndex, Point)>, + pub lines: LineString, +} + +fn try_select_pin( + context: &ActivityContext, +) -> Option<(usize, FixedDotIndex, Point)> { + let active_layer = context.interactive_input.active_layer?; + let pos = context.interactive_input.pointer_pos; + let board = context.invoker.autorouter().board(); + let layout = board.layout(); + let bbox = AABB::from_point([pos.x(), pos.y()]); + + // gather relevant resolved selectors + let mut pin = None; + let mut apex_node = None; + for &geom in + layout + .drawing() + .rtree() + .locate_in_envelope_intersecting(&AABB::<[f64; 3]>::from_corners( + [pos.x(), pos.y(), active_layer as f64 - f64::EPSILON], + [pos.x(), pos.y(), active_layer as f64 + f64::EPSILON], + )) + { + let node = geom.data; + if layout.node_shape(node).intersects_with_bbox(&bbox) { + if let Some(pin_name) = board.node_pinname(&node) { + // insert and check that we selected exactly one pin / node + log::debug!("node {:?} -> pin {:?}", node, pin_name); + if let Some(old_pin) = pin { + if pin_name != old_pin { + log::debug!( + "rejected pin due to ambiguity: [{:?}, {:?}]", + old_pin, + pin_name + ); + return None; + } + continue; + } + pin = Some(pin_name); + let (idx, pos) = board.pin_apex(pin_name, active_layer).unwrap(); + if let Some((idx2, _)) = apex_node { + if idx != idx2 { + // node index is ambiguous + log::debug!("rejected pin due to ambiguity: [{:?}, {:?}]", idx2, idx); + return None; + } + } else { + apex_node = Some((idx, pos)); + } + } + } + } + + let ret = apex_node.map(|(idx, pos)| (active_layer, idx, pos)); + log::debug!("result {:?}", ret); + ret +} + +impl RoutePlan { + pub fn new(active_layer: usize) -> Self { + log::debug!("initialized RoutePlan"); + Self { + active_layer, + start_pin: None, + end_pin: None, + lines: LineString(Vec::new()), + } + } + + pub fn last_pos(&self) -> Option { + self.lines.0.last().map(|i| Point(*i)) + } +} + +impl Step, String> for RoutePlan { + type Error = InteractionError; + + fn step( + &mut self, + _context: &mut ActivityContext, + ) -> Result, InteractionError> { + Ok(ControlFlow::Continue(())) + } +} + +impl Abort> for RoutePlan { + fn abort(&mut self, _context: &mut Invoker) { + self.start_pin = None; + self.end_pin = None; + self.lines.0.clear(); + } +} + +impl OnEvent, InteractiveEvent> for RoutePlan { + type Output = Result<(), InteractionError>; + + fn on_event( + &mut self, + context: &mut ActivityContext, + event: InteractiveEvent, + ) -> Result<(), InteractionError> { + match event { + InteractiveEvent::PointerPrimaryButtonClicked if self.end_pin.is_none() => { + if let Some((layer, idx, pos)) = try_select_pin(context) { + // make sure double-click or such doesn't corrupt state + if let Some(start_pin) = self.start_pin { + if self.active_layer == layer && idx != start_pin.0 { + log::debug!("clicked on end pin: {:?}", idx); + self.lines.0.push(pos.0); + self.end_pin = Some((idx, pos)); + } + } else { + log::debug!("clicked on start pin: {:?}", idx); + self.active_layer = layer; + self.start_pin = Some((idx, pos)); + self.lines.0.push(pos.0); + } + } else if let Some(last_pos) = self.last_pos() { + let pos = context.interactive_input.pointer_pos; + if last_pos != pos { + self.lines.0.push(pos.0); + } + } + } + InteractiveEvent::PointerSecondaryButtonClicked => { + if self.end_pin.is_some() { + log::debug!("un-clicked end pin"); + self.end_pin = None; + self.lines.0.pop(); + } + if !self.lines.0.is_empty() { + self.lines.0.pop(); + if self.lines.0.is_empty() { + log::debug!("un-clicked start pin"); + self.start_pin = None; + } + } + } + _ => {} + } + Ok(()) + } +} diff --git a/src/layout/layout.rs b/src/layout/layout.rs index 9997d26..7104fde 100644 --- a/src/layout/layout.rs +++ b/src/layout/layout.rs @@ -17,9 +17,9 @@ use crate::{ LooseDotWeight, }, gear::GearIndex, - graph::{GetMaybeNet, IsInLayer, MakePrimitive, PrimitiveIndex}, + graph::{GetMaybeNet, IsInLayer, MakePrimitive, PrimitiveIndex, PrimitiveWeight}, loose::LooseIndex, - primitive::{GetWeight, MakePrimitiveShape, Primitive}, + primitive::MakePrimitiveShape, rules::AccessRules, seg::{ FixedSegIndex, FixedSegWeight, LoneLooseSegIndex, LoneLooseSegWeight, SegIndex, @@ -296,26 +296,48 @@ impl Layout { .compound_members(GenericIndex::new(poly.petgraph_index())) } - pub fn node_shape(&self, index: NodeIndex) -> Shape { - match index { - NodeIndex::Primitive(primitive) => primitive.primitive(&self.drawing).shape().into(), - NodeIndex::Compound(compound) => match self.drawing.compound_weight(compound) { - CompoundWeight::Poly(_) => { - GenericIndex::::new(compound.petgraph_index()) - .ref_(self) - .shape() - .into() - } - CompoundWeight::Via(_) => self - .via(GenericIndex::::new(compound.petgraph_index())) - .shape() - .into(), - }, + fn compound_shape(&self, compound: GenericIndex) -> Shape { + match self.drawing.compound_weight(compound) { + CompoundWeight::Poly(_) => GenericIndex::::new(compound.petgraph_index()) + .ref_(self) + .shape() + .into(), + CompoundWeight::Via(weight) => weight.shape().into(), } } - /// Checks if a node is not a primitive part of a compound, and if yes, returns its center - pub fn center_of_compoundless_node(&self, node: NodeIndex) -> Option { + pub fn node_shape(&self, index: NodeIndex) -> Shape { + match index { + NodeIndex::Primitive(primitive) => primitive.primitive(&self.drawing).shape().into(), + NodeIndex::Compound(compound) => self.compound_shape(compound), + } + } + + /// Checks if a node is not a primitive part of a compound, and if yes, returns its apex and center + pub fn apex_of_compoundless_node( + &self, + node: NodeIndex, + active_layer: usize, + ) -> Option<(FixedDotIndex, Point)> { + fn handle_fixed_dot( + drawing: &Drawing, + index: PrimitiveIndex, + ) -> Option<(FixedDotIndex, &FixedDotWeight)> { + let PrimitiveIndex::FixedDot(dot) = index else { + return None; + }; + if let GenericNode::Primitive(PrimitiveWeight::FixedDot(weight)) = drawing + .geometry() + .graph() + .node_weight(dot.petgraph_index()) + .unwrap() + { + Some((dot, weight)) + } else { + unreachable!() + } + } + match node { NodeIndex::Primitive(primitive) => { if self @@ -327,13 +349,30 @@ impl Layout { { return None; } - match primitive.primitive(self.drawing()) { - Primitive::FixedDot(dot) => Some(dot.weight().pos()), - // Primitive::LooseDot(dot) => Some(dot.weight().pos()), - _ => None, - } + handle_fixed_dot(&self.drawing, primitive).map(|(dot, weight)| (dot, weight.pos())) } - NodeIndex::Compound(_) => Some(self.node_shape(node).center()), + NodeIndex::Compound(compound) => Some(match self.drawing.compound_weight(compound) { + CompoundWeight::Poly(_) => { + let poly = + GenericIndex::::new(compound.petgraph_index()).ref_(self); + (poly.apex(), poly.shape().center()) + } + CompoundWeight::Via(weight) => { + let mut dots = self.drawing.geometry().compound_members(compound); + let apex = loop { + // this returns None if the via is not present on this layer + let (entry_label, dot) = dots.next()?; + if entry_label == CompoundEntryLabel::NotInConvexHull { + if let Some((dot, weight)) = handle_fixed_dot(&self.drawing, dot) { + if weight.layer() == active_layer { + break dot; + } + } + } + }; + (apex, weight.shape().center()) + } + }), } } diff --git a/src/router/router.rs b/src/router/router.rs index 98cb6d4..8790de3 100644 --- a/src/router/router.rs +++ b/src/router/router.rs @@ -12,15 +12,10 @@ use crate::{ band::BandTermsegIndex, dot::FixedDotIndex, graph::{MakePrimitive, PrimitiveIndex}, - head::Head, primitive::MakePrimitiveShape, rules::AccessRules, }, - geometry::{ - primitive::PrimitiveShape, - shape::{AccessShape, MeasureLength}, - }, - graph::MakeRef, + geometry::{primitive::PrimitiveShape, shape::AccessShape}, layout::{Layout, LayoutEdit}, }; diff --git a/src/stepper.rs b/src/stepper.rs index 2af8d7f..724b1ce 100644 --- a/src/stepper.rs +++ b/src/stepper.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -use std::ops::ControlFlow; +use core::ops::ControlFlow; /// This trait represents a linearly advanceable state whose advancement may /// break or fail with many different return values, and to which part of @@ -52,3 +52,10 @@ pub trait Abort { /// Abort the stepper. fn abort(&mut self, context: &mut C); } + +/// Steppers that may receive discrete events and act on them, implement this trait. +pub trait OnEvent { + type Output; + + fn on_event(&mut self, context: &mut Ctx, event: Event) -> Self::Output; +}