Totoro Lightbox

I first saw a paper‑cut lightbox on Pinterest many many years ago. It was an artwork by Hari Panicker and Deepti Nair (@harianddeepti). It is a beautiful piece that still gives me a warm feeling, it reminds me of a mythic epic being told to me while I was a child.

Hari Panicker and Deepti Nair Deers
Hari Panicker and Deepti Nair Deers

So I wanted to build an installation like that. I'm trying to learn to draw, but with very variable success and irregularly. The process doesn't really excite me, I like the result more.

A few months ago I more or less accepted that drawing is not my thing and finally stopped delaying the release of this project. Then I mentally made a note to myself that I would finally make a lightbox, after I accidentally stumbled upon my old bookmarks and saw this very picture again.

The original work is very nice and all of that but i still cant draw to even copy it, so I went for a different route. I scoured the internet for a suitable template, and after several days of searching, I settled on this Totoro Lightbox. The file is marked as Creative Commons :blink

After a couple of hours of intense struggle with Blender, I managed to cut the 3D model into seven layers for printing: link. They can be printed on any standard paper (A4, A3, etc.) with an aspect ratio of 1.414 : 1.

At this point the easiest part of the job was completed, next I need to come up with a design, and since I didn't have to draw anything I will have more time to the engineering side.

Planning

First thing that came to my mind was dynamic lighting. Using some sort of LED matrix can allow me to present the picture in multiple ways. For example, I can change the color of the sky (if the picture has one) depending on the local time of day or weather.

Secondly, I wanted to create a configuration tool where I could change all the settings remotely. Since I already had some experience with ESP32, I thought about turning the Lightbox into a device accessible via Wi-Fi. This would also allow me to add more cool features in the future.

And once I had a general idea of what I wanted to create, I started ordering parts and planning the materials.

The parts I ordered for an A3-size Lightbox:

ItemParametersPrice
WS2812B ECO LED strip60 LEDs/m, 5 meters (300 LEDs)$8
Type-C controllerSupports 12V to 20V, 4–5A$1
DC-DC buck converter12–20V to 5V, 4–5A$1
ESP32 dev board5V input, no extra soldering needed$3
Foam board500x700 mm x3$8
Paper cutting mat and scalpelScalper - sharp, cutting mat - durable :D$6
Total~$30

After a month of patiently waiting for all the parts to arrive, I was finally ready to start the project, but I didn't :D. I still remember receiving all the parts in the mail, coming home, unpacking and testing them, all excited... And then the next day I was completely overwhelmed and couldn't get my shit together for another month. Yeah

Matrix

Anyways, let's get started. First, we'll solder the LEDs to form a matrix. I used thick copper wire, measured it, cut it into pieces of different lengths, and bent it into a U-shape to connect the LEDs in series.

I also made great use of the mat for this project, the measuring grid was very helpful in determining the number of pixels and the distance between rows. In my case it is 13x23 LED matrix with 10mm distance between rows. 299 LEDs in total.

LED Matrix | Soldering
LED Matrix | Soldering
LED Matrix | Soldering - close view
LED Matrix | Soldering - close view
LED Matrix | Voltage drop
LED Matrix | Voltage drop

A non-relativistic redshift, as you can see :D. I set all the LEDs to white, but instead of white I got a rainbow 🌈

Time to put my "excellent" soldering skills to use again and add those bridges for + and -

LED Matrix | Bridges for +
LED Matrix | Bridges for +
LED Matrix | Bridges for -
LED Matrix | Bridges for -

And voila

LED Matrix - finished
LED Matrix - finished

Now everything is white, as it should be.

Papercut

Let's jump to the next step, though I have to admit, jumping to the next step took me another month :D

I needed to print all the SVG files on A3 paper. I did this at a local print shop.

After printing everything I returned home, and started cutting the paper.

I used a scalpel, and it was exhausting for the first two hours, and even more exhausting for the next four hours.

Papercut | Stars
Papercut | Stars
Papercut | Background
Papercut | Background
Papercut | Totoro partial
Papercut | Totoro partial
Papercut | Totoro
Papercut | Totoro
Papercut | Final
Papercut | Final

There is about 8 hours between the first and the latest images. I used thin foam board pieces to support and separate the layers from each other. On the last image you can see the final result with all the layers glued together.

The case

Case
Case

I don't think there's much to say here. Making the case took about 30 minutes, plus a few hours of waiting for the glue to dry. I took measurements almost by eye, simply placing the papercut block on the foam board and marking with a pencil. As for depth, we only need 60-80mm of free space, and in my case, 40mm is for the papercut block, the rest is for the LED matrix and other electronics.

Here you can see my assistant. She wasn't very happy after the visit to the vet, but she still managed to pull herself together and "help" me.

My assistant
My assistant

Checking the LED strip

On the next day, I managed to put together some code for the LED strip. Here are some notes I took that day:

  1. Arduino IDE still sucks. It crashed twice, causing lost changes while I was experimenting with the LED strip. Basically unusable piece of shit even without crashes. If you're working with ESP32, prefer starting with ESP-IDF and your favorite code editor instead.
  2. React app size is fine when gzipped, I plan to embed gzipped js/css/html files into the firmware.
  3. ESP-IDF supports embedding files directly into the firmware without using any filesystem, by using target_add_binary_data CMake function
target_add_binary_data usage

Add this to CMakeLists.txt

target_add_binary_data(lightbox.elf "lightbox-ui/dist/assets/index.css.gz" BINARY RENAME_TO "assets/index.css") target_add_binary_data(lightbox.elf "lightbox-ui/dist/assets/index.js.gz" BINARY RENAME_TO "assets/index.js") target_add_binary_data(lightbox.elf "lightbox-ui/dist/index.html.gz" BINARY RENAME_TO "index.html") target_add_binary_data(lightbox.elf "lightbox-ui/dist/favicon.svg.gz" BINARY RENAME_TO "favicon.svg")

And use like this from your code

static esp_err_t get_front(httpd_req_t* req) { char filepath[FILE_PATH_MAX]; char* filename = get_path_from_uri("", req->uri, filepath, sizeof(filepath)); if (!filename) { httpd_resp_send_err(req, HTTPD_414_URI_TOO_LONG, "URI is too long"); return ESP_FAIL; } if (filename[strlen(filename) - 1] == '/') { filename[strlen(filename) - 1] = '\0'; } if (strcmp(filename, "/index.html") == 0 || filename[0] == '\0') { extern const uint8_t index_html_start[] asm("_binary_index_html_start"); extern const uint8_t index_html_end[] asm("_binary_index_html_end"); httpd_resp_set_type(req, "text/html"); httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); httpd_resp_send(req, (const char*)index_html_start, (int)(index_html_end - index_html_start)); return ESP_OK; } if (strcmp(filename, "/favicon.svg") == 0 || filename[0] == '\0'){ extern const uint8_t favicon_svg_start[] asm("_binary_favicon_svg_start"); extern const uint8_t favicon_svg_end[] asm("_binary_favicon_svg_end"); httpd_resp_set_type(req, "image/svg+xml"); httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); httpd_resp_send(req, (const char*)favicon_svg_start, (int)(favicon_svg_end - favicon_svg_start)); return ESP_OK; } if (strcmp(filename, "/assets/index.js") == 0) { extern const uint8_t assets_index_js_start[] asm("_binary_assets_index_js_start"); extern const uint8_t assets_index_js_end[] asm("_binary_assets_index_js_end"); httpd_resp_set_type(req, "application/javascript"); httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); httpd_resp_send(req, (const char*)assets_index_js_start, (int)(assets_index_js_end - assets_index_js_start)); return ESP_OK; } if (strcmp(filename, "/assets/index.css") == 0) { extern const uint8_t assets_index_css_start[] asm("_binary_assets_index_css_start"); extern const uint8_t assets_index_css_end[] asm("_binary_assets_index_css_end"); httpd_resp_set_type(req, "text/css"); httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); httpd_resp_send(req, (const char*)assets_index_css_start, (int)(assets_index_css_end - assets_index_css_start)); return ESP_OK; } httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Does not exist"); return ESP_FAIL; }
LED Matrix test
LED Matrix test

Assembling

After making sure the LED strip was ok, I removed the protective layer from the adhesive backing of the strip and glued everything to the back of the box (inside).

LED Matrix in the case
LED Matrix in the case

I was a bit impatient putting everything together, so this setup doesn't include any DC-DC converters. The reason is that I own a 5V 5A Type-C power source that can satisfy the LED strip's power needs. This power supply is designed for Raspberry Pi 5 (I guess they decided not to put the power converter on the board to reduce cost and board size). 5V 5A power mode isn't part of the PD standard and isn't supported by conventional power supplies.

My laptop charger only provides 3A at 5V, but it also supports 9V 3A and 15V 5A modes. Either of those would satisfy my 25W power requirement, if I convert the voltage down to 5V of course.

Since I don't want to rely on rare and expensive RPI5 charger I will setup the system to use the 20V PD mode. I will use two MINI560 DC-DC converters, one for the ESP32 and one for the LED strip.

Assembled lightbox
Assembled lightbox

I think I forgot to mention that the part that holds the "picture" is replaceable in my design.

And the moment of truth

First assembled test
First assembled test

Great, I was really happy with how it all turned out.

Next, I created a protection frame, mostly to keep this thing safe from my four-legged assistant. I hadn't planned to make one, but I noticed that she was very interested in Totoro's whiskers and other small paper details.

I bought an A3 transparent sheet from the same place where I printed everything. I believe this thing is used for paper lamination but I'm not sure.

Protection sheet | Front
Protection sheet | Front
Protection sheet | Back
Protection sheet | Back

I glued 4 small pieces of paper to the sides, they fit into the space between the body and the papercut block holding everything in place.

Web UI

I ran this thing for a month in this configuration and it was great. But I had some ideas on how to improve it. So I decided to rewrite the web UI.

My plan was to add next features:

  1. Changing Wi-Fi credentials
  2. AP mode configuration
  3. Automatic swithc to AP mode if Wi-Fi credentials are not set
  4. Persist configuration
  5. Persist current matrix state
  6. Save multiple LED matrix layouts

Once again here is some of my notes that i took during building this part:

  1. Vite doesn't support styled-jsx. At one point I got it working, but only partially and it wasn't enough. Sadge.
  2. CSS @container queries are ready to use.
  3. React 19 still isn't ready to use without a state manager.
  4. ESP-IDF has a super convenient API for updating firmware over the network.
  5. For some reason, my chip didn't behave well when I moved Wi-Fi processing to the second core, STA-AP mode stopped working. I spent a few hours tinkering with everything until I discovered the problem was a config parameter I had changed a few months ago.
  6. TanStack released a minimalistic state library that's enough for my project. It is still in alpha, but I haven't found any problems.
  7. Only conventional amount of flash memory (640KB, 0xA0000) remained for my littlefs partition after I finished allocating the other partitions. That's a funny coincidence, it is also funny that ESP32 is way more powerful than IBM PC running MS-DOS.
  8. gRPC is painful to deal with on ESP-IDF, so I decided to stick with JSON.
  9. headlessui from Tailwind is by far the best UI library I've worked with in terms of design, customizability, and ease of use, but it's missing datepickers, normal form elements, and layout support. Not a very good choice for production use. They sell a UI kit called “Tailwind Plus” that includes all of this. It costs $300 for individuals and $1000 for enterprises. That's a robbery for individuals, even though it's a one-time purchase.
  10. If you are creating web UI for an IoT device that supports OTA, start by implementing OTA first. It was surprisingly easy to implement, and it let me keep Lightbox intact while experimenting with the firmware. It works fine at least as long as you don't need logs, though I am sure it wouldn't be hard to implement some sort of logs web dispay, since ESP-IDF has log interception functionality. I'll put it in my TONDO* (TONDO: add a footnote explaining my "To Never Do" lists)

It took me a about a week to implement everything that I wanted: Wi-Fi and AP mode management, persistent settings and matrix state (now I can safely unplug it and it will display the same colors on next boot). I didn't planned to add OTA updates, but it turned out to be very easy to implement and it is amazing - I can safely update small things over the air without connecting the USB cable, unless I brick the device of course.

Here is how the final interface looks:

Web UI | Main page
Web UI | Main page
Web UI | Settings
Web UI | Settings
Web UI | Firmware
Web UI | Firmware

We have a contorl over every pixel on the matrix, can change brightness, draw custom patterns save/restore them. In settings menu we can configure Wi-Fi credentials, AP/STA mode, fallback logic, timeouts. And we can upload a new firmware over the air.

Epilogue

The result
The result

Hope you enjoyed reading this as much as I enjoyed building it. Very happy with the result.

I'm taking another break from this project. Next, I plan to make a wooden case with a top opening so I can replace the illustration and access the internals in more convenient way. I liked how the back part is semi-transparent for the light, so I'm also thinking about either making holes in the wooden backplate or making the whole backplate from white plastic, which would have similar effect to foam board for light diffusion.