A proximity responsive kinetic membrane

Concept

A wall that notices you.

As you walk toward it, the surface softens. White membranes inside a lattice of black frames draw inward, one by one, as if the wall is leaning away to make room for you. The closer you stand, the deeper the hollows fall, each triangle folding into a small pool of shadow. The wall does not reach for you. It yields.

Step back and the tension lets go. The folds fade, the shadows lift, and the surface returns to a calm, bright white. You leave a trace that lasts only a moment, a ripple of hollows that follows you along the wall and then disappears.

Initial Concept Sketchs
System Working

Schematic & Bill of Materials

Circuit Diagram
Bill of Materials

Prototype

KINETRA explores the potential of transforming static architectural surfaces into interactive, responsive systems. The prototype consists of two triangular fabric modules controlled by stepper motors and an ultrasonic sensor. As a person approaches, the sensor detects changes in proximity, triggering motor-driven movements that pull the fabric inward, creating dynamic variations in tension, depth, and surface geometry.

Membrane in Tension

Next Steps

The prototype serves as an initial exploration of physical computing, material behaviour, and kinetic architecture. Future development envisions expanding these individual modules into a larger tessellated surface, where independently controlled panels respond to human movement, creating fluid, wave-like transformations across the facade.

This opens possibilities for interactive installations, adaptive partitions, and responsive architectural skins that establish a dynamic relationship between people and the built environment.

____________________________________________________________________________________________________

Video

Code

#include <AccelStepper.h>

#include <U8x8lib.h>

// —————- Display settings —————-

#define OLED_USE_SW_I2C 1                    // 1 = software I2C (OLED in the A2 Grove socket), 0 = hardware I2C (I2C socket / A4, A5)

#define OLED_SCL A2                          // yellow wire of the A2 socket

#define OLED_SDA A3                          // white wire of the A2 socket

#define OLED_I2C_ADDR 0x3C                   // try 0x3D if the screen stays blank

#define GLYPH_GAP_MS 20                      // while the motors move, draw at most one character per 20 ms (0 = no limit)

#define DEBUG_SERIAL 0                       // 1 = print raw sensor reading and motor position at 115200 baud

#if OLED_USE_SW_I2C

U8X8_SSD1306_128X64_NONAME_SW_I2C u8x8(OLED_SCL, OLED_SDA, U8X8_PIN_NONE);

#else

U8X8_SSD1306_128X64_NONAME_HW_I2C u8x8(U8X8_PIN_NONE);

// If the screen is blank or shifted, try this variant instead:

// U8X8_SSD1306_128X64_ALT0_HW_I2C u8x8(U8X8_PIN_NONE);

#endif

// —————- Pins —————-

#define TRIG 6

#define ECHO 7

#define BUTTON 4                             // switch: toggles the system on/off

#define BUTTON_ACTIVE HIGH                   // Grove button = HIGH when pressed (use LOW for a bare button to GND)

#define DEBOUNCE_MS 50

// —————- Geometry (your table) —————-

#define NEAR_MM 50                           // 5 cm: 360 degrees

#define FAR_MM 500                           // 500 mm: 0 degrees

#define DETECT_MM 600                        // object counts as detected up to here (rotation stays 0 beyond 500 mm)

#define ANGLE_AT_NEAR 360                    // 360 degrees at 5 cm, 0.8 degrees per mm (400 mm = 80, 300 mm = 160)

#define STEPS_PER_REV 4096L                  // 28BYJ-48 in half-step mode (about 4076 in reality, verify)

#define MOTOR1_SIGN -1                        // 1 or -1: set so getting closer turns the spool clockwise

#define MOTOR2_SIGN -1                        // same for motor 2 (may need the opposite sign)

// —————- Motion —————-

#define WIND_SPEED 900                       // steps/s while the angle increases. Lower it if the motor skips or hums

#define UNWIND_SPEED 700                     // steps/s while the angle decreases

#define WIND_ACCEL 1000                      // steps/s per second

#define UNWIND_ACCEL 800

// —————- Filtering —————-

#define MEASURE_MS 100

#define DISPLAY_MS 100                       // screen values refresh 10 times a second

#define ECHO_TIMEOUT_US 5000UL               // gives up after about 85 cm, extra margin beyond 600 mm

#define MISS_LIMIT 4                         // failed readings in a row before “no object”

#define MEDIAN_N 5

#define EMA_ALPHA 0.25

#define DEADBAND_MM 4                        // at 0.8 degrees per mm this holds still for about 3 degrees of change

// Driver 1 is on D8-D11. Driver 2 is on D2, D3, D12, D5.

// AccelStepper order is IN1, IN3, IN2, IN4.

AccelStepper motor1(AccelStepper::HALF4WIRE, 8, 10, 9, 11);

AccelStepper motor2(AccelStepper::HALF4WIRE, 2, 12, 3, 5);

int samples[MEDIAN_N];

byte sampleCount = 0, sampleIndex = 0, missCount = 0;

bool objectPresent = false;

float smoothMm = 0;

int heldMm = 0;

int liveMm = 0;                              // latest filtered distance, shown on the screen

long rawMm = -1;                             // last raw reading (-1 = no echo), for debugging

long targetSteps = 0;                        // target position in steps

long lastTargetSteps = 0;

bool lastUnwinding = false;

unsigned long lastMeasure = 0, lastDisplay = 0, lastGlyph = 0;

bool systemOn = true;                        // the switch toggles this

bool coilsOff = false;                       // true once the motors are released while OFF

bool lastPressedReading = false;

bool stablePressed = false;

unsigned long lastChangeTime = 0;

const byte rowOf[4] = {0, 1, 3, 5};          // OLED text rows we use

char want[4][17];                            // what the screen should show

char have[4][17];                            // what the screen shows now

// —————- Sleeping eyes (shown on the OLED while the system is OFF) —————-   // NEW

#define SLEEP_FRAME_MS 600                   // NEW: time per animation frame (ms)

#define EYE_FLIP 0                           // NEW: set to 1 if the eyes are drawn upside down

uint8_t eyeTiles[2][24];                     // NEW: two eye shapes (breathing), 3 tiles of 8 bytes each

const byte eyeY[24] = {2,3,3,4,4,5,5,5,6,6,6,6,6,6,6,6,5,5,5,4,4,3,3,2};   // NEW: curve height per pixel column

const char zChars[3] = {‘z’, ‘Z’, ‘Z’};      // NEW: floating letters

const byte zx[3] = {13, 14, 15};             // NEW: letter columns

const byte zy[3] = {3, 2, 1};                // NEW: letter rows, rising

bool sleepActive = false;                    // NEW: true while the animation is on screen

byte sleepFrame = 0;                         // NEW

unsigned long lastSleepFrame = 0;            // NEW

uint8_t reverseBits(uint8_t b)               // NEW: flips a column of 8 pixels top to bottom

{uint8_t r = 0;

  for (byte i = 0; i < 8; i++) { r = (r << 1) | (b & 1); b >>= 1; }

  return r;}

void buildEyeTiles()                         // NEW: builds the closed-eye curve (a curve open towards the top)

{for (byte set = 0; set < 2; set++) {       // set 1 is one pixel higher: the “breathing” frame

    for (byte c = 0; c < 24; c++) {

      byte y = eyeY[c] – set;

      uint8_t mask = (1 << y) | (1 << (y + 1));   // 2 pixels thick

#if EYE_FLIP

      mask = reverseBits(mask);

#endif

      eyeTiles[set][c] = mask; }

  }

}

void drawEyes(byte set)                      // NEW: left eye on tile columns 2-4, right eye on 11-13, tile row 4

{u8x8.drawTile(2, 4, 3, eyeTiles[set]);

  u8x8.drawTile(11, 4, 3, eyeTiles[set]);}

void drawZzz(byte frame)                     // NEW: 0 none, 1 z, 2 zZ, 3 zZZ, 4 hold, 5 none

{byte n = (frame <= 3) ? frame : (frame == 4 ? 3 : 0);

  for (byte i = 0; i < 3; i++) u8x8.drawGlyph(zx[i], zy[i], (i < n) ? zChars[i] : ‘ ‘);}

void enterSleep()                            // NEW

{sleepActive = true;

  sleepFrame = 0;

  lastSleepFrame = millis();

  drawEyes(0);}

void exitSleep()                             // NEW: wipes the animation; the normal text then redraws by itself

{ sleepActive = false;

  u8x8.drawString(0, 4, ”                “);

  for (byte i = 0; i < 3; i++) u8x8.drawGlyph(zx[i], zy[i], ‘ ‘);}

long readDistanceMm()

{digitalWrite(TRIG, LOW);

  delayMicroseconds(2);

  digitalWrite(TRIG, HIGH);

  delayMicroseconds(10);

  digitalWrite(TRIG, LOW);

  unsigned long duration = pulseIn(ECHO, HIGH, ECHO_TIMEOUT_US);

  if (duration == 0) return -1;

  return duration * 343UL / 2000UL;}

int medianOfSamples()

{ int tmp[MEDIAN_N];

  for (byte i = 0; i < sampleCount; i++) tmp[i] = samples[i];

  for (byte i = 1; i < sampleCount; i++) {

    int v = tmp[i];

    int j = i – 1;

    while (j >= 0 && tmp[j] > v) { tmp[j + 1] = tmp[j]; j–; }

    tmp[j + 1] = v;}

  return tmp[sampleCount / 2];}

long stepsFromMm(int mm)                     // distance -> target position in steps, a straight line

{mm = constrain(mm, NEAR_MM, FAR_MM);       // 500 mm or farther = 0, 50 mm or closer = 360 degrees

  return (long)(FAR_MM – mm) * ANGLE_AT_NEAR * STEPS_PER_REV / (360L * (FAR_MM – NEAR_MM));}

void applyTarget()                           // sends the target position to both motors

{long steps = targetSteps;                  // wind position: 0 = unwound, positive = wound

  if (steps != lastTargetSteps) {

    long nowSteps = motor1.currentPosition() * MOTOR1_SIGN;

    bool unwinding = steps < nowSteps;

    if (unwinding != lastUnwinding) {        // switch speed set only when direction changes

      lastUnwinding = unwinding;

      motor1.setMaxSpeed(unwinding ? UNWIND_SPEED : WIND_SPEED);

      motor1.setAcceleration(unwinding ? UNWIND_ACCEL : WIND_ACCEL);

      motor2.setMaxSpeed(unwinding ? UNWIND_SPEED : WIND_SPEED);

      motor2.setAcceleration(unwinding ? UNWIND_ACCEL : WIND_ACCEL);}

    lastTargetSteps = steps;

    motor1.moveTo(steps * MOTOR1_SIGN);

    motor2.moveTo(steps * MOTOR2_SIGN);  }

}

void measure()

{ long mm = readDistanceMm();

  rawMm = mm;                                // keep the raw value for DEBUG_SERIAL

  if (mm >= 0 && mm <= DETECT_MM) {

    missCount = 0;

    samples[sampleIndex] = (int)mm;

    sampleIndex = (sampleIndex + 1) % MEDIAN_N;

    if (sampleCount < MEDIAN_N) sampleCount++;

    int med = medianOfSamples();

    liveMm = med;                            // live value for the screen

    if (!objectPresent) {

      objectPresent = true;

      smoothMm = med;

      heldMm = med;

    } else { smoothMm += EMA_ALPHA * (med – smoothMm);

      if (abs((int)smoothMm – heldMm) >= DEADBAND_MM) heldMm = (int)smoothMm;}

    targetSteps = stepsFromMm(heldMm);}

 else { if (missCount < 255) missCount++;

    if (missCount >= MISS_LIMIT && objectPresent) {

      objectPresent = false;

      sampleCount = 0;

      sampleIndex = 0;

      targetSteps = 0;                       // return to 0

    }

  }

  applyTarget();}

void pollButton()                            // debounce, then toggle on/off on each press

{bool pressed = (digitalRead(BUTTON) == BUTTON_ACTIVE);

  if (pressed != lastPressedReading) lastChangeTime = millis();

  lastPressedReading = pressed;

  if (millis() – lastChangeTime >= DEBOUNCE_MS && pressed != stablePressed) {

    stablePressed = pressed;

    if (stablePressed) {                     // a real press, not a release

      systemOn = !systemOn;

      if (!systemOn) {                       // turning OFF: motors unwind to 0

        objectPresent = false;

        sampleCount = 0;

        sampleIndex = 0;

        missCount = 0;

        targetSteps = 0;

        applyTarget();

      } else if (coilsOff) {                 // turning ON: re-energise the motors

        motor1.enableOutputs();

        motor2.enableOutputs();

        coilsOff = false;}

    }

  }

}

void setLine(byte i, const char* text)       // pads a line to 16 characters

{ snprintf(want[i], 17, “%-16s”, text);}

void buildScreen()                           // decides what the screen should say

{ long tenths = motor1.currentPosition() * MOTOR1_SIGN * 3600L / STEPS_PER_REV;   // live angle in tenths of a degree

  if (tenths < 0) tenths = 0;

  char tmp[17];

  if (!systemOn) {                           // CHANGED: OFF: the text is erased and the sleeping eyes take over

    setLine(0, “”);

    setLine(1, “”);

    setLine(2, “”);

    setLine(3, “”);

    return;}

  setLine(0, objectPresent ? “Object” : “No object”);     // status first

  setLine(1, “detected”);

  if (objectPresent) snprintf(tmp, sizeof(tmp), “Dist: %4d mm”, liveMm);   // live distance

  else               snprintf(tmp, sizeof(tmp), “Dist: —- mm”);

  setLine(2, tmp);

  snprintf(tmp, sizeof(tmp), “Angle: %3d.%d deg”, (int)(tenths / 10), (int)(tenths % 10));   // live angle, one decimal

  setLine(3, tmp);}

bool pumpDisplay()                           // draws ONE character per call and returns true if it drew

{for (byte i = 0; i < 4; i++) {

    for (byte c = 0; c < 16; c++) {

      if (want[i][c] != have[i][c]) {

        u8x8.drawGlyph(c, rowOf[i], want[i][c]);

        have[i][c] = want[i][c];

        return true;}

    }

  }

  return false;}

void setup()

{#if DEBUG_SERIAL

  Serial.begin(115200);

#endif

  pinMode(TRIG, OUTPUT);

  pinMode(ECHO, INPUT);

  pinMode(BUTTON, BUTTON_ACTIVE == HIGH ? INPUT : INPUT_PULLUP);

  motor1.setMaxSpeed(WIND_SPEED);

  motor1.setAcceleration(WIND_ACCEL);

  motor2.setMaxSpeed(WIND_SPEED);

  motor2.setAcceleration(WIND_ACCEL);

  motor1.setCurrentPosition(0);              // assumes the thread is fully unwound at power-up

  motor2.setCurrentPosition(0);

  buildEyeTiles();                           // NEW

  u8x8.setI2CAddress(OLED_I2C_ADDR << 1);    // U8x8 wants the 8-bit form (verify)

#if !OLED_USE_SW_I2C

  u8x8.setBusClock(400000);                  // faster I2C (remove if it errors)

#endif

  u8x8.begin();

  u8x8.setPowerSave(0);

  u8x8.setFont(u8x8_font_chroma48medium8_r);

  u8x8.clearDisplay();

  for (byte i = 0; i < 4; i++) memset(have[i], 0, sizeof(have[i]));   // force a full first draw

  buildScreen();}

void loop()

{ motor1.run();

  motor2.run();

  pollButton();

  if (systemOn && millis() – lastMeasure >= MEASURE_MS) {

    lastMeasure = millis();

    measure();}

  // OFF and fully unwound: release the coils so nothing heats up

  if (!systemOn && !coilsOff && motor1.distanceToGo() == 0 && motor2.distanceToGo() == 0) {

    motor1.disableOutputs();

    motor2.disableOutputs();

    coilsOff = true; }

  if (millis() – lastDisplay >= DISPLAY_MS) {

    lastDisplay = millis();

    buildScreen();

#if DEBUG_SERIAL

    Serial.print(F(“raw “)); Serial.print(rawMm);          // -1 means no echo came back

    Serial.print(F(” mm  live “)); Serial.print(liveMm);

    Serial.print(F(”  obj “)); Serial.print(objectPresent);

    Serial.print(F(”  tgt “)); Serial.print(targetSteps);

    Serial.print(F(”  pos “)); Serial.println(motor1.currentPosition());

#endif

  }

  // NEW: sleeping eyes while OFF. The animation only advances when the motors are still,

  // so it never slows the unwinding.

  if (!systemOn) {

    if (!sleepActive) enterSleep();

    bool idle = (motor1.distanceToGo() == 0 && motor2.distanceToGo() == 0);

    if (idle && millis() – lastSleepFrame >= SLEEP_FRAME_MS) {

      lastSleepFrame = millis();

      sleepFrame = (sleepFrame + 1) % 6;

      drawEyes(sleepFrame % 2);              // eyes drop and rise by one pixel: breathing

      drawZzz(sleepFrame); }                  // z, Z, Z float up one at a time, then vanish

  } else if (sleepActive) {

    exitSleep(); }

  // While the motors move, one character is drawn at most every GLYPH_GAP_MS

  bool moving = (motor1.distanceToGo() != 0 || motor2.distanceToGo() != 0);

  if (!moving || millis() – lastGlyph >= GLYPH_GAP_MS) {

    if (pumpDisplay()) lastGlyph = millis();

  }

}