Introduction: London Underground Map Clock

About: Hey I'm Tim, a product designer and maker.

In 2014, off the back of in internship at a 3D printing consultancy in London and an experiment with colour lithophanes using their Stratasys machine, I design my own going away present, a colour 3D print of tube lines local to their offices. I was determined to make something from it. A short 2 years later in 2016 I had my own 3D printer, and set to work making it into a clock.

As a kid I though the Tokyo Flash digital watches were the greatest things ever, and deiced that would be the inspiration point for the design.

And now it's just been a minor 4 year break till I've gotten round to writing it up!

While the exact instructions will be difficult to replicate, and the reduction of cost in hobbyist PCB manufacture in the past couple years might make my exact method for LED placement obsolete. I hope the ideas shared might lead to others making weird clocks from thin objects!

Step 1: Front Layer

As mentioned in the intro, this was a colour 3D print, I believe a Stratasys machine which used a powder bed and a modified ink cartridge for binder and pigment.

The file is lost to history, but this layer could be anything, a photo or a single colour lithophane would work wonders.

This part was made in 3DS max in 2014, but today there's online tools to turn an image into an SLT based on brightness

Step 2: Designing the Guide Layer

This is where we decide the complexity of the project and the method for reading the time. The images shows the 2 ideas I was playing with.

These were made by scanning in the design and drawing lines across it in inkscape.

This isn't a very readable clock, but I preferred the idea of lines filling throughout the day so that became the design goal.

Binary counting is a viable method for reducing the LED count, and it would improve readability if binary is your jam, but that undermined my 'filling lines' idea, so was not an option for this project

It's common on the Tokyo Flash Watches to minimise LED count but having one section counting in 3's or 5's and then another filling for each time that section fills, I used this technique for the minutes, to reduce them from 60 to 20 plus 2. I wasn't so worried about precision this for the seconds.

Step 3: Building the Guide Layer

This guide layer for the LED's has 2 purposes, it holds the LEDs in place, and it prevents spill between them

It was drawn as a layer on Inkscape directly on top of the scan that i was using for the design layout. 1mm thickness was added in blender before sending to my printer.

This was one of the hardest prints I has to make on my meagre Makibox A6, the part was printed in abs so a tonne of acetone slurry was used to keep it attached to the build platform with minimal warping. Fortunately this part isn't seen on the final product

The final image shows it held up to a lamp to check for spacing.

In hindsight, spill between lights along a line might actually be preferable for the visuals, is not harder to read, this could be achieved by adding a chamfer to the guide on the short sides of each light

Step 4: Wiring the LEDs

The fist image shows the test print I made for checking hole sizing, I was aiming for the LED to fit snugly in lace with a little force, the correct shape was then placed by hand when laying out the guide layer.

Due to the low tolerance of my 3D printer, some were loose and required a dab of superglue to stay in place while others were too tight, but were encouraged into place by pressing down on the LED while soldering, this was actually a better fit than the correctly sized holed, which had a tenancy to pull out once wired in.

To reduce the wire count the LEDs were soldered in a matrix of 7 by 8, meaning all 55 LEDs could be controlled by only 13 pins, I had a hand drawn map of each of these connections which has been unfortunately lost.

Enamel wire was used so sections could be exposed in place by heating a section with the iron and tinning prior to making the connection.

This process was very time consuming, I'd highly recommend designing a PCB

Step 5: Designing the Electronics

My initial plan was to use an Arduino microcontroller with an RTC, but opted for an ESP8266 on the Node MCU D1 board as it allowed for automatic daylight savings and the potential for control over WIFI.

To reduce the pin count further, I had the perfect number of LEDs to be able to use a MAX7219 (which can handle up to 64 LEDs).

This IC is commonly used to drive LED 7 Segment displays, but it had a very similar use case to mine, lighting an arbitrary number of LEDs with minimal flickering, it even has controllable brightness.

I decided to use protoboard for the wiring, but eagle was helpful for part placement and understanding wiring

I've attached my board files, but this was my first time using eagle (and an out of date version by now) so they're for reference only

Step 6: Wiring the Electronics

This was a repetitive simple step, following the Eagle schematic, using headers for the ESP and the LED matrix helped hugely in assembly.

Pin 1 on the Anode & Cathode LED headers was marked with a silver sharpie, they could be differentiated between as on was 7 the other 8.

Step 7: Programming

As our display is not a traditional matrix, I had to find a method for visualising which bits to turn on which it sent to the MAX IC in HEX. Fortunately I know just enough excel to get into trouble and made a 'Hex wizard' to guide me though the pattern i wanted displayed, hand placed check boxes an all.

This came with the revaluation that the hex for my hour, minute and seconds could be combined using a bitwise OR to produce the final hex command to send to the max7219, including a little animation i added to the seconds so I could make sure the board hadn't;t frozen.

So, almost at an end. and time for another decision which hasn't aged too well.

The code for the ESP is in LUA, Today I'd recommend using the arduino IDE for it's better documentation and robust package library, at the time the ESP community was still maturing and I chose LUA as the language for this project.

I made the questionable decision to regularly ping the google servers to read the time. This got around needing a RTC to minimise drift, this works, but you;d be better off using a true time API.

halfSec = 0
hour=0 minute=0 second=0

lowIntensity = 0highIntensity = 9

local SSID ="Wifi"local SSID_PASSWORD ="Password"

function time() --connect to internet to get the current time and date if wifi.sta.getip() then local conn=net.createConnection(net.TCP,0) conn:connect(80,"google.com")

conn:on("connection", function(conn, payload) conn:send("HEAD / HTTP/1.1\r\n".. "Host: time.is\r\n".. "Accept: */*\r\n".. "User-Agent: Mozilla/4.0 (compatible; esp8266 Lua;)".. "\r\n\r\n") end )

conn:on("receive", function(conn,payload) --print(payload) conn:close() local p=string.find(payload,"GMT") -- find the time and date string in the payload from internet, change for timezone if p~=nil then -- extract numbers corresponfing to the hour, minute, second, day, month hour=tonumber(string.sub(payload,p-9,p-8)) minute=tonumber(string.sub(payload,p-6,p-5)) second=tonumber(string.sub(payload,p-3,p-2)) addHour() --hard coded BST (British summer time) daylights saving print(hour,minute,second) halfSec = (second%6)*2 --print(halfSec) else print("web update failed!") end end --function ) --end of on "receive" event handler

conn:on("disconnection", function(conn,payload) conn=nil payload=nil end ) end print("no wifi yet")end

function borTable(a,b,...) --bitwise OR tables together if arg[1] then b = borTable(b,unpack(arg)) end local z = {} for i, v in ipairs(a) do table.insert(z, bit.bor(v,b[i])) end return zend

function bxorTable(a,b,...) --bitwise OR tables together if arg[1] then b = bxorTable(b,unpack(arg)) end local z = {} for i, v in ipairs(a) do table.insert(z, bit.bxor(v,b[i])) end return zend

function addSecond() second=second+1 if second>=60 then second=0 minute=minute+1 if minute>=60 then minute=0 addHour() end end end

function addHour() hour=hour+1 if hour>=24 then hour=0 end if hour == 2 or hour == 16 then max7219.setIntensity(lowIntensity) end if hour == 8 or hour == 18 then max7219.setIntensity(highIntensity) end end function update() local secGap = 6 local minGap = 3 local horGap = 1 local sec={{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x03},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x01, 0x03},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x03},{ 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x01, 0x03},{ 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x01, 0x03},{ 0x00, 0x00, 0x01, 0x01, 0x01, 0x03, 0x01, 0x03},{ 0x00, 0x01, 0x01, 0x01, 0x01, 0x03, 0x01, 0x03},{ 0x01, 0x01, 0x01, 0x01, 0x01, 0x03, 0x01, 0x03}}; local min={{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00},{ 0x00, 0x00, 0x00, 0x02, 0x02, 0x00, 0x02, 0x00},{ 0x00, 0x00, 0x02, 0x02, 0x02, 0x00, 0x02, 0x00},{ 0x00, 0x02, 0x02, 0x02, 0x02, 0x00, 0x02, 0x00},{ 0x02, 0x02, 0x02, 0x02, 0x02, 0x00, 0x02, 0x00},{ 0x02, 0x02, 0x02, 0x02, 0x02, 0x00, 0x02, 0x10},{ 0x02, 0x02, 0x02, 0x02, 0x02, 0x00, 0x12, 0x10},{ 0x02, 0x02, 0x02, 0x02, 0x02, 0x10, 0x12, 0x10},{ 0x02, 0x02, 0x02, 0x02, 0x12, 0x10, 0x12, 0x10},{ 0x02, 0x02, 0x02, 0x12, 0x12, 0x10, 0x12, 0x10},{ 0x02, 0x02, 0x12, 0x12, 0x12, 0x10, 0x12, 0x10},{ 0x02, 0x12, 0x12, 0x12, 0x12, 0x10, 0x12, 0x10},{ 0x12, 0x12, 0x12, 0x12, 0x12, 0x10, 0x12, 0x10},{ 0x12, 0x12, 0x12, 0x12, 0x12, 0x30, 0x12, 0x10},{ 0x12, 0x12, 0x12, 0x12, 0x32, 0x30, 0x12, 0x10},{ 0x12, 0x12, 0x12, 0x32, 0x32, 0x30, 0x12, 0x10},{ 0x12, 0x12, 0x32, 0x32, 0x32, 0x30, 0x12, 0x10},{ 0x12, 0x32, 0x32, 0x32, 0x32, 0x30, 0x12, 0x10},{ 0x32, 0x32, 0x32, 0x32, 0x32, 0x30, 0x12, 0x10}}; local hor={{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x04, 0x00},{ 0x00, 0x00, 0x00, 0x04, 0x04, 0x04, 0x04, 0x00},{ 0x00, 0x00, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00},{ 0x00, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00},{ 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00},{ 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08},{ 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0C, 0x08},{ 0x04, 0x04, 0x04, 0x04, 0x04, 0x0C, 0x0C, 0x08},{ 0x04, 0x04, 0x04, 0x04, 0x0C, 0x0C, 0x0C, 0x08},{ 0x04, 0x04, 0x04, 0x0C, 0x0C, 0x0C, 0x0C, 0x08},{ 0x04, 0x04, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x08},{ 0x04, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x08},{ 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x08},{ 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x48},{ 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x4C, 0x48},{ 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x4C, 0x4C, 0x48},{ 0x0C, 0x0C, 0x0C, 0x0C, 0x4C, 0x4C, 0x4C, 0x48},{ 0x0C, 0x0C, 0x0C, 0x4C, 0x4C, 0x4C, 0x4C, 0x48},{ 0x0C, 0x0C, 0x4C, 0x4C, 0x4C, 0x4C, 0x4C, 0x48},{ 0x0C, 0x4C, 0x4C, 0x4C, 0x4C, 0x4C, 0x4C, 0x48},{ 0x4C, 0x4C, 0x4C, 0x4C, 0x4C, 0x4C, 0x4C, 0x48}}; --print(hour,minute,second)

--the table starts on 0, so at 1 as currently sec[0] = nil) max7219.write({animate(borTable(sec[1+(second/secGap)],min[1+(minute/minGap)],hor[1+(hour/horGap)]))})

end --function

wifi.setmode(wifi.STATION)wifi.sta.config(SSID,SSID_PASSWORD)wifi.sta.autoconnect(1)

--configure max7219max7219 = require("max7219")max7219.setup({numberOfModules = 1, slaveSelectPin = 8, intensity = highIntensity })

--Main Program

checkOnline = tmr.create()

tmr.alarm(0,180000,1, time)

tmr.alarm(1,1000,1, addSecond)

tmr.alarm(2,500,1, update)

function animate(still) local frames={{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00},{ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00},{ 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00},{ 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},{ 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}; halfSec=halfSec+1 if halfSec >=12 then halfSec = 0 end --print(halfSec) return bxorTable(frames[halfSec+1],still) end

Step 8: The Housing

Now's your time to show off your incredible craftsmanship and house the project.

Either that or grab an amazon package out of the recycling and make a temporary housing that's still in use today.

The benefit of using this approach was that each layer of the project almost perfectly matched the thickness of the cardboard, so a sandwich could be stacked up and taped together. A similar premium version could use acrylic

Step 9: Closing Remarks

Thank you for reading, As many of you know documenting a project can be as difficult as making it. there are scraps of video with me talking which may eventually see the light of day.

In the years between making this project and writing it up I expected to see more examples of arbitrary LED displays using 3D printing, but the reduction in RGB strips has mostly removed the need for an alternative.

I hope this has been informative, and please ask questions as I'll try to give more detail on sections that don't fully satisfy.

Cheers

Make it Glow Contest

Participated in the
Make it Glow Contest