RDV with Cassidoo #201 : Cubes!

sql puzzle cassidoo

I feel like I'm gonna like this serie a lot :-) It's so much fun!

Let's dive right in!

The interview question

Write a function that draws an ASCII art cube of given height x.

Example:

$ drawCube(2)
  +----+
 /    /|
+----+ |
|    | +
|    |/
+----+

$ drawCube(4)

   +--------+
  /        /|
 /        / |
+--------+  |
|        |  |
|        |  +
|        | /
|        |/
+--------+

What to do with odd numbers is not specified. What to do when input is 0 or 1 neither.

My solution

I'm still doing it in SQL, because it adds even more fun to me (fun is a relative notion you know).

Here it is, explanations below:

create or replace function public.draw_cube(height integer)
  returns text
  language sql
  immutable
as $function$
  -- generate one line for each line of output, and calculate some preliminary infos
  with infos as (
    select
      line_number,
      -- the number of spaces we need before (for the top face)
      greatest(height / 2 - line_number + 2, 0)
        as space_prefix,
      -- the number of spaces we need to draw the right/east face
      -- it's an inverted, shifted, translated abs(x) :-)
      floor(least(height / 2, (3 * height)::float / 4 - abs(line_number - 2 - (3 * height)::float / 4)))::integer
        as space_right_face,
      -- are we drawing an horizontal line?
      line_number = 1 or line_number = height / 2 + 2 or line_number = 3 * height / 2 + 3
        as is_horizontal,
      -- we start drawing bottom_line after one face + 2 horizontal lines
      line_number > height + 2
        as drawing_bottom_face
    from
      -- the number of lines: height + height / 2 for the top face + 3 horizontal edges
      generate_series(1, 3 * height / 2 + 3 ) line_number
  ),
  -- draw the cube
  cube_lines as (
    select
      line_number,
      repeat(' ', space_prefix) ||
      case
        -- horizontal line
        when is_horizontal then
          '+' || repeat('-', 2 * height) || '+'
        else
          -- front facing square edge, see lateral below
          front_facing_side_char || repeat(' ', 2 * height) || front_facing_side_char
      end
      ||
      -- now drawing the right edge of the east face
      case
        when space_right_face >= 0 then
          -- spaces
          repeat(' ', space_right_face) ||
          -- rightmost edge
          case
            when drawing_bottom_face then
            '/'
            -- a special case for the end of the hidden edge, there might be a more elegant way here
            else case when line_number - 2 = height then '+' else '|' end
        end
        else '' -- we need this empty string, because 'foo' || null is null!
      end as line
    from
      infos,
      -- factorize top face char calculation
      lateral (select case when space_prefix > 0 then '/' else '|' end) as _(front_facing_side_char)
  )
  -- aggregate in one big string separated by \n
  select
    string_agg(line, E'\n' order by line_number)
  from cube_lines
  ;

$function$

Generate the lines

I chose to again use generate_series to get all the lines then aggregate them. It's certainly possible to do differently.

I've decided against using any union btw (I think they would have made the query slightly easier to write, but it feels less elegant to me).

Here there is 3 lines for the horizontal edges, height lines for the front-facing face and height / 2 lines for the top face. I've already made a choice here: 2*n and 2*n+1 cube will have the same apparent height for the top face (so rounding down height / 2 each time it's needed, which is actually what an integer division does, which is what postgres does with the / operator between integer, so the choice is really the lazy one here ;-) ).

I first use a CTE (infos) to precalculate some states per lines. Here is what it looks like if I execute it alone:

\set height 6
select
  line_number,
  -- the number of spaces we need before (for the top face)
  greatest(:height / 2 - line_number + 2, 0)
    as space_prefix,
  -- the number of spaces we need to draw the right/east face
  -- it's an inverted, shifted, translated abs(x) :-)
  floor(least(:height / 2, (3 * :height)::float / 4 - abs(line_number - 2 - (3 * :height)::float / 4)))::integer
    as space_right_face,
  -- are we drawing an horizontal line?
  line_number = 1 or line_number = :height / 2 + 2 or line_number = 3 * :height / 2 + 3
    as is_horizontal,
  -- we start drawing bottom_line after one face + 2 horizontal lines
  line_number > :height + 2
    as drawing_bottom_face
from
  -- the number of lines: :height + :height / 2 for the top face + 3 horizontal edges
  generate_series(1, 3 * :height / 2 + 3 ) line_number
;
 line_number | space_prefix | space_right_face | is_horizontal | drawing_bottom_face
-------------+--------------+------------------+---------------+---------------------
           1 |            4 |               -1 | t             | f
           2 |            3 |                0 | f             | f
           3 |            2 |                1 | f             | f
           4 |            1 |                2 | f             | f
           5 |            0 |                3 | t             | f
           6 |            0 |                3 | f             | f
           7 |            0 |                3 | f             | f
           8 |            0 |                3 | f             | f
           9 |            0 |                2 | f             | t
          10 |            0 |                1 | f             | t
          11 |            0 |                0 | f             | t
          12 |            0 |               -1 | t             | t
(12 rows)

Each line represents a line of output. If I break down each column:

  • is_horizontal tells me if I need to draw an horizontal edge on this line (so it's true for line 1, 5 and 12)
  • drawing_bottom_face helps me know whether to choose / or | as the last character of one line.
  • space_prefix is the number of space needed before the first non-empty ascii char.
  • space_right_face is similar, but for the empty space inside the right face.

Then using these informations, I can express the drawing logic in a - hopefully - clear way in the cube_lines CTE.

About space_prefix

The slope of the function x -> nb space is obviously -1, and I want it to be 0 at the second horizontal lines, so after 1 + height / 2 + 1 lines (line numbers start from 1). Moreover, it does not go below 0, hence the use of greatest.

Here is the plot for height = 6:

-10123456-2-10123456789

Space_right_face

The idea is to calculate the number of spaces I need to put inside the right face for each lines. For height=6 I need:

line_number space_right_face
1 -1
2 0
3 1
4 2
5 3
6 3
7 3
8 3
9 2
10 1
11 0
12 -1

It really looks like an abs capped by a max! The trick now is to find the exact abs function.

I didn't make complicated calculus for that:

  • I started by inverting it: -abs(line_number)
  • then by shifting the inflexion point:
    • -2 because line 1 and 2 should give back 0
    • then the right face has at least one space for 3 half-height. So I shift again by half this value: 3 * length / 4
    • but we need to make sure that f(2) = 0, whch gives us the 3*4 / height at the beginning
    • we then use least to avoid going under 0. Note that this part is not really necessary because I test for the result to be >=0 later in the query.

So why the floor? Well, here are the curve I want for heigth = 5, 6 and 7:

-101234560123456789101112

Notice how it gives non integer numbers? It's a problem when drawing ascii art: we can't draw half a column! Adding the floor calculation changes it to:

-101234560123456789101112

Much more like what we want!

Conclusion

Cassidy claims this is a follow-up of previous week question, but I felt this one was actually a lot harder. It was also a lot more fun (relative notion, remember?)!

The solution I chose feels a bit "naive" to me and I'm pretty sure there's more clever way to do this. If you have one, I'd love to see it! Please tell me on twitter.

Some ideas I have on top of my head:

  • do overlays: draw the first square (the rear face), then the edges, then the front face and keep only the front char. The idea is that it's easy to draw squares. Not sure how to deal with edges.
  • go full 3D: do a list of vertices, use a projection matrix and then "serialize" all that to ascii! Maybe overkill? And how to you translate edges that are not aligned to axis in a consistent way? This way would open up a whole field of possibilites (rotating ascii cube for instance).

Yes, in the fun-o-meter, a rotating ascii cube might score well!

Previous Post Next Post