feat(pipeline): add WOL validation + time-of-day sampling

Three additions to the Wowee Open Light format that landed
last commit:

  • WoweeLightLoader::sampleAtTime(light, timeMin) returns
    the linearly-interpolated keyframe at any time-of-day,
    correctly handling wrap-around between the last keyframe
    and the first (e.g. 21:00 blends from dusk toward
    midnight by going forward through 00:00).

  • --validate-wol <wol-base> [--json] walks every keyframe
    and reports structural problems: time bounds (must be
    [0, 1440)), strict-ascending sort order, fogEnd >
    fogStart, finite color components. Exit code 0 PASS /
    1 FAIL — CI-friendly.

  • --info-wol-at <wol-base> <HH:MM|minutes> samples the
    interpolated state at a specific time of day. Useful
    for previewing what the renderer would feed in at a
    given moment, debugging keyframe gaps, or previewing
    a sub-range of the cycle.

Smoke-tested: dawn-to-midnight blend at 03:00 yields a
plausible mid-fade ambient (0.18, 0.16, 0.15) and dusk-to-
midnight wrap at 21:00 yields the symmetric (0.19, 0.145,
0.14). The default 4-keyframe day/night cycle from
makeDefaultDayNight passes --validate-wol cleanly.
This commit is contained in:
Kelsi 2026-05-09 13:54:57 -07:00
parent d58ee0af7d
commit dc29f7f135
5 changed files with 202 additions and 1 deletions

View file

@ -58,6 +58,13 @@ public:
// outdoor defaults. Used by --gen-light to create a starter
// file users can edit.
static WoweeLight makeDefaultDayNight(const std::string& zoneName);
// Lookup the interpolated lighting state at any time-of-day
// (clamped to 0..1439 minutes). Linearly blends between the
// two adjacent keyframes; wraps around midnight if the query
// time falls between the last and first keyframe.
static WoweeLight::Keyframe sampleAtTime(const WoweeLight& light,
uint32_t timeMin);
};
} // namespace pipeline

View file

@ -104,6 +104,54 @@ bool WoweeLightLoader::exists(const std::string& basePath) {
return is.good();
}
WoweeLight::Keyframe WoweeLightLoader::sampleAtTime(
const WoweeLight& light, uint32_t timeMin) {
if (light.keyframes.empty()) return WoweeLight::Keyframe{};
if (light.keyframes.size() == 1) return light.keyframes.front();
timeMin = timeMin % 1440;
// Find the keyframe pair (a, b) such that a.t <= timeMin < b.t.
// Wrap: if timeMin is before the first keyframe or at/after the
// last, blend between (last, first + 1440).
const auto& kfs = light.keyframes;
auto it = std::upper_bound(kfs.begin(), kfs.end(), timeMin,
[](uint32_t t, const WoweeLight::Keyframe& kf) {
return t < kf.timeOfDayMin;
});
const WoweeLight::Keyframe* a;
const WoweeLight::Keyframe* b;
uint32_t aT, bT;
if (it == kfs.begin() || it == kfs.end()) {
// Wrap-around: between last and first (+ 1440).
a = &kfs.back();
b = &kfs.front();
aT = a->timeOfDayMin;
bT = b->timeOfDayMin + 1440;
if (it == kfs.begin()) {
// timeMin is BEFORE the first keyframe, so we're in
// the wrap window. Shift query into [aT, bT) by adding
// 1440 to it.
timeMin += 1440;
}
} else {
b = &(*it);
a = &(*(it - 1));
aT = a->timeOfDayMin;
bT = b->timeOfDayMin;
}
float t = (bT == aT) ? 0.0f
: static_cast<float>(timeMin - aT) /
static_cast<float>(bT - aT);
WoweeLight::Keyframe out;
out.timeOfDayMin = timeMin % 1440;
out.ambientColor = a->ambientColor + t * (b->ambientColor - a->ambientColor);
out.directionalColor = a->directionalColor + t * (b->directionalColor - a->directionalColor);
out.directionalDir = a->directionalDir + t * (b->directionalDir - a->directionalDir);
out.fogColor = a->fogColor + t * (b->fogColor - a->fogColor);
out.fogStart = a->fogStart + t * (b->fogStart - a->fogStart);
out.fogEnd = a->fogEnd + t * (b->fogEnd - a->fogEnd);
return out;
}
WoweeLight WoweeLightLoader::makeDefaultDayNight(
const std::string& zoneName) {
WoweeLight out;

View file

@ -15,7 +15,7 @@ const char* const kArgRequired[] = {
"--list-zone-meshes-detail", "--list-project-meshes-detail", "--info-mesh",
"--info-mesh-storage-budget", "--info-mesh-stats",
"--info-wob", "--info-wob-stats", "--info-woc", "--info-wot",
"--info-wol", "--gen-light",
"--info-wol", "--info-wol-at", "--validate-wol", "--gen-light",
"--info-creatures", "--info-objects", "--info-quests",
"--info-extract", "--info-extract-tree", "--info-extract-budget",
"--list-missing-sidecars",

View file

@ -773,6 +773,10 @@ void printUsage(const char* argv0) {
std::printf(" Print WOC collision metadata (triangle counts, bounds) and exit\n");
std::printf(" --info-wol <wol-base> [--json]\n");
std::printf(" Print WOL lighting keyframes (zone name + per-time-of-day ambient/directional/fog) and exit\n");
std::printf(" --info-wol-at <wol-base> <HH:MM|minutes>\n");
std::printf(" Sample the WOL's interpolated lighting at a specific time-of-day (linear blend between keyframes)\n");
std::printf(" --validate-wol <wol-base> [--json]\n");
std::printf(" Walk every keyframe; check time bounds + sort order + fogEnd > fogStart + finite color components\n");
std::printf(" --gen-light <wol-base> [zoneName]\n");
std::printf(" Emit a starter .wol with the canonical 4-keyframe day/night cycle (midnight + dawn + noon + dusk)\n");
std::printf(" --info-wot <wot-base> [--json]\n");

View file

@ -389,6 +389,142 @@ int handleInfoWol(int& i, int argc, char** argv) {
return 0;
}
int handleValidateWol(int& i, int argc, char** argv) {
// Walk every keyframe in a .wol and report structural problems:
// • times outside [0, 1440)
// • unsorted timeOfDayMin
// • duplicate timestamps
// • zero-area fog distances (fogEnd <= fogStart)
// • non-finite color components
// Returns 0 PASS / 1 FAIL.
std::string base = argv[++i];
bool jsonOut = (i + 1 < argc &&
std::strcmp(argv[i + 1], "--json") == 0);
if (jsonOut) ++i;
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wol")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeLightLoader::exists(base)) {
std::fprintf(stderr, "WOL not found: %s.wol\n", base.c_str());
return 1;
}
auto wol = wowee::pipeline::WoweeLightLoader::load(base);
std::vector<std::string> errors;
if (wol.keyframes.empty()) {
errors.push_back("no keyframes");
}
uint32_t prevTime = 0;
bool first = true;
auto checkColor = [&](const glm::vec3& c, const char* label, int idx) {
for (int k = 0; k < 3; ++k) {
float v = c[k];
if (!std::isfinite(v)) {
errors.push_back("kf " + std::to_string(idx) + " " +
label + " channel " + std::to_string(k) +
" is non-finite");
}
}
};
for (std::size_t k = 0; k < wol.keyframes.size(); ++k) {
const auto& kf = wol.keyframes[k];
if (kf.timeOfDayMin >= 1440) {
errors.push_back("kf " + std::to_string(k) +
" time " + std::to_string(kf.timeOfDayMin) +
" >= 1440");
}
if (!first && kf.timeOfDayMin <= prevTime) {
errors.push_back("kf " + std::to_string(k) +
" time " + std::to_string(kf.timeOfDayMin) +
" <= previous " + std::to_string(prevTime));
}
if (kf.fogEnd <= kf.fogStart) {
errors.push_back("kf " + std::to_string(k) +
" fogEnd " + std::to_string(kf.fogEnd) +
" <= fogStart " +
std::to_string(kf.fogStart));
}
checkColor(kf.ambientColor, "ambient", static_cast<int>(k));
checkColor(kf.directionalColor, "directional",
static_cast<int>(k));
checkColor(kf.fogColor, "fog", static_cast<int>(k));
prevTime = kf.timeOfDayMin;
first = false;
}
if (jsonOut) {
nlohmann::json j;
j["wol"] = base + ".wol";
j["passed"] = errors.empty();
j["errorCount"] = errors.size();
j["errors"] = errors;
std::printf("%s\n", j.dump(2).c_str());
return errors.empty() ? 0 : 1;
}
if (errors.empty()) {
std::printf("WOL %s.wol PASSED — %zu keyframe(s) valid\n",
base.c_str(), wol.keyframes.size());
return 0;
}
std::printf("WOL %s.wol FAILED — %zu error(s):\n",
base.c_str(), errors.size());
for (const auto& e : errors) std::printf(" - %s\n", e.c_str());
return 1;
}
int handleInfoWolAt(int& i, int argc, char** argv) {
// Sample the WOL's interpolated lighting state at a specific
// time-of-day, given as HH:MM (24-hour) or as raw minutes.
std::string base = argv[++i];
if (i + 1 >= argc) {
std::fprintf(stderr, "info-wol-at: missing time argument\n");
return 1;
}
std::string timeStr = argv[++i];
int timeMin = 0;
auto colon = timeStr.find(':');
if (colon != std::string::npos) {
try {
int hh = std::stoi(timeStr.substr(0, colon));
int mm = std::stoi(timeStr.substr(colon + 1));
timeMin = (hh * 60 + mm) % 1440;
} catch (...) {
std::fprintf(stderr, "info-wol-at: bad time %s (use HH:MM)\n",
timeStr.c_str());
return 1;
}
} else {
try { timeMin = std::stoi(timeStr) % 1440; } catch (...) {
std::fprintf(stderr, "info-wol-at: bad time %s (use minutes)\n",
timeStr.c_str());
return 1;
}
}
if (timeMin < 0) timeMin += 1440;
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wol")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeLightLoader::exists(base)) {
std::fprintf(stderr, "WOL not found: %s.wol\n", base.c_str());
return 1;
}
auto wol = wowee::pipeline::WoweeLightLoader::load(base);
if (!wol.isValid()) {
std::fprintf(stderr, "WOL parse failed: %s.wol\n", base.c_str());
return 1;
}
auto kf = wowee::pipeline::WoweeLightLoader::sampleAtTime(
wol, static_cast<uint32_t>(timeMin));
std::printf("WOL %s.wol sample at %02d:%02d\n",
base.c_str(), timeMin / 60, timeMin % 60);
std::printf(" ambient : (%.3f, %.3f, %.3f)\n",
kf.ambientColor.r, kf.ambientColor.g, kf.ambientColor.b);
std::printf(" directional: (%.3f, %.3f, %.3f) dir (%.2f, %.2f, %.2f)\n",
kf.directionalColor.r, kf.directionalColor.g,
kf.directionalColor.b,
kf.directionalDir.x, kf.directionalDir.y, kf.directionalDir.z);
std::printf(" fog : (%.3f, %.3f, %.3f) [%.1f..%.1f]\n",
kf.fogColor.r, kf.fogColor.g, kf.fogColor.b,
kf.fogStart, kf.fogEnd);
return 0;
}
int handleGenLight(int& i, int argc, char** argv) {
// Emit a starter .wol file with the default 4-keyframe day/
// night cycle (midnight, dawn, noon, dusk). User can edit
@ -434,6 +570,12 @@ bool handleWorldInfo(int& i, int argc, char** argv, int& outRc) {
if (std::strcmp(argv[i], "--info-wol") == 0 && i + 1 < argc) {
outRc = handleInfoWol(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--info-wol-at") == 0 && i + 2 < argc) {
outRc = handleInfoWolAt(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--validate-wol") == 0 && i + 1 < argc) {
outRc = handleValidateWol(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--gen-light") == 0 && i + 1 < argc) {
outRc = handleGenLight(i, argc, argv); return true;
}