Skip to content

feat: Custom viewshed polygon reconstructor#67

Open
tombh wants to merge 2 commits into
mainfrom
tombh/adjacent-polar-viewshed-builder
Open

feat: Custom viewshed polygon reconstructor#67
tombh wants to merge 2 commits into
mainfrom
tombh/adjacent-polar-viewshed-builder

Conversation

@tombh

@tombh tombh commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

At higher angle counts a traditional polygon unioning of all the polar segments suffers from O(N²). For example, even in Rust a 3600 angled viewshed takes ~20s to create its GeoJSON. And many minutes in browser JS.

Therefore we have to design an entirely new algorithm that takes advantage of the fact that we are only ever adding new pieces to the growing viewshed in a very specific way. Namely we only ever add polar segments, that are always contained within the arc of a single angle, onto the side of a polygon. This returns polygon reconstruction to 200ms 80ms for 3600 angled viewsheds. There's actually huuuuge amount of room for speeding up this approach, but seeing as its already so much faster than the old method, this is good enough for now.

TODO:

  • Handle polygons that wrap all the way around.
  • Handle segments that touch more than one polygon.

Apologies, this is rather big PR to review. Most of the changes are hidden in collapsed file views in the Github Diff view.

@tombh

tombh commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator Author

Current state of bencmarks:

Correct:
image

This PR:
image

@tombh tombh force-pushed the tombh/adjacent-polar-viewshed-builder branch from 60f7aac to d343bc7 Compare June 19, 2026 09:45
At higher angle counts a traditional polygon unioning of all the polar
segments suffers from O(N²). For example, even in Rust a 3600 angled
viewshed takes ~20s to create its GeoJSON. And many minutes in browser JS.

Therefore we have to design an entirely new algorithm that takes
advantage of the fact that we are only ever adding new pieces to the
growing viewshed in a very specific way. Namely we only ever add polar
segments, that are always contained within the arc of a single angle,
onto the _side_ of a polygon. This returns polygon reconstruction to a
~200ms for 3600 angled viewsheds.
@tombh tombh force-pushed the tombh/adjacent-polar-viewshed-builder branch from d343bc7 to ac0d147 Compare June 28, 2026 09:28
@tombh tombh marked this pull request as ready for review June 28, 2026 09:31
@tombh

tombh commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Benchmark viewshed is now pixel-perfect with the existing one. Though note that the file size is slightly smaller due to a tidier algorithm.

@tombh tombh requested a review from ryan-berger June 28, 2026 09:32

@ryan-berger ryan-berger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to leave this for some changes responses, and dig into the algorithm a lot heavier tomorrow

Comment thread crates/total-viewsheds/src/output/viewsheds/growable_polygon.rs

/// Keeps track of active and completed polygons within a viewshed.
#[derive(Default)]
pub struct Joiner {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To my eyes this shouldn't be public, and this file should be a single function, the idea of completed and active are internal implementation details, no?

In other words you can just have a fn join_segments([Vec<Segment>]) -> Result<geo::MultiPolygon>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made a separate PR addressing all the pub issues: #68

But I'm not sure how you mean the file should be a single function? Where should the other code go? In that PR I've separated the Joiner definition into its own file so that the implementation files can inherit it and this avoid the need for any visibility declarations at all, maybe that's what you mean?

Comment thread crates/total-viewsheds/src/output/viewsheds/joiner/last.rs
.get_mut(starting_polygon_index)
.context("Bad polygon index")?
.vertices
.clone()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why clone here? Can the borrow checker not figure out the borrow in both arms?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can figure it out. But that's not the issue. Inside the iterator that is borrowing the vertices there's a call to a &mut self method self.join_final_polygon().

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, that does complicate things. Let me re-review this with that in mind and think about the self borrow...

}
}

if let Some(touching_start) = maybe_touching_start

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be turned into:

let range = match (maybe_touching_start, maybe_touching_end) {
    (Some(start), Some(end)) => start..end
    _ => { return Ok(false) }
}

match (maybe_joining_polygon) {
   (Some(joining_polygon) => { /* join_non_starting */ }
   None => { /* join segment */ }
}

Ok(true)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Niiice chefs kiss

/// polygon as follows:
/// * `Opening::NewStart/NewEnd` become `Opening::Start/End`.
/// * `Opening::Start/End` become `Opening::Null`.
pub fn downgrade_openings(&mut self) -> Result<()> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a good panic! function?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually going to convert the whole thing to asserts/panics. For compiling to WASM there's no point having fancy error handling just increasing the bundle size.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, sounds good to me

Comment thread crates/total-viewsheds/src/output/viewsheds/growable_polygon.rs

/// A vertex is a single point in a polygon.
#[derive(Debug, Clone)]
pub struct Vertex {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably can be pub(crate) no?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #68

let mut maybe_touching_start = None;
let mut maybe_touching_end = None;

let (base_polygon, maybe_joining_polygon) =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be turned into:

let base_polygon =  self
                    .active
                    .get_mut(base_polygon_index)
                    .context("Bad polygon index")?;

let maybe_joining_polygon = maybe_joining_polygon_index
    .and_then(|index| { /* get completed code */ })

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So niiice 🙇

let completed = self
.active
.extract_if(.., |polygon| !polygon.is_touched)
.collect::<Vec<_>>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you have to collect() before you extend()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, nice.

Adds the following lints:

  unreachable_pub = "warn"
  redundant_pub_crate = "allow"

This enforces the practice of explicitly defining visibility. Therefore,
if a symbol isn't truly public to the outside world then it should be
qualified with `pub(crate)`, `pub(super)`, etc.

This is slightly more verbose but helps reasoning about API surfaces and
also safeguards against unexpected symbols becoming public when parent
visibility is changed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants