Packages

An A-star 2D polygon map search implementation and library for Elixir

Current section

Files

Jump to
scurry lib wx demo.ex
Raw

lib/wx/demo.ex

defmodule Scurry.Wx do
@moduledoc false
require Logger
alias Scurry.Vector
alias Scurry.Polygon
alias Scurry.PolygonMap
alias Scurry.Astar
alias Scurry.WxEnum
@behaviour :wx_object
@title "A-star Wx Demo"
@width 640
@height 500
@size {@width, @height}
@spec start() :: no_return()
def start() do
start_link([])
end
@dialyzer {:nowarn_function, start_link: 1}
def start_link(args) do
:wx_object.start_link(__MODULE__, args, name: __MODULE__)
end
@impl true
def init(_args) do
Logger.info("starting")
fps = Application.get_env(:scurry, :fps, 30)
slice = trunc(1_000 / fps)
# Setup window
wx = :wx.new([{:debug, :verbose}, {:silent_start, false}])
# Setup frame and allow resizing/handle close.
frame_id = System.unique_integer([:positive, :monotonic])
frame = :wxFrame.new(wx, frame_id, @title, size: @size)
:wxFrame.connect(frame, :size, [:callback])
:wxFrame.connect(frame, :close_window)
# Create panel we click/draw on.
panel = :wxPanel.new(frame, size: @size)
:wxPanel.connect(panel, :paint, [:callback])
:wxPanel.connect(panel, :left_up)
:wxPanel.connect(panel, :left_down)
:wxPanel.connect(panel, :motion)
:wxPanel.connect(panel, :enter_window)
:wxPanel.connect(panel, :leave_window)
sizer = :wxBoxSizer.new(WxEnum.wxVERTICAL())
:wxSizer.add(sizer, panel, proportion: 1, flag: WxEnum.wxSHAPED())
:wxSizer.setMinSize(sizer, @size)
:wxFrame.setSizerAndFit(frame, sizer)
:wxFrame.show(frame)
cursor = :wxCursor.new(WxEnum.wxCURSOR_BLANK())
:wxWindow.setCursor(panel, cursor)
# This is what we draw on.
blitmap = :wxBitmap.new(@width, @height)
memory_dc = :wxMemoryDC.new(blitmap)
Logger.info("starting timer #{slice}ms")
timer_ref = Process.send_after(self(), :tick, slice)
{start, polygons} = load("complex")
{polygon, holes} = classify_polygons(polygons)
walk_vertices = PolygonMap.get_vertices(polygon, holes)
walk_graph = PolygonMap.create_graph(polygon, holes, walk_vertices)
Logger.info("walk graphs = #{inspect(walk_graph, pretty: true)}")
state = %{
wx_frame: frame,
wx_panel: panel,
wx_memory_dc: memory_dc,
wx_sizer: sizer,
updated_at: nil,
timer_ref: timer_ref,
slice: slice,
# Start point to search from
start: start,
# Where the cursor is, also our stop point for saerch
cursor: nil,
polygon: polygon,
holes: holes,
# This is the list of fixed vertices (from the map) that we will draw
fixed_walk_vertices: walk_vertices,
# This is the fixed walk graph (from fixed_walk_vertices) that we'll
# outline in red
fixed_walk_graph: walk_graph,
# This is the computed path we'll draw in green
path: [],
# This is the extended walk graph path we'll draw in orange while the
# mouse is being pressed
click_walk_graph: nil
}
{frame, state}
end
##
## Wx Async Events
##
@impl true
def handle_event({:wx, _, _, _, {:wxSize, :size, {w, h} = _size, _}} = event, state) do
Logger.info("received size event: #{inspect(event)}")
# {w, h} = wx_aspect_ratio({w, h}, 640 / 480)
# Logger.info("size #{inspect size} = #{w} #{h}")
# :wxEvent.skip(event)
:wxFrame.layout(state.wx_frame)
:wxPanel.setMinSize(state.wx_panel, {w, h})
:wxFrame.fit(state.wx_frame)
{:noreply, state}
end
@impl true
def handle_event({:wx, _, _, _, {:wxClose, :close_window}} = event, state) do
Logger.info("received close event: #{inspect(event)}")
{:stop, :normal, state}
end
@impl true
def handle_event(
{:wx, _id, _wx_ref, _something,
{:wxMouse, :enter_window, x, y, _left_down, _middle_down, _right_down, _control_down,
_shift_down, _alt_down, _meta_down, _wheel_rotation, _wheel_delta,
_lines_per_action}} = _event,
state
) do
{:noreply, %{state | cursor: {x, y}}}
end
@impl true
def handle_event(
{:wx, _id, _wx_ref, _something,
{:wxMouse, :leave_window, _x, _y, _left_down, _middle_down, _right_down, _control_down,
_shift_down, _alt_down, _meta_down, _wheel_rotation, _wheel_delta,
_lines_per_action}} = _event,
state
) do
{:noreply, %{state | cursor: nil}}
end
@impl true
def handle_event(
{:wx, _id, _wx_ref, _something,
{:wxMouse, :motion, x, y, true, _middle_down, _right_down, _control_down, _shift_down,
_alt_down, _meta_down, _wheel_rotation, _wheel_delta, _lines_per_action}} = _event,
state
) do
stop = {x, y}
{graph, _vertices, path} =
get_updated_graph_vertices_path(
state.polygon,
state.holes,
state.fixed_walk_vertices,
state.fixed_walk_graph,
state.start,
stop
)
{:noreply,
%{
state
| click_walk_graph: graph,
cursor: stop,
path: path
}}
end
@impl true
def handle_event(
{:wx, _id, _wx_ref, _something,
{:wxMouse, :motion, x, y, _left_down, _middle_down, _right_down, _control_down,
_shift_down, _alt_down, _meta_down, _wheel_rotation, _wheel_delta,
_lines_per_action}} = _event,
state
) do
{:noreply, %{state | cursor: {x, y}}}
end
@impl true
def handle_event(
{:wx, _id, _wx_ref, _something,
{:wxMouse, :left_down, x, y, _left_down, _middle_down, _right_down, _control_down,
_shift_down, _alt_down, _meta_down, _wheel_rotation, _wheel_delta,
_lines_per_action}} = _event,
state
) do
Logger.info("click #{inspect({x, y})}")
stop = {x, y}
{graph, _vertices, path} =
get_updated_graph_vertices_path(
state.polygon,
state.holes,
state.fixed_walk_vertices,
state.fixed_walk_graph,
state.start,
stop
)
{:noreply,
%{
state
| click_walk_graph: graph,
path: path
}}
end
@impl true
def handle_event(
{:wx, _id, _wx_ref, _something,
{:wxMouse, :left_up, x, y, _left_up, _middle_down, _right_down, _control_down,
_shift_down, _alt_down, _meta_down, _wheel_rotation, _wheel_delta,
_lines_per_action}} = _event,
state
) do
Logger.info("click #{inspect({x, y})}")
# Reset fields so we only show the debug graph
{:noreply,
%{
state
| click_walk_graph: nil,
start: {x, y},
path: []
}}
end
@impl true
def handle_event({:wx, _, _, _, _} = event, state) do
Logger.info("unhandled wx event #{inspect(event, pretty: true)}")
{:noreply, state}
end
##
## Wx Sync Events
##
@impl true
def handle_sync_event({:wx, _, _, _, {:wxSize, :size, _, _}} = _xevent, wxo, state) do
:wxEvent.skip(wxo)
:wxFrame.layout(state.wx_frame)
panel_size = :wxPanel.getSize(state.wx_panel)
:wxPanel.setMinSize(state.wx_panel, panel_size)
:wxFrame.fit(state.wx_frame)
:ok
end
@impl true
def handle_sync_event({:wx, _, _, _, {:wxPaint, :paint}} = _event, _, state) do
dc = state.wx_memory_dc
wx_cls(dc)
{w, _h} = :wxPanel.getSize(state.wx_panel)
scale = w / 640
draw_polygons(dc, state)
draw_a_b_line(dc, state)
draw_cursors(dc, state)
draw_walk_vertices(dc, state)
draw_walk_graph(dc, state)
draw_walk_path(dc, state)
# Draw
paint_dc = :wxPaintDC.new(state.wx_panel)
:wxDC.setUserScale(paint_dc, scale, scale)
:wxDC.blit(paint_dc, {0, 0}, @size, dc, {0, 0})
:wxPaintDC.destroy(paint_dc)
:ok
end
@impl true
def handle_sync_event(event, _, _state) do
Logger.info("received sync event: #{inspect(event, pretty: true)}")
:ok
end
##
## Ticks
##
@impl true
def handle_info(:tick, state) do
# This is called at the configured frame rate, and updates state (nothing
# in this example), rerenders the screen and schedules the timer for next
# frame.
{elapsed_usec, {:ok, new_state}} =
:timer.tc(fn ->
new_state = update(state)
:ok = render(new_state)
{:ok, new_state}
end)
pause = max(0, state[:slice] - trunc(elapsed_usec / 1_000))
timer_ref = Process.send_after(self(), :tick, pause)
{:noreply, %{new_state | timer_ref: timer_ref}}
end
@impl true
def handle_info({:EXIT, _pid, reason}, _state) do
Logger.info("A child process died: #{reason}")
end
@impl true
def handle_info(msg, state) do
Logger.error("received unexpected message: #{inspect(msg)}")
{:noreply, state}
end
@impl true
def terminate(reason, state) do
Logger.info("Terminating, #{inspect(reason)}")
stop(state)
exit(reason)
end
##
## Gfx Loop
##
def update(state) do
state
end
def render(state) do
:wxPanel.refresh(state.wx_panel, eraseBackground: false)
end
def stop(_state) do
Logger.info("stopping")
end
##
## Render helper funtions
##
def draw_cursors(_dc, %{cursor: nil} = _state) do
:ok
end
def draw_cursors(dc, state) do
light_gray = {211, 211, 211}
bright_red = {255, 0, 0}
bright_green = {0, 255, 0}
# Draw start point bright green
wx_crosshair(dc, state.start, bright_green, size: 6)
# Draw stop (cursor) gray if inside hole (and inside main polygon), red
# otherwise.
if Polygon.is_inside?(state.polygon, state.cursor) do
if Enum.any?(state.holes, &Polygon.is_inside?(&1, state.cursor)) do
wx_crosshair(dc, state.cursor, light_gray, size: 6)
else
wx_crosshair(dc, state.cursor, bright_red, size: 6)
end
else
wx_crosshair(dc, state.cursor, light_gray, size: 6)
end
end
def draw_polygons(dc, state) do
blue = {0, 150, 255}
opaque_blue = {0, 150, 255, 64}
blue_pen = :wxPen.new(blue, [{:width, 1}, {:style, WxEnum.wxSOLID()}])
brush = :wxBrush.new({0, 0, 0}, [{:style, WxEnum.wxSOLID()}])
opaque_blue_brush = :wxBrush.new(opaque_blue, [{:style, WxEnum.wxSOLID()}])
:wxDC.setBrush(dc, opaque_blue_brush)
:wxDC.setPen(dc, blue_pen)
:ok = :wxDC.drawPolygon(dc, state.polygon)
for point <- state.polygon do
wx_crosshair(dc, point, blue)
end
:wxDC.setBrush(dc, brush)
:wxDC.setPen(dc, blue_pen)
for hole <- state.holes do
:ok = :wxDC.drawPolygon(dc, hole)
for point <- hole do
wx_crosshair(dc, point, blue)
end
end
:wxPen.destroy(blue_pen)
:wxBrush.destroy(brush)
:wxBrush.destroy(opaque_blue_brush)
end
def draw_walk_vertices(dc, state) do
blue = {0, 150, 255}
opaque_blue = {0, 150, 255, 64}
blue_pen = :wxPen.new(blue, [{:width, 1}, {:style, WxEnum.wxSOLID()}])
brush = :wxBrush.new(opaque_blue, [{:style, WxEnum.wxSOLID()}])
:wxDC.setPen(dc, blue_pen)
:wxDC.setBrush(dc, brush)
for point <- state.fixed_walk_vertices do
:wxDC.drawCircle(dc, point, 5)
end
:wxPen.destroy(blue_pen)
:wxBrush.destroy(brush)
end
def draw_a_b_line(_dc, %{cursor: nil} = _state) do
:ok
end
def draw_a_b_line(dc, state) do
brush = :wxBrush.new({0, 0, 0}, [{:style, WxEnum.wxTRANSPARENT()}])
:wxDC.setBrush(dc, brush)
light_gray = {211, 211, 211, 128}
light_gray_pen = :wxPen.new(light_gray, [{:width, 1}, {:style, WxEnum.wxSOLID()}])
bright_green = {0, 255, 0}
bright_green_pen = :wxPen.new(bright_green, [{:width, 1}, {:style, WxEnum.wxSOLID()}])
start = state.start
stop = state.cursor
line = {state.start, state.cursor}
if PolygonMap.is_line_of_sight?(state.polygon, state.holes, line) do
:wxDC.setPen(dc, bright_green_pen)
else
:wxDC.setPen(dc, light_gray_pen)
end
:ok = :wxDC.drawLine(dc, start, stop)
intersections =
for poly <- [state.polygon] ++ state.holes do
Polygon.intersections(poly, line)
end
|> List.flatten()
|> Enum.sort(fn ia, ib ->
v1 = Vector.sub(start, ia)
v2 = Vector.sub(start, ib)
# Sort closest to furthest
Vector.distance(start, v1) > Vector.distance(start, v2)
end)
|> Enum.map(&Vector.trunc_pos(&1))
for p <- intersections do
wx_crosshair(dc, p, light_gray, size: 3)
end
case intersections do
[] -> nil
[p | _] -> wx_crosshair(dc, p, {255, 0, 0}, size: 3)
end
:wxPen.destroy(light_gray_pen)
:wxPen.destroy(bright_green_pen)
:wxBrush.destroy(brush)
end
# Draw the entire walk graph when we don't have a "click"
def draw_walk_graph(dc, %{click_walk_graph: nil} = state) do
light_red = {255, 0, 0, 64}
light_red_pen = :wxPen.new(light_red, [{:width, 1}, {:style, WxEnum.wxSOLID()}])
:wxDC.setPen(dc, light_red_pen)
for {a, edges} <- state.fixed_walk_graph do
for {b, _} <- edges do
:ok = :wxDC.drawLine(dc, a, b)
end
end
:wxPen.destroy(light_red_pen)
end
# Draw the entire walk graph when we have a "click"
def draw_walk_graph(dc, state) do
bright_red = {255, 87, 51, 128}
bright_red_pen = :wxPen.new(bright_red, [{:width, 1}, {:style, WxEnum.wxSOLID()}])
:wxDC.setPen(dc, bright_red_pen)
for {a, edges} <- state.click_walk_graph do
for {b, _} <- edges do
:ok = :wxDC.drawLine(dc, a, b)
end
end
:wxPen.destroy(bright_red_pen)
end
def draw_walk_path(dc, state) do
bright_green_pen = :wxPen.new({64, 255, 64}, [{:width, 2}, {:style, WxEnum.wxSOLID()}])
:wxDC.setPen(dc, bright_green_pen)
pointsets = Enum.chunk_every(state.path, 2, 1)
Enum.map(pointsets, fn
[a, b] ->
:ok = :wxDC.drawLine(dc, a, b)
_ ->
:ok
end)
:wxPen.destroy(bright_green_pen)
end
# Convert time in microseconds to "pretty" time.
defp usec_to_str(usec) when usec < 1_000 do
"#{usec}µs"
end
defp usec_to_str(usec) when usec < 1_000_000 do
"#{usec / 1_000}ms"
end
defp usec_to_str(usec) do
"#{usec / 1_000_000}s"
end
def get_updated_graph_vertices_path(polygon, holes, vertices, graph, start, stop) do
# Determine the nearest point inside the polygon, in case the user clicked
# outside the polygon.
np = PolygonMap.nearest_point(polygon, holes, stop)
# Extend the map to include start/stop (nearest)
{graph_usec, {new_graph, new_vertices}} =
:timer.tc(fn ->
PolygonMap.extend_graph(graph, polygon, holes, vertices, [start, np])
end)
# Search the graph from start to np using euclidean distance heur_fun
{astar_usec, path} =
:timer.tc(fn ->
astar = Astar.search(new_graph, start, np, fn a, b -> Vector.distance(a, b) end)
Astar.path(astar)
end)
# Curtesy compute distance for the user
distance =
path
|> Enum.chunk_every(2, 1)
|> Enum.reduce(0, fn
[a, b], acc -> acc + Vector.distance(a, b)
_, acc -> acc
end)
# make it a float so Float.floor works
|> Kernel.*(1.0)
|> Float.floor(2)
Logger.info(
"graph extend = #{usec_to_str(graph_usec)} a-star = #{usec_to_str(astar_usec)} distance = #{distance}"
)
{new_graph, new_vertices, path}
end
##
## WxWidgets helper methods to draw things
##
@doc """
Draw a crosshair at the given `{x, y}` and with the given `color`
Returns `:ok`
## Parameters
* `dc`, a `wxDC` context to draw in
* `pos`, a `{x, y}` coordinate to center the crosshair at
* `color` a `{r, g, b}` colour value
## Options
* `:width`, width of the pen. 1 is thin given the context, 2 is close to the
pixel size.
* `:size`, the length of each crosshair.
"""
def wx_crosshair(dc, pos, color, options \\ []) do
{x, y} = pos
width = Keyword.get(options, :width, 1)
size = Keyword.get(options, :size, 2)
pen = :wxPen.new(color, [{:width, width}, {:style, WxEnum.wxSOLID()}])
:wxDC.setPen(dc, pen)
:ok = :wxDC.drawLine(dc, {x, y - size}, {x, y + size})
:ok = :wxDC.drawLine(dc, {x - size, y}, {x + size, y})
:wxPen.destroy(pen)
:ok
end
def wx_cls(dc, color) do
brush = :wxBrush.new(color, [{:style, WxEnum.wxSOLID()}])
:ok = :wxDC.setBackground(dc, brush)
:ok = :wxDC.clear(dc)
:wxBrush.destroy(brush)
end
def wx_cls(dc) do
wx_cls(dc, {0, 0, 0})
end
def wx_aspect_ratio({w, h} = _size, ratio) do
cond do
w / h > ratio ->
{w, round(w / ratio)}
true ->
{round(h * ratio), h}
end
end
##
## Helper methods to load json map
##
# Transform a json `[x, y]` list to a `{x, y}` tuple and ensure it's a integer (trunc)
defp transform_point([x, y]) do
{round(x), round(y)}
end
# Transform a json polygon, `name, [[x, y], [x, y]...]` list to a `{name, [{x, y}, ...]}`.
defp transform_walkbox({name, points}) do
points = Enum.map(points, &transform_point(&1))
{name, points}
end
defp transform_walkboxes(polygons) do
polygons
|> Enum.map(&transform_walkbox(&1))
end
# In case the json polygon is closed (last == first) point, drop the last
# since we handle them as open.
defp unclose_walkbox({name, points}) do
if Enum.at(points, 0) == Enum.at(points, -1) do
{name, Enum.drop(points, -1)}
else
{name, points}
end
end
defp unclose_walkboxes(polygons) do
polygons
|> Enum.map(&unclose_walkbox(&1))
end
@doc """
Helper to split polygons into the the main and the holes.
## Examples
iex> classify_polygons([{:main, [{0, 0}, {10, 0}, {5, 5}]}, {:hole1, [{1, 1}, {9, 1}, {4, 5}]}])
{[{0, 0}, {10, 0}, {5, 5}], [[{1, 1}, {9, 1}, {4, 5}]]}
"""
def classify_polygons(polygons) do
{mains, holes} = Enum.split_with(polygons, fn {name, _} -> name == :main end)
holes = Enum.map(holes, fn {_name, polygon} -> polygon end)
{mains[:main], holes}
end
# Quick and dirty "tap" function that'll crash earlyif any polygon isn't clockwise.
defp check_clockwise(polygons) do
true = Enum.all?(polygons, fn {_name, polygon} -> Polygon.is_clockwise?(polygon) end)
end
@doc """
Load and prepare a a json file from `priv/`.
Eg. `load("complex")` will load `priv/complex.json`.
## Examples
iex> load("scene1")
{{50, 50}, [hole: [{300, 200}, {400, 200}, {400, 300}, {300, 300}], main: [{40, 40}, {590, 40}, {590, 460}, {40, 460}]]}
"""
def load(scene) do
path = Application.app_dir(:scurry)
filename = "#{path}/priv/#{scene}.json"
{:ok, file} = File.read(filename)
{:ok, json} = Poison.decode(file, keys: :atoms)
polygons =
json[:polygons]
|> transform_walkboxes
|> unclose_walkboxes
|> tap(&check_clockwise/1)
{
transform_point(json[:start]),
polygons
}
end
end