/* Program title: VL53L5CXTOFScanner8x8grid_distandzonecount_neopixelpatterns_V21 Written by Jeff Ritchie with assistance from Open AI Codex Date: May 27, 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. */ #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, 241, 303, 367, 429, 485, 547, 609, 673, 729, 792, 854, 916 }; 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 MAX_VALID_MM = 3000; // 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 = 300; const int MIN_FOREGROUND_ZONES = 3; 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.45; const float Z_MOTION_THRESHOLD_MM = 90.0; const float AREA_MOTION_THRESHOLD = 2.0; // 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 = 350; 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; // 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 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 = 2400; const int APPROACH_NEAR_MM = 450; 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 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; 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; 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 showHorizontalPositionBand(bool movingLeftToRight); void showVerticalTails(bool movingUp); void showDepthFill(); void drawPerimeterAttractWave(); void drawAttractPulse(); float normalizedDepthAmount(); 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("VL53L5CXTOFScanner8x8grid_distandzonecount_neopixelpatterns_V21"); 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() { // First locate the nearest zone that is closer than the calibrated background. int closest = findClosestForegroundDistance(); if (closest < 0) { resetTracking(); motionState = NO_PERSON; printState(-1, -1, 0, -1, 0, 0, 0); return; } float totalWeight = 0; float weightedX = 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; float weight = (float)((closest + FOREGROUND_WINDOW_MM) - distance + 1); weightedX += col * weight; weightedZ += distance * weight; totalWeight += weight; foregroundZones++; } } if (foregroundZones < MIN_FOREGROUND_ZONES || totalWeight <= 0) { resetTracking(); motionState = NO_PERSON; printState(-1, -1, foregroundZones, closest, 0, 0, 0); return; } unsigned long now = millis(); if (personCandidateStartTime == 0) { personCandidateStartTime = now; } float currentX = weightedX / totalWeight; float currentZ = weightedZ / totalWeight; float currentArea = foregroundZones; // The first valid person frame initializes the tracking variables. if (smoothX < 0 || smoothZ < 0 || smoothArea < 0) { smoothX = currentX; 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.35); smoothZ = smoothValue(smoothZ, currentZ, 0.35); smoothArea = smoothValue(smoothArea, currentArea, 0.35); 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) { if (strongStillPresence) { candidateState = STANDING_STILL; } else { candidateState = NO_PERSON; } } else if (sideScore >= 1.0) { if (deltaX > 0) { candidateState = LEFT_TO_RIGHT; } else { candidateState = RIGHT_TO_LEFT; } } 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 = NO_PERSON; } } // 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; } 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; smoothZ = -1; smoothArea = -1; previousX = -1; previousZ = -1; previousArea = -1; lastClassifyTime = 0; stillCandidateStartTime = 0; personCandidateStartTime = 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: showHorizontalPositionBand(true); break; case RIGHT_TO_LEFT: showHorizontalPositionBand(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 calm breathing pattern. 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 += 0.05; if (breathingTime > 6.28318) { breathingTime = 0.0; } } void showHorizontalPositionBand(bool movingLeftToRight) { pixels.clear(); // Side movement: map the person's x position directly to the 16 slats. This // makes the light band reverse smoothly when the visitor reverses direction. int centerSlat = constrain((int)round((smoothX / (GRID_SIZE - 1)) * (NUM_SLATS - 1)), 0, NUM_SLATS - 1); for (int t = 0; t < SLAT_SWEEP_TAIL; t++) { int slat = movingLeftToRight ? centerSlat - t : centerSlat + t; if (slat >= 0 && slat < NUM_SLATS) { int brightness = 140 - (t * (95 / SLAT_SWEEP_TAIL)); fillSlat(slat, redLevel(brightness)); } } pixels.show(); } 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); }