feat: Add interaction stepper for route building

This commit is contained in:
Ellen Emilia Anna Zscheile 2025-06-16 20:39:04 +02:00
parent e2aaa932fc
commit a4b1b3893c
19 changed files with 586 additions and 215 deletions

View File

@ -54,6 +54,7 @@ allowed_scopes = [
"interactor/activity", "interactor/activity",
"interactor/interaction", "interactor/interaction",
"interactor/interactor", "interactor/interactor",
"interactor/route_plan",
"layout/layout", "layout/layout",
"layout/poly", "layout/poly",
"layout/via", "layout/via",

View File

@ -235,6 +235,7 @@ impl ViewActions {
pub struct PlaceActions { pub struct PlaceActions {
pub place_via: Switch, pub place_via: Switch,
pub place_route_plan: Trigger,
} }
impl PlaceActions { impl PlaceActions {
@ -246,6 +247,8 @@ impl PlaceActions {
egui::Key::P, egui::Key::P,
) )
.into_switch(), .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<()> { ) -> egui::InnerResponse<()> {
ui.add_enabled_ui(have_workspace, |ui| { ui.add_enabled_ui(have_workspace, |ui| {
self.place_via.toggle_widget(ui, is_placing_via); self.place_via.toggle_widget(ui, is_placing_via);
self.place_route_plan.button(ctx, ui);
}) })
} }
} }

View File

@ -2,20 +2,15 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use geo::point;
use std::{ use std::{
future::Future, future::Future,
io, io,
ops::ControlFlow,
path::Path, path::Path,
sync::mpsc::{channel, Receiver, Sender}, sync::mpsc::{channel, Receiver, Sender},
}; };
use unic_langid::{langid, LanguageIdentifier}; use unic_langid::{langid, LanguageIdentifier};
use topola::{ use topola::specctra::{design::SpecctraDesign, ParseErrorContext as SpecctraLoadingError};
interactor::activity::InteractiveInput,
specctra::{design::SpecctraDesign, ParseErrorContext as SpecctraLoadingError},
};
use crate::{ use crate::{
config::Config, error_dialog::ErrorDialog, menu_bar::MenuBar, status_bar::StatusBar, config::Config, error_dialog::ErrorDialog, menu_bar::MenuBar, status_bar::StatusBar,
@ -37,8 +32,6 @@ pub struct App {
error_dialog: ErrorDialog, error_dialog: ErrorDialog,
maybe_workspace: Option<Workspace>, maybe_workspace: Option<Workspace>,
update_counter: f32,
} }
impl Default for App { impl Default for App {
@ -52,7 +45,6 @@ impl Default for App {
status_bar: StatusBar::new(), status_bar: StatusBar::new(),
error_dialog: ErrorDialog::new(), error_dialog: ErrorDialog::new(),
maybe_workspace: None, maybe_workspace: None,
update_counter: 0.0,
} }
} }
} }
@ -72,23 +64,8 @@ impl App {
this 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. /// 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 // 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. // 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() { 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 /// 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(), self.maybe_workspace.as_mut(),
); );
let pointer_pos = self.viewport.transform.inverse() self.update_state();
* 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()),
);
if self.menu_bar.show_appearance_panel { if self.menu_bar.show_appearance_panel {
if let Some(workspace) = &mut self.maybe_workspace { if let Some(workspace) = &mut self.maybe_workspace {
@ -241,10 +193,21 @@ impl eframe::App for App {
let _viewport_rect = self.viewport.update( let _viewport_rect = self.viewport.update(
&self.config, &self.config,
ctx, ctx,
&self.translator,
&self.menu_bar, &self.menu_bar,
&mut self.error_dialog,
self.maybe_workspace.as_mut(), 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_locale();
self.update_title(ctx); self.update_title(ctx);

View File

@ -9,6 +9,7 @@ use topola::{
execution::Command, invoker::InvokerError, selection::Selection, AutorouterOptions, execution::Command, invoker::InvokerError, selection::Selection, AutorouterOptions,
}, },
board::AccessMesadata, board::AccessMesadata,
interactor::{interaction::InteractionStepper, route_plan::RoutePlan},
router::RouterOptions, router::RouterOptions,
specctra::{design::SpecctraDesign, ParseError, ParseErrorContext as SpecctraLoadingError}, specctra::{design::SpecctraDesign, ParseError, ParseErrorContext as SpecctraLoadingError},
}; };
@ -303,16 +304,13 @@ impl MenuBar {
workspace workspace
.interactor .interactor
.schedule(op(selection, self.autorouter_options)); .schedule(op(selection, self.autorouter_options));
Ok::<(), InvokerError>(())
}; };
if actions.edit.remove_bands.consume_key_triggered(ctx, ui) { if actions.edit.remove_bands.consume_key_triggered(ctx, ui) {
schedule(|selection, _| { schedule(|selection, _| Command::RemoveBands(selection.band_selection));
Command::RemoveBands(selection.band_selection)
})?;
} else if actions.route.autoroute.consume_key_triggered(ctx, ui) { } else if actions.route.autoroute.consume_key_triggered(ctx, ui) {
schedule(|selection, opts| { schedule(|selection, opts| {
Command::Autoroute(selection.pin_selection, opts) Command::Autoroute(selection.pin_selection, opts)
})?; });
} else if actions } else if actions
.inspect .inspect
.compare_detours .compare_detours
@ -320,7 +318,7 @@ impl MenuBar {
{ {
schedule(|selection, opts| { schedule(|selection, opts| {
Command::CompareDetours(selection.pin_selection, opts) Command::CompareDetours(selection.pin_selection, opts)
})?; });
} else if actions } else if actions
.inspect .inspect
.measure_length .measure_length
@ -328,7 +326,18 @@ impl MenuBar {
{ {
schedule(|selection, _| { schedule(|selection, _| {
Command::MeasureLength(selection.band_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),
));
}
} }
} }
} }

View File

@ -104,8 +104,8 @@ impl Overlay {
.filter_map(|node| { .filter_map(|node| {
board board
.layout() .layout()
.center_of_compoundless_node(node) .apex_of_compoundless_node(node, active_layer)
.map(|pos| (node, pos)) .map(|(_, pos)| (node, pos))
}) })
.map(|(idx, pos)| TrianVertex { .map(|(idx, pos)| TrianVertex {
idx, idx,

View File

@ -2,7 +2,7 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use geo::{CoordsIter, Point, Polygon}; use geo::{CoordsIter, LineString, Point, Polygon};
use rstar::AABB; use rstar::AABB;
use topola::{ use topola::{
geometry::{primitive::PrimitiveShape, shape::AccessShape}, 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) { pub fn paint_polygon(&mut self, polygon: &Polygon, color: egui::epaint::Color32) {
self.ui.painter().add(egui::Shape::convex_polygon( self.ui.painter().add(egui::Shape::convex_polygon(
polygon polygon

View File

@ -2,6 +2,7 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use core::ops::ControlFlow;
use geo::point; use geo::point;
use petgraph::{ use petgraph::{
data::DataMap, data::DataMap,
@ -23,18 +24,27 @@ use topola::{
}, },
geometry::{shape::AccessShape, GenericNode}, geometry::{shape::AccessShape, GenericNode},
graph::MakeRef, graph::MakeRef,
interactor::{
activity::{ActivityStepper, InteractiveEvent, InteractiveInput},
interaction::InteractionStepper,
},
layout::{poly::MakePolygon, via::ViaWeight}, layout::{poly::MakePolygon, via::ViaWeight},
math::{Circle, RotationSense}, math::{Circle, RotationSense},
router::navmesh::NavnodeIndex, 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 struct Viewport {
pub transform: egui::emath::TSTransform, pub transform: egui::emath::TSTransform,
/// how much should a single arrow key press scroll /// how much should a single arrow key press scroll
pub kbd_scroll_delta_factor: f32, pub kbd_scroll_delta_factor: f32,
pub scheduled_zoom_to_fit: bool, pub scheduled_zoom_to_fit: bool,
update_counter: f32,
} }
impl Viewport { impl Viewport {
@ -43,6 +53,7 @@ impl Viewport {
transform: egui::emath::TSTransform::new([0.0, 0.0].into(), 0.01), transform: egui::emath::TSTransform::new([0.0, 0.0].into(), 0.01),
kbd_scroll_delta_factor: 5.0, kbd_scroll_delta_factor: 5.0,
scheduled_zoom_to_fit: false, scheduled_zoom_to_fit: false,
update_counter: 0.0,
} }
} }
@ -50,7 +61,9 @@ impl Viewport {
&mut self, &mut self,
config: &Config, config: &Config,
ctx: &egui::Context, ctx: &egui::Context,
tr: &Translator,
menu_bar: &MenuBar, menu_bar: &MenuBar,
error_dialog: &mut ErrorDialog,
maybe_workspace: Option<&mut Workspace>, maybe_workspace: Option<&mut Workspace>,
) -> egui::Rect { ) -> egui::Rect {
egui::CentralPanel::default() egui::CentralPanel::default()
@ -123,51 +136,120 @@ impl Viewport {
let mut painter = Painter::new(ui, self.transform, menu_bar.show_bboxes); let mut painter = Painter::new(ui, self.transform, menu_bar.show_bboxes);
if let Some(workspace) = maybe_workspace { 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 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 !workspace.interactor.maybe_activity().as_ref().map_or(true, |activity| {
if menu_bar.is_placing_via { matches!(activity.maybe_status(), Some(ControlFlow::Break(..)))
workspace.interactor.execute(Command::PlaceVia(ViaWeight { }) {
from_layer: 0, // there is currently some activity
to_layer: 0, let interactive_event = if response.clicked_by(egui::PointerButton::Primary) {
circle: Circle { Some(InteractiveEvent::PointerPrimaryButtonClicked)
pos: latest_point, } else if response.clicked_by(egui::PointerButton::Secondary) {
r: menu_bar Some(InteractiveEvent::PointerSecondaryButtonClicked)
.autorouter_options
.router_options
.routed_band_width
/ 2.0,
},
maybe_net: Some(1234),
}));
} else { } 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(); let board = workspace.interactor.invoker().autorouter().board();
for i in (0..layers.visible.len()).rev() { for i in (0..layers.visible.len()).rev() {
@ -466,6 +548,10 @@ impl Viewport {
.paint_primitive(ghost, egui::Color32::from_rgb(75, 75, 150)); .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) = if let Some(ref navmesh) =
activity.maybe_thetastar().map(|astar| astar.graph()) activity.maybe_thetastar().map(|astar| astar.graph())
{ {

View File

@ -9,7 +9,10 @@ use std::{
use topola::{ use topola::{
autorouter::history::History, autorouter::history::History,
interactor::{activity::InteractiveInput, Interactor}, interactor::{
activity::{InteractiveEvent, InteractiveInput},
Interactor,
},
layout::LayoutEdit, layout::LayoutEdit,
specctra::{design::SpecctraDesign, mesadata::SpecctraMesadata}, specctra::{design::SpecctraDesign, mesadata::SpecctraMesadata},
}; };
@ -63,6 +66,7 @@ impl Workspace {
tr: &Translator, tr: &Translator,
error_dialog: &mut ErrorDialog, error_dialog: &mut ErrorDialog,
interactive_input: &InteractiveInput, interactive_input: &InteractiveInput,
interactive_event: Option<InteractiveEvent>,
) -> ControlFlow<()> { ) -> ControlFlow<()> {
if let Ok(data) = self.history_channel.1.try_recv() { if let Ok(data) = self.history_channel.1.try_recv() {
match data { 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::Continue(()) => ControlFlow::Continue(()),
ControlFlow::Break(Ok(())) => ControlFlow::Break(()), ControlFlow::Break(Ok(())) => ControlFlow::Break(()),
ControlFlow::Break(Err(err)) => { ControlFlow::Break(Err(err)) => {

View File

@ -32,6 +32,7 @@ tr-menu-view-frame-timestep = Frame Timestep
tr-menu-place = Place tr-menu-place = Place
tr-menu-place-place-via = Place Via tr-menu-place-place-via = Place Via
tr-menu-place-place-route-plan = Place Route Plan
tr-menu-route = Route tr-menu-route = Route
tr-menu-route-autoroute = Autoroute tr-menu-route-autoroute = Autoroute

View File

@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
board::AccessMesadata, board::AccessMesadata,
layout::{via::ViaWeight, LayoutEdit}, layout::{via::ViaWeight, LayoutEdit},
stepper::Step, stepper::{Abort, Step},
}; };
use super::{ use super::{
@ -110,3 +110,10 @@ impl<M: AccessMesadata> Step<Invoker<M>, String> for ExecutionStepper {
} }
} }
} }
impl<M: AccessMesadata> Abort<Invoker<M>> for ExecutionStepper {
fn abort(&mut self, context: &mut Invoker<M>) {
// TODO: fix this
self.finish(context);
}
}

View File

@ -10,6 +10,7 @@ pub use specctra_core::mesadata::AccessMesadata;
use bimap::BiBTreeMap; use bimap::BiBTreeMap;
use derive_getters::Getters; use derive_getters::Getters;
use geo::Point;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use crate::{ use crate::{
@ -175,6 +176,17 @@ impl<M: AccessMesadata> Board<M> {
self.node_to_pinname.get(node) 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. /// Returns the band name associated with a given band.
pub fn band_bandname(&self, band: &BandUid) -> Option<&BandName> { pub fn band_bandname(&self, band: &BandUid) -> Option<&BandName> {
self.band_bandname.get_by_left(band) self.band_bandname.get_by_left(band)

View File

@ -25,15 +25,23 @@ use crate::{
navmesh::{Navmesh, NavnodeIndex}, navmesh::{Navmesh, NavnodeIndex},
thetastar::ThetastarStepper, thetastar::ThetastarStepper,
}, },
stepper::{Abort, Step}, stepper::{Abort, OnEvent, Step},
}; };
/// Stores the interactive input data from the user /// Stores the interactive input data from the user
pub struct InteractiveInput { pub struct InteractiveInput {
pub active_layer: Option<usize>,
pub pointer_pos: Point, pub pointer_pos: Point,
pub dt: f32, 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 /// This is the execution context passed to the stepper on each step
pub struct ActivityContext<'a, M> { pub struct ActivityContext<'a, M> {
pub interactive_input: &'a InteractiveInput, pub interactive_input: &'a InteractiveInput,
@ -75,14 +83,27 @@ impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for ActivityStepper
} }
} }
impl<M: AccessMesadata> Abort<ActivityContext<'_, M>> for ActivityStepper { impl<M: AccessMesadata> Abort<Invoker<M>> for ActivityStepper {
fn abort(&mut self, context: &mut ActivityContext<M>) { fn abort(&mut self, context: &mut Invoker<M>) {
match self { match self {
ActivityStepper::Interaction(interaction) => interaction.abort(context), ActivityStepper::Interaction(interaction) => interaction.abort(context),
ActivityStepper::Execution(execution) => { ActivityStepper::Execution(execution) => execution.abort(context),
execution.finish(context.invoker); }
} // TODO. }
}; }
impl<M: AccessMesadata> OnEvent<ActivityContext<'_, M>, InteractiveEvent> for ActivityStepper {
type Output = Result<(), InteractionError>;
fn on_event(
&mut self,
context: &mut ActivityContext<M>,
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<ControlFlow<String>> { pub fn maybe_status(&self) -> Option<ControlFlow<String>> {
self.maybe_status.clone() self.maybe_status.clone()
} }
@ -118,13 +150,27 @@ impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for ActivityStepper
} }
} }
impl<M: AccessMesadata> Abort<ActivityContext<'_, M>> for ActivityStepperWithStatus { impl<M: AccessMesadata> Abort<Invoker<M>> for ActivityStepperWithStatus {
fn abort(&mut self, context: &mut ActivityContext<M>) { fn abort(&mut self, context: &mut Invoker<M>) {
self.maybe_status = Some(ControlFlow::Break(String::from("aborted"))); self.maybe_status = Some(ControlFlow::Break(String::from("aborted")));
self.activity.abort(context); self.activity.abort(context);
} }
} }
impl<M: AccessMesadata> OnEvent<ActivityContext<'_, M>, InteractiveEvent>
for ActivityStepperWithStatus
{
type Output = Result<(), InteractionError>;
fn on_event(
&mut self,
context: &mut ActivityContext<M>,
event: InteractiveEvent,
) -> Result<(), InteractionError> {
self.activity.on_event(context, event)
}
}
impl GetMaybeThetastarStepper for ActivityStepperWithStatus { impl GetMaybeThetastarStepper for ActivityStepperWithStatus {
fn maybe_thetastar(&self) -> Option<&ThetastarStepper<Navmesh, f64>> { fn maybe_thetastar(&self) -> Option<&ThetastarStepper<Navmesh, f64>> {
self.activity.maybe_thetastar() self.activity.maybe_thetastar()

View File

@ -2,26 +2,22 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use std::ops::ControlFlow; use core::ops::ControlFlow;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
autorouter::invoker::{ autorouter::invoker::{
GetGhosts, GetMaybeNavcord, GetMaybeThetastarStepper, GetNavmeshDebugTexts, GetObstacles, GetGhosts, GetMaybeNavcord, GetMaybeThetastarStepper, GetNavmeshDebugTexts, GetObstacles,
Invoker,
}, },
board::AccessMesadata, board::AccessMesadata,
drawing::graph::PrimitiveIndex, stepper::{Abort, OnEvent, Step},
geometry::primitive::PrimitiveShape,
router::{
navcord::Navcord,
navmesh::{Navmesh, NavnodeIndex},
thetastar::ThetastarStepper,
},
stepper::{Abort, Step},
}; };
use super::activity::ActivityContext; use super::{
activity::{ActivityContext, InteractiveEvent},
route_plan::RoutePlan,
};
#[derive(Error, Debug, Clone)] #[derive(Error, Debug, Clone)]
pub enum InteractionError { pub enum InteractionError {
@ -30,10 +26,10 @@ pub enum InteractionError {
} }
pub enum InteractionStepper { pub enum InteractionStepper {
// No interactions yet. This is only an empty skeleton for now.
// Examples of interactions: // Examples of interactions:
// - interactively routing a track // - interactively routing a track
// - interactively moving a footprint. // - interactively moving a footprint.
RoutePlan(RoutePlan),
} }
impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for InteractionStepper { impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for InteractionStepper {
@ -41,48 +37,38 @@ impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for InteractionStep
fn step( fn step(
&mut self, &mut self,
_context: &mut ActivityContext<M>, context: &mut ActivityContext<M>,
) -> Result<ControlFlow<String>, InteractionError> { ) -> Result<ControlFlow<String>, InteractionError> {
Ok(ControlFlow::Break(String::from(""))) match self {
Self::RoutePlan(rp) => rp.step(context),
}
} }
} }
impl<M: AccessMesadata> Abort<ActivityContext<'_, M>> for InteractionStepper { impl<M: AccessMesadata> Abort<Invoker<M>> for InteractionStepper {
fn abort(&mut self, _context: &mut ActivityContext<M>) { fn abort(&mut self, context: &mut Invoker<M>) {
todo!(); match self {
Self::RoutePlan(rp) => rp.abort(context),
}
} }
} }
impl GetMaybeThetastarStepper for InteractionStepper { impl<M: AccessMesadata> OnEvent<ActivityContext<'_, M>, InteractiveEvent> for InteractionStepper {
fn maybe_thetastar(&self) -> Option<&ThetastarStepper<Navmesh, f64>> { type Output = Result<(), InteractionError>;
todo!()
fn on_event(
&mut self,
context: &mut ActivityContext<M>,
event: InteractiveEvent,
) -> Result<(), InteractionError> {
match self {
Self::RoutePlan(rp) => rp.on_event(context, event),
}
} }
} }
impl GetMaybeNavcord for InteractionStepper { impl GetGhosts for InteractionStepper {}
fn maybe_navcord(&self) -> Option<&Navcord> { impl GetMaybeThetastarStepper for InteractionStepper {}
todo!() impl GetMaybeNavcord for InteractionStepper {}
} impl GetNavmeshDebugTexts for InteractionStepper {}
} impl GetObstacles for InteractionStepper {}
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!()
}
}

View File

@ -14,10 +14,14 @@ use crate::{
Autorouter, Autorouter,
}, },
board::{AccessMesadata, Board}, board::{AccessMesadata, Board},
interactor::activity::{ interactor::{
ActivityContext, ActivityError, ActivityStepperWithStatus, InteractiveInput, activity::{
ActivityContext, ActivityError, ActivityStepperWithStatus, InteractiveEvent,
InteractiveInput,
},
interaction::InteractionStepper,
}, },
stepper::{Abort, Step}, stepper::{Abort, OnEvent, Step},
}; };
/// Structure that manages the invoker and activities /// Structure that manages the invoker and activities
@ -48,6 +52,11 @@ impl<M: AccessMesadata> Interactor<M> {
Ok(()) Ok(())
} }
/// Start an interaction activity
pub fn interact(&mut self, interaction: InteractionStepper) {
self.activity = Some(ActivityStepperWithStatus::new_interaction(interaction));
}
/// Undo last command /// Undo last command
pub fn undo(&mut self) -> Result<(), InvokerError> { pub fn undo(&mut self) -> Result<(), InvokerError> {
self.invoker.undo() self.invoker.undo()
@ -61,13 +70,7 @@ impl<M: AccessMesadata> Interactor<M> {
/// Abort the currently running execution or activity /// Abort the currently running execution or activity
pub fn abort(&mut self) { pub fn abort(&mut self) {
if let Some(ref mut activity) = self.activity.take() { if let Some(ref mut activity) = self.activity.take() {
activity.abort(&mut ActivityContext::<M> { activity.abort(&mut self.invoker);
interactive_input: &InteractiveInput {
pointer_pos: [0.0, 0.0].into(),
dt: 0.0,
},
invoker: &mut self.invoker,
});
} }
} }
@ -80,24 +83,42 @@ impl<M: AccessMesadata> Interactor<M> {
pub fn update( pub fn update(
&mut self, &mut self,
interactive_input: &InteractiveInput, interactive_input: &InteractiveInput,
interactive_event: Option<InteractiveEvent>,
) -> ControlFlow<Result<(), ActivityError>> { ) -> ControlFlow<Result<(), ActivityError>> {
if let Some(ref mut activity) = self.activity { if let Some(ref mut activity) = self.activity {
return match activity.step(&mut ActivityContext { if let Some(event) = interactive_event {
interactive_input, match activity.on_event(
invoker: &mut self.invoker, &mut ActivityContext {
}) { interactive_input,
Ok(ControlFlow::Continue(())) => ControlFlow::Continue(()), invoker: &mut self.invoker,
Ok(ControlFlow::Break(_msg)) => { },
self.activity = None; event,
ControlFlow::Break(Ok(())) ) {
Ok(()) => ControlFlow::Continue(()),
Err(err) => {
self.activity = None;
ControlFlow::Break(Err(err.into()))
}
} }
Err(err) => { } else {
self.activity = None; match activity.step(&mut ActivityContext {
ControlFlow::Break(Err(err)) 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 /// Returns the invoker

View File

@ -5,5 +5,6 @@
pub mod activity; pub mod activity;
pub mod interaction; pub mod interaction;
mod interactor; mod interactor;
pub mod route_plan;
pub use interactor::*; pub use interactor::*;

View File

@ -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<M: AccessMesadata>(
context: &ActivityContext<M>,
) -> 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<Point> {
self.lines.0.last().map(|i| Point(*i))
}
}
impl<M: AccessMesadata> Step<ActivityContext<'_, M>, String> for RoutePlan {
type Error = InteractionError;
fn step(
&mut self,
_context: &mut ActivityContext<M>,
) -> Result<ControlFlow<String>, InteractionError> {
Ok(ControlFlow::Continue(()))
}
}
impl<M: AccessMesadata> Abort<Invoker<M>> for RoutePlan {
fn abort(&mut self, _context: &mut Invoker<M>) {
self.start_pin = None;
self.end_pin = None;
self.lines.0.clear();
}
}
impl<M: AccessMesadata> OnEvent<ActivityContext<'_, M>, InteractiveEvent> for RoutePlan {
type Output = Result<(), InteractionError>;
fn on_event(
&mut self,
context: &mut ActivityContext<M>,
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(())
}
}

View File

@ -17,9 +17,9 @@ use crate::{
LooseDotWeight, LooseDotWeight,
}, },
gear::GearIndex, gear::GearIndex,
graph::{GetMaybeNet, IsInLayer, MakePrimitive, PrimitiveIndex}, graph::{GetMaybeNet, IsInLayer, MakePrimitive, PrimitiveIndex, PrimitiveWeight},
loose::LooseIndex, loose::LooseIndex,
primitive::{GetWeight, MakePrimitiveShape, Primitive}, primitive::MakePrimitiveShape,
rules::AccessRules, rules::AccessRules,
seg::{ seg::{
FixedSegIndex, FixedSegWeight, LoneLooseSegIndex, LoneLooseSegWeight, SegIndex, FixedSegIndex, FixedSegWeight, LoneLooseSegIndex, LoneLooseSegWeight, SegIndex,
@ -296,26 +296,48 @@ impl<R: AccessRules> Layout<R> {
.compound_members(GenericIndex::new(poly.petgraph_index())) .compound_members(GenericIndex::new(poly.petgraph_index()))
} }
pub fn node_shape(&self, index: NodeIndex) -> Shape { fn compound_shape(&self, compound: GenericIndex<CompoundWeight>) -> Shape {
match index { match self.drawing.compound_weight(compound) {
NodeIndex::Primitive(primitive) => primitive.primitive(&self.drawing).shape().into(), CompoundWeight::Poly(_) => GenericIndex::<PolyWeight>::new(compound.petgraph_index())
NodeIndex::Compound(compound) => match self.drawing.compound_weight(compound) { .ref_(self)
CompoundWeight::Poly(_) => { .shape()
GenericIndex::<PolyWeight>::new(compound.petgraph_index()) .into(),
.ref_(self) CompoundWeight::Via(weight) => weight.shape().into(),
.shape()
.into()
}
CompoundWeight::Via(_) => self
.via(GenericIndex::<ViaWeight>::new(compound.petgraph_index()))
.shape()
.into(),
},
} }
} }
/// Checks if a node is not a primitive part of a compound, and if yes, returns its center pub fn node_shape(&self, index: NodeIndex) -> Shape {
pub fn center_of_compoundless_node(&self, node: NodeIndex) -> Option<Point> { 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<R: AccessRules>(
drawing: &Drawing<CompoundWeight, CompoundEntryLabel, R>,
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 { match node {
NodeIndex::Primitive(primitive) => { NodeIndex::Primitive(primitive) => {
if self if self
@ -327,13 +349,30 @@ impl<R: AccessRules> Layout<R> {
{ {
return None; return None;
} }
match primitive.primitive(self.drawing()) { handle_fixed_dot(&self.drawing, primitive).map(|(dot, weight)| (dot, weight.pos()))
Primitive::FixedDot(dot) => Some(dot.weight().pos()),
// Primitive::LooseDot(dot) => Some(dot.weight().pos()),
_ => None,
}
} }
NodeIndex::Compound(_) => Some(self.node_shape(node).center()), NodeIndex::Compound(compound) => Some(match self.drawing.compound_weight(compound) {
CompoundWeight::Poly(_) => {
let poly =
GenericIndex::<PolyWeight>::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())
}
}),
} }
} }

View File

@ -12,15 +12,10 @@ use crate::{
band::BandTermsegIndex, band::BandTermsegIndex,
dot::FixedDotIndex, dot::FixedDotIndex,
graph::{MakePrimitive, PrimitiveIndex}, graph::{MakePrimitive, PrimitiveIndex},
head::Head,
primitive::MakePrimitiveShape, primitive::MakePrimitiveShape,
rules::AccessRules, rules::AccessRules,
}, },
geometry::{ geometry::{primitive::PrimitiveShape, shape::AccessShape},
primitive::PrimitiveShape,
shape::{AccessShape, MeasureLength},
},
graph::MakeRef,
layout::{Layout, LayoutEdit}, layout::{Layout, LayoutEdit},
}; };

View File

@ -2,7 +2,7 @@
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use std::ops::ControlFlow; use core::ops::ControlFlow;
/// This trait represents a linearly advanceable state whose advancement may /// This trait represents a linearly advanceable state whose advancement may
/// break or fail with many different return values, and to which part of /// break or fail with many different return values, and to which part of
@ -52,3 +52,10 @@ pub trait Abort<C> {
/// Abort the stepper. /// Abort the stepper.
fn abort(&mut self, context: &mut C); fn abort(&mut self, context: &mut C);
} }
/// Steppers that may receive discrete events and act on them, implement this trait.
pub trait OnEvent<Ctx, Event> {
type Output;
fn on_event(&mut self, context: &mut Ctx, event: Event) -> Self::Output;
}