/* Program title: VL53L5CX_V45_ForegroundTravel_FullSlatFill Written by Jeff Ritchie with assistance from Open AI Codex Date: June 1, 2026 Prompt used to create this program: Revise VL53L5CXTOFScanner8x8grid_distandzonecount_neopixelpatterns_V7. Keep the six VL53L5CX motion states and the 16 slats of 56 NeoPixels on D7. Improve the user experience and power behavior. NO_PERSON should invite interest and movement toward the exhibit by moving lights from the four corners of the LED matrix toward the center. The NeoPixel system is powered by a 10 amp supply, while 896 mounted NeoPixels at full white could require about 54 amps, so do not use white or full brightness. Keep all patterns low power, use mostly one or two color channels, and avoid lighting the full matrix at high intensity. Include this prompt in the header comments for Fab Academy documentation. Version 9 revision: Create a new version in which the program only lights every third NeoPixel in all animations. Apply this as a global power-saving rule so the attract mode, breathing pattern, left/right slat sweeps, approaching tails, and walking-away tails all obey the same every-third-pixel limit. Version 10 revision: Revise the APPROACHING animation so that the display gradually turns on more lights as the viewer gets nearer to the sensor. Keep the global every-third NeoPixel limit for power safety, avoid white, and scale the fill amount and brightness from the measured z distance. Version 11 revision: Add a global maximum brightness cap so no color helper can request more than half of the possible 8-bit brightness value. This keeps every animation at or below 127 out of 255 before the NeoPixel library's global brightness scaling is applied. Version 12 revision: Add a one-second delay before publishing STANDING_STILL. Brief pauses during approach, walking away, or side-to-side movement should not immediately switch the display into the standing-still breathing animation. Version 13 revision: Add explanatory comments throughout the program and create a companion HTML document that identifies each major part of the program and explains how it functions. Version 14 revision: Reduce false STANDING_STILL classifications when no person is present. Add a stronger foreground requirement for standing still, track how long a valid foreground person has been present, and force weak still detections back to NO_PERSON instead of allowing small background noise to become STANDING_STILL. Version 15 revision: Make the NO_PERSON attract animation more visible and inviting while keeping the every-third-pixel and half-brightness safety rules. Replace the subtle corner comets with broader corner-to-center fan waves plus a soft center pulse. Version 16 revision: Revise the NO_PERSON default animation so it starts from the full perimeter of the LED matrix: slat 1, slat 16, the top row of every slat, and the bottom row of every slat. Later versions revise the address map after the strand was looped between slats. Version 17 revision: Make the movement animations more seamless and directly controlled by sensor values. LEFT_TO_RIGHT and RIGHT_TO_LEFT now use smoothX to place the lit slat band, so reversing direction moves the band back instead of restarting a canned sweep. APPROACHING and WALKING_AWAY now both use the same smoothZ depth fill, so walking forward fills the display and backing up removes the fill. Version 18 revision: Make approach and walking-away easier to see by dividing the depth interaction into 20 visible steps, matching the previous 60-pixel slats with the every-third-pixel power rule. The depth fill amount is based mostly on distance from the sensor and partly on foreground zone count, so moving closer and taking up more of the VL53L5CX grid both increase the number of lit levels. Version 19 revision: Revise the NeoPixel matrix from 16 slats of 60 LEDs to 16 slats of 56 LEDs. The total display now has 896 NeoPixels. Update the matrix mapping comments, pixel count constants, and depth-fill level calculation to match the shorter slats. Version 20 revision: Update the matrix mapping after the NeoPixel strand was looped between slats instead of cut. Each slat still has 56 mounted/live NeoPixels, but the looped strand includes dead address ranges between slats. The code now uses a real slat-to-address map instead of the simple slat * height formula. Version 21 revision: Change the global power-saving rule from lighting every third mounted NeoPixel to lighting every fourth mounted NeoPixel. With 56 LEDs per slat, this creates 14 evenly spaced visible vertical positions per slat. Version 24 revision: Base this version on the full Version 21 motion program and update the NeoPixel slat start addresses using the corrected map confirmed with the LED address test programs. Version 25 revision: Revise LEFT_TO_RIGHT and RIGHT_TO_LEFT so the VL53L5CX 8x8 foreground grid is mapped onto the 16-slat by 56-pixel LED matrix. Side-to-side movement now lights the active sensor zones on the display and adds a brighter centroid marker so the visitor's movement is easier to see. Version 26 revision: Make LEFT_TO_RIGHT and RIGHT_TO_LEFT much more visually apparent. The side movement states now draw a bold full-height tracking band across the matrix, with a bright leading edge and trailing wake tied directly to the person's smoothed X position on the VL53L5CX grid. Version 27 revision: Revise LEFT_TO_RIGHT and RIGHT_TO_LEFT into progressive slat-fill animations. LEFT_TO_RIGHT lights slats 0 through the person's mapped position. RIGHT_TO_LEFT lights slats 15 down through the person's mapped position. Version 28 revision: Speed up STANDING_STILL to a 90-pulse-per-minute visual rhythm. Revise LEFT_TO_RIGHT and RIGHT_TO_LEFT so the occupied VL53L5CX grid columns map to slat pairs: LTR fills from column 0 toward the highest occupied column, and RTL fills from column 7 toward the lowest occupied column. Version 40 revision: Use Version 28 as the movement/animation baseline and keep LEFT_TO_RIGHT and RIGHT_TO_LEFT enabled. Add a six-foot interaction cap so readings beyond 1830 mm are treated as invalid, and depth fill maps to that same six-foot far edge. Version 41 revision: Preserve the Version 40 baseline and add stability tuning for the angled sensor mount: hold brief foreground dropouts before switching to NO_PERSON, reduce rapid LEFT_TO_RIGHT/RIGHT_TO_LEFT direction flips, and keep progressive per-slat side-fill animation visible during lateral movement. Version 42 revision: Prioritize foreground lateral movement for LEFT_TO_RIGHT and RIGHT_TO_LEFT. Any valid foreground object movement across X (including hand motion while standing in place) now drives side-direction animation more directly, with reduced dependence on approach/walk-away scoring. Version 43 revision: Improve direction switching so LEFT_TO_RIGHT can take over promptly after RIGHT_TO_LEFT (and vice versa). Reduce side-state hold time and lower the score needed to switch directions, while keeping foreground-driven LTR/RTL behavior. Version 44 revision: Fix left/right direction polarity for the current sensor mounting orientation. Physical left-to-right movement now maps to LEFT_TO_RIGHT fill (slats 0 to 15), and physical right-to-left maps to RIGHT_TO_LEFT fill (slats 15 to 0). Version 45 revision: Make side-fill use full slat range based on foreground travel across the sensor view. Track each person session's minimum and maximum X positions and normalize current X within that range so LEFT_TO_RIGHT and RIGHT_TO_LEFT can progressively fill all 16 slats, instead of staying clustered near one edge. */ #include #include #include // Hardware connections. // XIAO ESP32C3 D4/GPIO6 is SDA, D5/GPIO7 is SCL, and D7 drives NeoPixel data. #define SDA_PIN 6 #define SCL_PIN 7 #define NEOPIXEL_PIN D7 // VL53L5CX is read in 8x8 mode, so each frame contains 64 distance zones. const int GRID_SIZE = 8; const int ZONE_COUNT = 64; // LED matrix geometry: 16 vertical slats, each with 56 mounted/live NeoPixels. // The strand is looped between slats, so some address ranges are dead spacer // pixels and should not be lit. The NeoPixel object still needs the full strand // length so later slats can be addressed. const int NUM_SLATS = 16; const int PIXELS_PER_SLAT = 56; const int LIVE_PIXELS = NUM_SLATS * PIXELS_PER_SLAT; const int NUMPIXELS = 972; // Address where each physical slat begins in the continuous NeoPixel strand. // Even slats run bottom-to-top; odd slats run top-to-bottom. const int SLAT_START_ADDRESS[NUM_SLATS] = { 0, 61, 123, 185, 240, 303, 366, 426, 480, 541, 601, 665, 720, 782, 844, 905 }; const bool SLAT_RUNS_UP[NUM_SLATS] = { true, false, true, false, true, false, true, false, true, false, true, false, true, false, true, false }; // Sensor readings outside this range are treated as invalid. const int MIN_VALID_MM = 150; const int SIX_FEET_MM = 1830; const int MAX_VALID_MM = SIX_FEET_MM; // Background subtraction settings. A zone becomes foreground only when it is // significantly closer than the empty-scene background recorded at startup. const int BACKGROUND_DELTA_MM = 220; const int MIN_FOREGROUND_ZONES = 2; const int MIN_STILL_FOREGROUND_ZONES = 7; const int FOREGROUND_WINDOW_MM = 800; // Motion thresholds. X is measured in 8x8 grid columns, Z is distance in mm, // and area is the number of foreground zones. const float X_MOTION_THRESHOLD = 0.28; const float Z_MOTION_THRESHOLD_MM = 90.0; const float AREA_MOTION_THRESHOLD = 2.0; const float SIDE_ACTIVATION_SCORE = 0.45; // Small changes below these thresholds count as stillness. const float STILL_X_THRESHOLD = 0.25; const float STILL_Z_THRESHOLD_MM = 60.0; const float STILL_AREA_THRESHOLD = 1.5; // Timing controls. The loop is nonblocking so the sensor and animations can // update independently. const unsigned long CLASSIFY_INTERVAL_MS = 180; const unsigned long SENSOR_POLL_MS = 5; const unsigned long LED_FRAME_MS = 20; const unsigned long STANDING_STILL_DELAY_MS = 1000; const unsigned long PERSON_CONFIRM_MS = 700; const unsigned long NO_PERSON_HOLD_MS = 700; const unsigned long SIDE_STATE_MIN_HOLD_MS = 180; const float SIDE_DIRECTION_SWITCH_SCORE = 0.70; // NeoPixel power controls. The separate LED supply is limited, so the program // avoids white, limits brightness, and only lights every fourth mounted pixel. const int DISPLAY_BRIGHTNESS = 45; const int TAIL_LENGTH = 10; const int SLAT_SWEEP_TAIL = 3; const int BREATHING_MAX = 90; const float STANDING_STILL_PULSES_PER_MINUTE = 90.0; const int ATTRACT_STEPS = 30; const int ATTRACT_TAIL = 7; const int LIT_PIXEL_SPACING = 4; const int MAX_SAFE_BRIGHTNESS = 127; const int APPROACH_FAR_MM = SIX_FEET_MM; const int APPROACH_NEAR_MM = 305; const int APPROACH_MIN_BRIGHTNESS = 18; const int APPROACH_MAX_BRIGHTNESS = 115; const int DEPTH_LEVELS = (PIXELS_PER_SLAT + LIT_PIXEL_SPACING - 1) / LIT_PIXEL_SPACING; const int AREA_FAR_ZONES = 3; const int AREA_NEAR_ZONES = 28; // The classifier publishes one of these six states. The animation layer maps // each state to a different NeoPixel behavior. enum MotionState { NO_PERSON, STANDING_STILL, LEFT_TO_RIGHT, RIGHT_TO_LEFT, APPROACHING, WALKING_AWAY }; SparkFun_VL53L5CX sensor; VL53L5CX_ResultsData data; Adafruit_NeoPixel pixels(NUMPIXELS, NEOPIXEL_PIN, NEO_RGB + NEO_KHZ800); // Empty-scene distance values captured during startup calibration. int background[ZONE_COUNT]; // Smoothed person/blob measurements. float smoothX = -1; float smoothY = -1; float smoothZ = -1; float smoothArea = -1; // Previous smoothed measurements used to calculate motion over time. float previousX = -1; float previousZ = -1; float previousArea = -1; unsigned long lastClassifyTime = 0; unsigned long lastSensorPollTime = 0; unsigned long lastLedFrameTime = 0; unsigned long stillCandidateStartTime = 0; unsigned long personCandidateStartTime = 0; unsigned long lastForegroundSeenTime = 0; unsigned long sideStateEnteredTime = 0; MotionState motionState = NO_PERSON; MotionState previousMotionState = NO_PERSON; // Animation counters. These are reset when the motion state changes. int slatSweepPosition = 0; int verticalTailPosition = 0; int attractPosition = 0; float breathingTime = 0.0; float sessionMinX = GRID_SIZE - 1; float sessionMaxX = 0; void calibrateBackground(); void updateMotionClassifier(); int findClosestForegroundDistance(); bool isForeground(int index, int distance); bool validDistance(int distance); float smoothValue(float oldValue, float newValue, float amount); void resetTracking(); void printState(float x, float z, float area, int closest, float approachScore, float awayScore, float sideScore); const char *motionStateName(MotionState state); void updateNeoPixels(); void resetAnimationForState(MotionState state); void showNoPerson(); void showBreathing(); void showGridTracking(bool movingLeftToRight); void showVerticalTails(bool movingUp); void showDepthFill(); void drawPerimeterAttractWave(); void drawAttractPulse(); float normalizedDepthAmount(); void drawProgressiveSideFill(bool movingLeftToRight); bool foregroundColumnRange(int &leftmostColumn, int &rightmostColumn); void fillProgressiveSlatRange(int startSlat, int endSlat, bool movingLeftToRight); void fillSlat(int slat, uint32_t color); void setPixelBySlatHeight(int slat, int height, uint32_t color); int pixelIndexForSlatHeight(int slat, int height); uint32_t redLevel(int brightness); uint32_t dimBlueLevel(int brightness); uint32_t amberLevel(int brightness); uint32_t dimPurpleLevel(int brightness); void setup() { Serial.begin(115200); delay(1000); Serial.println(); Serial.println("VL53L5CX_V45_ForegroundTravel_FullSlatFill"); pixels.begin(); pixels.setBrightness(DISPLAY_BRIGHTNESS); pixels.clear(); pixels.show(); Wire.begin(SDA_PIN, SCL_PIN); Wire.setClock(400000); Serial.println("Initializing VL53L5CX..."); if (sensor.begin() == false) { Serial.println("Sensor not found. Check wiring."); while (1) { showNoPerson(); delay(250); } } sensor.setResolution(64); sensor.setRangingFrequency(10); sensor.startRanging(); Serial.println("Keep the area in front of the sensor empty."); Serial.println("Calibrating background in 3 seconds..."); delay(3000); calibrateBackground(); Serial.println("Background calibration complete."); Serial.println("Begin testing motion and NeoPixel patterns."); } void loop() { unsigned long now = millis(); // Poll the sensor frequently, but only process a frame when the VL53L5CX says // new data is ready. if (now - lastSensorPollTime >= SENSOR_POLL_MS) { lastSensorPollTime = now; if (sensor.isDataReady()) { if (sensor.getRangingData(&data)) { updateMotionClassifier(); } } } // When the classifier changes states, restart that animation from a clean // starting point. if (motionState != previousMotionState) { resetAnimationForState(motionState); previousMotionState = motionState; } // Draw LED frames on their own timer so animations stay smooth. if (now - lastLedFrameTime >= LED_FRAME_MS) { lastLedFrameTime = now; updateNeoPixels(); } } void calibrateBackground() { const int samplesNeeded = 20; long sums[ZONE_COUNT]; int counts[ZONE_COUNT]; for (int i = 0; i < ZONE_COUNT; i++) { sums[i] = 0; counts[i] = 0; background[i] = MAX_VALID_MM; } int samplesCollected = 0; // Average several empty-scene frames. This lets the program ignore fixed // objects such as the wall, floor, display frame, or furniture. while (samplesCollected < samplesNeeded) { if (sensor.isDataReady()) { if (sensor.getRangingData(&data)) { for (int i = 0; i < ZONE_COUNT; i++) { int d = data.distance_mm[i]; if (validDistance(d)) { sums[i] += d; counts[i]++; } } samplesCollected++; Serial.print("."); } } delay(10); } Serial.println(); for (int i = 0; i < ZONE_COUNT; i++) { if (counts[i] > 0) { background[i] = sums[i] / counts[i]; } else { background[i] = MAX_VALID_MM; } } } void updateMotionClassifier() { unsigned long now = millis(); // First locate the nearest zone that is closer than the calibrated background. int closest = findClosestForegroundDistance(); if (closest < 0) { if (lastForegroundSeenTime != 0 && now - lastForegroundSeenTime < NO_PERSON_HOLD_MS && smoothX >= 0 && smoothY >= 0 && smoothZ >= 0 && smoothArea >= 0) { if (now - lastClassifyTime >= CLASSIFY_INTERVAL_MS) { printState(smoothX, smoothZ, max(smoothArea, 0.0f), -1, 0, 0, 0); } return; } resetTracking(); motionState = NO_PERSON; printState(-1, -1, 0, -1, 0, 0, 0); return; } float totalWeight = 0; float weightedX = 0; float weightedY = 0; float weightedZ = 0; int foregroundZones = 0; // Build a foreground "blob" from zones near the closest foreground point. // Closer zones receive more weight so hands/torso edges do not dominate. for (int i = 0; i < ZONE_COUNT; i++) { int distance = data.distance_mm[i]; if (!isForeground(i, distance)) { continue; } if (distance <= closest + FOREGROUND_WINDOW_MM) { int col = i % GRID_SIZE; int row = i / GRID_SIZE; float weight = (float)((closest + FOREGROUND_WINDOW_MM) - distance + 1); weightedX += col * weight; weightedY += row * weight; weightedZ += distance * weight; totalWeight += weight; foregroundZones++; } } if (foregroundZones < MIN_FOREGROUND_ZONES || totalWeight <= 0) { if (lastForegroundSeenTime != 0 && now - lastForegroundSeenTime < NO_PERSON_HOLD_MS && smoothX >= 0 && smoothY >= 0 && smoothZ >= 0 && smoothArea >= 0) { if (now - lastClassifyTime >= CLASSIFY_INTERVAL_MS) { printState(smoothX, smoothZ, max(smoothArea, 0.0f), closest, 0, 0, 0); } return; } resetTracking(); motionState = NO_PERSON; printState(-1, -1, foregroundZones, closest, 0, 0, 0); return; } lastForegroundSeenTime = now; if (personCandidateStartTime == 0) { personCandidateStartTime = now; } float currentX = weightedX / totalWeight; float currentY = weightedY / totalWeight; float currentZ = weightedZ / totalWeight; float currentArea = foregroundZones; // The first valid person frame initializes the tracking variables. if (smoothX < 0 || smoothY < 0 || smoothZ < 0 || smoothArea < 0) { smoothX = currentX; smoothY = currentY; smoothZ = currentZ; smoothArea = currentArea; previousX = currentX; previousZ = currentZ; previousArea = currentArea; motionState = NO_PERSON; printState(smoothX, smoothZ, smoothArea, closest, 0, 0, 0); return; } // Smooth noisy sensor values before comparing the current frame to the // previous classified frame. smoothX = smoothValue(smoothX, currentX, 0.45); smoothY = smoothValue(smoothY, currentY, 0.45); smoothZ = smoothValue(smoothZ, currentZ, 0.45); smoothArea = smoothValue(smoothArea, currentArea, 0.45); // Track the X span covered during this person-present session so side-fill // can use the full slat range even if sensor mounting skews raw X values. sessionMinX = min(sessionMinX, smoothX); sessionMaxX = max(sessionMaxX, smoothX); if (now - lastClassifyTime >= CLASSIFY_INTERVAL_MS) { float deltaX = smoothX - previousX; float deltaZ = smoothZ - previousZ; float deltaArea = smoothArea - previousArea; // Convert raw deltas into normalized scores. A score above 1.0 means that // motion type crossed its threshold. float sideScore = abs(deltaX) / X_MOTION_THRESHOLD; float approachScore = 0; float awayScore = 0; if (deltaZ < 0) { approachScore += abs(deltaZ) / Z_MOTION_THRESHOLD_MM; } if (deltaArea > 0) { approachScore += abs(deltaArea) / AREA_MOTION_THRESHOLD; } if (deltaZ > 0) { awayScore += abs(deltaZ) / Z_MOTION_THRESHOLD_MM; } if (deltaArea < 0) { awayScore += abs(deltaArea) / AREA_MOTION_THRESHOLD; } bool mostlyStill = abs(deltaX) < STILL_X_THRESHOLD && abs(deltaZ) < STILL_Z_THRESHOLD_MM && abs(deltaArea) < STILL_AREA_THRESHOLD; MotionState candidateState = motionState; // Horizontal movement wins before approach/away. This prevents angled // walking across the display from being mislabeled as only approaching. bool strongStillPresence = foregroundZones >= MIN_STILL_FOREGROUND_ZONES && now - personCandidateStartTime >= PERSON_CONFIRM_MS; if (!mostlyStill && sideScore >= SIDE_ACTIVATION_SCORE) { MotionState sideCandidate = deltaX > 0 ? RIGHT_TO_LEFT : LEFT_TO_RIGHT; if ((motionState == LEFT_TO_RIGHT || motionState == RIGHT_TO_LEFT) && sideCandidate != motionState && ((now - sideStateEnteredTime) < SIDE_STATE_MIN_HOLD_MS || sideScore < SIDE_DIRECTION_SWITCH_SCORE)) { candidateState = motionState; } else { candidateState = sideCandidate; } } else if (mostlyStill) { if (strongStillPresence) { candidateState = STANDING_STILL; } else if (motionState == LEFT_TO_RIGHT || motionState == RIGHT_TO_LEFT) { // Hold side animation briefly during tiny pauses so hand sweeps look continuous. candidateState = motionState; } else { candidateState = motionState == NO_PERSON ? APPROACHING : motionState; } } else if (approachScore > awayScore && approachScore >= 1.0) { candidateState = APPROACHING; } else if (awayScore > approachScore && awayScore >= 1.0) { candidateState = WALKING_AWAY; } else { if (strongStillPresence) { candidateState = STANDING_STILL; } else { candidateState = motionState == NO_PERSON ? APPROACHING : motionState; } } // Standing still is delayed by one second so a brief pause during a gesture // does not immediately interrupt the current motion animation. if (candidateState == NO_PERSON) { resetTracking(); motionState = NO_PERSON; } else if (candidateState == STANDING_STILL) { if (stillCandidateStartTime == 0) { stillCandidateStartTime = now; } if (now - stillCandidateStartTime >= STANDING_STILL_DELAY_MS) { motionState = STANDING_STILL; } } else { motionState = candidateState; stillCandidateStartTime = 0; if (motionState == LEFT_TO_RIGHT || motionState == RIGHT_TO_LEFT) { sideStateEnteredTime = now; } } previousX = smoothX; previousZ = smoothZ; previousArea = smoothArea; lastClassifyTime = now; printState(smoothX, smoothZ, smoothArea, closest, approachScore, awayScore, sideScore); } } int findClosestForegroundDistance() { int closest = MAX_VALID_MM + 1; // Search all zones for the nearest valid foreground point. for (int i = 0; i < ZONE_COUNT; i++) { int distance = data.distance_mm[i]; if (isForeground(i, distance) && distance < closest) { closest = distance; } } if (closest == MAX_VALID_MM + 1) { return -1; } return closest; } bool isForeground(int index, int distance) { if (!validDistance(distance)) { return false; } // The reading must be closer than the empty-scene background by enough to be // considered a person or moving object. return distance < background[index] - BACKGROUND_DELTA_MM; } bool validDistance(int distance) { return distance >= MIN_VALID_MM && distance <= MAX_VALID_MM; } float smoothValue(float oldValue, float newValue, float amount) { return oldValue + amount * (newValue - oldValue); } void resetTracking() { smoothX = -1; smoothY = -1; smoothZ = -1; smoothArea = -1; previousX = -1; previousZ = -1; previousArea = -1; lastClassifyTime = 0; stillCandidateStartTime = 0; personCandidateStartTime = 0; lastForegroundSeenTime = 0; sideStateEnteredTime = 0; sessionMinX = GRID_SIZE - 1; sessionMaxX = 0; } void printState(float x, float z, float area, int closest, float approachScore, float awayScore, float sideScore) { Serial.print("state="); Serial.print(motionStateName(motionState)); Serial.print(" x="); Serial.print(x, 2); Serial.print(" z="); Serial.print(z, 0); Serial.print("mm"); Serial.print(" area="); Serial.print(area, 1); Serial.print(" closest="); Serial.print(closest); Serial.print("mm"); Serial.print(" approachScore="); Serial.print(approachScore, 2); Serial.print(" awayScore="); Serial.print(awayScore, 2); Serial.print(" sideScore="); Serial.println(sideScore, 2); } const char *motionStateName(MotionState state) { switch (state) { case NO_PERSON: return "NO_PERSON"; case STANDING_STILL: return "STANDING_STILL"; case LEFT_TO_RIGHT: return "LEFT_TO_RIGHT"; case RIGHT_TO_LEFT: return "RIGHT_TO_LEFT"; case APPROACHING: return "APPROACHING"; case WALKING_AWAY: return "WALKING_AWAY"; default: return "UNKNOWN"; } } void updateNeoPixels() { // The classifier decides what the visitor is doing. This switch chooses the // visual response for that state. switch (motionState) { case NO_PERSON: showNoPerson(); break; case STANDING_STILL: showBreathing(); break; case LEFT_TO_RIGHT: showGridTracking(true); break; case RIGHT_TO_LEFT: showGridTracking(false); break; case APPROACHING: showDepthFill(); break; case WALKING_AWAY: showDepthFill(); break; } } void resetAnimationForState(MotionState state) { // Reset position counters so a newly selected animation begins at an expected // edge, corner, or starting row. if (state == NO_PERSON) { attractPosition = 0; } } void showNoPerson() { pixels.clear(); // Attract mode: begin from the full perimeter and pull inward. In the // zero-based spreadsheet addresses include dead loop pixels between slats. // Bottom row: 0, 116, 123 ... 971. // Top row: 55, 61, 178 ... 916. drawPerimeterAttractWave(); drawAttractPulse(); pixels.show(); attractPosition++; if (attractPosition > ATTRACT_STEPS + ATTRACT_TAIL * 2) { attractPosition = 0; } } void showBreathing() { // Standing still: a faster pulse. The global every-fourth-pixel rule in // setPixelBySlatHeight keeps this from lighting all mounted LEDs. int brightness = (sin(breathingTime) + 1.0) * (BREATHING_MAX / 2); uint32_t color = redLevel(brightness); pixels.clear(); for (int slat = 0; slat < NUM_SLATS; slat++) { for (int height = 0; height < PIXELS_PER_SLAT; height++) { setPixelBySlatHeight(slat, height, color); } } pixels.show(); breathingTime += 6.28318 * (STANDING_STILL_PULSES_PER_MINUTE / 60.0) * (LED_FRAME_MS / 1000.0); if (breathingTime > 6.28318) { breathingTime -= 6.28318; } } void showGridTracking(bool movingLeftToRight) { pixels.clear(); drawProgressiveSideFill(movingLeftToRight); pixels.show(); } void drawProgressiveSideFill(bool movingLeftToRight) { if (smoothX < 0) { return; } float span = max(0.001f, sessionMaxX - sessionMinX); float normalizedX = (smoothX - sessionMinX) / span; normalizedX = constrain(normalizedX, 0.0f, 1.0f); int boundarySlat = constrain((int)round(normalizedX * (NUM_SLATS - 1)), 0, NUM_SLATS - 1); if (movingLeftToRight) { // Walking left to right: fill progressively from slat 0 toward slat 15. fillProgressiveSlatRange(0, boundarySlat, true); } else { // Walking right to left: fill progressively from slat 15 toward slat 0. fillProgressiveSlatRange(NUM_SLATS - 1, boundarySlat, false); } } bool foregroundColumnRange(int &leftmostColumn, int &rightmostColumn) { bool hasForeground = false; leftmostColumn = GRID_SIZE - 1; rightmostColumn = 0; for (int zone = 0; zone < ZONE_COUNT; zone++) { int distance = data.distance_mm[zone]; if (!isForeground(zone, distance)) { continue; } int col = zone % GRID_SIZE; leftmostColumn = min(leftmostColumn, col); rightmostColumn = max(rightmostColumn, col); hasForeground = true; } return hasForeground; } void fillProgressiveSlatRange(int startSlat, int endSlat, bool movingLeftToRight) { int direction = startSlat <= endSlat ? 1 : -1; int totalSteps = max(abs(endSlat - startSlat), 1); int step = 0; for (int slat = startSlat; slat != endSlat + direction; slat += direction) { int brightness = map(step, 0, totalSteps, 52, 112); if (slat == endSlat) { fillSlat(slat, redLevel(126)); } else if (movingLeftToRight) { fillSlat(slat, amberLevel(brightness)); } else { fillSlat(slat, dimPurpleLevel(brightness)); } step++; } } void showVerticalTails(bool movingUp) { pixels.clear(); // Legacy vertical tails. The current depth interaction uses showDepthFill() // for both approach and walking away so the animation can reverse smoothly. for (int slat = 0; slat < NUM_SLATS; slat++) { for (int t = 0; t < TAIL_LENGTH; t++) { int height; if (movingUp) { height = verticalTailPosition - t; } else { height = verticalTailPosition + t; } if (height >= 0 && height < PIXELS_PER_SLAT) { int brightness = 145 - (t * (110 / TAIL_LENGTH)); // Light alternating slats to keep the vertical motion legible and power-conscious. if (slat % 2 == 0) { setPixelBySlatHeight(slat, height, dimPurpleLevel(brightness)); } } } } pixels.show(); if (movingUp) { verticalTailPosition++; if (verticalTailPosition >= PIXELS_PER_SLAT + TAIL_LENGTH) { verticalTailPosition = 0; } } else { verticalTailPosition--; if (verticalTailPosition < -TAIL_LENGTH) { verticalTailPosition = PIXELS_PER_SLAT - 1; } } } void showDepthFill() { pixels.clear(); // Depth movement: divide the interaction into the visible every-fourth-pixel // levels. With 56 LEDs per slat, this gives 14 visible levels: 0, 4, 8 ... 52. float amount = normalizedDepthAmount(); int activeLevels = constrain((int)round(amount * DEPTH_LEVELS), 1, DEPTH_LEVELS); int brightness = APPROACH_MIN_BRIGHTNESS + (int)(amount * (APPROACH_MAX_BRIGHTNESS - APPROACH_MIN_BRIGHTNESS)); int activeSlatRadius = constrain(1 + (int)(amount * 8), 1, 8); // A slight pulse keeps the fill alive without using full brightness. float pulse = (sin(breathingTime * 1.7) + 1.0) * 0.18 + 0.82; brightness = constrain((int)(brightness * pulse), 0, APPROACH_MAX_BRIGHTNESS); int centerLeft = (NUM_SLATS / 2) - 1; int centerRight = NUM_SLATS / 2; for (int offset = 0; offset < activeSlatRadius; offset++) { int leftSlat = centerLeft - offset; int rightSlat = centerRight + offset; int edgeDim = constrain(brightness - offset * 8, 8, APPROACH_MAX_BRIGHTNESS); for (int level = 0; level < activeLevels; level++) { int height = level * LIT_PIXEL_SPACING; int verticalDim = map(level, 0, DEPTH_LEVELS - 1, edgeDim, edgeDim / 2); if (leftSlat >= 0) { setPixelBySlatHeight(leftSlat, height, amberLevel(verticalDim)); } if (rightSlat < NUM_SLATS) { setPixelBySlatHeight(rightSlat, height, amberLevel(verticalDim)); } } } pixels.show(); breathingTime += 0.04; if (breathingTime > 6.28318) { breathingTime = 0.0; } } void drawPerimeterAttractWave() { // Draw a rectangular frame that collapses inward toward the center. The tail // leaves several earlier perimeter positions visible, making the default // state feel more active from a distance. for (int t = 0; t < ATTRACT_TAIL; t++) { int step = attractPosition - t; if (step < 0 || step > ATTRACT_STEPS) { continue; } int brightness = 118 - (t * (78 / ATTRACT_TAIL)); int horizontalInset = map(step, 0, ATTRACT_STEPS, 0, NUM_SLATS / 2); int verticalInset = map(step, 0, ATTRACT_STEPS, 0, PIXELS_PER_SLAT / 2); int leftSlat = horizontalInset; int rightSlat = (NUM_SLATS - 1) - horizontalInset; int bottomHeight = verticalInset; int topHeight = (PIXELS_PER_SLAT - 1) - verticalInset; for (int slat = leftSlat; slat <= rightSlat; slat++) { setPixelBySlatHeight(slat, bottomHeight, amberLevel(brightness)); setPixelBySlatHeight(slat, topHeight, amberLevel(brightness)); } for (int height = bottomHeight; height <= topHeight; height++) { setPixelBySlatHeight(leftSlat, height, amberLevel(brightness)); setPixelBySlatHeight(rightSlat, height, amberLevel(brightness)); } } } void drawAttractPulse() { int centerLeft = (NUM_SLATS / 2) - 1; int centerRight = NUM_SLATS / 2; int centerHeight = PIXELS_PER_SLAT / 2; int pulseWindowStart = ATTRACT_STEPS - 10; int pulseBrightness = 0; if (attractPosition >= pulseWindowStart && attractPosition <= ATTRACT_STEPS + 4) { int localStep = attractPosition - pulseWindowStart; pulseBrightness = 35 + (int)((sin(localStep * 0.45) + 1.0) * 34); } if (pulseBrightness <= 0) { return; } for (int offset = 0; offset < 4; offset++) { int brightness = pulseBrightness - offset * 12; for (int hOffset = -offset; hOffset <= offset; hOffset++) { setPixelBySlatHeight(centerLeft - offset, centerHeight + hOffset, amberLevel(brightness)); setPixelBySlatHeight(centerRight + offset, centerHeight + hOffset, amberLevel(brightness)); } } } float normalizedDepthAmount() { if (smoothZ < 0) { return 0.0; } // Far distance maps to 0.0, near distance maps to 1.0. float clampedZ = constrain(smoothZ, APPROACH_NEAR_MM, APPROACH_FAR_MM); float zAmount = (APPROACH_FAR_MM - clampedZ) / (float)(APPROACH_FAR_MM - APPROACH_NEAR_MM); // More foreground zones usually means the visitor is closer or taking up more // of the sensor view. It acts as a secondary cue so distance noise is less // likely to hide the approach/withdraw movement. float clampedArea = constrain(smoothArea, AREA_FAR_ZONES, AREA_NEAR_ZONES); float areaAmount = (clampedArea - AREA_FAR_ZONES) / (float)(AREA_NEAR_ZONES - AREA_FAR_ZONES); float amount = zAmount * 0.70 + areaAmount * 0.30; return constrain(amount, 0.0, 1.0); } void fillSlat(int slat, uint32_t color) { for (int height = 0; height < PIXELS_PER_SLAT; height++) { setPixelBySlatHeight(slat, height, color); } } void setPixelBySlatHeight(int slat, int height, uint32_t color) { int pixel = pixelIndexForSlatHeight(slat, height); // Global power rule: only every fourth mounted NeoPixel in each slat may be lit. if (pixel >= 0 && pixel < NUMPIXELS && height % LIT_PIXEL_SPACING == 0) { pixels.setPixelColor(pixel, color); } } int pixelIndexForSlatHeight(int slat, int height) { if (slat < 0 || slat >= NUM_SLATS || height < 0 || height >= PIXELS_PER_SLAT) { return -1; } // Spreadsheet mapping: bottom-left is the origin, but the physical strand // alternates direction through the slats and includes dead spacer pixels. if (SLAT_RUNS_UP[slat]) { return SLAT_START_ADDRESS[slat] + height; } return SLAT_START_ADDRESS[slat] + ((PIXELS_PER_SLAT - 1) - height); } uint32_t redLevel(int brightness) { brightness = constrain(brightness, 0, MAX_SAFE_BRIGHTNESS); // On this installation, pixels.Color(0, brightness, 0) produces red. return pixels.Color(0, brightness, 0); } uint32_t dimBlueLevel(int brightness) { brightness = constrain(brightness, 0, MAX_SAFE_BRIGHTNESS); return pixels.Color(0, 0, brightness); } uint32_t amberLevel(int brightness) { brightness = constrain(brightness, 0, MAX_SAFE_BRIGHTNESS); return pixels.Color(brightness / 6, brightness, 0); } uint32_t dimPurpleLevel(int brightness) { brightness = constrain(brightness, 0, MAX_SAFE_BRIGHTNESS); return pixels.Color(brightness / 3, brightness, brightness / 5); }