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.

Schematic & 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.


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();
}
}