ELECTRONICS PRODUCTION

Assignment Overview

Group Assignment

Individual Assignment

The full group documentation is available here.

Group Assignment

This week's group assignment focused on characterizing the design rules for our in-house PCB milling process. By systematically testing different trace widths, clearances, and via sizes on the Roland Modela MDX-20, we established reliable minimum values that guide all future PCB designs produced at our lab. Understanding these limits before committing to a design prevents frustrating rework and wasted material.

PCB Milling Machine — Roland Modela MDX-20

The Roland Modela MDX-20 is a compact desktop CNC milling machine widely used across Fab Labs for PCB fabrication. Rather than etching copper with chemicals, it mechanically removes unwanted copper from FR1 copper-clad boards using precision carbide end mills. This dry, subtractive process produces single- or double-sided boards entirely in-house, with no chemical handling required.

Key specifications of the MDX-20 relevant to PCB work:

Roland Modela MDX-20 compact desktop CNC milling machine used for PCB fabrication
The Roland Modela MDX-20 — our lab's primary PCB milling machine

Design Rule Test Board

To establish reliable design rules, we milled a purpose-built test board containing traces of varying widths (ranging from 0.004" to 0.020"), different inter-trace clearances, and a series of drill holes at increasing diameters. After milling, each feature was visually inspected under a digital microscope and continuity-tested with a multimeter.

Results from the characterization test:

Traces below 0.006" showed inconsistent results on our machine — some cuts were incomplete due to minor board-level warping. Securing boards flat with double-sided tape on a levelled sacrificial layer significantly improved trace quality at finer widths.
Milled PCB design rule test board showing varying trace widths and clearances under inspection
Design rule test board after milling — trace widths and clearances evaluated under magnification

Submitting a Design to a Board House

As part of the group assignment, we also submitted a PCB design to a professional board house — JLCPCB — to understand the commercial manufacturing workflow and appreciate the differences in precision, layer count, and finish quality compared to in-house milling.

The workflow involved:

The comparison was illuminating — professional boards offer tighter tolerances (down to 0.1 mm trace/space), plated through-holes, solder mask, and silkscreen, none of which are available with our in-house Roland milling process.

JLCPCB online order portal showing Gerber preview, board options, and order confirmation
JLCPCB order page — automated Gerber preview with board specifications and order confirmation

Individual Assignment — Making the PCB

For the individual assignment, I fabricated the PCB I had designed during Electronics Design week (Week 06) — a custom board built around the Seeed Studio XIAO RP2040 microcontroller. The board was milled, stuffed with SMD components, soldered, and tested entirely within the Fab Lab.

The design features the XIAO RP2040 as the central processing unit, a tactile push button for user input, an SMD LED with a current-limiting resistor as a visual indicator, and pin headers for I/O breakout. The design intentionally keeps the footprint small while exposing all key GPIO pins for prototyping.

KiCad PCB layout of the custom XIAO RP2040 board showing copper traces, SMD footprints, and board outline
KiCad PCB layout — copper traces, SMD footprints, and board outline
KiCad 3D render of the PCB showing the XIAO RP2040 module, SMD resistors, LEDs, push button, and pin headers
KiCad 3D render — components visualised before fabrication

Step 1
Exporting Files — Gerbers & PNG Conversion

Before loading anything into the CAM tool (Mods CE), the PCB layout must be exported from KiCad and converted into a format the milling machine understands. This is a two-stage process: first exporting Gerber files from KiCad, then converting the relevant copper layer to a high-resolution black-and-white PNG that Mods CE reads as its input image.

Exporting Gerber Files from KiCad

In KiCad's PCB editor, I navigated to File → Fabrication Outputs → Gerbers to open the Plot dialog. The following layers were selected for export:

KiCad PCB editor with File > Fabrication Outputs menu open showing Gerbers and Drill Files options
Navigating to Fabrication Outputs in KiCad's PCB editor
KiCad Plot dialog configured for Gerber export with all required layers selected
KiCad Plot dialog — all required layers selected for export
KiCad Generate Drill Files dialog configured for Excellon format with millimetre units
Generate Drill Files dialog — Excellon format, millimetre units
Windows Explorer folder showing the exported Gerber files including copper, mask, silkscreen, edge cuts, and drill files
Exported Gerber folder — copper, mask, silkscreen, edge cuts, and drill files

What Are Gerber Files?

Gerber files are the universal standard for communicating PCB design data to fabrication equipment.

They describe each physical layer of a PCB as a separate file containing vector graphics instructions: where copper should exist, where solder mask openings should be, where silkscreen text should print, and where the board edge should be cut.

Each layer of your board maps to a dedicated Gerber file:

Together, this set of files forms a complete description of the board. Any board house or in-house milling setup can read the same Gerber package and reproduce the design faithfully, regardless of which EDA tool was used to create it.

Gerbers describe geometry only — they carry no net names, component values, or schematic intelligence. A Gerber viewer shows you exactly what will be physically manufactured, which makes it the definitive final check before sending a design to fabrication.

In the context of this week's workflow, Gerber files served two distinct roles. For the board house submission (JLCPCB), the full Gerber package was zipped and uploaded directly — the board house's automated system reads each layer and produces the finished PCB. For in-house Roland MDX milling, Gerbers cannot be used directly because Mods CE requires a raster image input; the Gerber files were therefore converted to high-resolution PNGs using the Gerber2PNG tool, which renders each layer as a precise black-and-white bitmap that Mods CE can process into machine toolpaths.

Converting Gerbers to PNG with Gerber2PNG

Mods CE does not read Gerber files directly, it needs a raster image where copper regions are black and gaps are white. To produce this, our lab uses the Gerber2PNG web tool hosted at gerber2png.fablabkerala.in, developed by Fab Lab Kerala.

I uploaded all exported Gerber files to the tool, selected Generate All to produce PNG layers at 1000 DPI, and downloaded the resulting images, one for the copper traces, one for the edge cuts, and one for the drill positions.

Gerber2PNG web tool interface showing uploaded Gerber files and the Generate PNG button
Gerber2PNG web tool — uploading Gerber files and generating
Gerber2PNG options panel with Generate All quick setup selected and Generate PNG button highlighted
Generate All
Gerber2PNG output showing PCB board preview and separate trace, drill, and outline PNGs ready for download
Generated PNG layers — trace, drill, and outline images ready for Mods CE

Gerber2PNG KiCad Plugin

Fab Lab Kerala also maintains a dedicated KiCad plugin version of the Gerber2PNG tool that integrates directly into the KiCad toolbar. Rather than switching to a browser-based workflow, this plugin lets you convert Gerbers to PNG with a single click from within KiCad, streamlining the process considerably.

It can be installed through KiCad's built-in Plugin and Content Manager or manually from the GitHub repository. Once installed, a toolbar button appears in the PCB editor. Clicking it generates the trace, edge-cuts, and drill PNGs at the correct resolution for milling, without leaving the application.

The plugin is open source: Gerber2PNG

Installing the Gerber2PNG KiCad Plugin

The plugin is distributed as a ZIP archive from its GitHub repository. Installation is handled entirely through KiCad's built-in Plugin and Content Manager, requiring no manual file copying or command-line steps. The four-step process below covers downloading the archive, opening the manager, installing from file, and confirming the installation.

Step 1 — Navigate to the GitHub repository

Open the plugin's GitHub repository at the link provided above. On the repository's main page, click the green Code button and select Download ZIP to download the full plugin archive to your machine. Save it to a memorable location — you will need to browse to this file in the next step.

GitHub repository page for the Gerber2PNG KiCad plugin showing the Code dropdown and Download ZIP option
Gerber2PNG GitHub repository
File system showing the downloaded Gerber2PNG plugin ZIP archive saved locally before installation
Downloading the plugin as a ZIP archive

Confirm the ZIP file has been saved to your local machine. The archive contains the plugin's Python source files, metadata, and any required assets.

Step 2 — Open the Plugin and Content Manager

In KiCad's main project window (or from within the PCB editor), navigate to Tools → Plugin and Content Manager. This opens the manager, which lists all installed plugins alongside available packages from the official KiCad repository.

KiCad Plugin and Content Manager window showing the installed plugins list and the Install from File button
KiCad Plugin and Content Manager — use Install from File… to install a locally downloaded plugin

For a local installation from file, the standard repository listing is not used — instead the Install from File… button at the bottom of the window is used to bypass the online catalogue entirely.

Step 3 — Install from file and select the ZIP

Click Install from File… to open a file browser dialog. Navigate to the location where the plugin ZIP was saved and select it, then click Open. KiCad will extract the archive, register the plugin, and prompt you to apply pending changes. Click Apply Pending Changes to complete the installation. A new toolbar button for Gerber2PNG will appear in the PCB editor on the next launch.

KiCad file browser dialog with the Gerber2PNG ZIP archive selected, ready to be installed via Install from File
Selecting the Gerber2PNG ZIP in the file browser to complete the local installation
Fab Lab Kerala Gerber2PNG KiCad plugin visible in the KiCad PCB editor toolbar
Plugin can be seen in the installed plugins tab
KiCad PCB editor toolbar showing the Gerber2PNG plugin button added after successful installation
Fab Lab Kerala's Gerber2PNG plugin integrated directly into the KiCad PCB editor toolbar — one click generates all milling-ready PNG layers

Once the PCB design is complete, clicking the Gerber2PNG toolbar button launches the tool directly in the browser with the current board's Gerber files pre-loaded — no manual export or file browsing required. A single click on Generate All produces the trace, edge-cuts, and drill PNGs at the correct resolution, ready to be fed straight into Mods CE for toolpath generation.

Step 2
Generating Toolpaths with Mods CE

Mods CE (Community Edition) is a browser-based, modular CAM tool developed and maintained by the Fab Academy community. It runs entirely in the browser with no installation required and converts the PCB PNG images into machine-ready .rml toolpath files for the Roland MDX/SRM-series mills. You can access it at modsproject.org.

You can take the PNG imgaes generated form the Gerber2PNG and load it into the Mods CE and generate the toolpath for milling.

Unlike traditional desktop CAM software, Mods CE uses a visual, node-based pipeline, you connect processing modules together to chain operations from image input through to machine output. The community provides pre-built programs for common machines, so most users only need to load a program and adjust a few parameters.

Opening Mods CE and Loading the Roland MDX Program

I opened Mods CE in the browser and right-clicked the canvas to bring up the program menu, then navigated to:

This loads a pre-built node network configured specifically for the Roland MDX-20, including modules for reading a PNG, computing isolation offsets, generating the mill path, and outputting Roland RML commands.

Mods CE browser interface with right-click program selection menu open
Mods CE
Mods CE node network fully loaded for Roland MDX PCB milling, showing connected processing modules
Node to load PNG

Loading the PCB Trace Image

I clicked the select png image module and loaded the copper trace PNG exported earlier. The image must be strictly black and white: copper areas appear black, and the areas to be milled away are white. Mods CE reads the image dimensions and embedded DPI metadata to correctly scale all toolpath coordinates into real-world millimetres.

Mods CE read PNG module showing the loaded PCB trace image with dimensions and DPI information
Loading the copper trace PNG into the Mods CE read PNG module
Black-and-white PCB copper trace image exported from KiCad at 1000 DPI, showing trace patterns in black
Copper trace PNG exported from KiCad — black = copper, white = areas to mill

Configuring Mill Settings

The mill raster 2D module is where all the key cutting parameters are set. These values directly affect trace quality, milling time, and the risk of shorts or broken traces.

The offset number controls how much copper is cleared around each trace. A higher value removes more surrounding copper — reducing short-circuit risk but proportionally increasing milling time. For a design with 0402 SMD components, an offset of 4 provides a good balance.

Mill raster 2D settings panel in Mods CE showing tool diameter, cut depth, offset number, and speed configuration
Mods CE mill settings — tool diameter, depth, offset count, and feed rate configured for the Roland MDX-20

Previewing the Toolpath

Before sending any commands to the machine, Mods CE renders a live preview of the computed toolpath in the view module. I carefully inspected the preview to confirm:

Mods CE toolpath preview showing yellow computed mill paths running cleanly around all copper traces
Mods CE toolpath preview — isolation paths computed around all copper traces
Mods CE Roland MDX output module showing port settings and the Send File button ready to transmit the toolpath
Roland MDX output module — port selected and toolpath ready to send to the machine

Changing the Bit

Before anything else, the bit needs to go in. I jogged the spindle to a safe raised position through Mods, then used an Allen key to loosen the collet and seat the V-bit (0.2 mm tip). This is the tool for etching the traces. The 1/32" (0.8 mm) end mill comes later, for drilling and the board outline. The order matters: traces first, then drills, then outline — working from inside the board out.

Setting Parameters and Zeroing Z

With the V-bit in, I used the V-bit calculator to get the right cut parameters. Depth came out to 0.09 mm, offset set to 4. Then I zeroed Z manually — lowering the bit slowly until it just touched the copper surface, then tightening it in place. Getting this right matters a lot. Too shallow and you leave thin copper bridges between traces. Too deep and you're cutting into the FR1 substrate and the bit wears out fast.

Roland SRM-20 spindle at the board origin with Z zeroed on the copper surface before milling
Z zeroed on the copper surface — spindle ready at origin before sending the trace file

Sending the File

Back in Mods, I opened the socket and hit Send File to push the trace toolpath to the machine. It started cutting right away. If something looks off mid-run — depth too shallow, strange sound — the right move is to pause at the machine, close the socket in Mods, and reset before trying again. I stayed close and watched the first few passes to make sure the depth and clearance looked right.

Step 3
Milling the Board

The FR1 board was taped down flat to the sacrificial bed with double-sided tape across the full surface. Flatness really does affect trace quality, so I pressed it down evenly before starting.

Roland MDX-20 spindle verified within the milling area before starting the trace run
Confirming the spindle is within the milling area before starting
Trace isolation passes running with the V-bit
Freshly milled PCB showing clean copper trace isolation cuts from the V-bit
Traces done — copper isolation cuts clean across the board

Once the traces were done, I swapped to the 1/32" (0.8 mm) end mill and ran the drills, then the board outline. Same process: re-zero Z, open socket, send file.

Drilling pass with the 0.8 mm end mill
Vacuuming copper dust and FR1 debris from the milled board and machine bed
Cleaning up copper dust and FR1 debris after milling
Completed milled PCB with traces isolated, holes drilled, and board outline cut
Board out of the machine — traces, drills, and outline all done

Step 4
Soldering Components

With the board milled and sanded, I proceeded to solder the components into it.

Sourcing Components from FabStash

FabStash web interface showing the searchable component catalogue with stock levels and datasheets
FabStash web interface — searchable component catalogue with datasheets and real-time stock levels

All components were sourced from FabStash — Fab Lab Kerala's in-house component inventory and management system. FabStash provides a searchable catalogue of lab-stocked parts complete with datasheets, package types, and live stock levels, making it straightforward to locate and verify exactly what is needed for a design without waiting for external procurement.

The FabStash inventory is accessible at inventory.fablabkerala.in.

In Fabstash, you can search for the components you need, and add them to the cart. Once you have added all the components you need, you can click 'Request' and the components will be reserved for you.

Resistor component from FabStash — 0402 SMD resistor package used on the XIAO RP2040 board
Adding components to cart

After you click 'Request', you can click the clipboard icon and see the pending request you send to the lab.

SMD LED component from FabStash — 0402 red LED with 1.8–2.2 V forward voltage
Pending request

You can click on the request and then you can see the components you requested. At the bottom right corner, there is a button to print the file. Click it and print the file.

Tactile push button and XIAO RP2040 module components laid out before soldering
Printing the request

After you print out the request, you can take the components from the Academy inventory and attach them to the corresponding component name.

FabStash component inventory system showing the XIAO RP2040, 0402 resistors, SMD LED, and tactile switch items selected for the board
Final look

Soldering Process

Before soldering, each component footprint on the board was verified against its physical package to confirm correct orientation and pin assignment. The 0402 passive components (resistors and LED) were placed under a soldering microscope for precision work.

For each SMD component, my process was:

The XIAO RP2040 module was soldered last — its castellated pads were first pre-tinned, then aligned to the board footprint and reflowed carefully to avoid cold joints or bridging between adjacent pads.

PCB board under the soldering microscope with 0402 SMD components being placed and soldered
Bill Of Materials Plugin
Fully populated PCB with XIAO RP2040, SMD resistors, LED, tactile button, and pin headers all soldered
Fully soldered board

Step 5
Testing the Board

Before applying power, I performed a thorough pre-power inspection:

The board passed all pre-power checks. I connected it to a PC via USB-C. The XIAO RP2040 was recognised immediately as a USB device, confirming the board's power rails were clean and the microcontroller was functional.

Functional test — board connected over USB, LED blinking and button input verified

Step 6
Programming Environment Setup

With the board tested and confirmed functional over USB, the next step was to configure a programming environment and write firmware.

Configuring the Arduino IDE for the XIAO RP2040

The XIAO RP2040 is not included in the Arduino IDE's default board list and requires a third-party board package to be added manually. Follow the steps below to prepare the IDE:

  1. Open the Arduino IDE and navigate to File → Preferences.
  2. In the Additional Board Manager URLs field, paste the following URL and click OK:

    https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json
  3. Navigate to Tools → Board → Board Manager, search for Raspberry Pi Pico/RP2040, and click Install. This installs the full RP2040 board family including the XIAO variant.
  4. Once installed, select the target board via Tools → Board → Raspberry Pi RP2040 Boards → Seeed XIAO RP2040.
  5. Connect the board via USB-C, select the correct port under Tools → Port, and click Upload to flash a sketch.
If the XIAO RP2040 does not appear as a serial port, hold the BOOT button while connecting USB to enter UF2 bootloader mode, the board mounts as a USB mass storage drive. The Arduino IDE will automatically handle flashing once the correct board and port are selected.

Step 7
Source Code — Christmas Lights

To demonstrate the board's capabilities, I wrote a Christmas lights animation program that drives four LEDs through a sequence of patterns. The program also accepts input from the tactile push button to cycle through animation speeds at runtime, making it an interactive demonstration of both digital output and digital input on the XIAO RP2040.

Program Behaviour

Each iteration of loop() runs through four distinct LED animation patterns in sequence:

The timing of every animation step is governed by a single delayTime variable (initialised to 150 ms). Pressing the push button increments this value by 50 ms, slowing the animation, and wraps back to 50 ms once it exceeds 400 ms, cycling through five distinct speed levels.

Complete Code


                // Christmas Lights — XIAO RP2040
                // Cycles through four LED animation patterns.
                // Tactile button press increments animation speed.

                const int ledPins[]  = {D0, D1, D2, D3};
                const int NUM_LEDS   = 4;
                const int BUTTON_PIN = D8;

                int delayTime = 150;   // ms between each animation step

                void setup() {
                for (int i = 0; i < NUM_LEDS; i++) {
                    pinMode(ledPins[i], OUTPUT);
                    digitalWrite(ledPins[i], LOW);
                }
                pinMode(BUTTON_PIN, INPUT_PULLUP);  // active-low with internal pull-up
                }

                void loop() {
                // 1 — Chase forward (D0 → D3)
                for (int i = 0; i < NUM_LEDS; i++) {
                    digitalWrite(ledPins[i], HIGH);
                    delay(delayTime);
                    digitalWrite(ledPins[i], LOW);
                }

                // 2 — Chase backward (D3 → D0)
                for (int i = NUM_LEDS - 1; i >= 0; i--) {
                    digitalWrite(ledPins[i], HIGH);
                    delay(delayTime);
                    digitalWrite(ledPins[i], LOW);
                }

                // 3 — Fill up then empty
                for (int i = 0; i < NUM_LEDS; i++) {
                    digitalWrite(ledPins[i], HIGH);
                    delay(delayTime);
                }
                for (int i = NUM_LEDS - 1; i >= 0; i--) {
                    digitalWrite(ledPins[i], LOW);
                    delay(delayTime);
                }

                // 4 — Blink all together x3
                for (int j = 0; j < 3; j++) {
                    for (int i = 0; i < NUM_LEDS; i++) digitalWrite(ledPins[i], HIGH);
                    delay(delayTime);
                    for (int i = 0; i < NUM_LEDS; i++) digitalWrite(ledPins[i], LOW);
                    delay(delayTime);
                }

                // Button press cycles speed: 50 → 100 → ... → 400 → 50 ms
                if (digitalRead(BUTTON_PIN) == LOW) {
                    delayTime += 50;
                    if (delayTime > 400) delayTime = 50;
                    delay(300);   // software debounce
                }
                }

            
Christmas lights program running on the completed board

Step 8
Improved Program — Interrupt-Driven Button Control

The first version of the Christmas lights program polled the button state at the end of each loop() iteration, meaning a button press could be missed entirely if it happened while the animation was mid-pattern. For the improved version I rewrote the button handling to use a hardware interrupt, so every press is captured immediately regardless of where the animation is in its cycle.

The program also expands to five animation patterns (up from four), a four-speed mode system (Slow → Medium → Fast → Crazy), and an LED doubling effect in fast modes where two adjacent LEDs light simultaneously to give the illusion of faster movement.

Key Improvements Over v1

  • Hardware interrupt (ISR)attachInterrupt() registers buttonISR() on the falling edge of the button pin. The ISR sets a volatile flag rather than acting directly, keeping the handler as short as possible.
  • Interrupt debounce — a 250 ms guard window inside the ISR using millis() rejects spurious re-triggers caused by contact bounce, with no hardware RC filter needed.
  • smartDelay() — replaces every raw delay() call inside the animation patterns. It breaks the wait into 10 ms slices and calls checkButton() between each slice, so a mode change takes effect within 10 ms rather than waiting for the full animation step to finish.
  • Mode feedback flash — on each button press, all LEDs extinguish briefly, then (mode + 1) LEDs light up as a visual indicator of the new speed level before the animation resumes.
  • LED doubling in fast modes — in modes 2 and 3, the chase patterns simultaneously illuminate two adjacent LEDs, widening the apparent moving block and keeping the animation readable at high speed.
  • Pattern 5 — alternating pairs — a new pattern that toggles LEDs 0 & 2 against LEDs 1 & 3, producing a strobe-like effect that intensifies at higher speeds.

Speed Mode Reference

Mode 0 — Slow: 300 ms per step — gentle, readable animation.
Mode 1 — Medium: 150 ms per step — standard pace.
Mode 2 — Fast: 80 ms per step, LED doubling active, double repetition count on blink and pair patterns.
Mode 3 — Crazy: 40 ms per step, LED doubling active — maximum intensity.

Complete Code

// Christmas Lights v2 — XIAO RP2040
// 4 LEDs, 1 Button | 4 speed modes with interrupt-driven button control

const int ledPins[]  = {D0, D1, D2, D3};
const int NUM_LEDS   = 4;
const int BUTTON_PIN = D8;

volatile bool buttonPressed = false;
int mode      = 0;    // 0=slow  1=medium  2=fast  3=crazy
int delayTime = 300;  // ms per animation step

unsigned long lastDebounce = 0;

// ── Interrupt Service Routine ──────────────────────────────────────────────
// Called instantly on every falling edge of BUTTON_PIN.
// Sets a flag only; never acts directly from within the ISR.
// 250 ms guard window rejects contact bounce without hardware filtering.
void buttonISR() {
  if (millis() - lastDebounce > 250) {
    buttonPressed = true;
    lastDebounce  = millis();
  }
}

// ── Mode change handler ────────────────────────────────────────────────────
// Reads and clears the ISR flag, advances the mode, updates delayTime,
// then flashes (mode+1) LEDs as visual confirmation of the new speed.
void checkButton() {
  if (buttonPressed) {
    buttonPressed = false;
    mode = (mode + 1) % 4;

    switch (mode) {
      case 0: delayTime = 300; break;
      case 1: delayTime = 150; break;
      case 2: delayTime = 80;  break;
      case 3: delayTime = 40;  break;
    }

    // Flash feedback: lit LED count equals current mode index + 1
    for (int i = 0; i < NUM_LEDS; i++) digitalWrite(ledPins[i], LOW);
    delay(100);
    for (int i = 0; i <= mode; i++)    digitalWrite(ledPins[i], HIGH);
    delay(300);
    for (int i = 0; i < NUM_LEDS; i++) digitalWrite(ledPins[i], LOW);
    delay(100);
  }
}

// ── Responsive delay ───────────────────────────────────────────────────────
// Breaks the wait into 10 ms slices so a button press is acted on
// within 10 ms rather than at the end of the full animation step.
void smartDelay(int ms) {
  for (int i = 0; i < ms; i += 10) {
    delay(10);
    checkButton();
  }
}

// ── Setup ──────────────────────────────────────────────────────────────────
void setup() {
  for (int i = 0; i < NUM_LEDS; i++) {
    pinMode(ledPins[i], OUTPUT);
    digitalWrite(ledPins[i], LOW);
  }
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
}

// ── Main loop ──────────────────────────────────────────────────────────────
void loop() {

  // PATTERN 1: Chase forward (modes 2+ light the next LED simultaneously)
  for (int i = 0; i < NUM_LEDS; i++) {
    digitalWrite(ledPins[i], HIGH);
    if (mode >= 2 && i + 1 < NUM_LEDS) digitalWrite(ledPins[i + 1], HIGH);
    smartDelay(delayTime);
    digitalWrite(ledPins[i], LOW);
    if (mode >= 2 && i + 1 < NUM_LEDS) digitalWrite(ledPins[i + 1], LOW);
  }

  // PATTERN 2: Chase backward
  for (int i = NUM_LEDS - 1; i >= 0; i--) {
    digitalWrite(ledPins[i], HIGH);
    if (mode >= 2 && i - 1 >= 0) digitalWrite(ledPins[i - 1], HIGH);
    smartDelay(delayTime);
    digitalWrite(ledPins[i], LOW);
    if (mode >= 2 && i - 1 >= 0) digitalWrite(ledPins[i - 1], LOW);
  }

  // PATTERN 3: Fill up then empty
  for (int i = 0; i < NUM_LEDS; i++) {
    digitalWrite(ledPins[i], HIGH);
    smartDelay(delayTime);
  }
  for (int i = NUM_LEDS - 1; i >= 0; i--) {
    digitalWrite(ledPins[i], LOW);
    smartDelay(delayTime);
  }

  // PATTERN 4: Blink all (6 times in fast modes, 3 in slow/medium)
  int blinkCount = (mode >= 2) ? 6 : 3;
  for (int j = 0; j < blinkCount; j++) {
    for (int i = 0; i < NUM_LEDS; i++) digitalWrite(ledPins[i], HIGH);
    smartDelay(delayTime);
    for (int i = 0; i < NUM_LEDS; i++) digitalWrite(ledPins[i], LOW);
    smartDelay(delayTime);
  }

  // PATTERN 5: Alternating pairs (D0+D2 vs D1+D3)
  int pairCount = (mode >= 2) ? 6 : 3;
  for (int j = 0; j < pairCount; j++) {
    digitalWrite(ledPins[0], HIGH); digitalWrite(ledPins[2], HIGH);
    digitalWrite(ledPins[1], LOW);  digitalWrite(ledPins[3], LOW);
    smartDelay(delayTime);
    digitalWrite(ledPins[0], LOW);  digitalWrite(ledPins[2], LOW);
    digitalWrite(ledPins[1], HIGH); digitalWrite(ledPins[3], HIGH);
    smartDelay(delayTime);
  }
  for (int i = 0; i < NUM_LEDS; i++) digitalWrite(ledPins[i], LOW);
}

Key Learnings