diff --git a/moonlive/layouts/backlight.mll b/moonlive/layouts/backlight.mll index 8bfe3eb6..af0b0083 100644 --- a/moonlive/layouts/backlight.mll +++ b/moonlive/layouts/backlight.mll @@ -1,10 +1,20 @@ -// A backlight strip around a wall: one strand clockwise from the top-left corner, its lights spread over the wall's edge pixels, each corner light shared by the two sides it joins. +// A backlight strand around a wall, its lights spread over the wall's edge pixels. Hyperion's shape, so a layout copied from there carries over: a count per edge, a gap, and the strand's entry and direction. class BacklightLayout { byte wallCols = 64; byte wallRows = 32; - byte across = 30; - byte down = 15; + byte top = 30; + byte right = 15; + byte bottom = 30; + byte left = 15; + int gapStart = 0; + int gapLength = 0; + byte startCorner = 0; + int position = 0; + byte reverse = 0; + int g0 = 0; + int gl = 0; + int gw = 0; int dimensions() { return 2; } @@ -13,22 +23,87 @@ class BacklightLayout { void defineControls() { addControl("wallCols", wallCols, 2, 255); // the wall's width in pixels addControl("wallRows", wallRows, 2, 255); // the wall's height in pixels - addControl("across", across, 2, 255); // strip lights along the top, both corners included - addControl("down", down, 2, 255); // strip lights along a side, both corners included + addControl("top", top, 2, 255); // strand lights along the top, both corners included + addControl("right", right, 2, 255); // and along the right + addControl("bottom", bottom, 2, 255); // and along the bottom + addControl("left", left, 2, 255); // and along the left + addControl("gapLength", gapLength, 0, 2000); // lights the strand does not have, for the stretch a TV stand covers + addControl("gapStart", gapStart, 0, 2000); // where that gap begins, counted round from the top-left corner + addSelect("startCorner", startCorner, "TL|TR|BR|BL"); // the corner the strand enters at, clockwise from top-left + addControl("position", position, 0, 2000); // lights along the strand, past startCorner, where it actually begins + addControl("reverse", reverse, 0, 1); // 1 runs the strand the other way round } - void placeLights() { - for (int i = 0; i < across - 1; i = i + 1) { - addLight(i * (wallCols - 1) / (across - 1), 0, 0); + // One step along an edge of `count` lights spanning `span` pixels. A single light sits at the + // start corner, where the span would divide by zero. + int along(int i, int count, int span) { + if (count <= 1) { return 0; } + return i * span / (count - 1); + } + + // The light at perimeter position `k`: clockwise from the top-left, each edge one short of its + // own count, since the next edge opens with the corner they share. + void emitAt(int k) { + int j = k; + if (j < top - 1) { + addLight(along(j, top, wallCols - 1), 0, 0); + return; } - for (int i = 0; i < down - 1; i = i + 1) { - addLight(wallCols - 1, i * (wallRows - 1) / (down - 1), 0); + j = j - (top - 1); + if (j < right - 1) { + addLight(wallCols - 1, along(j, right, wallRows - 1), 0); + return; } - for (int i = 0; i < across - 1; i = i + 1) { - addLight(wallCols - 1 - i * (wallCols - 1) / (across - 1), wallRows - 1, 0); + j = j - (right - 1); + if (j < bottom - 1) { + addLight(wallCols - 1 - along(j, bottom, wallCols - 1), wallRows - 1, 0); + return; } - for (int i = 0; i < down - 1; i = i + 1) { - addLight(0, wallRows - 1 - i * (wallRows - 1) / (down - 1), 0); + j = j - (bottom - 1); + addLight(0, wallRows - 1 - along(j, left, wallRows - 1), 0); + } + + // The gap is a stretch of the frame the strand skips, counted round from the top-left, so a + // strand position past it sits that many lights further round. One that runs past the top-left + // corner wraps onto the top edge: its part there, gw lights long, is skipped before the rest. + int perimeterAt(int i) { + if (gw > 0) { return i + gw; } + if (gl > 0 && i >= g0) { return i + gl; } + return i; + } + + // Index 0 sits at the start corner, in strand positions: the lights before it, or the first light + // past the gap when the corner falls inside it. + int strandStart() { + int k = 0; + if (startCorner == 1) { k = top - 1; } + else if (startCorner == 2) { k = top - 1 + right - 1; } + else if (startCorner == 3) { k = top - 1 + right - 1 + bottom - 1; } + if (gw > 0) { + if (k < gw || k >= g0) { return 0; } + return k - gw; + } + if (gl > 0 && k >= g0 + gl) { return k - gl; } + if (gl > 0 && k >= g0) { return g0; } + return k; + } + + void placeLights() { + int full = top - 1 + right - 1 + bottom - 1 + left - 1; + // The gap reduced onto the frame, and at most every light but one, so any setting places lights. + g0 = mod(gapStart, full); + gl = gapLength; + if (gl > full - 1) { gl = full - 1; } + gw = g0 + gl - full; + if (gw < 0) { gw = 0; } + int n = full - gl; + // Along the STRAND, not around the frame: on a reversed strand the entry moves the other way too. + int p = mod(position, n); + int s = mod(strandStart() + p, n); + if (reverse == 1) { s = mod(strandStart() - p + n, n); } + for (int i = 0; i < n; i = i + 1) { + if (reverse == 1) { emitAt(perimeterAt(mod(s + n - mod(i, n), n))); } + else { emitAt(perimeterAt(mod(s + i, n))); } } } } diff --git a/test/unit/light/moonlive_ir_golden.inc b/test/unit/light/moonlive_ir_golden.inc index f2db03a8..f2eb78d4 100644 --- a/test/unit/light/moonlive_ir_golden.inc +++ b/test/unit/light/moonlive_ir_golden.inc @@ -1,4 +1,4 @@ - {"layouts/backlight.mll", 0x45cad59768d0942aull}, + {"layouts/backlight.mll", 0x8fb9b3dac83279edull}, {"layouts/diagonal.mll", 0x8d903da3498de21aull}, {"layouts/grid.mll", 0x19ba4f6c02fbec77ull}, {"layouts/lattice.mll", 0xbf932d2667cc1e2bull}, diff --git a/test/unit/light/unit_MoonLiveLayout.cpp b/test/unit/light/unit_MoonLiveLayout.cpp index af882f67..89a88cf0 100644 --- a/test/unit/light/unit_MoonLiveLayout.cpp +++ b/test/unit/light/unit_MoonLiveLayout.cpp @@ -7,6 +7,7 @@ #include "doctest.h" #include "MoonLiveScriptFixture.h" +#include "moonlive_script_files.h" #include "../core/moonlive_script_wrap.h" #include "light/moonlive/MoonLiveLayout.h" #include "light/moonlive/MoonLiveModifier.h" // the cycle-break case below drives a modifier @@ -19,6 +20,12 @@ #include #include +#include +#include +#include +#include +#include +#include #include #include @@ -45,6 +52,48 @@ std::vector place(const char* script) { l.placeLights(sink); return out; } + +/// The shipped backlight layout run at one wiring, collecting what it places; the wiring is the JSON a device would apply, since the script declares its own controls. +std::vector placeBacklight(const char* wiring) { + const std::string src = readText(scriptRoot() / "layouts" / "backlight.mll"); + REQUIRE_FALSE(src.empty()); + + MoonLiveLayout l; + l.defineControls(); + l.setScript(mmWriteScript(src.c_str())); + l.prepare(); + auto& cs = l.controls(); + for (uint8_t i = 0; i < cs.count(); i++) + if (cs[i].name) applyControlValue(cs[i], wiring, cs[i].name, ApplyPolicy::Clamp); + l.prepare(); // the controls size the walk, so the layout is rebuilt against them + + std::vector out; + CoordSink sink{ + [](void* ctx, nrOfLightsType, lengthType x, lengthType y, lengthType z) { + static_cast*>(ctx)->push_back({x, y, z}); + }, + nullptr, &out}; + l.placeLights(sink); + return out; +} + +/// The distinct cells a walk visits, for the shape-is-invariant checks below. +// A walk written as "xy" cells in strand order, so an expected order reads as one line. +std::string walkOf(const std::vector& p) { + std::string got; + for (const auto& k : p) { + if (!got.empty()) got += ' '; + got += static_cast('0' + k.x); + got += static_cast('0' + k.y); + } + return got; +} + +std::set> cellsOf(const std::vector& p) { + std::set> out; + for (const auto& c : p) out.insert({c.x, c.y}); + return out; +} } // namespace TEST_CASE("the default script lays out a grid, one light per cell") { @@ -932,4 +981,133 @@ TEST_CASE("the loader accepts any role extension, and nothing else") { CHECK(std::string(l.status()).find("must end in") != std::string::npos); } + +// --- The shipped backlight layout: its controls choose an ORDER, not a shape ------------------ + +TEST_CASE("backlight.mll: each edge contributes its own count, each corner once") { + const auto p = placeBacklight("{\"wallCols\":60,\"wallRows\":34,\"top\":60,\"right\":34,\"bottom\":60,\"left\":34}"); + CHECK(p.size() == 184); // 59 + 33 + 59 + 33: every edge one short of its own count, the corners shared + + const auto cells = cellsOf(p); + CHECK(cells.size() == p.size()); // no cell twice, so no corner counted by both its sides + // On the edges and filling the box: a light off the perimeter would light the wall's middle. + for (const auto& c : p) { + CHECK(c.z == 0); + CHECK((c.x == 0 || c.x == 59 || c.y == 0 || c.y == 33)); + CHECK((c.x >= 0 && c.x < 60 && c.y >= 0 && c.y < 34)); // on the wall too: (60, 0) is on an edge's line but off the wall + } + CHECK(cells.count({0, 0}) == 1); + CHECK(cells.count({59, 0}) == 1); + CHECK(cells.count({59, 33}) == 1); + CHECK(cells.count({0, 33}) == 1); +} + +// Written out, not compared to each other: "the eight differ" passes on a walk visiting the right cells in the wrong order. A 4x3 wall is ten lights, so each wiring is one line. +TEST_CASE("backlight.mll: each wiring starts at its corner and runs its own way round") { + struct Wiring { int corner; int reverse; const char* walk; }; + // Coordinates as "xy", clockwise meaning x rising along the top, as the canonical walk does. + const Wiring cases[8] = { + {0, 0, "00 10 20 30 31 32 22 12 02 01"}, // top-left, forward: the shipped default + {0, 1, "00 01 02 12 22 32 31 30 20 10"}, // and the same corner reversed + {1, 0, "30 31 32 22 12 02 01 00 10 20"}, + {1, 1, "30 20 10 00 01 02 12 22 32 31"}, + {2, 0, "32 22 12 02 01 00 10 20 30 31"}, + {2, 1, "32 31 30 20 10 00 01 02 12 22"}, + {3, 0, "02 01 00 10 20 30 31 32 22 12"}, + {3, 1, "02 12 22 32 31 30 20 10 00 01"}, + }; + for (const auto& c : cases) { + char wiring[160]; + std::snprintf(wiring, sizeof(wiring), + "{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3," + "\"startCorner\":%d,\"reverse\":%d}", c.corner, c.reverse); + const auto p = placeBacklight(wiring); + REQUIRE(p.size() == 10); + CHECK(walkOf(p) == std::string(c.walk)); + } +} + +TEST_CASE("backlight.mll: offset slides the strand along itself, whichever way it runs") { + const auto at0 = placeBacklight("{\"wallCols\":60,\"wallRows\":34,\"top\":60,\"right\":34,\"bottom\":60,\"left\":34}"); + const auto at2 = placeBacklight("{\"wallCols\":60,\"wallRows\":34,\"top\":60,\"right\":34,\"bottom\":60,\"left\":34,\"position\":2}"); + REQUIRE(at0.size() == at2.size()); + CHECK(cellsOf(at0) == cellsOf(at2)); + + // A ROTATION by two: light i of the offset walk is light i+2 of the plain one. + for (size_t i = 0; i < at0.size(); i++) { + const auto& expect = at0[(i + 2) % at0.size()]; + CHECK(at2[i].x == expect.x); + CHECK(at2[i].y == expect.y); + } + + // Along the STRAND, so a reversed one moves the other way round the frame. Counted clockwise regardless, an offset would send a reversed strand backwards past its own corner. + const auto ccw0 = placeBacklight("{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3,\"reverse\":1}"); + const auto ccw2 = placeBacklight("{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3,\"reverse\":1,\"position\":2}"); + REQUIRE(ccw0.size() == 10); + REQUIRE(ccw2.size() == 10); + for (size_t i = 0; i < ccw0.size(); i++) { + const auto& expect = ccw0[(i + 2) % ccw0.size()]; + CHECK(ccw2[i].x == expect.x); + CHECK(ccw2[i].y == expect.y); + } +} + +// Hyperion's gap: the lights a strand does not have. A length and a place in the strand, not a dark edge, so the stretch a TV stand covers can sit mid-edge. +TEST_CASE("backlight.mll: a gap removes a run of lights and leaves the rest where they were") { + const char* full = "{\"wallCols\":8,\"wallRows\":6,\"top\":8,\"right\":4,\"bottom\":8,\"left\":4}"; + const char* gapped = "{\"wallCols\":8,\"wallRows\":6,\"top\":8,\"right\":4,\"bottom\":8,\"left\":4," + "\"gapStart\":11,\"gapLength\":3}"; + const auto whole = placeBacklight(full); + const auto p = placeBacklight(gapped); + REQUIRE(whole.size() == 20); + REQUIRE(p.size() == 17); // the three it does not have + + // The bottom loses its middle three; every other light sits exactly where it did. + CHECK(walkOf(p) == std::string("00 10 20 30 40 50 60 70 71 73 75 35 25 15 05 04 02")); + + // A gap REMOVES, it does not move: each remaining cell is one the whole strand also had. + const auto had = cellsOf(whole); + for (const auto& k : p) CHECK(had.count({k.x, k.y}) == 1); +} + +// The controls allow a position past the strand's own length; it counts round the strand again, whichever way the strand runs, rather than leaving the wall. +TEST_CASE("backlight.mll: a position past the strand's length wraps round it, reversed too") { + const char* wall = "{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3,\"reverse\":1,\"position\":%d}"; + char at5[160], at25[160]; + std::snprintf(at5, sizeof(at5), wall, 5); + std::snprintf(at25, sizeof(at25), wall, 25); // 25 = two rounds of 10, then 5 + const auto p5 = placeBacklight(at5); + const auto p25 = placeBacklight(at25); + REQUIRE(p25.size() == 10); + CHECK(walkOf(p25) == walkOf(p5)); + for (const auto& c : p25) CHECK((c.x >= 0 && c.x < 4 && c.y >= 0 && c.y < 3)); +} + +// A gap counts round from the top-left like the strand, so one starting near the end of the frame runs on past the corner onto the top edge. +TEST_CASE("backlight.mll: a gap crossing the top-left corner skips its lights on both sides of it") { + // A 4x3 wall walks 00 10 20 30 31 32 22 12 02 01; a gap of two from the last light covers 01 and 00. + const auto p = placeBacklight("{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3,\"gapStart\":9,\"gapLength\":2}"); + CHECK(walkOf(p) == std::string("10 20 30 31 32 22 12 02")); +} + +TEST_CASE("backlight.mll: a gap ending at the last light leaves the walk up to it") { + const auto p = placeBacklight("{\"wallCols\":4,\"wallRows\":3,\"top\":4,\"right\":3,\"bottom\":4,\"left\":3,\"gapStart\":8,\"gapLength\":2}"); + CHECK(walkOf(p) == std::string("00 10 20 30 31 32 22 12")); +} + +// A strand with fewer lights than the edge has pixels gets them spread along it, so one script serves a 30-light strip on a 128-pixel wall. The corners still land on the box. +TEST_CASE("backlight.mll: a strand coarser than the wall still spans its edges") { + const auto p = placeBacklight("{\"wallCols\":128,\"wallRows\":128,\"top\":30,\"right\":20,\"bottom\":30,\"left\":20}"); + CHECK(p.size() == 2 * 29 + 2 * 19); + + int maxX = 0, maxY = 0; + for (const auto& c : p) { + maxX = c.x > maxX ? c.x : maxX; + maxY = c.y > maxY ? c.y : maxY; + } + // The bounding box stays the wall's, so the strip sits on its edge pixels rather than inside them. + CHECK(maxX == 127); + CHECK(maxY == 127); +} + #endif // MM_MOONLIVE_HAS_HOST_JIT: the script must COMPILE for the count to mean anything.