Skip to content

feat: add theta_star, an any-angle search - #830

Draft
tachsin wants to merge 1 commit into
evenfurther:mainfrom
tachsin:feat/theta-star
Draft

tachsin wants to merge 1 commit into
evenfurther:mainfrom
tachsin:feat/theta-star

Conversation

@tachsin

@tachsin tachsin commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Draft for #829 — something concrete to react to, not a finished proposal. If the answer to question 1 over there is "no", close this and nothing is lost.

What it does

astar with one changed relaxation step. On reaching a node, the search first asks whether the node it came from can see the successor directly; if so the successor is attached to that node by a straight line instead of stepping through the current one. The waypoints stay graph nodes, the segments between them do not.

pub fn theta_star<N, C, FN, IN, FH, FL, FS>(
    start: &N,
    successors: FN,
    heuristic: FH,
    sight: FL,     // FnMut(&N, &N) -> Option<C>
    success: FS,
) -> Option<(Vec<N>, C)>

sight gives the cost of a straight line between two nodes that need not be neighbours, or None when it is blocked. That is the only thing Theta* needs that astar does not, so line-of-sight and the straight-line metric both stay with the caller and nothing here gains a dependency on coordinates or floating point.

150 lines of algorithm, 207 of tests.

The two caveats from the issue, as implemented

Both are documented on the function under their own heading rather than buried:

  • Not optimal. Never longer than the astar path, usually close to the true any-angle optimum, but no guarantee — that needs a visibility graph. First inexact search in the crate.
  • Consecutive path nodes need not be neighbours. path.windows(2) are straight lines, not edges of successors.

There is also a stated requirement on the caller: sight has to agree with successors in the triangle-inequality sense, or the shortcut can make a path worse and the reported cost stops matching it.

Testing

Three properties, on 8-connected grids with randomly blocked cells:

  • With sight always returning None, the result equals astar exactly — path and cost, over 40 random maps. The shortcut is the only difference, so refusing every shortcut must reproduce astar.
  • On open ground the path is [start, goal] at exactly the straight-line cost.
  • On 60 random maps: never longer than astar, every segment is a clear line, the segment lengths sum to the cost reported, and no waypoint repeats. The test also asserts it is shorter than astar on most maps, so a version that silently stopped taking shortcuts would fail rather than pass quietly.

Plus a walled-off goal returning None.

296 tests pass, clippy and rustfmt clean.

One thing I would flag for review

SmallestCostHolder is shared with astar rather than duplicated — it becomes pub(super) with pub(super) fields. No public API change, but it does mean astar.rs is touched by this. Happy to duplicate the 30 lines instead if you would rather the modules stayed independent.

Still open from #829: whether sight should be one closure or two, whether a line-of-sight helper on Grid would be welcome, and whether Lazy Theta* is worth having alongside. None of those are settled here.

`astar` returns paths made of graph edges, so on a grid it can only travel in
the directions the grid offers and crosses open ground as a staircase. Theta*
keeps the same search order but, on reaching a node, first asks whether the
node it came from can see the successor directly, and joins them in a straight
line when it can.

The geometry stays with the caller. The algorithm needs one thing `astar` does
not — given two nodes that may not be neighbours, can you travel straight
between them and at what cost — and that is a single closure, so `N` remains an
opaque hashable value and `C` remains `Zero + Ord + Copy`. Nothing here gains a
dependency on coordinates or floating point.

Two ways this differs from the rest of the crate, both documented on the
function: the path is not optimal, and consecutive nodes of the path need not
be neighbours, since the segments between waypoints are straight lines rather
than edges.

`SmallestCostHolder` is shared with `astar` rather than duplicated; it becomes
visible to the module, with no change to the public API.

Tests cover the three properties that matter: with sight always blocked the
result is exactly what `astar` returns, on open ground the path collapses to
its endpoints, and on random maps the path is never longer than `astar`'s while
every segment is a clear line whose lengths add up to the cost reported.
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.

1 participant