Jan 31, 2025

Practicing graphical debugging using too many visualizations of the Hilbert curve

..you don't understand things, you just get used to them.”
— John von Neumann

For a while now I've been advocating for a particular style of programming:

Lua and LÖVE have been one nice way to get these properties. As I've used them, I've enjoyed an additional benefit: the ubiquitous presence of a canvas I can draw on as I program. This has been new to me with my erstwhile conservative and terminal-bound habits, and I've been pushing myself to lean more on graphics to understand what my programs are doing. Here I want to share one such experience. I'm using my run-anywhere Lua Carousel app, and you can paste the programs directly into it, but the workflow translates to any platform with a canvas.

A few weeks ago Jack Rusher shared a baffling function to compute the Hilbert curve. Here it is, translated to Lua:

function h(x, y, xi, yi, xj, yj, n)
  if n <= 0 then
    return {x+xi/2+xj/2, y+yi/2+yj/2}
  end
  return array_join(
    h(x, y, xj/2, yj/2, xi/2, yi/2, n-1),
    h(x+xi/2, y+yi/2, xi/2, yi/2, xj/2, yj/2, n-1),
    h(x+xi/2+xj/2, y+yi/2+yj/2, xi/2, yi/2, xj/2, yj/2, n-1),
    h(x+xi/2+xj, y+yi/2+yj,  -xj/2, -yj/2, -xi/2, -yi/2, n-1))
end

When I first looked at it, I could see a few superficial facts:

But the details were still unclear. Why the swaps/rotations? Why the negative signs in one of the 4 quadrants? Looking for answers led me to several iterations and some graphical infrastructure that promises to help with my next debugging task.

v1: The first thing to look at is the curve itself. A single blue continuous fractal space-filling Hilbert curve made of straight lines bending in perpendicular corners

code
function h(x, y, xi, yi, xj, yj, n)
  if n <= 0 then
    return {{x=x+xi/2+xj/2, y=y+yi/2+yj/2}}
  end
  return array_join(
    h(x, y, xj/2, yj/2, xi/2, yi/2, n-1),
    h(x+xi/2, y+yi/2, xi/2, yi/2, xj/2, yj/2, n-1),
    h(x+xi/2+xj/2, y+yi/2+yj/2, xi/2, yi/2, xj/2, yj/2, n-1),
    h(x+xi/2+xj, y+yi/2+yj,  -xj/2, -yj/2, -xi/2, -yi/2, n-1))
end

function array_join(...)
  local result = {}
  for i, arg in ipairs{...} do
    for _,x in ipairs(arg) do
      table.insert(result, x)
    end end
  return result
end

local pts = h(60, 60, 800, 0, 0, 800, 5)

function car.draw()
  color(0,0,1)
  line(unpack(pts))
end

This uses some some abbreviations from Lua Carousel. We save the list of points and draw them as a polyline.

Compare the L-system based implementation on Wikipedia:

code

function lsys(s)
  local result = {}
  for i=1,#s do
    local c = s:sub(i,i)
    if c == 'A' then
      table.insert(result, '+BF-AFA-FB+')
    elseif c == 'B' then
      table.insert(result, '-AF+BFB+FA-')
    else
      table.insert(result, c)
    end
  end
  return table.concat(result)
end

function draw_lsys(s)
  for i=1,#s do
    local c = s:sub(i,i)
    if c == 'F' then
      forward()
    elseif c == '+' then
      left()
    elseif c == '-' then
      right()
    end end end

function forward()
  local x2 = x+dirx*n
  local y2 = y+diry*n
  line(x,y, x2,y2)
  x,y = x2,y2
end

function left()
  if dirx == 0 then
    dirx = diry
    diry = 0
  else
    diry = -dirx
    dirx = 0
  end end

function right()
  if dirx == 0 then
    dirx = -diry
    diry = 0
  else
    diry = dirx
    dirx = 0
  end end

x,y = 100,100
dirx,diry = 0,1
n = 10
g.setLineWidth(3)
color(1,0,1, 0.1)

s = 'A'
for _ = 1,5 do
  s = lsys(s)
end
draw_lsys(s)
There's nothing in common!

How does it work?!

v2: Print out the sequence of calls in Jack's program.

code
function h(x, y, xi, yi, xj, yj, n)
  print(x,y, xi,yi, xj,yj)
  if n <= 0 then
    return {{x=x+xi/2+xj/2, y=y+yi/2+yj/2}}
  end
  return array_join(
    h(x, y, xj/2, yj/2, xi/2, yi/2, n-1),
    h(x+xi/2, y+yi/2, xi/2, yi/2, xj/2, yj/2, n-1),
    h(x+xi/2+xj/2, y+yi/2+yj/2, xi/2, yi/2, xj/2, yj/2, n-1),
    h(x+xi/2+xj, y+yi/2+yj,  -xj/2, -yj/2, -xi/2, -yi/2, n-1))
end

h(0,0, 800,0, 0,800, 2)

To keep the output manageable, we'll look at just a second order Hilbert curve (so the final n input is 2).

Running this results in the following output.
60      60      800     0       0       800
60      60      0       400     400     0
60      60      200     0       0       200
60      260     0       200     200     0
260     260     0       200     200     0
460     260     -200    -0      -0      -200
460     60      400     0       0       400
460     60      0       200     200     0
660     60      200     0       0       200
660     260     200     0       0       200
660     460     -0      -200    -200    -0
460     460     400     0       0       400
460     460     0       200     200     0
660     460     200     0       0       200
660     660     200     0       0       200
660     860     -0      -200    -200    -0
460     860     -0      -400    -400    -0
460     860     -200    -0      -0      -200
460     660     -0      -200    -200    -0
260     660     -0      -200    -200    -0
60      660     200     0       0       200

Looking at this, some facts are clear without needing to think too hard:

But what do those xi, yi, xj, yj parameters mean beyond the metronomic division by 2? It's still unclear.

v3: Let's look at Jack's original animation.

code
function h(x, y, xi, yi, xj, yj, n)
  if n <= 0 then
    return {{x=x+xi/2+xj/2, y=y+yi/2+yj/2}}
  end
  return array_join(
    h(x, y, xj/2, yj/2, xi/2, yi/2, n-1),
    h(x+xi/2, y+yi/2, xi/2, yi/2, xj/2, yj/2, n-1),
    h(x+xi/2+xj/2, y+yi/2+yj/2, xi/2, yi/2, xj/2, yj/2, n-1),
    h(x+xi/2+xj, y+yi/2+yj,  -xj/2, -yj/2, -xi/2, -yi/2, n-1))
end

function array_join(...)
  local result = {}
  for i, arg in ipairs{...} do
    for _,x in ipairs(arg) do
      table.insert(result, x)
    end end
  return result
end

local pts = h(60, 60, 800, 0, 0, 800, 5)
local curr_index = 0
local speed = 100

function car.draw()
  color(0,0,0)
  for i=2,curr_index do
    color(0,0,0)
    line(pts[i-1].x, pts[i-1].y, pts[i].x, pts[i].y)
  end
end

function car.update(dt)
  curr_index = curr_index + dt*speed
  if curr_index > #pts then curr_index = #pts end
end

It shows the order in which leaf calls are computed, but that path is pretty complicated.

v4: Maybe it'll help to see a few iterations next to each other.

4 iterations of the Hilbert curve lined up left to right, from first to fourth order. Each occupies a square area, and uses 2x2 times more space than the lower order so that all contain equal density of blue lines.

code
function initialize_curves()
  local pts = {}
  local side = 80
  local x, y = 60, 60
  for n=1,4 do
    table.insert(pts, h(x,y, side,0, 0,side, n))
    x = x + side + 40
    side = side*2
  end
  return pts
end

local pts = initialize_curves()

function car.draw()
  color(0,0,1)
  for _, pts in ipairs(pts) do
    draw_lines(pts)
  end end

function draw_lines(pts)
  for i=2,#pts do
    line(pts[i-1].x, pts[i-1].y, pts[i].x, pts[i].y)
  end end

Hmm, not so much. It's helpful to see the first iteration in particular. 4 calls yield 4 points, which string together into 3 lines that don't quite complete a square. But beyond that, it's still murky.

v5: As I said, only the leaf calls actually add any points. What if we show more details for each of them? Each leaf call uses 3 points on the way to adding one point to the result.

The previous picture of four Hilbert curves of orders 1-4, but now each point on it is also connected to one red point and two green points

code
function h(x, y, xi, yi, xj, yj, n)
  if n <= 0 then
    local resultx, resulty = x+xi/2+xj/2, y+yi/2+yj/2
    local x3, y3 = x+xi, y+yi
    local x4, y4 = x+xj, y+yj
    local debug = {
      {type='circle', drawmode='fill', x=x, y=y, radius=2, r=1,g=0,b=0},
      {type='line', x1=resultx, y1=resulty, x2=x, y2=y, r=1,g=0.5,b=0.5},
      {type='circle', drawmode='line', x=x3, y=y3, radius=5, r=0, g=1, b=0},
      {type='line', x1=resultx, y1=resulty, x2=x3, y2=y3, r=0.5,g=1,b=0.5},
      {type='circle', drawmode='line', x=x4, y=y4, radius=5, r=0, g=1, b=0},
      {type='line', x1=resultx, y1=resulty, x2=x4, y2=y4, r=0.5,g=1,b=0.5},
    }
    return {{x = resultx, y = resulty, draw=debug}}
  end
  return array_join(
    h(x, y, xj/2, yj/2, xi/2, yi/2, n-1),
    h(x+xi/2, y+yi/2, xi/2, yi/2, xj/2, yj/2, n-1),
    h(x+xi/2+xj/2, y+yi/2+yj/2, xi/2, yi/2, xj/2, yj/2, n-1),
    h(x+xi/2+xj, y+yi/2+yj,  -xj/2, -yj/2, -xi/2, -yi/2, n-1))
end

function array_join(...)
  local result = {}
  for i, arg in ipairs{...} do
    for _,x in ipairs(arg) do
      table.insert(result, x)
    end end
  return result
end

function initialize_curves()
  local pts = {}
  local side = 80
  local x, y = 60, 60
  for n=1,4 do
    table.insert(pts, h(x,y, side,0, 0,side, n))
    x = x + side + 40
    side = side*2
  end
  return pts
end

local pts = initialize_curves()

function draw_hilbert(pts)
  color(0,0,1)
    g.setLineWidth(2)
  for i=2,#pts do
    line(pts[i-1].x, pts[i-1].y, pts[i].x, pts[i].y)
  end
  for _, pt in ipairs(pts) do
    if pt.draw then
      for _,shape in ipairs(pt.draw) do
        color(shape.r, shape.g, shape.b, shape.a)
        if shape.type == 'circle' then
          circle(shape.drawmode, shape.x, shape.y, shape.radius)
        elseif shape.type == 'line' then
          line(shape.x1, shape.y1, shape.x2, shape.y2)
        end end end end
end

function car.draw()
  for _, pts in ipairs(pts) do
    draw_hilbert(pts)
  end end

This is pretty. Every point can now contain a bag of debug data, commands to draw additional shapes. Since xi, yi, xj, yj are all distances not positions, I'm plotting (x+xi, y+yi) and (x+xj, y+yj), and it becomes obvious how the 3 points collaborate to form each point on the Hilbert curve. It becomes apparent that the control points are always oriented north-west to south-east, translating the base point (x, y) along a north-east to south-west orientation (the red lines).

But what's the pattern beyond that orientation? There's still more to dig here.

v6: Perhaps it would help to look at the scaffolding. Instead of showing me how the three points form the fourth, show me the “envelope” for each recursive call. Hilbert curve of order 4 drawn as before with 3 control points connected to each point on the curve, but now there are also irregular straight grey lines showing some of the edges of sub-squares within the main square. The whole looks quite busy.

code

local ox,oy = 300,100  -- where to start drawing
local N = 800  -- size of the drawing
local depth = 4  -- levels of recursion; 0 = single point

-- colors
local primary = {r=1, g=0.8, b=0}
local control = {r=0, g=0.8, b=0.8}
local c = 0.8
local scaffold = {r=c, g=c, b=c}

function h(x, y, xi, yi, xj, yj, n, N)
  if N == nil then N = n end
  local x3, y3 = x+xi, y+yi
  local x4, y4 = x+xj, y+yj
  if n <= 0 then
  local resultx, resulty = x+xi/2+yi/2, y+yi/2+yj/2
  local debug = {
    {type='circle', drawmode='fill', x=x, y=y, radius=5, color=primary},
    {type='line', x1=resultx, y1=resulty, x2=x, y2=y, color=primary},
    {type='circle', drawmode='line', x=x3, y=y3, radius=10, color=control},
    {type='line', x1=resultx, y1=resulty, x2=x3, y2=y3, color=control},
    {type='circle', drawmode='line', x=x4, y=y4, radius=10, color=control},
    {type='line', x1=resultx, y1=resulty, x2=x4, y2=y4, color=control},
  }
    return {{x = resultx, y = resulty, draw=debug}}
  end
  local result = array_join(
    h(x, y, xj/2, yj/2, xi/2, yi/2, n-1, N),
    h(x+xi/2, y+yi/2, xi/2, yi/2, xj/2, yj/2, n-1, N),
    h(x+xi/2+xj/2, y+yi/2+yj/2, xi/2, yi/2, xj/2, yj/2, n-1, N),
    h(x+xi/2+xj, y+yi/2+yj,  -xj/2, -yj/2, -xi/2, -yi/2, n-1, N))
  if result[1].draw == nil then result[1].draw = {} end
  result[1].draw = array_join(result[1].draw, {
      {type='line', x1=x, y1=y, x2=x3, y2=y3, color=scaffold},
      {type='line', x1=x, y1=y, x2=x4, y2=y4, color=scaffold},
  })
  return result
end

function car.draw()
  local pts = h(0,0, N,0, 0,N, depth)
  color(0,0,1)
  love.graphics.setLineWidth(5)
  for i=2,#pts do
      line(ox+pts[i-1].x, oy+pts[i-1].y, ox+pts[i].x, oy+pts[i].y)
  end
  for _, pt in ipairs(pts) do
    if pt.draw then
      for _,shape in ipairs(pt.draw) do
        color(shape.color.r, shape.color.g, shape.color.b, shape.color.a)
        if shape.type == 'circle' then
          love.graphics.setLineWidth(1)
          circle(shape.drawmode, ox+shape.x, oy+shape.y, shape.radius)
        elseif shape.type == 'line' then
          love.graphics.setLineWidth(2)
          line(ox+shape.x1, oy+shape.y1, ox+shape.x2, oy+shape.y2)
        end end end end end

function array_join(...)
  local result = {}
  for i, arg in ipairs{...} do
    for _,x in ipairs(arg) do
      table.insert(result, x)
    end end
  return result
end

No, that's pretty but too messy. The computation is partitioned but the image is full of overlapping points and lines (in spite of my efforts at using filled and hollow circles to show two things in one place). How can we reveal the overlaps? Maybe some animation?

code
local ox,oy = 300,100  -- where to start drawing
local N = 800  -- size of the drawing
local depth = 4  -- levels of recursion; 0 = single point
local d = 0  -- instantaneous offset of the corner of the scaffold
local dmax = 10
local ddd = 10  -- how fast the corner of the scaffold moves
local dd = ddd  -- instantaneous speed of the corner of the scaffold

-- colors
local primary = {r=1, g=0.8, b=0}
local control = {r=0, g=0.8, b=0.8}
local c = 0.8
local scaffold = {r=c, g=c, b=c}

function h(x, y, xi, yi, xj, yj, n, N)
  if N == nil then N = n end
  local x3, y3 = x+xi, y+yi
  local x4, y4 = x+xj, y+yj
  if n <= 0 then
  local resultx, resulty = x+xi/2+yi/2, y+yi/2+yj/2
  local debug = {
    {type='circle', drawmode='fill', x=x, y=y, radius=5, color=primary},
    {type='line', x1=resultx, y1=resulty, x2=x, y2=y, color=primary},
    {type='circle', drawmode='line', x=x3, y=y3, radius=10, color=control},
    {type='line', x1=resultx, y1=resulty, x2=x3, y2=y3, color=control},
    {type='circle', drawmode='line', x=x4, y=y4, radius=10, color=control},
    {type='line', x1=resultx, y1=resulty, x2=x4, y2=y4, color=control},
  }
    return {{x = resultx, y = resulty, draw=debug}}
  end
  local result = array_join(
    h(x, y, xj/2, yj/2, xi/2, yi/2, n-1, N),
    h(x+xi/2, y+yi/2, xi/2, yi/2, xj/2, yj/2, n-1, N),
    h(x+xi/2+xj/2, y+yi/2+yj/2, xi/2, yi/2, xj/2, yj/2, n-1, N),
    h(x+xi/2+xj, y+yi/2+yj,  -xj/2, -yj/2, -xi/2, -yi/2, n-1, N))
  -- i's and j's always share the same sign
  -- at least one of xi and yi is always non-zero
  local dir = (xi == 0) and sign(yi) or sign(xi)
  local xs, ys = x+(N-n)*d*dir, y+(N-n)*d*dir
  result[1].draw = array_join(result[1].draw, {
    {type='line', x1=xs, y1=ys, x2=x3, y2=y3, color=scaffold},
    {type='line', x1=xs, y1=ys, x2=x4, y2=y4, color=scaffold},
  })
  return result
end

function car.draw()
  local pts = h(0,0, N,0, 0,N, depth)
  color(0,0,1)
  love.graphics.setLineWidth(5)
  for i=2,#pts do
      line(ox+pts[i-1].x, oy+pts[i-1].y, ox+pts[i].x, oy+pts[i].y)
  end
  for _, pt in ipairs(pts) do
    if pt.draw then
      for _,shape in ipairs(pt.draw) do
        color(shape.color.r, shape.color.g, shape.color.b, shape.color.a)
        if shape.type == 'circle' then
          love.graphics.setLineWidth(1)
          circle(shape.drawmode, ox+shape.x, oy+shape.y, shape.radius)
        elseif shape.type == 'line' then
          love.graphics.setLineWidth(2)
          line(ox+shape.x1, oy+shape.y1, ox+shape.x2, oy+shape.y2)
        end end end end end

function car.update(dt) 
  d = d+dd*dt
  if d >= dmax then
    d, dd = dmax, -ddd
  elseif d < 0 then
    d, dd = 0, ddd
  end end

function array_join(...)
  local result = {}
  for i, arg in ipairs{...} do
    for _,x in ipairs(arg) do
      table.insert(result, x)
    end end
  return result
end

function sign(a)
  if a > 0 then return 1
  elseif a < 0 then return -1
  else return 0
  end
end

Again, pretty. But too busy; I'm not sure I'm learning anything by staring at it.


At this point I'm starting to feel overwhelmed by the number of different versions of this program I've created. They're also competing for space with just the clean Hilbert curve, and I find myself commenting and uncommenting code to bounce between the curve and its internals. I realize I can create a dedicated space for debug UIs while also extracting a few common patterns of debug UIs that might be useful to other programs.

The dedicated space can be a goofy little window manager. But Carousel has its own eponymous metaphor for giving programs their own dedicated space/screen that you can navigate using buttons along the left and right margins even on the small screen of a phone. Let's give each debug UI its own screen. Programs write data for a specific UI under a special key in a table called `Windows`, and now debug UIs in other screens can render what they find there.

Some patterns start to come into focus:

  1. Text log. This is trivial and doesn't require its own screen. It's the starting point for generalization. I reached for it in v2 above.
  2. Replay-log. The program appends groups of shapes to a log, and they appear over time in the same order they were appended to the log. In effect, we're showing time as time, just offset, a recording with adjustable speed. This is akin to v3 above.
    code (150 lines)
    -- Debug window with a pannable, zoomable, infinite 2D surface that plays groups of vector commands
    -- in a loop.
    -- Groups cumulate; frame 2 draws shapes from groups 1 and 2, and so on.
    run_screen('ticks')
    run_screen('widgets')
    
    function debug_window_replay_log(window_name, speeds)
    
    local I = {}
    
    if Windows == nil then Windows = {} end
    if Windows.__viewport == nil then Windows.__viewport = {} end
    if Windows[window_name] == nil then Windows[window_name] = {} end
    
    -- Windows.__viewport[window_name] = nil -- uncomment to reset viewport
    if Windows.__viewport[window_name] == nil then
      run_screen('infinite-viewport')
      Windows.__viewport[window_name] = run_screen_return
      run_screen_return = nil
    end
    
    local v = Windows.__viewport[window_name]
    
    local frame_index = 0
    local speed_index = 1
    for i,speed in ipairs(speeds) do
      if speed == 1 then speed_index = i end
    end
    
    function car.draw()
      local title = ('%d/%d'):format(frame_index, #Windows[window_name])
      love.graphics.print(title, 100, Menu_bottom + 15)
      I.draw_axes()
      assert(frame_index < #Windows[window_name]+1)
      for i=1,frame_index do
        local shape_batch = Windows[window_name][i]
        I.draw_shapes(shape_batch)
      end
      -- stuff in viewport coordinates
      love.graphics.setColor(0.5,0.5,0.5)
      love.graphics.print('replay speed (shapes/s)', 50, 250-9*20)
      widgets.__draw()
      I.draw_hud()
    end
    
    function I.draw_shapes(batch)
      for i,shape in ipairs(batch) do
        I.draw_shape(shape)
      end
    end
    
    function I.draw_shape(shape)
      color(shape.r or 0.5, shape.g or 0.5, shape.b or 0.5, shape.a or 1)
      if shape.type == 'point' then
        circle('fill', v.vx(shape.x), v.vy(shape.y), 2)
      elseif shape.type == 'line' then
        line(v.vx(shape.x1), v.vy(shape.y1), v.vx(shape.x2), v.vy(shape.y2))
      elseif shape.type == 'rectangle' then
        rect(shape.drawmode or 'fill', v.vx(shape.x), v.vy(shape.y), v.scale(shape.w), v.scale(shape.h))
      elseif shape.type == 'circle' then
        circle(shape.drawmode or 'fill', v.vx(shape.x), v.vy(shape.y), v.scale(shape.radius))
      elseif shape.type == 'text' then
        g.print(shape.data, v.vx(shape.x), v.vy(shape.y))
      elseif shape.type == 'group' then
        I.draw_shapes(shape)
      end end
    
    function I.speed_indicator(index)
      local drawmode, x,y, w,h
      local function refresh()
        drawmode = 'line'
        x, w,h = 80, 10, 20
        if speed_index >= index then
          drawmode = 'fill'
          x, w = x-1, w+2 -- make 'fill' same width as 'line'
        end
        y = Menu_bottom + 100 + #speeds*h - index*h
      end
      refresh()
      local draw = function()
        refresh()
        love.graphics.setColor(1,0,1)
        love.graphics.rectangle(drawmode, x,y, w, h)
        love.graphics.line(x+w, y, x+w+5, y)
        love.graphics.setColor(0.5,0.5,0.5)
        love.graphics.print(speeds[index], x+w+10, y-10)
      end
      local ispress = function(x2,y2)
        return x <= x2 and x2 <= x+w and y <= y2 and y2 <= y+h
      end
      local press = function() speed_index = index return true end
      return {draw=draw, ispress=ispress, press=press}
    end
    
    for i=1,#speeds do
      widgets['speed_indicator_'..i] = I.speed_indicator(i)
    end
    
    function car.update(dt)
      widgets.__update(dt)
      frame_index = frame_index + dt*speeds[speed_index]
      if frame_index >= #Windows[window_name] + 1 then
        frame_index = 0
      elseif frame_index < 0 then
        frame_index = #Windows[window_name]
      end
    end
    
    function I.draw_axes()
      color(0.5,0.5,0.5)
      line(0, v.vy(0), Safe_width, v.vy(0))
      line(v.vx(0), 0, v.vx(0), Safe_height)
      if ticks == nil then return end
      local xlo, xhi = ticks(v.sx(0), v.sx(Safe_width))
      for i=0,10 do
        local x = xlo+i/10*(xhi-xlo)
        local vx, vy = v.vx(x), v.vy(0)
        line(vx, vy, vx, vy+5)
        g.print(x, vx-10, vy+10)
      end
      local ylo, yhi = ticks(v.sy(Menu_bottom), v.sy(Safe_height))
      for i=0,10 do
        local y = ylo+i/10*(yhi-ylo)
        local vx, vy = v.vx(0), v.vy(y)
        line(vx, vy, vx+5, vy)
        g.print(y, vx+10, vy+5)
      end
    end
    
    function I.draw_hud()
      color(0.5, 0.5, 0.5)
      for _,id in ipairs(touches()) do
        local x, y = touch(id)
        circle('fill', x, y, 10)
      end
    end
    
    car.mouse_press = v.mouse_press
    car.mouse_move = v.mouse_move
    car.mouse_release = v.mouse_release
    car.mouse_wheel_move = v.mouse_wheel_move
    function car.touch_press(id, x,y, ...)
      if widgets.__press(x,y, id) then return end
      v.touch_press(id, x,y, ...)
    end
    car.touch_move = v.touch_move
    function car.touch_release(id, x,y, ...)
      if widgets.__release(x,y, id) then return end
      v.touch_release(id, x,y, ...)
    end
    
    end -- function debug_window_replay
    
    debug_window_replay_log('replay', {-10, -4, -2, -1, -0.5, -0.25, -0.1, 0, 0.1, 0.25, 0.5, 1, 2, 4, 10})
    
  3. Draw shapes on a surface without any further structure, and always draw them all. v4 and v5 demonstrated this, but we can also support panning and zooming on the surface with multitouch support.
    code (85 lines)
    -- Debug window with a pannable, zoomable, infinite 2D surface, that plots vector commands
    run_screen('ticks')
    
    function debug_window_surface(window_name)
    
    local I = {}
    
    if Windows == nil then Windows = {} end
    if Windows.__viewport == nil then Windows.__viewport = {} end
    if Windows[window_name] == nil then Windows[window_name] = {} end
    
    -- Windows.__viewport[window_name] = nil -- uncomment to reset viewport
    if Windows.__viewport[window_name] == nil then
      run_screen('infinite-viewport')
      Windows.__viewport[window_name] = run_screen_return
      run_screen_return = nil
    end
    
    local v = Windows.__viewport[window_name]
    
    function car.draw()
      I.draw_axes()
      for _,shape in ipairs(Windows[window_name]) do
        I.draw_shape(shape)
      end
      I.draw_hud()
    end
    
    function I.draw_shape(shape)
      color(shape.r or 0.5, shape.g or 0.5, shape.b or 0.5, shape.a or 1)
      if shape.type == 'point' then
        circle('fill', v.vx(shape.x), v.vy(shape.y), 2)
      elseif shape.type == 'line' then
        line(v.vx(shape.x1), v.vy(shape.y1), v.vx(shape.x2), v.vy(shape.y2))
      elseif shape.type == 'rectangle' then
        rect(shape.drawmode or 'fill', v.vx(shape.x), v.vy(shape.y), v.scale(shape.w), v.scale(shape.h))
      elseif shape.type == 'circle' then
        circle(shape.drawmode or 'fill', v.vx(shape.x), v.vy(shape.y), v.scale(shape.radius))
      elseif shape.type == 'text' then
        g.print(shape.data, v.vx(shape.x), v.vy(shape.y))
      elseif shape.type == 'group' then
        I.draw_shapes(shape)
      end end
    
    function I.draw_axes()
      color(0.5,0.5,0.5)
      line(0, v.vy(0), Safe_width, v.vy(0))
      line(v.vx(0), 0, v.vx(0), Safe_height)
      if ticks == nil then return end
      local xlo, xhi = ticks(v.sx(0), v.sx(Safe_width))
      for i=0,10 do
        local x = xlo+i/10*(xhi-xlo)
        local vx, vy = v.vx(x), v.vy(0)
        line(vx, vy, vx, vy+5)
        g.print(x, vx-10, vy+10)
      end
      local ylo, yhi = ticks(v.sy(Menu_bottom), v.sy(Safe_height))
      for i=0,10 do
        local y = ylo+i/10*(yhi-ylo)
        local vx, vy = v.vx(0), v.vy(y)
        line(vx, vy, vx+5, vy)
        g.print(y, vx+10, vy+5)
      end
    end
    
    function I.draw_hud()
      color(0.5, 0.5, 0.5)
      for _,id in ipairs(touches()) do
        local x, y = touch(id)
        circle('fill', x, y, 10)
      end
    end
    
    car.mouse_press = v.mouse_press
    car.mouse_move = v.mouse_move
    car.mouse_release = v.mouse_release
    car.mouse_wheel_move = v.mouse_wheel_move
    car.touch_press = v.touch_press
    car.touch_move = v.touch_move
    car.touch_release = v.touch_release
    
    end  -- function debug_window_surface
    
    debug_window_surface('surface')
    
  4. A graphical log. Show time on the y-axis. It didn't help much here, but you can see it in action on a different program.
    code (80 lines)
    -- Debug window with a bounded pannable, zoomable 2D surface that contains a sequence of drawings
    -- down the y axis. Like a text log, but graphical.
    -- ox and oy describe the coordinates from which to start drawing each image. w,h describe the size to
    -- draw.
    -- All 4 arguments are in the coordinates of the drawing.
    function debug_window_plot_log(window_name, ox,oy, w,h)
    
    local I = {}
    
    if Windows == nil then Windows = {} end
    if Windows.__viewport == nil then Windows.__viewport = {} end
    if Windows[window_name] == nil then Windows[window_name] = {} end
    
    -- Windows.__viewport[window_name] = nil -- uncomment to reset viewport and force load screen
    if Windows.__viewport[window_name] == nil then
      run_screen('infinite-viewport')
      Windows.__viewport[window_name] = run_screen_return
      run_screen_return = nil
    end
    
    local v = Windows.__viewport[window_name]
    
    function car.draw()
      local x0,y0 = 0,0
      for _,batch in ipairs(Windows[window_name]) do
        I.draw_shapes(batch, x0,y0)
        y0 = y0 + h + 50
      end
      I.draw_hud()
    end
    
    function I.draw_shapes(batch, x0,y0)
      color(0.5,0.5,0.5)
      line(v.vx(x0), v.vy(y0-oy), v.vx(x0+w), v.vy(y0-oy))
      line(v.vx(x0-ox), v.vy(y0+0), v.vx(x0-ox), v.vy(y0+h))
      for _,shape in ipairs(batch) do
        I.draw_shape(shape, x0-ox,y0-oy)
      end
    end
    
    function I.draw_shape(shape, x0,y0)
      color(shape.r or 0.5, shape.g or 0.5, shape.b or 0.5, shape.a or 1)
      if shape.type == 'point' then
        circle('fill', v.vx(x0+shape.x), v.vy(y0+shape.y), 2)
      elseif shape.type == 'line' then
        line(v.vx(x0+shape.x1), v.vy(y0+shape.y1), v.vx(x0+shape.x2), v.vy(y0+shape.y2))
      elseif shape.type == 'rectangle' then
        rect(shape.drawmode or 'fill', v.vx(x0+shape.x), v.vy(y0+shape.y), v.scale(shape.w), v.scale(shape.h))
      elseif shape.type == 'circle' then
        circle(shape.drawmode or 'fill', v.vx(x0+shape.x), v.vy(y0+shape.y), v.scale(shape.radius))
      elseif shape.type == 'text' then
        g.print(shape.data, v.vx(x0+shape.x), v.vy(y0+shape.y))
      elseif shape.type == 'group' then
        I.draw_shapes(shape, x0,y0)
      end end
    
    function I.draw_hud()
      color(0.5, 0.5, 0.5)
      for _,id in ipairs(touches()) do
        local x, y = touch(id)
        circle('fill', x, y, 10)
      end
    end
    
    function car.keychord_press(chord)
      if chord == 'down' then
        v.v.y = v.v.y + h + 50
      elseif chord == 'up' then
        if v.v.y > 0 then v.v.y = v.v.y - h - 50 end
      end
    end
    
    car.mouse_press = v.mouse_press
    car.mouse_move = v.mouse_move
    car.mouse_release = v.mouse_release
    car.mouse_wheel_move = v.mouse_wheel_move
    car.touch_press = v.touch_press
    car.touch_move = v.touch_move
    car.touch_release = v.touch_release
    
    end -- function debug_window_plot_log
    
    debug_window_plot_log('log', 0, 0, 500,500)
    
  5. Exploding view drawings give me a way to overlay and decompose sub-computations.
    code (200 lines)
    -- Debug window with a bounded pannable, zoomable 2D surface that contains a sequence of drawings
    -- with nested structure
    -- Drawings can have type line, circle, etc. and also group.
    function debug_window_explode(window_name, w,h)
    
    local I = {}
    
    if Windows == nil then Windows = {} end
    if Windows.__viewport == nil then Windows.__viewport = {} end
    if Windows[window_name] == nil then Windows[window_name] = {} end
    
    -- Windows.__viewport[window_name] = nil -- uncomment to reset viewport
    if Windows.__viewport[window_name] == nil then
      -- origin at center
      run_screen('infinite-viewport')
      Windows.__viewport[window_name] = run_screen_return
      run_screen_return = nil
      local v = Windows.__viewport[window_name]
      v.x, v.y = -5, -5*Safe_height/Safe_width
      v.w, v.h = -2*v.x, -2*v.y
      v.zoom = Safe_width/v.w
    end
    
    local v = Windows.__viewport[window_name]
    
    ---- lay out the state of the log
    local layout = {}  -- a flat list of (sub)charts to draw
    local pad = 500
    
    -- return some options for places to put r near r2
    -- ignores r.x/r.y, cares only about r.w/r.h
    function I.adjacent_options(r, r2)
      return {
        {x=r2.x - r.w - pad, y=r2.y, w=r.w, h=r.h},  -- left
        {x=r2.x+r2.w+pad, y=r2.y, w=r.w, h=r.h},  -- right
        {x=r2.x, y=r2.y - r.h - pad, w=r.w, h=r.h},  -- above
        {x=r2.x, y=r2.y+r2.h+pad, w=r.w, h=r.h},  -- right
      }
    end
    
    -- where should we move r to avoid overlap with multiple rs?
    function I.place(r, rs)
      for i, r2 in ipairs(rs) do
        local options = I.adjacent_options(r, r2)
        for _, r1 in ipairs(options) do
          if not I.aabb_any(r1, rs) then return r1 end
        end
      end
      return r
    end
    
    function I.aabb_any(r, rs)
      for _, r2 in ipairs(rs) do
        if I.aabb(r, r2) then return true end
      end
    end
    
    function I.aabb(r1, r2)
      return r1.x < r2.x+r2.w+pad
        and r1.x+r1.w+pad > r2.x
        and r1.y < r2.y+r2.h+pad
        and r1.y+r1.h+pad > r2.y
    end
    
    -- alternative approach: biased to place near some candidates (a subset of rs)
    -- will give up if cands are surrounded completely
    function I.place_near(r, cands, rs)
      for i, r2 in ipairs(cands) do
        local options = I.adjacent_options(r, r2)
        for j, r1 in ipairs(options) do
          if not I.aabb_any(r1, rs) then return r1 end
        end
      end
      assert(false)
    end
    
    -- a batch is a group and all its descendants
    function I.place_batch(batch)
      batch.w, batch.h = w, h
      local pos = I.place(batch, layout)
      pos.data = batch
      table.insert(layout, pos)
    end
    
    function I.place_batch_near(batch, cands)
      batch.w, batch.h = w, h
      local pos = I.place_near(batch, cands, layout)
      pos.data = batch
      table.insert(layout, pos)
      return pos
    end
    
    function I.place_lower_batches(batch)
      for _, b in ipairs(batch) do
        if #b > 0 then
          I.place_batch(b)
        end
      end
      for _, b in ipairs(batch) do
        if b.type == 'group' then
          I.place_lower_batches(b)
        end
    end end
    
    function I.title(msg)
      return {type='text', data=msg, x=50, y=-10000}
    end
    
    function I.handle_layout_touch(x,y)
      local sx,sy = v.sx(x), v.sy(y)
      for i,batch in ipairs(layout) do
        if I.within_rect(batch, sx,sy) then
          if batch.expanded then return end
          batch.expanded = true
          local cands = {batch}
          for j, b in ipairs(batch.data) do
            if b.type == 'group' and #b > 0 then
              local pos = I.place_batch_near(b, cands)
              table.insert(cands, pos)
            end
          end
          return true
        end end end
    
    function I.within_rect(rect, x,y)
      return rect.x <= x and x <= rect.x+rect.w
        and rect.y <= y and y <= rect.y+rect.h
    end
    
    function I.collapse_all(batch)
      batch.expanded = nil
      for _, b in ipairs(batch) do
        if b.type == 'group' then
          I.collapse_all(b)
        end end end
    
    -- assume window has a single object for now
    local save_data  -- remember data so we redraw if it changes
    function car.update(dt)
      if #Windows[window_name] == 0 then return end
      if save_data == Windows[window_name][1] then return end
      layout = {}
      save_data = Windows[window_name][1]
      save_data.x, save_data.y = 0, 0
      I.collapse_all(save_data)  -- we shove some data hackily in window data, clear that out
      I.place_batch(save_data)
    --  I.place_lower_batches(save_data)  -- uncomment this to expand all
    end
    
    -- draw batches according to the layout
    
    function car.draw()
      for _, batch in ipairs(layout) do
        I.draw_batch(batch.data, batch.x, batch.y)
      end
      I.draw_hud()
    end
    
    function I.draw_batch(batch, x0,y0)
      color(0.5,0.5,0.5)
      line(v.vx(x0), v.vy(y0+0), v.vx(x0+w), v.vy(y0+0))
      line(v.vx(x0+0), v.vy(y0), v.vx(x0+0), v.vy(y0+h))
      I.draw_shapes(batch, x0,y0, true)
    end
    
    function I.draw_shapes(batch, x0,y0, top)
      for _,shape in ipairs(batch) do
        if top or not shape.at_top then
          I.draw_shape(shape, x0,y0)
        end end end
    
    function I.draw_shape(shape, x0,y0)
      color(shape.r or 0.5, shape.g or 0.5, shape.b or 0.5, shape.a or 1)
      if shape.type == 'point' then
        circle('fill', v.vx(x0+shape.x), v.vy(y0+shape.y), 2)
      elseif shape.type == 'line' then
        if shape.name then print(shape.name) end
        line(v.vx(x0+shape.x1), v.vy(y0+shape.y1), v.vx(x0+shape.x2), v.vy(y0+shape.y2))
      elseif shape.type == 'rectangle' then
        rect(shape.drawmode or 'fill', v.vx(x0+shape.x), v.vy(y0+shape.y),
             v.scale(shape.w), v.scale(shape.h))
      elseif shape.type == 'circle' then
        circle(shape.drawmode or 'fill', v.vx(x0+shape.x), v.vy(y0+shape.y), v.scale(shape.radius))
      elseif shape.type == 'text' then
        g.print(shape.data, v.vx(x0+shape.x), v.vy(y0+shape.y))
      elseif shape.type == 'group' then
        I.draw_shapes(shape, x0,y0)
      end end
    
    function I.draw_hud()
      color(0.5, 0.5, 0.5)
      for _,id in ipairs(touches()) do
        local x, y = touch(id)
        circle('fill', x, y, 10)
      end
    end
    
    car.mouse_press = v.mouse_press
    car.mouse_move = v.mouse_move
    car.mouse_release = v.mouse_release
    car.mouse_wheel_move = v.mouse_wheel_move
    function car.touch_press(id, x,y, ...)
      if I.handle_layout_touch(x,y) then return end
      v.touch_press(id, x,y, ...)
    end
    car.touch_move = v.touch_move
    car.touch_release = v.touch_release
    
    end  -- function debug_window_explode
    
    debug_window_explode('nested', 500,500)
    
    screenshot showing the final result of the previous video, after clicking around to reveal all possible drawings
    Generating data for the exploding view
    function h(x, y, xi, yi, xj, yj, n, log, points_so_far)
      log.type = 'group'
      local p = points_so_far
      if #p > 0 then
        for i=2,#p do
          table.insert(log, {type='line',
            x1=p[i-1].x, y1=p[i-1].y, x2=p[i].x, y2=p[i].y,
            r=0, g=0, b=0.8,
            at_top = true,
          })
        end
      end
      if n <= 0 then
        local resultx, resulty = x+xi/2+yi/2, y+yi/2+yj/2
        if p and #p > 0 then
          table.insert(log, {type='line',
            x1=p[#p].x, y1=p[#p].y, x2=resultx, y2=resulty,
            r=0.5, g=0.5, b=0.5,
          })
        end
        local x3, y3 = x+xi, y+yi
        local x4, y4 = x+xj, y+yj
        insert_all(log,
          {type='circle', drawmode='fill', x=x, y=y, radius=2, r=1,g=0.8,b=0},
          {type='line', x1=resultx, y1=resulty, x2=x, y2=y, r=1,g=0.8,b=0},
          {type='circle', drawmode='line', x=x3, y=y3, radius=5, r=0, g=1, b=1},
          {type='line', x1=resultx, y1=resulty, x2=x3, y2=y3, r=0.5,g=1,b=1},
          {type='circle', drawmode='line', x=x4, y=y4, radius=5, r=0, g=1, b=1},
          {type='line', x1=resultx, y1=resulty, x2=x4, y2=y4, r=0.5,g=1,b=1},
          {type='point', x=resultx, y=resulty,r=1,g=0,b=0}
        )
        return {{x = resultx, y = resulty, draw=debug}}
      end
      local points_so_far_at_start = points_so_far
      local log1, log2, log3, log4 = {}, {}, {}, {}
      local res1 = h(x, y, xj/2, yj/2, xi/2, yi/2, n-1, log1, points_so_far)
      points_so_far = array_join(points_so_far, res1)
      local res2 = h(x+xi/2, y+yi/2, xi/2, yi/2, xj/2, yj/2, n-1, log2, points_so_far)
      points_so_far = array_join(points_so_far, res2)
      local res3 = h(x+xi/2+xj/2, y+yi/2+yj/2, xi/2, yi/2, xj/2, yj/2, n-1, log3, points_so_far)
      points_so_far = array_join(points_so_far, res3)
      local res4 = h(x+xi/2+xj, y+yi/2+yj,  -xj/2, -yj/2, -xi/2, -yi/2, n-1, log4, points_so_far)
      insert_all(log, log1, log2, log3, log4)
      local result = array_join(res1, res2, res3, res4)
      if #points_so_far_at_start > 0 then
        local p = points_so_far_at_start
        table.insert(log, {type='line',
          x1=p[#p].x, y1=p[#p].y,
          x2=result[1].x, y2=result[1].y,
          r=0.5, g=0.5, b=0.5,
          at_top=true,
        })
      end
      local p = result
      for i=2,#p do
        table.insert(log, {type='line',
            x1=p[i-1].x, y1=p[i-1].y, x2=p[i].x, y2=p[i].y,
            r=0.5, g=0.5, b=0.5,
            at_top=true,
        })
      end
      append(points_so_far, result)
      return result
    end
    
    function insert_all(h, ...)
      local args = {...}
      for _, arg in ipairs(args) do
        table.insert(h, arg)
      end
    end
    
    -- mutate r1
    function append(r, r1)
      for _,x in ipairs(r1) do
        table.insert(r, x)
      end end
    
    function array_join(...)
      local result = {}
      for i, arg in ipairs{...} do
        for _,x in ipairs(arg) do
          table.insert(result, x)
        end end
      return result
    end
    
    function dbg(window_name, data)
      table.insert(Windows[window_name], data)
    end
    
    if Windows == nil then Windows = {} end
    Windows.surface = {}
    Windows.log = {}
    local log = {}
    local pts = h(60, 60, 400, 0, 0, 400, 2, log, {})
    Windows.nested = {log}
    
    local ox,oy = 200,200
    
    function car.draw()
      color(0,0,0)
      for i=2,#pts do
          line(ox+pts[i-1].x, oy+pts[i-1].y, ox+pts[i].x, oy+pts[i].y)
      end end
    

    They require a little more thought to use. Every call creates a group of shapes, and the groups created by recursive calls become children of their caller. Groups can thus contain more groups, to arbitrary depth. A group draws everything under it when collapsed, and expanding a group corresponds to giving its child groups their own disjoint space on the surface.

  6. Replay. After working through these options I think of another pattern. What I need is to be able to animate things, but in a different order than I computed them. Made-up time. Show higher levels together, pretend the computation was breadth-first.
    code (150) lines
    -- Debug window with a pannable, zoomable, infinite 2D surface that plays groups of vector commands
    -- in a loop.
    run_screen('ticks')
    run_screen('widgets')
    
    function debug_window_replay(window_name, speeds)
    
    local I = {}
    
    if Windows == nil then Windows = {} end
    if Windows.__viewport == nil then Windows.__viewport = {} end
    if Windows[window_name] == nil then Windows[window_name] = {} end
    
    -- Windows.__viewport[window_name] = nil -- uncomment to reset viewport
    if Windows.__viewport[window_name] == nil then
      run_screen('infinite-viewport')
      Windows.__viewport[window_name] = run_screen_return
      run_screen_return = nil
    end
    
    local v = Windows.__viewport[window_name]
    
    local frame_index = 0
    local speed_index = 1
    for i,speed in ipairs(speeds) do
      if speed == 1 then speed_index = i end
    end
    
    function car.draw()
      local title = ('%d/%d'):format(frame_index, #Windows[window_name])
      love.graphics.print(title, 100, Menu_bottom + 15)
      I.draw_axes()
      local f = floor(frame_index)
      assert(f <= #Windows[window_name])
      I.draw_shapes(Windows[window_name][f])
      -- stuff in viewport coordinates
      love.graphics.setColor(0.5,0.5,0.5)
      love.graphics.print('replay speed (shapes/s)', 50, 250-9*20)
      widgets.__draw()
      I.draw_hud()
    end
    
    function I.draw_shapes(batch)
      if batch == nil then return end
      for i,shape in ipairs(batch) do
        I.draw_shape(shape)
      end
    end
    
    function I.draw_shape(shape)
      color(shape.r or 0.5, shape.g or 0.5, shape.b or 0.5, shape.a or 1)
      if shape.type == 'point' then
        circle('fill', v.vx(shape.x), v.vy(shape.y), 2)
      elseif shape.type == 'line' then
        line(v.vx(shape.x1), v.vy(shape.y1), v.vx(shape.x2), v.vy(shape.y2))
      elseif shape.type == 'rectangle' then
        rect(shape.drawmode or 'fill', v.vx(shape.x), v.vy(shape.y), v.scale(shape.w), v.scale(shape.h))
      elseif shape.type == 'circle' then
        circle(shape.drawmode or 'fill', v.vx(shape.x), v.vy(shape.y), v.scale(shape.radius))
      elseif shape.type == 'text' then
        g.print(shape.data, v.vx(shape.x), v.vy(shape.y))
      elseif shape.type == 'group' then
        I.draw_shapes(shape)
      end end
    
    function I.speed_indicator(index)
      local drawmode, x,y, w,h
      local function refresh()
        drawmode = 'line'
        x, w,h = 80, 10, 20
        if speed_index >= index then
          drawmode = 'fill'
          x, w = x-1, w+2 -- make 'fill' same width as 'line'
        end
        y = Menu_bottom + 100 + #speeds*h - index*h
      end
      refresh()
      local draw = function()
        refresh()
        love.graphics.setColor(1,0,1)
        love.graphics.rectangle(drawmode, x,y, w, h)
        love.graphics.line(x+w, y, x+w+5, y)
        love.graphics.setColor(0.5,0.5,0.5)
        love.graphics.print(speeds[index], x+w+10, y-10)
      end
      local ispress = function(x2,y2)
        return x <= x2 and x2 <= x+w and y <= y2 and y2 <= y+h
      end
      local press = function() speed_index = index return true end
      return {draw=draw, ispress=ispress, press=press}
    end
    
    for i=1,#speeds do
      widgets['speed_indicator_'..i] = I.speed_indicator(i)
    end
    
    function car.update(dt)
      widgets.__update(dt)
      frame_index = frame_index + dt*speeds[speed_index]
      if frame_index >= #Windows[window_name] + 1 then
        frame_index = 0
      elseif frame_index < 0 then
        frame_index = #Windows[window_name]
      end
    end
    
    function I.draw_axes()
      color(0.5,0.5,0.5)
      line(0, v.vy(0), Safe_width, v.vy(0))
      line(v.vx(0), 0, v.vx(0), Safe_height)
      if ticks == nil then return end
      local xlo, xhi = ticks(v.sx(0), v.sx(Safe_width))
      for i=0,10 do
        local x = xlo+i/10*(xhi-xlo)
        local vx, vy = v.vx(x), v.vy(0)
        line(vx, vy, vx, vy+5)
        g.print(x, vx-10, vy+10)
      end
      local ylo, yhi = ticks(v.sy(Menu_bottom), v.sy(Safe_height))
      for i=0,10 do
        local y = ylo+i/10*(yhi-ylo)
        local vx, vy = v.vx(0), v.vy(y)
        line(vx, vy, vx+5, vy)
        g.print(y, vx+10, vy+5)
      end
    end
    
    function I.draw_hud()
      color(0.5, 0.5, 0.5)
      for _,id in ipairs(touches()) do
        local x, y = touch(id)
        circle('fill', x, y, 10)
      end
    end
    
    car.mouse_press = v.mouse_press
    car.mouse_move = v.mouse_move
    car.mouse_release = v.mouse_release
    car.mouse_wheel_move = v.mouse_wheel_move
    function car.touch_press(id, x,y, ...)
      if widgets.__press(x,y, id) then return end
      v.touch_press(id, x,y, ...)
    end
    car.touch_move = v.touch_move
    function car.touch_release(id, x,y, ...)
      if widgets.__release(x,y, id) then return end
      v.touch_release(id, x,y, ...)
    end
    
    end -- function debug_window_replay
    
    debug_window_replay('replay', {-10, -4, -2, -1, -0.5, -0.25, -0.1, 0, 0.1, 0.25, 0.5, 1, 2, 4, 10})
    

    Visualize all calls at the same depth in the same frame of the replay animation.
    if then Windows = {} end
    if == nil then Windows.replay = {} end
    
    function h(x, y, xi, yi, xj, yj, n, depth)
      if depth == nil then depth = 1 end
      if Windows.replay[depth] == nil then Windows.replay[depth] = {type='group'} end
      local r = 5
      table.insert(Windows.replay[depth], {type='group',
        {type='circle', drawmode='fill', x=x, y=y, radius=r},
        {type='circle', drawmode='line', x=x+xi, y=y+yi, radius=r},
        {type='circle', drawmode='line', x=x+xj, y=y+yj, radius=r},
        {type='line', x1=x, y1=y, x2=x+xi, y2=y+yi},
        {type='line', x1=x, y1=y, x2=x+xj, y2=y+yj},
      })
      if n <= 0 then
        return {x+xi/2+xj/2, y+yi/2+yj/2}
      end
      return array_join(
        h(x, y, xj/2, yj/2, xi/2, yi/2, n-1, depth+1),
        h(x+xi/2, y+yi/2, xi/2, yi/2, xj/2, yj/2, n-1, depth+1),
        h(x+xi/2+xj/2, y+yi/2+yj/2, xi/2, yi/2, xj/2, yj/2, n-1, depth+1),
        h(x+xi/2+xj, y+yi/2+yj,  -xj/2, -yj/2, -xi/2, -yi/2, n-1, depth+1))
    end
    
    function array_join(...)
      local result = {}
      for i, arg in ipairs{...} do
        for _,x in ipairs(arg) do
          table.insert(result, x)
        end end
      return result
    end
    
    if Windows == nil then Windows = {} end
    Windows.replay = {}
    local pts = h(60, 60, 800, 0, 0, 800, --[[n]] 2)
    
    function car.draw()
      color(0,0,0)
      line(unpack(pts))
    end
    

    Add some color and offset the lines to separate them visually:

    the minor code changes made
    if Windows == nil then Windows = {} end
    if Windows.replay == nil then Windows.replay = {} end
    
    function h(x, y, xi, yi, xj, yj, n, depth)
      if depth == nil then depth = 1 end
      if Windows.replay[depth] == nil then Windows.replay[depth] = {type='group'} end
      local r = 5
      local x1,y1, x2,y2, x3,y3
      x1 = x+xi/10+xj/10
      y1 = y+yi/10+yj/10
      if xi == 0 then
        x2 = x + xj/10
        y2 = y + yi*9/10
      else
        x2 = x + xi*9/10
        y2 = y + yj/10
      end
      if xj == 0 then
        x3 = x + xi/10
        y3 = y + yj*9/10
      else
        x3 = x + xj*9/10
        y3 = y + yi/10
      end
      table.insert(Windows.replay[depth], {type='group',
        {type='circle', drawmode='fill', x=x1, y=y1, radius=r, r=1,g=0.8,b=0},
        {type='line', x1=x1, y1=y1, x2=x2, y2=y2, r=0, g=1, b=1},
        {type='line', x1=x1, y1=y1, x2=x3, y2=y3, r=1, g=0, b=1},
      })
      if n <= 0 then
        return {x+xi/2+xj/2, y+yi/2+yj/2}
      end
      return array_join(
        h(x, y, xj/2, yj/2, xi/2, yi/2, n-1, depth+1),
        h(x+xi/2, y+yi/2, xi/2, yi/2, xj/2, yj/2, n-1, depth+1),
        h(x+xi/2+xj/2, y+yi/2+yj/2, xi/2, yi/2, xj/2, yj/2, n-1, depth+1),
        h(x+xi/2+xj, y+yi/2+yj,  -xj/2, -yj/2, -xi/2, -yi/2, n-1, depth+1))
    end
    
    function array_join(...)
      local result = {}
      for i, arg in ipairs{...} do
        for _,x in ipairs(arg) do
          table.insert(result, x)
        end end
      return result
    end
    
    if Windows == nil then Windows = {} end
    Windows.replay = {}
    local pts = h(60, 60, 800, 0, 0, 800, --[[n]] 2)
    
    function car.draw()
      color(0,0,0)
      line(unpack(pts))
    end
    

I stare at this last one a while.

A small insight arrives. To draw a hilbert curve you need only to answer this question:

Given a square divided into 4 quadrants, in what order should the quadrants be visited to efficiently fill the space?
An answer to this question gives you the Hilbert curve as you subdivide quadrants. Given a sequence of quadrants at the finest level, the Hilbert curve simply connects up their centroids.

And the image above gives the answer to this question. The magenta line (bearing: xj,yj) shows for each square the quadrants where the start and end point lie. And the cyan line (bearing: xi,yi) indicates the direction of the remaining 2 quadrants, the direction the curve must head out to before returning to the magenta line.

The job of a debug UI is done when it forms a picture in my mind. However, for a blog post I'll clearly draw out the picture in my mind: A square outline in grey subdivided twice into quadrants, to yield a 4x4 grid. A grey order-2 Hilbert curve in the background shows that the centroids of all the squares of the grid lie on the Hilbert curve. Each 2x2 quadrant has one magenta and one cyan line within it, though their orientations vary between quadrants. Each 2x2 quadrant also contains an order-1 Hilbert curve (containing just 3 sides of a square) in blue. The blue order-1 lines perfectly overlay the grey order-2 lines.

The Hilbert curve always starts at the (center of the) quadrant containing the corner of the magenta and cyan lines (quadrant x,y), continues along the cyan direction (bearing xi,yi) and then turns in the magenta direction (bearing xj,yj) before returning to the magenta quadrants. Convince yourself this is true in the above image!

Perhaps this is obvious to you. Perhaps it was hard for me to get to because I was thinking in terms of lines, conditioned by my starting point with L-systems. I didn't attend enough to the quadrants.

Perhaps you're still mystified. Perhaps it just took me a while to get used to it all, and gain some illusion of understanding. Perhaps I'd have gotten there with one of the earlier visualizations, if I just stared at it a while. It's certainly true that it's been a slow, soaking understanding rather than a flash of insight. Attributing an outcome to a single cause is always a fraught exercise.

I'm going to keep using these patterns of debug UIs. Hopefully I won't encounter too many more such patterns before completing a basis set for all seasons.


Comments gratefully appreciated. Please send them to me by any method of your choice and I'll include them here.