All Projects
Project · Embedded / Sensor Fusion

ToF 3D Room Scanner

ARM Cortex-M room scanner — stepper motor + VL53L1X time-of-flight sensor, streams distances over UART, renders as a 3D point cloud in Open3D.

MCU TI TM4C1294NCPDT (Cortex-M4F @ 120 MHz)
ToF sensor VL53L1X over I²C0 @ 100 kbps, addr 0x29
Stepper motor 4-phase, 512 steps/rev, 640 µs/step (~328 ms/rev)
Angular resolution 128 measurements/rev (4 stepper steps = 1 sample)
Vertical resolution 300 mm spacing between scan planes
Telemetry UART @ 115 200 baud, CSV per frame
Visualisation Open3D LineSet — circumferential + vertical edges

The 2DX3 capstone project at McMaster. A tabletop room scanner built around a TI Tiva C (TM4C1294NCPDT) microcontroller. A stepper motor rotates a ST VL53L1X time-of-flight ranging sensor through a full 360° plane while the firmware samples 128 distances per revolution, ships them over UART as comma-separated text, and a Python host program assembles the planes into a 3D point cloud rendered with Open3D.

It’s the project where every lab from the course (GPIO control, finite-state machines, ADC sampling, PWM, I²C, UART, interrupt-driven control) finally has to coexist on one board.

System overview

        ┌─────────────────────────────────────────────────────────────┐
        │                  TM4C1294 (Cortex-M4F, 120 MHz)          │
        │                                                          │
   PH ──┤ stepper motor driver  4-phase  ◀──┐                      │
   PB ──┤ I²C0  ──────────▶ VL53L1X ToF     │  120 MHz PLL clock   │
   PJ ──┤ buttons (start / home)            │  SysTick µs delays   │
   PG ──┤ XSHUT (sensor reset)              │                      │
        │                                   ▼                      │
        │            UART0 @ 115 200 baud — CSV per frame          │
        └──────────────────────────────────┬───────────────────────┘

                            Python host (open3Dmodel.py)
                            → polar (r, θ) → Cartesian (x, y, z)
                            → Open3D LineSet visualisation

The MCU is a TI Tiva C TM4C1294NCPDT clocked to 120 MHz via the PLL. Bare-metal C, no RTOS, peripherals driven through register-level access from the TI device header.

Stepper motor control

The motor is a 4-phase unipolar stepper, 512 full steps per revolution, driven from GPIO PORTH[3:0]. Each step holds the coil energised for 160 µs (set with SysTick_Wait10us(16)), so a full revolution takes about 328 ms. Direction is selected by walking the four-phase sequence forwards or backwards:

// Rotate clockwise by `steps` motor steps. step_counter wraps mod 512
// so we always know absolute angular position for homing.
void spinClockWise(uint32_t steps) {
    int delay = 160;  // µs per phase
    for (uint32_t i = 0; i < steps; i++) {
        GPIO_PORTH_DATA_R = 0b00000011; SysTick_Wait10us(delay);
        GPIO_PORTH_DATA_R = 0b00000110; SysTick_Wait10us(delay);
        GPIO_PORTH_DATA_R = 0b00001100; SysTick_Wait10us(delay);
        GPIO_PORTH_DATA_R = 0b00001001; SysTick_Wait10us(delay);
        step_counter = (step_counter + 1) % 512;
    }
    GPIO_PORTH_DATA_R = 0b00000000;  // power coils off when idle
}

Two design notes worth flagging:

  • Coils are powered down between sweeps. Leaving any of the four phases driven holds the rotor torqued, which heats the motor and the driver transistors. Writing 0x00 after the loop costs nothing and lets the platform run cool.
  • step_counter is the homing oracle. With 512 steps per rev and 128 ToF samples per rev, exactly 4 motor steps occur between samples. The counter modulo 512 means the home button can spin back to phase 0 in the shortest direction without any encoder or limit switch.

ToF distance sensor over I²C

The VL53L1X is a 940 nm ST time-of-flight ranging sensor that reports millimetre distances over I²C. It’s connected to I²C0 at 100 kbps:

  • PB2 → SCL
  • PB3 → SDA (open-drain, with the bus pull-ups)
  • PG0 → XSHUT (sensor reset)
  • I²C address 0x29

The driver is the ST-supplied VL53L1X_api.c, which exposes a polled-mode “start ranging → wait for data ready → read range” cycle. Each ranging takes a few tens of milliseconds, well within the 640 µs × 4 = 2.56 ms inter-sample stepper budget. The limiting factor on scan speed isn’t the motor, it’s the sensor’s integration time.

Initial bring-up was the usual I²C scavenger hunt. SDA stuck low because the open-drain bit on GPIO_PORTB_ODR_R wasn’t set (the line tried to drive high actively, and the pull-up couldn’t recover the falling edge). A pull-up on the wrong rail. An XSHUT line floating at boot that left the sensor in an undefined state until PG0 was explicitly initialised.

Scan loop and UART telemetry

The main loop is straightforward: for each of MEASUREMENTS = 128 angular positions in a frame, take a ToF reading, advance the motor by 4 steps (360° / 128 ≈ 2.81° per sample), and accumulate into the per-frame distance array. After the frame closes, the firmware emits the 128 distances as a single comma-separated UART line at 115 200 baud and starts the next frame.

The host script binds to COM9, writes a single 's' byte to start the scan, then reads one CSV line per frame and converts polar measurements to Cartesian:

# open3Dmodel.py — host-side polar-to-Cartesian conversion
NUM_MEASUREMENTS = 128            # angular samples per revolution
NUM_FRAMES       = 50             # vertical scan planes
Z_DISTANCE       = 300            # mm between planes

def toXYZ(measurements):
    points = []
    angle_step = 360 / NUM_MEASUREMENTS
    for frame_idx, frame in enumerate(measurements):
        z = frame_idx * Z_DISTANCE
        for i, distance in enumerate(frame):
            # 270° offset aligns the sensor's zero angle with the +y axis
            angle = math.radians(270 - i * angle_step)
            x = distance * math.cos(angle)
            y = distance * math.sin(angle)
            points.append([x, y, z])
    return points

The 270° offset in the angle calculation is the only piece that wasn’t obvious from first principles. It lines the sensor’s mounted zero up with the visualization frame’s +y axis. Without it the scan came out rotated and reflected, which looked exactly like a real room geometry that just happened to be wrong. That’s the worst kind of bug.

Visualisation in Open3D

Once the points are in (x, y, z), Open3D handles the rest. The output is a LineSet, not a raw point cloud. Within each frame, the 128 points are connected in a closed loop, so each scan plane renders as an octagon-ish polygon outlining the room slice at that height. Corresponding points across adjacent frames are connected vertically. The result looks like a wireframe contour map of the scanned space.

# Within-plane edges: close each scan into a polygon
for frame in range(num_frames):
    base = frame * num_measurements
    for i in range(num_measurements - 1):
        lines.append([base + i, base + i + 1])
    lines.append([base + num_measurements - 1, base])

# Cross-plane edges: vertical struts between corresponding angular samples
for frame in range(num_frames - 1):
    base, nxt = frame * num_measurements, (frame + 1) * num_measurements
    for i in range(num_measurements):
        lines.append([base + i, nxt + i])

What I’d do differently

  • Move the ToF poll into a hardware timer interrupt. The current loop blocks on SysTick_Wait10us between motor phases and again on the ToF data-ready flag. A timer ISR firing at the sensor’s expected ranging rate would let me overlap the next motor step with the previous reading.
  • Stream binary, not CSV. ASCII is friendly to debug but UART at 115 200 baud is the bottleneck for tall scans. A 4-byte little-endian distance per measurement is ~3× faster on the wire than 4–5 ASCII digits + comma, and trivial to parse with struct.unpack on the host side.
  • Use the VL53L1X’s interrupt pin instead of polling. The sensor exposes a GPIO that pulses when a measurement is ready. Wiring it to a Tiva GPIO interrupt would replace the busy-wait inside the API with a real edge-triggered event.

What it taught me

This was the project where I stopped thinking of MCU peripherals as separate units and started thinking of them as one bus-shared resource you have to schedule. The motor wants the µs scale. The ToF wants the ms scale. The UART wants the serial scale. They all share the same clock and the same processor, and getting them to cooperate without dropping samples or stalling the motor is the thing that turns out to actually be embedded engineering.