diff --git a/docs/help/help.json b/docs/help/help.json index 6bf19017..23413c65 100644 --- a/docs/help/help.json +++ b/docs/help/help.json @@ -173,6 +173,9 @@ }, "ch": { "line": "What one channel carries, such as red, dimmer or pan; none for a spacer or a channel set elsewhere." + }, + "gain": { + "line": "One die pulled down to meet the others, 255 for a type that needs none; the gain lands on emitted light, so 128 is half of it." } } } diff --git a/src/light/drivers/Correction.h b/src/light/drivers/Correction.h index 821f4a1d..fdcf0715 100644 --- a/src/light/drivers/Correction.h +++ b/src/light/drivers/Correction.h @@ -31,6 +31,19 @@ struct Correction { /// Marks a role this light does not carry. static constexpr uint8_t kAbsent = 255; // color role not carried by this light + /// How many gained rows the tables carry: red, green, blue and the white dies. + static constexpr uint8_t kGainRows = 4; + + /// The tables one light is read through, so a channel's gain costs a pointer at rebuild and nothing per light. + template + struct Tables { + const T* base = nullptr; ///< the ungained curve, which amber, UV and every channel at gain 255 read + const T* red = nullptr; ///< the red die's table, gained or the curve itself + const T* green = nullptr; ///< the green die's + const T* blue = nullptr; ///< the blue die's + const T* white = nullptr; ///< both white dies, which the RGB gains do not reach + }; + /// The perceptual curve the output LUT is filled through; Linear is a requirement, not a fallback, since correcting twice darkens as the square. enum class Curve : uint8_t { Cie = 0, Gamma22, Gamma28, Linear }; @@ -47,6 +60,12 @@ struct Correction { uint16_t* lut16 = nullptr; /// Set by a driver whose output resolves more than a byte, so the 16-bit table is filled whatever the profile. bool wide = false; + /// The fixture profile's per-channel gain, 255 where the LED type needs none. Applied after the curve, so the value means emitted light: green at 128 is half the green. + uint8_t gainRed = 255, gainGreen = 255, gainBlue = 255, gainWhite = 255; + /// The gained tables, `kGainRows` of 256, which a driver allocates only for a profile that carries a gain; null leaves every channel on the ungained curve. + uint8_t* gainLut = nullptr; + /// The same to 16 bits, so a wide output keeps its full resolution rather than the 8-bit table's levels. + uint16_t* gainLut16 = nullptr; /// Which curve the brightness rebuild fills through; a driver's setting, not a global one. Curve curve = Curve::Cie; /// Output byte position of the red role; each color role's position is recomputed from the role array. @@ -81,16 +100,64 @@ struct Correction { /// Whether the fixture carries red, green and blue alone, resolved at rebuild so a plain strip skips every optional role per light. bool plainRgb = true; uint8_t outChannels = 3; // bytes emitted per light (= channelsPerLight of the wiring) + /// Each color channel's gained row, or null for the ungained curve. Into the driver's `gainLut`, never this object's own table, so a Correction stays copyable. + const uint8_t* rowRed = nullptr; ///< red's gained row, or null for the curve itself + const uint8_t* rowGreen = nullptr; ///< green's + const uint8_t* rowBlue = nullptr; ///< blue's + const uint8_t* rowWhite = nullptr; ///< the white dies' + /// Red's gained row to 16 bits, or null where no wide table is filled, which leaves the channel 8-bit. + const uint16_t* rowRed16 = nullptr; + const uint16_t* rowGreen16 = nullptr; ///< green's, likewise + const uint16_t* rowBlue16 = nullptr; ///< blue's + const uint16_t* rowWhite16 = nullptr; ///< the white dies' WhiteMode whiteMode = WhiteMode::Min; // how white is synthesized from RGB (white lights only) - /// Refresh the brightness LUT alone, leaving the channel offsets untouched; order is the design: brightness is a linear pre-scale and the curve is applied last. + /// Whether any channel carries a gain, so a driver allocates the gained tables only for a profile that needs them. + bool wantsGains() const MM_NONBLOCKING { + return gainRed != 255 || gainGreen != 255 || gainBlue != 255 || gainWhite != 255; + } + + /// Refresh the brightness LUT alone, leaving the channel offsets untouched; order is the design: brightness is a linear pre-scale, the curve lands next, and the gain last, on emitted light. void rebuildBrightness(uint8_t brightness) MM_NONBLOCKING { + const uint8_t gain[kGainRows] = {gainRed, gainGreen, gainBlue, gainWhite}; for (int v = 0; v < 256; v++) { const float out = shape(static_cast(v) * brightness / 255.0f); // scale first, then curve const bool lit = v > 0 && brightness > 0; briLut[v] = static_cast(quantize(out, 255, lit)); if (wantsTable() && lut16) lut16[v] = static_cast(quantize(out * 257.0f, 65535, lit)); // 255 * 257 = 65535 + for (uint8_t g = 0; g < kGainRows; g++) { + // From the curve's own float, so the gain does not quantize a value the curve already rounded. + const float scaled = out * gain[g] / 255.0f; + const bool onAtAll = lit && gain[g] > 0; + if (gainLut) gainLut[g * 256 + v] = static_cast(quantize(scaled, 255, onAtAll)); + if (gainLut16) gainLut16[g * 256 + v] = static_cast(quantize(scaled * 257.0f, 65535, onAtAll)); + } } + resolveTables(); + } + + /// Point each channel at its gained row, or at null where its gain is 255 or the gained tables are absent. + void resolveTables() MM_NONBLOCKING { + const uint8_t gain[kGainRows] = {gainRed, gainGreen, gainBlue, gainWhite}; + const uint8_t** row8[kGainRows] = {&rowRed, &rowGreen, &rowBlue, &rowWhite}; + const uint16_t** row16[kGainRows] = {&rowRed16, &rowGreen16, &rowBlue16, &rowWhite16}; + for (uint8_t g = 0; g < kGainRows; g++) { + const bool gained = gain[g] != 255; + *row8[g] = gained && gainLut ? gainLut + g * 256 : nullptr; + *row16[g] = gained && gainLut16 ? gainLut16 + g * 256 : nullptr; + } + } + + /// The 8-bit tables one light is read through, the gained rows over the curve this object carries. + Tables tables8() const MM_NONBLOCKING { + return Tables{briLut, rowRed ? rowRed : briLut, rowGreen ? rowGreen : briLut, + rowBlue ? rowBlue : briLut, rowWhite ? rowWhite : briLut}; + } + + /// The same to 16 bits, each row null where no table is filled, which leaves that channel 8-bit. + Tables tables16() const MM_NONBLOCKING { + return Tables{lut16, rowRed16 ? rowRed16 : lut16, rowGreen16 ? rowGreen16 : lut16, + rowBlue16 ? rowBlue16 : lut16, rowWhite16 ? rowWhite16 : lut16}; } /// Whether the 16-bit table is filled: a profile with fine roles, or a wide driver. @@ -145,19 +212,20 @@ struct Correction { } /// Hot path: transform one source light into its output bytes, integer-only and allocation-free. - inline void apply(const uint8_t* src, uint8_t* out, uint8_t srcChannels) const { applyThrough(briLut, src, out, srcChannels); } + inline void apply(const uint8_t* src, uint8_t* out, uint8_t srcChannels) const { applyThrough(tables8(), src, out, srcChannels); } /// Hot path, wide drivers only: the same transform through the 16-bit table, one value per channel; needs `lut16`. - inline void applyWide(const uint8_t* src, uint16_t* out, uint8_t srcChannels) const { applyThrough(lut16, src, out, srcChannels); } + inline void applyWide(const uint8_t* src, uint16_t* out, uint8_t srcChannels) const { applyThrough(tables16(), src, out, srcChannels); } /// The transform through a curve table of either width, so the 8-bit and 16-bit outputs share every line. A remap, not a copy: the layer's packed slots become the fixture's own offsets. template - inline void MM_RAMFUNC applyThrough(const T* lut, const uint8_t* src, T* out, uint8_t srcChannels) const { + inline void MM_RAMFUNC applyThrough(const Tables& t, const uint8_t* src, T* out, uint8_t srcChannels) const { + const T* lut = t.base; // A plain strip: the curve on each color and nothing optional, the drivers' commonest light. if (plainRgb) { - if (offRed != kAbsent) out[offRed] = lut[src[0]]; - if (offGreen != kAbsent) out[offGreen] = lut[src[1]]; - if (offBlue != kAbsent) out[offBlue] = lut[src[2]]; + if (offRed != kAbsent) out[offRed] = t.red[src[0]]; + if (offGreen != kAbsent) out[offGreen] = t.green[src[1]]; + if (offBlue != kAbsent) out[offBlue] = t.blue[src[2]]; return; } constexpr T kFull = static_cast(~T{0}); // 255 or 65535 @@ -190,7 +258,7 @@ struct Correction { } else { w = r < g ? (r < b ? r : b) : (g < b ? g : b); // min(r,g,b): the white component // Computed off the PRE-subtraction values, which only rebalance the RGB emitters. - if (offWarmWhite != kAbsent) out[offWarmWhite] = lut[w]; + if (offWarmWhite != kAbsent) out[offWarmWhite] = t.white[w]; // yellow ≈ min(R,G) (the shared red+green component). if (offYellow != kAbsent) out[offYellow] = lut[r < g ? r : g]; // Driven from the blue with no red or green to pair with, so it stays dark on warm colors. @@ -201,13 +269,13 @@ struct Correction { // White last: it is the only emitter that rebalances RGB. if (offWhite != kAbsent) { if (whiteMode == WhiteMode::Accurate) { r -= w; g -= w; b -= w; } // pull white out of RGB - out[offWhite] = lut[w]; + out[offWhite] = t.white[w]; } } // The curve, applied ONCE: everything above this line is linear light. - if (offRed != kAbsent) out[offRed] = lut[r]; - if (offGreen != kAbsent) out[offGreen] = lut[g]; - if (offBlue != kAbsent) out[offBlue] = lut[b]; + if (offRed != kAbsent) out[offRed] = t.red[r]; + if (offGreen != kAbsent) out[offGreen] = t.green[g]; + if (offBlue != kAbsent) out[offBlue] = t.blue[b]; if constexpr (sizeof(T) == 1) { if (hasFine) applyFine(r, g, b, w, out); } } @@ -215,12 +283,17 @@ struct Correction { void applyFine(uint8_t r, uint8_t g, uint8_t b, uint8_t w, uint8_t* out) const { if (offDimmerFine != kAbsent) out[offDimmerFine] = 255; // wide open is 65535, not 65280 const uint8_t value[5] = {r, g, b, w, w}; // both whites carry the white component, 0 when none is synthesized + // Each channel's own gained table, so a fine channel is gained exactly as an 8-bit one and at full resolution. + const Tables w16 = tables16(); + const Tables n8 = tables8(); + const uint16_t* wide16[5] = {w16.red, w16.green, w16.blue, w16.white, w16.white}; + const uint8_t* narrow8[5] = {n8.red, n8.green, n8.blue, n8.white, n8.white}; const uint8_t coarse[5] = {offRed, offGreen, offBlue, offWhite, offWarmWhite}; const uint8_t fine[5] = {offRedFine, offGreenFine, offBlueFine, offWhiteFine, offWarmWhiteFine}; for (uint8_t k = 0; k < 5; k++) { if (fine[k] == kAbsent) continue; // Without its table (an allocation that failed) the channel stays 8-bit: the coarse byte as written, the fine one 0. - const uint16_t q = lut16 ? lut16[value[k]] : static_cast(briLut[value[k]] << 8); + const uint16_t q = wide16[k] ? wide16[k][value[k]] : static_cast(narrow8[k][value[k]] << 8); if (coarse[k] != kAbsent) out[coarse[k]] = static_cast(q >> 8); out[fine[k]] = static_cast(q & 0xFF); } diff --git a/src/light/drivers/DriverBase.h b/src/light/drivers/DriverBase.h index ed02ec10..5caff835 100644 --- a/src/light/drivers/DriverBase.h +++ b/src/light/drivers/DriverBase.h @@ -89,6 +89,7 @@ class DriverBase : public MoonModule { const bool firstTable = correction_.wantsTable() && !lut16_; if (firstTable) lut16_.resize(256); correction_.lut16 = lut16_.data(); + resolveGainTables(); if (firstTable) correction_.rebuildBrightness(tableBrightness); // a table that existed was filled by the rebuild above onCorrectionChanged(); // let a driver resize its correction-applied buffer } @@ -130,6 +131,8 @@ class DriverBase : public MoonModule { void release() override { freeWire(); // the shared correction scratch: owned here, so no driver re-frees it correction_.lut16 = nullptr; // the table goes with the module's scratch buffers below + correction_.gainLut = nullptr; + correction_.gainLut16 = nullptr; clearFailBuf(); clearConfigErr(); setConfigWarn(nullptr); @@ -175,6 +178,8 @@ class DriverBase : public MoonModule { /// The resolved correction: the wiring comes from a named profile by stable id, and the render loop never reads the library. Correction correction_; ScratchBuffer lut16_{*this}; // the correction's 16-bit table, allocated the first time it is wanted + ScratchBuffer gainLut_{*this}; // the gained tables, only for a profile whose LED type carries a gain + ScratchBuffer gainLut16_{*this}; // the same to 16 bits, only where a channel is read wide uint32_t profileId_ = 0; // stable id into the FixtureProfiles library (0 → resolve to default) /// The fixture profile applied per light, for channel order and white. /// @guide A reorder or a reboot keeps it. After renaming the profile, re-pick it: the next reboot falls back to the default. @@ -219,6 +224,18 @@ class DriverBase : public MoonModule { /// Set this driver's default referenced profile by name, from its constructor. void setDefaultFixtureName(const char* name) { defaultFixture_ = name; } + /// Give the correction the gained tables its profile asks for, and take them back when it asks for none. Only a profile whose LED type carries a gain pays for them, the way the 16-bit table is allocated only where it is read. + void resolveGainTables() { + const bool wants = correction_.wantsGains(); + if (wants && !gainLut_) gainLut_.resize(Correction::kGainRows * 256); + if (!wants) gainLut_.resize(0); + correction_.gainLut = wants ? gainLut_.data() : nullptr; + const bool wide = wants && correction_.wantsTable(); + if (wide && !gainLut16_) gainLut16_.resize(Correction::kGainRows * 256); + if (!wide) gainLut16_.resize(0); + correction_.gainLut16 = gainLut16_ ? gainLut16_.data() : nullptr; + } + /// Whether `name` is one of the correction controls, for a driver's own prepare test. static bool isCorrectionControl(const char* name) { return std::strcmp(name, "fixture") == 0 || std::strcmp(name, "fixtureRef") == 0 || std::strcmp(name, "localBrightness") == 0 diff --git a/src/light/drivers/FixtureProfilesModule.h b/src/light/drivers/FixtureProfilesModule.h index 904efbb0..b0c9c0b6 100644 --- a/src/light/drivers/FixtureProfilesModule.h +++ b/src/light/drivers/FixtureProfilesModule.h @@ -74,23 +74,30 @@ class FixtureProfilesModule : public MoonModule, public ListSource { return 0; // a dangling id renders at the first profile; rebuildCorrection falls back to it too } + /// Whether the profile carries a white die, cold or warm, at either precision; narrower than profileHasSynthChannel, since a profile with amber but no white has nothing the white trim reaches. + bool profileHasWhiteDie(uint32_t id) const { + return profileHasRole(id, [](ChannelRole r) { + return r == ChannelRole::White || r == ChannelRole::WarmWhite + || r == ChannelRole::WhiteFine || r == ChannelRole::WarmWhiteFine; + }); + } + /// Whether the profile carries a channel the white mode would synthesize; any channel apply() synthesizes from RGB counts, since one control governs them all. bool profileHasSynthChannel(uint32_t id) const { + return profileHasRole(id, [](ChannelRole r) { + return r == ChannelRole::White || r == ChannelRole::WarmWhite + || r == ChannelRole::WhiteFine || r == ChannelRole::WarmWhiteFine + || r == ChannelRole::Yellow || r == ChannelRole::UV; + }); + } + + /// Whether any of the profile's channels carries a role `wanted` accepts, the walk both role questions share. + bool profileHasRole(uint32_t id, bool (*wanted)(ChannelRole)) const { const Profile* p = find(id); if (!p) return false; const uint8_t* r = roleAt(*p); - for (uint8_t c = 0; c < p->channelCount; c++) { - switch (static_cast(r[c])) { - case ChannelRole::White: - case ChannelRole::WarmWhite: - case ChannelRole::WhiteFine: - case ChannelRole::WarmWhiteFine: - case ChannelRole::Yellow: - case ChannelRole::UV: - return true; - default: break; - } - } + for (uint8_t c = 0; c < p->channelCount; c++) + if (wanted(static_cast(r[c]))) return true; return false; } @@ -99,6 +106,10 @@ class FixtureProfilesModule : public MoonModule, public ListSource { const Profile* p = find(id); if (!p) return false; // The option indices are aligned with the enum, so a role byte IS a ChannelRole value. + out.gainRed = p->gainRed; + out.gainGreen = p->gainGreen; + out.gainBlue = p->gainBlue; + out.gainWhite = p->gainWhite; out.rebuild(brightness, reinterpret_cast(roleAt(*p)), p->channelCount); return true; } @@ -129,6 +140,7 @@ class FixtureProfilesModule : public MoonModule, public ListSource { /// @field name The profile's name, as a driver's fixture picker lists it. /// @field channels How many DMX channels the fixture takes. /// @field ch What one channel carries, such as red, dimmer or pan; none for a spacer or a channel set elsewhere. + /// @field gain One die pulled down to meet the others, 255 for a type that needs none; the gain lands on emitted light, so 128 is half of it. /// @guide Built-in rows are read-only, and custom rows persist across a reboot. controls_.addList("profiles", *this); // this module is the (editable) ListSource } @@ -149,6 +161,11 @@ class FixtureProfilesModule : public MoonModule, public ListSource { for (uint8_t c = 0; c < p.channelCount; c++) sink.appendf("%s%u", c ? "," : "", static_cast(roles[c])); sink.append("]"); + // Only a type that needs one, so every built-in row stays the shape it was. + if (hasGain(p)) + sink.appendf(",\"gains\":[%u,%u,%u,%u]", static_cast(p.gainRed), + static_cast(p.gainGreen), static_cast(p.gainBlue), + static_cast(p.gainWhite)); if (p.locked) sink.append(",\"locked\":true"); sink.append("}"); } @@ -176,6 +193,13 @@ class FixtureProfilesModule : public MoonModule, public ListSource { sink.appendf(",{\"name\":\"ch%u\",\"type\":\"select\",\"value\":%u,\"optionsRef\":\"channelRole\"}", static_cast(c), static_cast(roles[c])); } + const char* const kGainFields[] = {"gainRed", "gainGreen", "gainBlue", "gainWhite"}; + const uint8_t gains[] = {p.gainRed, p.gainGreen, p.gainBlue, p.gainWhite}; + // The white gain reaches the white dies alone, so a profile carrying none is not offered a field that changes nothing. + const uint8_t offered = profileHasWhiteDie(p.id) ? 4u : 3u; + for (uint8_t gi = 0; gi < offered; gi++) + sink.appendf(",{\"name\":\"%s\",\"type\":\"uint8\",\"value\":%u,\"min\":0,\"max\":255}", + kGainFields[gi], static_cast(gains[gi])); sink.append("]}"); } @@ -250,6 +274,7 @@ class FixtureProfilesModule : public MoonModule, public ListSource { setChannelCount(p, static_cast(v)); return true; } + if (setGain(p, field, valueJson)) return true; return setChannelRole(p, field, valueJson); } @@ -281,6 +306,8 @@ class FixtureProfilesModule : public MoonModule, public ListSource { uint16_t poolOffset = 0; // start of this profile's roles in pool_ (set by rebuildPool); 48 x 255 fits uint16_t poolLen = 0; // the OLD slice size, distinct from channelCount mid-change bool locked = false; + /// Per-channel gain, 255 where this LED type needs none: an off batch gets a profile of its own. + uint8_t gainRed = 255, gainGreen = 255, gainBlue = 255, gainWhite = 255; }; static_assert(kMaxProfiles * 255 <= 0xFFFF, "a full library's role pool must fit the 16-bit pool offsets"); @@ -322,6 +349,25 @@ class FixtureProfilesModule : public MoonModule, public ListSource { return field[0] == 'c' && field[1] == 'h' && field[2] >= '0' && field[2] <= '9'; } + /// Whether this profile carries a gain, which is what decides whether a driver allocates the gained tables. + static bool hasGain(const Profile& p) { + return p.gainRed != 255 || p.gainGreen != 255 || p.gainBlue != 255 || p.gainWhite != 255; + } + + /// Set the gain a `gainRed`-style field names; false for any other field or an out-of-range value. + bool setGain(Profile& p, const char* field, const char* valueJson) { + uint8_t* slot = std::strcmp(field, "gainRed") == 0 ? &p.gainRed + : std::strcmp(field, "gainGreen") == 0 ? &p.gainGreen + : std::strcmp(field, "gainBlue") == 0 ? &p.gainBlue + : std::strcmp(field, "gainWhite") == 0 ? &p.gainWhite + : nullptr; + if (!slot) return false; + const int v = mm::json::parseInt(valueJson, "value"); + if (v < 0 || v > 255) return false; + *slot = static_cast(v); + return true; + } + /// Set the role of the channel a `chN` field names; false for any other field or an out-of-range value. bool setChannelRole(Profile& p, const char* field, const char* valueJson) { if (!isChannelField(field)) return false; @@ -350,9 +396,20 @@ class FixtureProfilesModule : public MoonModule, public ListSource { const int ch = mm::json::readInt(mm::json::member(doc, row, "channels"), 3); p.channelCount = static_cast(ch < 1 ? 1 : ch > 255 ? 255 : ch); p.locked = mm::json::readBool(mm::json::member(doc, row, "locked"), false); + restoreGains(doc, row, p); if (p.id >= nextId_) nextId_ = p.id + 1; // never reissue a persisted id } + /// Read one persisted row's gains, an absent array leaving the type ungained. Clamped, since a corrupt file can carry a value outside the range the UI offers. + void restoreGains(const mm::json::JsonDoc& doc, const mm::json::JsonNode* row, Profile& p) { + const mm::json::JsonNode* gains = mm::json::member(doc, row, "gains"); + uint8_t* slot[] = {&p.gainRed, &p.gainGreen, &p.gainBlue, &p.gainWhite}; + for (uint8_t gi = 0; gi < 4; gi++) { + const int v = mm::json::readInt(mm::json::element(doc, gains, gi), 255); + *slot[gi] = static_cast(v < 0 ? 255 : v > 255 ? 255 : v); + } + } + /// Read one persisted row's roles into its slice of the pool, an absent or invalid one as None. It is clamped, since a corrupt file can carry a byte the UI mis-renders and the Correction drops. void restoreRoles(const mm::json::JsonDoc& doc, const mm::json::JsonNode* row, const Profile& p) { const mm::json::JsonNode* roles = mm::json::member(doc, row, "roles"); diff --git a/test/unit/light/unit_Correction.cpp b/test/unit/light/unit_Correction.cpp index 8d2533b7..f0243071 100644 --- a/test/unit/light/unit_Correction.cpp +++ b/test/unit/light/unit_Correction.cpp @@ -642,3 +642,130 @@ TEST_CASE("Dimmer fine is driven wide open with its coarse byte") { c.apply(src, out, 3); CHECK((out[0] == 255 && out[1] == 255)); } + +// The gain lives on the fixture profile, since a cast is a property of the LED type; a driver allocates these tables only for a profile that carries one. +namespace { +struct Gained { + uint8_t lut[mm::Correction::kGainRows * 256] = {}; + uint16_t lut16[mm::Correction::kGainRows * 256] = {}; + /// Wire the tables a driver would own, then fill them through the production rebuild. + void wire(Correction& c) { + c.gainLut = lut; + c.gainLut16 = lut16; + } +}; +} // namespace + +// An LED's three emitters are not equally efficient, so a neutral triple can render with a cast. The profile's gain pulls one die down to meet the others, at the output rather than in every effect. +TEST_CASE("Correction gain: one channel's gain leaves the others untouched") { + Correction c; + Gained g; + g.wire(c); + c.gainGreen = 128; + mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGB); + const uint8_t src[3] = {200, 200, 200}; // the triple a green-cast strip renders wrong + uint8_t out[3] = {}; + c.apply(src, out, 3); + CHECK(out[0] == 200); + CHECK(out[1] == 100); // half the green light, which is what 128 says + CHECK(out[2] == 200); +} + +// A white phosphor is separate hardware, so an RGB gain must say nothing about it. +TEST_CASE("Correction gain: an RGB gain does not reach the white die") { + Correction c; + Gained g; + g.wire(c); + c.gainBlue = 128; + mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGBW); + const uint8_t src[3] = {200, 200, 200}; + uint8_t out[4] = {}; + c.apply(src, out, 3); + CHECK(out[0] == 200); + CHECK(out[1] == 200); + CHECK(out[2] == 100); // blue gained + CHECK(out[3] == 200); // the white component, ungained +} + +// The gain lands on emitted light, after the curve, so its value means what it says at any curve. +TEST_CASE("Correction gain: the curve and the gain compose to the gain's own ratio") { + Correction c; + Gained g; + g.wire(c); + c.gainBlue = 128; + mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGB); + c.curve = Correction::Curve::Gamma22; + c.rebuildBrightness(255); // the same offsets, the tables refilled through the curve + const uint8_t src[3] = {200, 200, 200}; + uint8_t out[3] = {}; + c.apply(src, out, 3); + CHECK(out[0] == 149); // red: the curve alone, (200/255)^2.2 * 255 + CHECK(out[2] == 75); // blue: that light at 128/255 of it, not an index pulled down before the curve +} + +// Every built-in profile carries no gain, so it allocates nothing and every channel reads the one curve. +TEST_CASE("Correction gain: a profile with no gain wants no tables") { + Correction c; + mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGBW); + CHECK_FALSE(c.wantsGains()); + const uint8_t src[3] = {200, 160, 120}; + uint8_t out[4] = {}; + c.apply(src, out, 3); + CHECK(out[0] == 200); + CHECK(out[1] == 160); + CHECK(out[2] == 120); + CHECK(out[3] == 120); +} + +// The white die carries its own gain, since it is often brighter than the trio beside it. +TEST_CASE("Correction gainWhite: gains the white die without touching RGB") { + const uint8_t src[3] = {200, 160, 120}; // the white component is the minimum, 120 + + Correction full; + mm::test::rebuildFromPreset(full, 255, mm::test::PresetOrder::RGBW); + uint8_t a[4] = {}; + full.apply(src, a, 3); + + Correction half; + Gained g; + g.wire(half); + half.gainWhite = 128; + mm::test::rebuildFromPreset(half, 255, mm::test::PresetOrder::RGBW); + uint8_t b[4] = {}; + half.apply(src, b, 3); + + CHECK(a[3] == 120); + CHECK(b[3] == 60); // the white die alone moves + CHECK((b[0] == a[0] && b[1] == a[1] && b[2] == a[2])); +} + +// A die asked for nothing goes out, rather than resting on the floor that keeps a fade from snapping off. +TEST_CASE("Correction gainWhite: zero puts the white die out") { + Correction c; + Gained g; + g.wire(c); + c.gainWhite = 0; + mm::test::rebuildFromPreset(c, 255, mm::test::PresetOrder::RGBW); + const uint8_t src[3] = {200, 160, 120}; + uint8_t out[4] = {}; + c.apply(src, out, 3); + CHECK(out[3] == 0); + CHECK(out[0] == 200); // and the trio is untouched +} + +// A driver resolving a profile assigns a Correction whole, and a test does the same, so a gained one must carry no pointer into the object it came from. +TEST_CASE("Correction gain: a copied correction reads its own tables") { + Correction built; + Gained g; + g.wire(built); + built.gainGreen = 128; + mm::test::rebuildFromPreset(built, 255, mm::test::PresetOrder::RGB); + + Correction copy = built; // what a driver's assignment does + const uint8_t src[3] = {200, 200, 200}; + uint8_t out[3] = {}; + copy.apply(src, out, 3); + CHECK(out[0] == 200); + CHECK(out[1] == 100); // the gain survives the copy, read through the copy's own curve + CHECK(out[2] == 200); +} diff --git a/test/unit/light/unit_FixtureProfilesModule.cpp b/test/unit/light/unit_FixtureProfilesModule.cpp index 3068b320..632e10cc 100644 --- a/test/unit/light/unit_FixtureProfilesModule.cpp +++ b/test/unit/light/unit_FixtureProfilesModule.cpp @@ -622,3 +622,26 @@ TEST_CASE("A saved profile without an id gets its own, and its name is not seede CHECK(grb == 1); CHECK(builtinId(m, "GRB") > 1); // not the id the next saved row holds } + +// A cast is a property of the LED type, so the gain is the profile's; an edit must survive the row being written and restored. +TEST_CASE("FixtureProfiles gain: an edited gain is kept, serialized and restored") { + FixtureProfilesModule m; + m.setup(); + uint32_t id = 0; + REQUIRE(m.addListRow(id)); + REQUIRE(m.setListRowField(id, "channels", "{\"value\":4}")); + REQUIRE(m.setListRowField(id, "ch3", "{\"value\":4}")); // a white die, so gainWhite is offered + + CHECK(m.setListRowField(id, "gainGreen", "{\"value\":180}")); + CHECK(m.setListRowField(id, "gainWhite", "{\"value\":150}")); + + // The correction the driver resolves carries them. + Correction c; + REQUIRE(m.deriveCorrection(id, 255, c)); + CHECK(c.gainGreen == 180); + CHECK(c.gainWhite == 150); + + // The row the UI re-reads carries them, rather than snapping back to 255. + const std::string rows = profilesJson(m); + CHECK(rows.find("\"gains\":[255,180,255,150]") != std::string::npos); +}