feat(topola/board): Load boundary into `Board`

This commit is contained in:
Mikolaj Wielgus 2026-03-09 13:30:59 +01:00
parent 439c005fa2
commit 78242f16fc
14 changed files with 454 additions and 42 deletions

View File

@ -11,7 +11,7 @@ merge_commit = false
allowed_scopes = [
# Generated using `ls crates | awk '{ print "\"" $1 "\","; }' | sort`.
"planar-incr-embed",
"specctra-core",
"specctra",
"specctra_derive",
"topola-cli",
"topola-egui",

View File

@ -1,34 +1,48 @@
# SPDX-FileCopyrightText: 2024 Topola contributors
#
# SPDX-License-Identifier: MIT OR Apache-2.0
[package]
name = "topola-egui"
description = "Graphical user interface for Topola PCB router in Egui/Eframe framework."
version = "0.1.0"
edition = "2024"
[features]
default = ["xdg-portal"]
gtk3 = ["rfd/gtk3"]
xdg-portal = ["rfd/xdg-portal"]
[package.metadata.docs.rs]
all-features = true
targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"]
[dependencies]
egui = "0.33.0"
eframe = { version = "0.33.0", default-features = false, features = [
"accesskit", # Make egui compatible with screen readers. NOTE: adds a lot of dependencies.
derive-getters.workspace = true
egui = "0.33"
eframe = { version = "0.33", default-features = false, features = [
#"accesskit", # Make egui compatible with screen readers. NOTE: adds a lot of dependencies.
"default_fonts", # Embed the default egui fonts.
"glow", # Use the glow rendering backend. Alternative: "wgpu".
"persistence", # Enable restoring app state when restarting the app.
"wayland", # To support Linux (and CI).
"x11", # To support older Linux distributions (restores one of the default features).
] }
log = "0.4.27"
topola = { version = "0.1", path = "../topola" }
fluent-templates = "0.13"
log = "0.4"
rfd = { version = "0.17", default-features = false }
specctra = { path = "../specctra" }
topola = { path = "../topola" }
unic-langid = { version = "0.9", features = ["macros", "serde"] }
# You only need serde if you want app persistence:
serde = { version = "1.0.219", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
# Native:
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
env_logger = "0.11.8"
futures-lite = "2.6"
env_logger = "0.11"
# Web:
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4.50"
web-sys = "0.3.70" # to access the DOM to hide the loading text.
wasm-bindgen-futures = "0.4"
web-sys = "0.3" # To access the DOM to hide the loading text.

106
topola-egui/src/action.rs Normal file
View File

@ -0,0 +1,106 @@
// SPDX-FileCopyrightText: 2024 Topola contributors
//
// SPDX-License-Identifier: MIT
pub struct Action {
name: String,
shortcut: Option<egui::KeyboardShortcut>,
}
pub struct Trigger {
action: Action,
triggered: bool,
}
pub struct Switch {
action: Action,
}
impl Action {
pub fn new(name: String, modifiers: egui::Modifiers, key: egui::Key) -> Self {
Self {
name,
shortcut: Some(egui::KeyboardShortcut::new(modifiers, key)),
}
}
pub fn new_keyless(name: String) -> Self {
Self {
name,
shortcut: None,
}
}
fn widget_text(&self) -> String {
if let Some(shortcut) = self.shortcut {
format!(
"{} ({})",
self.name,
shortcut.format(&egui::ModifierNames::NAMES, false)
)
} else {
self.name.clone()
}
}
#[inline]
pub fn into_trigger(self) -> Trigger {
Trigger {
action: self,
triggered: false,
}
}
#[inline(always)]
pub fn into_switch(self) -> Switch {
Switch { action: self }
}
}
impl Trigger {
pub fn button(&mut self, _ctx: &egui::Context, ui: &mut egui::Ui) {
self.triggered = ui.button(self.action.widget_text()).clicked();
}
pub fn hyperlink(&self, _ctx: &egui::Context, ui: &mut egui::Ui, url: &str) {
ui.hyperlink_to(self.action.widget_text(), url);
}
pub fn consume_key_triggered(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) -> bool {
self.consume_key(ctx, ui);
self.triggered
}
fn consume_key(&mut self, ctx: &egui::Context, _ui: &mut egui::Ui) {
if let Some(shortcut) = self.action.shortcut {
if ctx.input_mut(|i| i.consume_shortcut(&shortcut)) {
self.triggered = true;
}
}
}
}
impl Switch {
pub fn toggle_widget(&self, ui: &mut egui::Ui, selected: &mut bool) {
ui.toggle_value(selected, self.action.widget_text());
}
pub fn checkbox(&self, ui: &mut egui::Ui, selected: &mut bool) {
ui.checkbox(selected, self.action.widget_text());
}
pub fn consume_key_enabled(
&self,
ctx: &egui::Context,
_ui: &mut egui::Ui,
selected: &mut bool,
) -> bool {
if let Some(shortcut) = self.action.shortcut {
if ctx.input_mut(|i| i.consume_shortcut(&shortcut)) {
*selected = !*selected;
}
}
*selected
}
}

View File

@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2026 Topola contributors
//
// SPDX-License-Identifier: MIT
use crate::{
action::{Action, Trigger},
translator::Translator,
};
pub struct Actions {
pub file: FileActions,
}
impl Actions {
pub fn new(tr: &Translator) -> Self {
Self {
file: FileActions::new(tr),
}
}
}
pub struct FileActions {
pub open_design: Trigger,
pub export_session: Trigger,
pub quit: Trigger,
}
impl FileActions {
pub fn new(tr: &Translator) -> Self {
Self {
open_design: Action::new(
tr.text("tr-menu-file-open"),
egui::Modifiers::CTRL,
egui::Key::O,
)
.into_trigger(),
export_session: Action::new(
tr.text("tr-menu-file-export-session-file"),
egui::Modifiers::CTRL,
egui::Key::O,
)
.into_trigger(),
quit: Action::new(
tr.text("tr-menu-file-quit"),
egui::Modifiers::CTRL,
egui::Key::Q,
)
.into_trigger(),
}
}
pub fn render_menu(&mut self, ctx: &egui::Context, ui: &mut egui::Ui, have_workspace: bool) {
self.open_design.button(ctx, ui);
ui.add_enabled_ui(have_workspace, |ui| {
self.export_session.button(ctx, ui);
ui.separator();
/*self.import_history.button(ctx, ui);
self.export_history.button(ctx, ui);*/
});
ui.separator();
// "Quit" button wouldn't work on a Web page.
if !cfg!(target_arch = "wasm32") {
self.quit.button(ctx, ui);
}
}
}

View File

@ -1,17 +1,39 @@
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] // if we add new fields, give them default values when deserializing old state
// SPDX-FileCopyrightText: 2026 Topola contributors
//
// SPDX-License-Identifier: MIT OR Apache-2.0
use std::sync::mpsc::{Receiver, Sender, channel};
use specctra::{error::ParseErrorContext, structure::DsnFile};
use topola::Board;
use unic_langid::langid;
use crate::{menu_bar::MenuBar, translator::Translator, workspace::Workspace};
pub struct App {
translator: Translator,
content_channel: (
Sender<Result<DsnFile, ParseErrorContext>>,
Receiver<Result<DsnFile, ParseErrorContext>>,
),
menu_bar: MenuBar,
view_rect: egui::Rect,
workspace: Option<Workspace>,
}
impl Default for App {
fn default() -> Self {
Self {
translator: Translator::new(langid!("en-US")),
content_channel: channel(),
menu_bar: MenuBar::new(),
view_rect: egui::Rect::from_min_max(
egui::pos2(-100.0, 100.0),
egui::pos2(100.0, 100.0),
),
workspace: None,
}
}
}
@ -22,38 +44,91 @@ impl App {
// Restore the persistent part of the app's state from its previous run
// if there is one.
if let Some(storage) = cc.storage {
eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default()
//eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default()
Default::default()
} else {
Default::default()
}
}
fn update_state(&mut self) {
if let Ok(data) = self.content_channel.1.try_recv() {
self.workspace = Some(Workspace::new(
Board::from_specctra(data.unwrap()),
&self.translator,
));
}
}
/// Update the title displayed on the application window's frame to show the
/// currently opened file, if any, and other possible information about the
/// application state.
#[cfg(not(target_arch = "wasm32"))]
fn update_title(&mut self, ctx: &egui::Context) {
/*if let Some(workspace) = &self.maybe_workspace {
if let Some(filename) = Path::new(workspace.design.get_name())
.file_name()
.and_then(|n| n.to_str())
{
ctx.send_viewport_cmd(egui::ViewportCommand::Title(filename.to_string()));
// TODO: Also show file's dirty bit.
}
}*/
}
/// Update the title displayed on the application window's frame to show the
/// currently opened file, if any, and other possible information about the
/// application state.
#[cfg(target_arch = "wasm32")]
fn update_title(&mut self, ctx: &egui::Context) {
if let Some(workspace) = &self.maybe_workspace {
if let Some(filename) = Path::new(workspace.design.get_name())
.file_name()
.and_then(|n| n.to_str())
{
let document = eframe::web_sys::window()
.expect("No window")
.document()
.expect("No document");
document.set_title(filename);
// TODO: Also show file's dirty bit.
}
}
}
/// Handle a possible locale change.
#[cfg(not(target_arch = "wasm32"))]
fn update_locale(&mut self) {
// I don't know any equivalent of changing the lang property in desktop.
}
/// Handle a possible locale change.
#[cfg(target_arch = "wasm32")]
fn update_locale(&mut self) {
let document_element = eframe::web_sys::window()
.expect("No window")
.document()
.expect("No document")
.document_element()
.expect("No document element");
document_element.set_attribute("lang", &self.translator.langid().to_string());
}
}
impl eframe::App for App {
/// Called to save state before shutdown.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, eframe::APP_KEY, self);
//eframe::set_value(storage, eframe::APP_KEY, self);
}
/// Called each time the UI has to be repainted.
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("File", |ui| {
// Quit is not present on Web; the app can't close a webpage
// by itself.
if !cfg!(target_arch = "wasm32") {
if ui.button("Quit").clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
});
self.menu_bar
.update(ctx, &mut self.translator, self.content_channel.0.clone());
ui.separator();
egui::widgets::global_theme_preference_switch(ui);
});
});
self.update_state();
egui::CentralPanel::default().show(ctx, |ui| {
egui::Scene::new()
@ -63,5 +138,30 @@ impl eframe::App for App {
.circle_filled(egui::pos2(0.0, 0.0), 20.0, egui::Color32::RED);
});
});
self.update_locale();
self.update_title(ctx);
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn execute<F: Future<Output = ()> + Send + 'static>(f: F) {
std::thread::spawn(move || futures_lite::future::block_on(f));
}
#[cfg(target_arch = "wasm32")]
pub fn execute<F: Future<Output = ()> + 'static>(f: F) {
wasm_bindgen_futures::spawn_local(f);
}
pub async fn handle_file(
file_handle: &rfd::FileHandle,
) -> std::io::Result<impl std::io::BufRead + std::io::Seek> {
#[cfg(not(target_arch = "wasm32"))]
let res = std::io::BufReader::new(std::fs::File::open(file_handle.path())?);
#[cfg(target_arch = "wasm32")]
let res = std::io::Cursor::new(file_handle.read().await);
Ok(res)
}

View File

@ -1,11 +1,16 @@
// SPDX-FileCopyrightText: 2026 Topola contributors
//
// SPDX-License-Identifier: MIT
// SPDX-License-Identifier: MIT OR Apache-2.0
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
mod action;
mod actions;
mod app;
use app::App;
mod menu_bar;
mod translator;
mod workspace;
use crate::app::App;
// When compiling natively:
#[cfg(not(target_arch = "wasm32"))]

View File

@ -1,3 +1,69 @@
// SPDX-FileCopyrightText: 2026 Topola contributors
//
// SPDX-License-Identifier: MIT
use std::sync::mpsc::Sender;
use serde::{Deserialize, Serialize};
use specctra::{
error::{ParseError, ParseErrorContext},
read::ListTokenizer,
structure::DsnFile,
};
use crate::{
actions::Actions,
app::{execute, handle_file},
translator::Translator,
};
#[derive(Deserialize, Serialize)]
pub struct MenuBar {
step_rate: f32,
}
impl MenuBar {
pub fn new() -> Self {
Self { step_rate: 1.0 }
}
pub fn update(
&mut self,
ctx: &egui::Context,
tr: &mut Translator,
content_sender: Sender<Result<DsnFile, ParseErrorContext>>,
) {
let mut actions = Actions::new(tr);
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("File", |ui| {
actions.file.render_menu(ctx, ui, false);
});
ui.separator();
egui::widgets::global_theme_preference_switch(ui);
});
if actions.file.open_design.consume_key_triggered(ctx, ui) {
// NOTE: On Linux, this requires Zenity to be installed on your system.
let ctx = ctx.clone();
let task = rfd::AsyncFileDialog::new()
.add_filter(tr.text("tr-menu-open-specctra-design-file"), &["dsn"])
.pick_file();
execute(async move {
if let Some(file_handle) = task.await {
let data = handle_file(&file_handle)
.await
.map_err(|e| ParseError::from(e).add_context((0, 0)))
.and_then(|reader| ListTokenizer::new(reader).read_value::<DsnFile>());
content_sender.send(data);
ctx.request_repaint();
}
});
}
});
}
}

View File

@ -3,13 +3,13 @@
// SPDX-License-Identifier: MIT
use derive_getters::Getters;
use fluent_templates::{static_loader, Loader};
use fluent_templates::{Loader, static_loader};
use serde::{Deserialize, Serialize};
use unic_langid::LanguageIdentifier;
static_loader! {
static LOCALES = {
locales: "../../locales",
locales: "../locales",
fallback_language: "en-US",
};
}

View File

@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2026 Topola contributors
//
// SPDX-License-Identifier: MIT OR Apache-2.0
use topola::Board;
use crate::translator::Translator;
pub struct Workspace {
pub board: Board,
}
impl Workspace {
pub fn new(board: Board, tr: &Translator) -> Self {
Self { board }
}
}

View File

@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2026 Topola contributors
#
# SPDX-License-Identifier: MIT OR Apache-2.0
[package]
name = "topola"
description = "Work-in-progress free and open-source topological (rubberband) router and autorouter for printed circuit boards (PCBs)"
@ -8,6 +12,7 @@ edition = "2024"
derive-getters.workspace = true
derive_more.workspace = true
serde.workspace = true
specctra = { path = "../specctra" }
stable-vec = "0.4"
undoredo.workspace = true

View File

@ -1,6 +1,6 @@
// SPDX-FileCopyrightText: 2026 Topola contributors
//
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-License-Identifier: MIT
use derive_getters::{Dissolve, Getters};
use undoredo::{ApplyDelta, Delta, FlushDelta};
@ -18,9 +18,9 @@ pub struct Board {
}
impl Board {
pub fn new() -> Self {
pub fn new(boundary: Vec<[i64; 2]>) -> Self {
Self {
layout: Layout::new(),
layout: Layout::new(boundary),
}
}
}

View File

@ -1,6 +1,6 @@
// SPDX-FileCopyrightText: 2026 Topola contributors
//
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-License-Identifier: MIT
use std::collections::BTreeMap;
@ -103,6 +103,8 @@ pub struct Via {
#[derive(Clone, Debug)]
pub struct Layout {
boundary: Vec<[i64; 2]>,
place_boundary: Vec<[i64; 2]>,
joints: Recorder<StableVec<Joint>>,
segments: Recorder<StableVec<Segment>>,
arcs: Recorder<StableVec<Arc>>,
@ -110,8 +112,10 @@ pub struct Layout {
}
impl Layout {
pub fn new() -> Self {
pub fn new(boundary: Vec<[i64; 2]>) -> Self {
Self {
boundary: boundary.clone(),
place_boundary: boundary,
joints: Recorder::new(StableVec::new()),
segments: Recorder::new(StableVec::new()),
arcs: Recorder::new(StableVec::new()),

View File

@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: 2026 Topola contributors
//
// SPDX-License-Identifier: MIT OR Apache-2.0
// SPDX-License-Identifier: MIT
mod board;
mod layout;
mod specctra;
pub use crate::board::Board;

22
topola/src/specctra.rs Normal file
View File

@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2026 Topola contributors
//
// SPDX-License-Identifier: MIT OR Apache-2.0
use specctra::structure::DsnFile;
use crate::board::Board;
impl Board {
pub fn from_specctra(dsn: DsnFile) -> Self {
Board::new(
dsn.pcb
.structure
.boundary
.coords()
.into_owned()
.into_iter()
.map(|p| [p.x as i64, p.y as i64])
.collect(),
)
}
}