feat: Implement TopoNavmesh DualOuter-DualOuter extraction

This also fixes a sign bug in LineIntersection calculation
This commit is contained in:
Ellen Emilia Anna Zscheile 2025-06-19 21:33:31 +02:00
parent cfde2eac20
commit d3dc826be4
7 changed files with 268 additions and 60 deletions

View File

@ -176,6 +176,7 @@ impl eframe::App for App {
ctx,
&mut self.translator,
self.content_channel.0.clone(),
&mut self.error_dialog,
&mut self.viewport,
self.maybe_workspace.as_mut(),
);

View File

@ -17,6 +17,7 @@ use topola::{
use crate::{
actions::Actions,
app::{execute, handle_file},
error_dialog::ErrorDialog,
translator::Translator,
viewport::Viewport,
workspace::Workspace,
@ -63,6 +64,7 @@ impl MenuBar {
ctx: &egui::Context,
tr: &mut Translator,
content_sender: Sender<Result<SpecctraDesign, SpecctraLoadingError>>,
error_dialog: &mut ErrorDialog,
viewport: &mut Viewport,
maybe_workspace: Option<&mut Workspace>,
) -> Result<(), InvokerError> {
@ -287,6 +289,7 @@ impl MenuBar {
) {
} else if workspace_activities_enabled {
fn schedule<F: FnOnce(Selection) -> Command>(
error_dialog: &mut ErrorDialog,
workspace: &mut Workspace,
op: F,
) {
@ -306,11 +309,13 @@ impl MenuBar {
.0
.retain(|i| i.layer == active_layer);
}
workspace.interactor.schedule(op(selection));
if let Err(err) = workspace.interactor.schedule(op(selection)) {
error_dialog.push_error("tr-module-invoker", format!("{}", err));
}
}
let opts = self.autorouter_options;
if actions.edit.remove_bands.consume_key_triggered(ctx, ui) {
schedule(workspace, |selection| {
schedule(error_dialog, workspace, |selection| {
Command::RemoveBands(selection.band_selection)
})
} else if actions.route.topo_autoroute.consume_key_triggered(ctx, ui) {
@ -325,15 +330,17 @@ impl MenuBar {
.layer_layername(active_layer)
.expect("unknown active layer")
.to_string();
schedule(workspace, |selection| Command::TopoAutoroute {
selection: selection.pin_selection,
allowed_edges: BTreeSet::new(),
active_layer,
routed_band_width: opts.router_options.routed_band_width,
schedule(error_dialog, workspace, |selection| {
Command::TopoAutoroute {
selection: selection.pin_selection,
allowed_edges: BTreeSet::new(),
active_layer,
routed_band_width: opts.router_options.routed_band_width,
}
});
}
} else if actions.route.autoroute.consume_key_triggered(ctx, ui) {
schedule(workspace, |selection| {
schedule(error_dialog, workspace, |selection| {
Command::Autoroute(selection.pin_selection, opts)
});
} else if actions
@ -341,7 +348,7 @@ impl MenuBar {
.compare_detours
.consume_key_triggered(ctx, ui)
{
schedule(workspace, |selection| {
schedule(error_dialog, workspace, |selection| {
Command::CompareDetours(selection.pin_selection, opts)
});
} else if actions
@ -349,7 +356,7 @@ impl MenuBar {
.measure_length
.consume_key_triggered(ctx, ui)
{
schedule(workspace, |selection| {
schedule(error_dialog, workspace, |selection| {
Command::MeasureLength(selection.band_selection)
});
} else if actions

View File

@ -44,8 +44,8 @@ pub enum AutorouterError {
Navmesh(#[from] NavmeshError),
#[error("routing failed: {0}")]
Thetastar(#[from] ThetastarError),
#[error(transparent)]
Spade(#[from] spade::InsertionError),
#[error("TopoNavmesh generation failed: {0}")]
TopoNavmeshGeneration(#[from] ng::NavmeshCalculationError),
#[error("could not place via")]
CouldNotPlaceVia(#[from] Infringement),
#[error("could not remove band")]

View File

@ -176,9 +176,9 @@ impl<M: AccessMesadata + Clone> Invoker<M> {
&mut self,
command: Command,
) -> Result<ExecutionStepper<M>, InvokerError> {
let execute = self.dispatch_command(&command);
let execute = self.dispatch_command(&command)?;
self.ongoing_command = Some(command);
execute
Ok(execute)
}
#[debug_requires(self.ongoing_command.is_none())]

View File

@ -7,7 +7,7 @@ use core::iter;
use contracts_try::debug_ensures;
use derive_getters::Getters;
use enum_dispatch::enum_dispatch;
use geo::Point;
use geo::{Coord, Line, Point};
use planar_incr_embed::RelaxedPath;
use rstar::AABB;
@ -42,7 +42,7 @@ use crate::{
poly::{add_poly_with_nodes_intern, MakePolygon, PolyWeight},
via::{Via, ViaWeight},
},
math::{LineIntersection, NormalLine, RotationSense},
math::{intersect_linestring_and_beam, LineIntersection, NormalLine, RotationSense},
};
/// Represents a weight for various compounds
@ -379,20 +379,12 @@ impl<R: AccessRules> Layout<R> {
}
}
// TODO: computation of bands between outer node and direction towards "outside"
/// Finds all bands on `layer` between `left` and `right`
/// (usually assuming `left` and `right` are neighbors in a Delaunay triangulation)
/// and returns them ordered from `left` to `right`.
pub fn bands_between_nodes(
fn bands_between_positions_internal(
&self,
layer: usize,
left: NodeIndex,
right: NodeIndex,
) -> impl Iterator<Item = (BandUid, LooseIndex)> {
assert_ne!(left, right);
let left_pos = self.node_shape(left).center();
let right_pos = self.node_shape(right).center();
left_pos: Point,
right_pos: Point,
) -> impl Iterator<Item = (f64, BandUid, LooseIndex)> + '_ {
let ltr_line = geo::Line {
start: left_pos.into(),
end: right_pos.into(),
@ -406,11 +398,10 @@ impl<R: AccessRules> Layout<R> {
orig_hline.make_normal_unit();
let orig_hline = orig_hline;
let location_denom = orig_hline.segment_interval(&ltr_line);
let location_start = location_denom.start();
let location_denom = location_denom.end() - location_denom.start();
let location_start = *location_denom.start();
let location_denom = *location_denom.end() - *location_denom.start();
let mut bands: Vec<_> = self
.drawing
self.drawing
.rtree()
.locate_in_envelope_intersecting(&{
let aabb_init = AABB::from_corners(
@ -432,7 +423,7 @@ impl<R: AccessRules> Layout<R> {
let shape = prim.primitive(&self.drawing).shape();
(loose, shape)
})
.filter_map(|(loose, shape)| {
.filter_map(move |(loose, shape)| {
let band_uid = self.drawing.loose_band_uid(loose).ok()?;
let loose_hline = orig_hline.orthogonal_through(&match shape {
PrimitiveShape::Seg(seg) => {
@ -461,6 +452,22 @@ impl<R: AccessRules> Layout<R> {
.contains(&location)
.then_some((location, band_uid, loose))
})
}
/// Finds all bands on `layer` between `left` and `right`
/// (usually assuming `left` and `right` are neighbors in a Delaunay triangulation)
/// and returns them ordered from `left` to `right`.
pub fn bands_between_nodes(
&self,
layer: usize,
left: NodeIndex,
right: NodeIndex,
) -> impl Iterator<Item = (BandUid, LooseIndex)> {
assert_ne!(left, right);
let left_pos = self.node_shape(left).center();
let right_pos = self.node_shape(right).center();
let mut bands: Vec<_> = self
.bands_between_positions_internal(layer, left_pos, right_pos)
.filter(|(_, band_uid, _)| {
// filter entries which are connected to either lhs or rhs (and possibly both)
let (bts1, bts2) = band_uid.into();
@ -482,6 +489,49 @@ impl<R: AccessRules> Layout<R> {
.map(|(_, band_uid, loose)| (band_uid, loose))
}
/// Finds all bands on `layer` between direction `left` and node `right`
/// and returns them ordered from `left` to `right`.
pub fn bands_between_node_and_boundary(
&self,
layer: usize,
left: Coord,
right: NodeIndex,
) -> Option<impl Iterator<Item = (BandUid, LooseIndex)>> {
// First, decode the `left` direction into a point on the boundary
let right_pos = self.node_shape(right).center();
let left_pos = intersect_linestring_and_beam(
self.drawing.boundary().exterior(),
&Line {
start: right_pos.0,
end: right_pos.0 + left,
},
)?;
let mut bands: Vec<_> = self
.bands_between_positions_internal(layer, left_pos, right_pos)
.filter(|(_, band_uid, _)| {
// filter entries which are connected to rhs
let (bts1, bts2) = band_uid.into();
let (bts1, bts2) = (bts1.petgraph_index(), bts2.petgraph_index());
let geometry = self.drawing.geometry();
[(bts1, right), (bts2, right)]
.iter()
.all(|&(x, y)| !geometry.is_joined_with(x, y))
})
.collect();
bands.sort_by(|a, b| f64::total_cmp(&a.0, &b.0));
// TODO: handle "loops" of bands, or multiple primitives from the band crossing the segment
// both in the case of "edge" of a primitive/loose, and in case the band actually goes into a segment
// and then again out of it.
Some(
bands
.into_iter()
.map(|(_, band_uid, loose)| (band_uid, loose)),
)
}
fn does_compound_have_core(&self, primary: NodeIndex, core: DotIndex) -> bool {
let core: PrimitiveIndex = core.into();
match primary {

View File

@ -3,7 +3,7 @@
// SPDX-License-Identifier: MIT
use geo::algorithm::line_measures::{Distance, Euclidean};
use geo::{geometry::Point, point, Line};
use geo::{point, Line, LineString, Point};
pub use specctra_core::math::{Circle, PointWithRotation};
mod cyclic_search;
@ -102,8 +102,8 @@ impl NormalLine {
let apt = geo::point! { x: a.x, y: a.y };
let bpt = geo::point! { x: b.x, y: b.y };
let det = perp_dot_product(apt, bpt);
let rpx = -b.y * a.offset + a.y * b.offset;
let rpy = b.x * a.offset - a.x * b.offset;
let rpx = b.y * a.offset - a.y * b.offset;
let rpy = -b.x * a.offset + a.x * b.offset;
if det.abs() > ALMOST_ZERO {
LineIntersection::Point(geo::point! { x: rpx, y: rpy } / det)
@ -302,6 +302,16 @@ pub fn intersect_line_and_beam(line1: &Line, beam2: &Line) -> Option<Point> {
}
}
/// Returns `Some(p)` when `p` lies in the intersection of a linestring and a beam
pub fn intersect_linestring_and_beam(linestring: &LineString, beam: &Line) -> Option<Point> {
for line in linestring.lines() {
if let Some(pt) = intersect_line_and_beam(&line, beam) {
return Some(pt);
}
}
None
}
/// Returns `true` the point `p` is between the supporting lines of vectors
/// `from` and `to`.
pub fn between_vectors(p: Point, from: Point, to: Point) -> bool {
@ -446,4 +456,22 @@ mod tests {
None
);
}
#[test]
fn intersect_line_and_beam02() {
let pt = intersect_line_and_beam(
&Line {
start: geo::coord! { x: 140., y: -110. },
end: geo::coord! { x: 160., y: -110. },
},
&Line {
start: geo::coord! { x: 148., y: -106. },
end: geo::coord! { x: 148., y: -109. },
},
)
.unwrap();
approx::assert_abs_diff_eq!(pt.x(), 148.);
approx::assert_abs_diff_eq!(pt.y(), -110.);
}
}

View File

@ -8,7 +8,7 @@ use pie::{
};
pub use planar_incr_embed as pie;
use geo::geometry::{LineString, Point};
use geo::{Coord, LineString, Point};
use rstar::AABB;
use std::{
collections::{BTreeMap, BTreeSet},
@ -274,10 +274,27 @@ impl EvalException {
}
}
#[derive(Clone, Debug, thiserror::Error)]
pub enum NavmeshCalculationError {
#[error("Layer contains too few nodes to generate meaningful navmesh")]
NotEnoughNodes,
#[error("Unable to find boundary from node {node:?}, direction {direction:?}")]
UnableToFindBoundary {
node: FixedDotIndex,
direction: Coord,
},
#[error(transparent)]
Insertion(#[from] spade::InsertionError),
}
/// NOTE: this only works if the layer has ≥ 3 nodes
// TODO: handle the case with 2 nodes on the layer specifically.
pub fn calculate_navmesh<R: AccessRules>(
board: &Board<R>,
active_layer: usize,
) -> Result<PieNavmesh, spade::InsertionError> {
) -> Result<PieNavmesh, NavmeshCalculationError> {
use pie::NavmeshIndex::*;
use spade::Triangulation;
@ -302,37 +319,142 @@ pub fn calculate_navmesh<R: AccessRules>(
.collect(),
)?;
if triangulation.num_inner_faces() == 0 {
log::warn!("calculate_navmesh: not enough nodes");
return Err(NavmeshCalculationError::NotEnoughNodes);
}
let mut navmesh = navmesh::NavmeshSer::<PieNavmeshBase>::from_triangulation(&triangulation);
let barrier2: Arc<[RelaxedPath<_, _>]> =
Arc::from(vec![RelaxedPath::Weak(()), RelaxedPath::Weak(())]);
// populate DualInner-Dual* routed traces
for value in navmesh.edges.values_mut() {
if let (Some(lhs), Some(rhs)) = (value.0.lhs, value.0.rhs) {
value.1 = barrier2.clone();
let wrap = |dot| GenericNode::Primitive(PrimitiveIndex::FixedDot(dot));
let bands = board
.layout()
.bands_between_nodes_with_alignment(active_layer, wrap(lhs), wrap(rhs))
.map(|i| match i {
RelaxedPath::Weak(()) => RelaxedPath::Weak(()),
RelaxedPath::Normal(band_uid) => {
RelaxedPath::Normal(*board.bands_by_id().get_by_right(&band_uid).unwrap())
}
})
.collect::<Vec<_>>();
let barrier0: Arc<[RelaxedPath<_, _>]> = Arc::from(vec![]);
if bands != *barrier2 {
log::debug!("navmesh generated with {:?} = {:?}", value, &bands);
value.1 = Arc::from(bands);
log::debug!("boundary = {:?}", board.layout().drawing().boundary());
// populate Dual*-Dual* routed traces
for (key, value) in &mut navmesh.edges {
let wrap = |dot| GenericNode::Primitive(PrimitiveIndex::FixedDot(dot));
match (value.0.lhs, value.0.rhs) {
(Some(lhs), Some(rhs)) => {
value.1 = barrier2.clone();
let bands = board
.layout()
.bands_between_nodes_with_alignment(active_layer, wrap(lhs), wrap(rhs))
.map(|i| match i {
RelaxedPath::Weak(()) => RelaxedPath::Weak(()),
RelaxedPath::Normal(band_uid) => RelaxedPath::Normal(
*board.bands_by_id().get_by_right(&band_uid).unwrap(),
),
})
.collect::<Vec<_>>();
if bands != *barrier2 {
log::debug!("navmesh generated with {:?} = {:?}", value, &bands);
value.1 = Arc::from(bands);
}
}
(None, Some(rhs)) => {
value.1 = barrier0.clone();
let direction = {
let (prev_key, next_key) = key.into();
let prev_dir = navmesh.nodes[prev_key]
.open_direction
.expect("expected DualOuter entry");
let next_dir = navmesh.nodes[next_key]
.open_direction
.expect("expected DualOuter entry");
Coord {
x: (prev_dir.x + next_dir.x) / 2.0,
y: (prev_dir.y + next_dir.y) / 2.0,
}
};
let bands = match board.layout().bands_between_node_and_boundary(
active_layer,
direction,
wrap(rhs),
) {
None => {
log::warn!("calculate_navmesh: unable to find boundary from node {:?}, direction {:?}", rhs, direction);
continue;
/*
return Err(NavmeshCalculationError::UnableToFindBoundary {
node: rhs,
direction,
});
*/
}
Some(x) => {
log::debug!("calculate_navmesh: successfully found boundary from node {:?}, direction {:?}", rhs, direction);
x.map(|(band_uid, _)| {
RelaxedPath::Normal(
*board.bands_by_id().get_by_right(&band_uid).unwrap(),
)
})
.collect::<Vec<_>>()
}
};
if bands != *barrier0 {
log::debug!("navmesh generated with {:?} = {:?}", value, &bands);
value.1 = Arc::from(bands);
}
}
(Some(lhs), None) => {
value.1 = barrier0.clone();
let direction = {
let (prev_key, next_key) = key.into();
let prev_dir = navmesh.nodes[prev_key]
.open_direction
.expect("expected DualOuter entry");
let next_dir = navmesh.nodes[next_key]
.open_direction
.expect("expected DualOuter entry");
Coord {
x: (prev_dir.x + next_dir.x) / 2.0,
y: (prev_dir.y + next_dir.y) / 2.0,
}
};
let mut bands = match board.layout().bands_between_node_and_boundary(
active_layer,
direction,
wrap(lhs),
) {
None => {
log::warn!("calculate_navmesh: unable to find boundary from node {:?}, direction {:?}", lhs, direction);
continue;
/*
return Err(NavmeshCalculationError::UnableToFindBoundary {
node: rhs,
direction,
});
*/
}
Some(x) => {
log::debug!("calculate_navmesh: successfully found boundary from node {:?}, direction {:?}", lhs, direction);
x.map(|(band_uid, _)| {
RelaxedPath::Normal(
*board.bands_by_id().get_by_right(&band_uid).unwrap(),
)
})
.collect::<Vec<_>>()
}
};
bands.reverse();
if bands != *barrier0 {
log::debug!("navmesh generated with {:?} = {:?}", value, &bands);
value.1 = Arc::from(bands);
}
}
(None, None) => {
// nothing to do
}
}
}
// TODO: insert fixed and outer routed traces/bands into the navmesh
// see also: https://codeberg.org/topola/topola/issues/166
// due to not handling outer routed traces/bends,
// the above code might produce an inconsistent navmesh
// TODO: insert fixed routed traces/bands into the navmesh
// populate Primal-Dual* routed traces
let dual_ends: BTreeMap<_, _> = navmesh