espace in motion

Concept
How can static architectural elements become responsive to human presence and movement, creating more interactive spatial experiences?
PROXIMA is a system of interactive panels that open and close according to the user’s proximity. It turns the door, normally a fixed boundary you push against, into a surface that reacts to the body and makes room for it.
The installation is a sequence of pivoting panels arranged in pairs along a path. An ultrasonic distance sensor tracks the position of the person as they move, and an Arduino translates that distance into the rotation of a set of motors, one for each pair of panels. As the person gets closer, the next panel begins to turn. When they reach it, it is fully open and aligned with the path. Once they have passed, it closes behind them. Each panel rotates 180° in the direction of movement, so the system responds not only to how close the person is but also to which way they are going.
The result is a space that does not exist in advance. The passage is made in real time, one threshold at a time, and disappears behind the user. PROXIMA explores architecture as a responsive system rather than a static object: the boundary is no longer a wall or a closed door, but a relationship between the body, distance and movement.
Schematic

Prototype
Scale prototype of PROXIMA. Built with cardboard panels, a continuous wire structure, servo motors and an Arduino, the model illustrates how each pair of panels rotates in sequence, opening as the user approaches and closing once they pass.

Render

Scalability: Adapt the system to larger architectural and human scale doors and partitions.
Interaction: Explore multiple sensors to detect users from different directions.
Integration: Apply the system to responsive facades and adaptable interior spaces.
Video
Final Code
#include <Servo.h>
#include <Ultrasonic.h> // Grove Ultrasonic Ranger library (single SIG pin)
#include <math.h>
// Single signal pin: in Wokwi, the HC-SR04 TRIG and ECHO are both wired to A1
#define SENSOR_PIN A1
const unsigned long SENSOR_INTERVAL = 40; // ms between sensor readings
const unsigned long SERIAL_INTERVAL = 100; // ms between serial prints
const float SERVO_SPEED = 360.0; // max servo speed in degrees per second
Ultrasonic ultrasonic(SENSOR_PIN);
Servo servoGreen;
Servo servoBlue;
Servo servoYellow;
Servo servoOrange;
Servo servoPurple;
// Current (smoothed) position of each servo, in degrees
float posGreen = 0;
float posBlue = 0;
float posYellow = 0;
float posOrange = 0;
float posPurple = 0;
// Last valid distance in cm (-1 = no valid reading yet)
float currentDistance = -1;
unsigned long lastSensorRead = 0;
unsigned long lastServoUpdate = 0;
unsigned long lastSerialPrint = 0;
// Maps a distance to a proportional angle:
// at or beyond ‘start’ -> 180 degrees, at or closer than ‘end’ -> 0 degrees
float targetAngle(float distance, float start, float end) {
float progress = (start – distance) / (start – end);
progress = constrain(progress, 0.0, 1.0);
return (1.0 – progress) * 180.0;
}
// Moves a servo toward its target at a time-based, limited speed
void moveSmoothly(
Servo &servo,
float &position,
float target,
float deltaTime
) {
float maxStep = SERVO_SPEED * deltaTime;
if (position < target) {
position += min(maxStep, target – position);
} else if (position > target) {
position -= min(maxStep, position – target);
}
servo.write((int)round(position));
}
void setup() {
Serial.begin(9600);
// Attach each servo to its PWM pin
servoGreen.attach(3);
servoBlue.attach(6);
servoYellow.attach(9);
servoOrange.attach(10);
servoPurple.attach(11);
// Start every servo at 0 degrees
servoGreen.write(0);
servoBlue.write(0);
servoYellow.write(0);
servoOrange.write(0);
servoPurple.write(0);
// Initialize timers
lastSensorRead = millis();
lastServoUpdate = millis();
lastSerialPrint = millis();
}
void loop() {
unsigned long now = millis();
// Raw sensor reading, no median filter or smoothing
if (now – lastSensorRead >= SENSOR_INTERVAL) {
lastSensorRead = now;
long distance = ultrasonic.MeasureInCentimeters();
// Ignore failed readings (0); clamp far readings to 100 cm so all doors fully react
if (distance > 0) {
currentDistance = min(distance, 100L);
}
}
// Wait until there is at least one valid reading
if (currentDistance < 0) {
return;
}
// Distance zones for the five doors (start cm, end cm)
float targetPurple = targetAngle(currentDistance, 100, 80);
float targetOrange = targetAngle(currentDistance, 95, 65);
float targetYellow = targetAngle(currentDistance, 70, 50);
float targetBlue = targetAngle(currentDistance, 55, 35);
float targetGreen = targetAngle(currentDistance, 40, 20);
// Update servos based on elapsed time (capped to avoid big jumps)
float deltaTime = (now – lastServoUpdate) / 1000.0;
lastServoUpdate = now;
deltaTime = min(deltaTime, 0.05f);
moveSmoothly(servoGreen, posGreen, targetGreen, deltaTime);
moveSmoothly(servoBlue, posBlue, targetBlue, deltaTime);
moveSmoothly(servoYellow, posYellow, targetYellow, deltaTime);
moveSmoothly(servoOrange, posOrange, targetOrange, deltaTime);
moveSmoothly(servoPurple, posPurple, targetPurple, deltaTime);
// Print distance and servo positions to the Serial Monitor
if (now – lastSerialPrint >= SERIAL_INTERVAL) {
lastSerialPrint = now;
Serial.print(“Distancia: “);
Serial.print(currentDistance);
Serial.print(” cm | Verde: “);
Serial.print(posGreen);
Serial.print(” | Azul: “);
Serial.print(posBlue);
Serial.print(” | Amarelo: “);
Serial.print(posYellow);
Serial.print(” | Laranja: “);
Serial.print(posOrange);
Serial.print(” | Roxo: “);
Serial.println(posPurple);
}