Checkpoint 5: complete asset freshness, schema migrations and editor diagnostics

This commit is contained in:
Emil
2026-09-18 05:43:02 +03:00
parent e0b965166e
commit 0f34b03631
37 changed files with 1438 additions and 52 deletions
+10 -2
View File
@@ -4,6 +4,7 @@
#include <condition_variable>
#include <deque>
#include <faset/assets/asset_data.hpp>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/schema.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
@@ -225,9 +226,16 @@ struct BuildService::Impl {
}
}
void validate_assets(const Json& scene) {
assets::AssetStore pipeline(config.cache_root);
for (const auto& id : asset_references(scene))
assets::AssetPipeline pipeline(config.cache_root);
for (const auto& id : asset_references(scene)) {
pipeline.load_asset(id);
const auto freshness = pipeline.freshness(id);
if (freshness.value("state", std::string("unavailable")) != "current")
throw std::runtime_error("Asset " + id +
" is stale or unavailable. Reimport before cooking or "
"exporting. Details: " +
freshness.dump());
}
}
Json build(Job& job, bool exporting = false) {
const auto& configuration = exporting ? config.export_configuration : config.configuration;
+2 -1
View File
@@ -114,7 +114,8 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
true);
add("faset_scene_edit",
"Apply one atomic authoring batch with optimistic revision checking and one Undo step. "
"Operations: entity.create/rename/delete/duplicate/reparent; component.add/remove/set; "
"Operations: entity.create/rename/delete/duplicate/reparent; "
"component.add/remove/set/migrate; "
"scene.rename/simulation; "
"template.instance/override/revert/suppress/restore/add/addition_set/reparent/remove/"
"source_set. Use "
+265
View File
@@ -0,0 +1,265 @@
#include <algorithm>
#include <cmath>
#include <faset/editor/debug_overlay.hpp>
#include <imgui.h>
#include <stdexcept>
#include <string_view>
namespace faset::editor {
namespace {
struct CurrentContext {
ImGuiContext* previous{ImGui::GetCurrentContext()};
explicit CurrentContext(ImGuiContext* context) {
ImGui::SetCurrentContext(context);
}
~CurrentContext() {
ImGui::SetCurrentContext(previous);
}
};
ImGuiKey key(std::string_view name) {
if (name.size() == 1 && name[0] >= 'A' && name[0] <= 'Z')
return static_cast<ImGuiKey>(ImGuiKey_A + name[0] - 'A');
if (name.size() == 1 && name[0] >= '0' && name[0] <= '9')
return static_cast<ImGuiKey>(ImGuiKey_0 + name[0] - '0');
const std::pair<std::string_view, ImGuiKey> names[] = {
{"Tab", ImGuiKey_Tab}, {"Left", ImGuiKey_LeftArrow},
{"Right", ImGuiKey_RightArrow}, {"Up", ImGuiKey_UpArrow},
{"Down", ImGuiKey_DownArrow}, {"PageUp", ImGuiKey_PageUp},
{"PageDown", ImGuiKey_PageDown}, {"Home", ImGuiKey_Home},
{"End", ImGuiKey_End}, {"Insert", ImGuiKey_Insert},
{"Delete", ImGuiKey_Delete}, {"Backspace", ImGuiKey_Backspace},
{"Space", ImGuiKey_Space}, {"Return", ImGuiKey_Enter},
{"Escape", ImGuiKey_Escape}};
for (const auto& [label, value] : names)
if (label == name)
return value;
return ImGuiKey_None;
}
} // namespace
struct DebugOverlay::Impl {
ImGuiContext* context{};
bool visible{}, freeze{};
std::uint32_t overlay_buttons{}, editor_buttons{};
std::array<float, 2> pointer{-1, -1};
float scale{};
std::array<float, 4> window_rect{};
render::FrameStats displayed;
std::shared_ptr<render::Texture> atlas;
Impl() {
IMGUI_CHECKVERSION();
auto* previous = ImGui::GetCurrentContext();
context = ImGui::CreateContext();
auto& io = ImGui::GetIO();
io.IniFilename = nullptr;
io.LogFilename = nullptr;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset;
io.BackendPlatformName = "faset_events";
io.BackendRendererName = "faset_ui_triangles";
unsigned char* pixels{};
int width{}, height{};
io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
atlas = std::make_shared<render::Texture>();
atlas->width = width;
atlas->height = height;
atlas->rgba.assign(pixels, pixels + std::size_t(width) * height * 4);
io.Fonts->SetTexID(ImTextureID{1});
ImGui::SetCurrentContext(previous);
}
~Impl() {
ImGui::DestroyContext(context);
}
};
DebugOverlay::DebugOverlay() : impl_(std::make_unique<Impl>()) {}
DebugOverlay::~DebugOverlay() = default;
bool DebugOverlay::visible() const {
return impl_->visible;
}
void DebugOverlay::set_visible(bool value) {
impl_->visible = value;
}
std::vector<render::Event> DebugOverlay::process_events(std::span<const render::Event> events) {
CurrentContext current(impl_->context);
auto& io = ImGui::GetIO();
std::vector<render::Event> forwarded;
const auto pointer_in_overlay = [&] {
return impl_->visible && impl_->pointer[0] >= impl_->window_rect[0] &&
impl_->pointer[1] >= impl_->window_rect[1] &&
impl_->pointer[0] < impl_->window_rect[0] + impl_->window_rect[2] &&
impl_->pointer[1] < impl_->window_rect[1] + impl_->window_rect[3];
};
const auto capture_pointer = [&] {
if (impl_->overlay_buttons)
return true;
if (impl_->editor_buttons)
return false;
return pointer_in_overlay();
};
for (const auto& event : events) {
using Type = render::Event::Type;
if ((event.type == Type::KeyDown || event.type == Type::KeyUp) && event.key == "F12") {
if (event.type == Type::KeyDown && !event.repeat)
impl_->visible = !impl_->visible;
continue;
}
io.AddKeyEvent(ImGuiMod_Ctrl, event.control);
io.AddKeyEvent(ImGuiMod_Shift, event.shift);
io.AddKeyEvent(ImGuiMod_Alt, event.alt);
bool captured{};
bool pointer_event{};
switch (event.type) {
case Type::MouseMove:
io.AddMousePosEvent(event.x, event.y);
impl_->pointer = {event.x, event.y};
pointer_event = true;
captured = capture_pointer();
break;
case Type::MouseDown:
case Type::MouseUp:
io.AddMousePosEvent(event.x, event.y);
impl_->pointer = {event.x, event.y};
pointer_event = true;
captured = capture_pointer();
if (event.button >= 1 && event.button <= 5) {
const int buttons[] = {0, 2, 1, 3, 4};
io.AddMouseButtonEvent(buttons[event.button - 1], event.type == Type::MouseDown);
const auto mask = std::uint32_t{1} << (event.button - 1);
if (event.type == Type::MouseDown) {
if (captured)
impl_->overlay_buttons |= mask;
else
impl_->editor_buttons |= mask;
} else {
if (impl_->overlay_buttons & mask)
captured = true;
else if (impl_->editor_buttons & mask)
captured = false;
impl_->overlay_buttons &= ~mask;
impl_->editor_buttons &= ~mask;
}
}
break;
case Type::Wheel:
io.AddMouseWheelEvent(event.x, event.y);
pointer_event = true;
captured = capture_pointer();
break;
case Type::KeyDown:
case Type::KeyUp:
if (const auto mapped = key(event.key); mapped != ImGuiKey_None)
io.AddKeyEvent(mapped, event.type == Type::KeyDown);
captured = io.WantCaptureKeyboard;
break;
case Type::TextInput:
io.AddInputCharactersUTF8(event.text.c_str());
captured = io.WantCaptureKeyboard;
break;
case Type::FocusGained:
case Type::FocusLost:
io.AddFocusEvent(event.type == Type::FocusGained);
if (event.type == Type::FocusLost)
impl_->overlay_buttons = impl_->editor_buttons = 0;
break;
default:
break;
}
if (!captured || (!pointer_event && !impl_->visible))
forwarded.push_back(event);
}
return forwarded;
}
void DebugOverlay::append(render::Snapshot& output, const render::Renderer& renderer, float delta) {
CurrentContext current(impl_->context);
auto& state = *impl_;
auto& io = ImGui::GetIO();
io.DisplaySize = {float(renderer.width()), float(renderer.height())};
io.DisplayFramebufferScale = {1, 1}; // Faset events and geometry already use drawable pixels.
io.DeltaTime = std::isfinite(delta) && delta > 0 ? std::clamp(delta, .0001f, .1f) : 1.f / 60.f;
const float scale = renderer.display_scale();
if (state.scale != scale) {
auto& style = ImGui::GetStyle();
style = ImGuiStyle{};
ImGui::StyleColorsDark();
style.ScaleAllSizes(scale);
style.FontScaleMain = scale;
if (state.window_rect[2] == 0)
state.window_rect = {16 * scale, 44 * scale, 390 * scale, 310 * scale};
state.scale = scale;
}
// Complete an ImGui frame while hidden too, so queued input cannot accumulate.
ImGui::NewFrame();
if (state.visible) {
ImGui::SetNextWindowPos({16 * scale, 44 * scale}, ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize({390 * scale, 310 * scale}, ImGuiCond_FirstUseEver);
if (ImGui::Begin("Faset diagnostics (F12)", &state.visible,
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoCollapse)) {
const auto position = ImGui::GetWindowPos(), size = ImGui::GetWindowSize();
state.window_rect = {position.x, position.y, size.x, size.y};
if (!state.freeze)
state.displayed = renderer.stats();
const auto& stats = state.displayed;
ImGui::TextUnformatted("Previous completed frame");
ImGui::TextWrapped("%s", stats.device.c_str());
ImGui::Checkbox("Freeze counters", &state.freeze);
ImGui::Separator();
ImGui::Text("Frame: %llu", static_cast<unsigned long long>(stats.frame));
ImGui::Text("Render call (wall): %.3f ms", stats.cpu_ms);
if (stats.gpu_ms > 0)
ImGui::Text("GPU: %.3f ms", stats.gpu_ms);
else
ImGui::TextUnformatted("GPU timestamps: unavailable");
ImGui::Text("Readback (wall): %.3f ms", stats.readback_cpu_ms);
ImGui::Text("Draws: %u Packed vertices: %u", stats.draw_calls, stats.vertices);
ImGui::Text("Culled meshes: %u Textures: %u", stats.culled_meshes,
stats.texture_count);
ImGui::Text("Vulkan allocations: %.2f MiB",
double(stats.gpu_allocated_bytes) / 1048576.0);
ImGui::Text("Validation: %s Errors: %u",
stats.validation_enabled ? "on" : "unavailable/off",
stats.validation_errors);
ImGui::Text("GPU pass labels: %s (%u)", stats.gpu_labels_enabled ? "on" : "unavailable",
stats.gpu_label_count);
}
ImGui::End();
}
ImGui::Render();
const auto* data = ImGui::GetDrawData();
if (!data || !data->Valid)
return;
for (const auto* list : data->CmdLists) {
for (const auto& command : list->CmdBuffer) {
if (command.UserCallback) {
if (command.UserCallback != ImDrawCallback_ResetRenderState)
command.UserCallback(list, &command);
continue;
}
if (command.GetTexID() != ImTextureID{1})
throw std::runtime_error("Unsupported texture in Faset diagnostic overlay");
const float x = command.ClipRect.x - data->DisplayPos.x;
const float y = command.ClipRect.y - data->DisplayPos.y;
const float width = command.ClipRect.z - command.ClipRect.x;
const float height = command.ClipRect.w - command.ClipRect.y;
if (width <= 0 || height <= 0)
continue;
render::UiTriangles batch;
batch.texture = state.atlas;
batch.clip_rect = {x, y, width, height};
batch.vertices.reserve(command.ElemCount);
for (unsigned i = 0; i < command.ElemCount; ++i) {
const auto index = list->IdxBuffer[command.IdxOffset + i] + command.VtxOffset;
const auto& vertex = list->VtxBuffer[index];
const auto channel = [&](unsigned shift) {
return float((vertex.col >> shift) & 0xffu) / 255.f;
};
batch.vertices.push_back(
{{vertex.pos.x - data->DisplayPos.x, vertex.pos.y - data->DisplayPos.y, 0},
{},
{channel(IM_COL32_R_SHIFT), channel(IM_COL32_G_SHIFT),
channel(IM_COL32_B_SHIFT), channel(IM_COL32_A_SHIFT)},
{vertex.uv.x, vertex.uv.y}});
}
output.ui_triangles.push_back(std::move(batch));
}
}
}
} // namespace faset::editor
+59 -4
View File
@@ -847,6 +847,24 @@ struct EditorUI::Impl {
transaction(Json::array(
{{{"op", "component.remove"}, {"entity", selected}, {"component", cid}}}));
}
void migrate_component(const std::string& cid) {
const auto* object = entity(resolved, selected);
if (!object)
return;
Json operation = {{"op", "component.migrate"}, {"entity", selected}, {"component", cid}};
if (owned_addition(*object)) {
operation["instance"] = object->at("origin").at("path").front();
operation["entity"] = object->at("origin").at("object");
for (const auto& c : object->at("components"))
if (c.at("id") == cid)
operation["component"] = c.at("source_id");
} else if (inherited(*object)) {
open_template_source(object->at("origin").at("path"),
object->at("origin").at("object"));
return;
}
transaction(Json::array({operation}));
}
Json relative_address(const Json& object, const std::string& component_id = {},
const std::string& field = {}) const {
const auto& origin = object.at("origin");
@@ -1119,7 +1137,9 @@ struct EditorUI::Impl {
return;
current = session.authoring().query(document);
schemas = session.authoring().schemas().manifest();
const auto document_stamp = session.authoring().documents().dump();
// Resolution also depends on schemas: a rebuilt component version may
// invalidate an override or change how entity references are remapped.
const auto document_stamp = Json::array({session.authoring().documents(), schemas}).dump();
if (shown_revision != current.at("revision").get<std::uint64_t>() ||
resolved_stamp != document_stamp) {
resolved_stamp = document_stamp;
@@ -1440,6 +1460,24 @@ struct EditorUI::Impl {
if (!known) {
label(body, "opaque-note-" + cid, "Schema unavailable. Data is preserved.");
keep.insert("opaque-note-" + cid);
if (has_schema &&
c.value("version", 1) <
session.authoring().schemas().schema(type).value("version", 1)) {
const auto version =
session.authoring().schemas().schema(type).value("version", 1);
label(body, "opaque-note-" + cid,
"Stored v" + std::to_string(c.value("version", 1)) + " / schema v" +
std::to_string(version) + ". Migration is explicit and undoable.");
const auto action_id = "component-migrate-" + cid;
auto& action = button(body, action_id,
source_object && !local_addition
? "Open source to migrate"
: "Migrate to v" + std::to_string(version),
[this, cid] { migrate_component(cid); });
action.tooltip =
"Apply the schema's declared migration rules. Errors keep all stored data.";
keep.insert(action_id);
}
auto& raw =
body.add(Kind::TextField, "opaque-fields-" + cid, c.at("fields").dump());
raw.enabled = false;
@@ -1617,13 +1655,30 @@ struct EditorUI::Impl {
asset.contains("manifest")
? path_to_utf8(path_from_utf8(asset["manifest"].value("source", id)).filename())
: id;
auto& row = list.add(Kind::TreeRow, "asset-" + id, "Imported / " + name);
const auto freshness = asset.value("freshness", Json::object());
const auto state = freshness.value("state", std::string("unavailable"));
const auto prefix = state == "current" ? "Imported"
: state == "stale" ? "Stale"
: "Unavailable";
auto& row =
list.add(Kind::TreeRow, "asset-" + id, std::string(prefix) + " / " + name);
row.layout.height = 25;
row.indent = 1;
row.drag_payload = {{"kind", "asset"}, {"id", id}, {"label", name}};
row.on_click = [this, id](Widget&) {
row.on_click = [this, id, asset, freshness, state](Widget&) {
renderer.set_clipboard(id);
status = "Asset ID copied; drag to viewport or an asset field";
if (asset.contains("manifest")) {
source_file = generic_path_to_utf8(std::filesystem::relative(
path_from_utf8(asset.at("manifest").at("source").get<std::string>()),
session.config().project_root));
assets_dirty = true;
}
status = state == "current"
? "Asset ID copied; drag to viewport or an asset field"
: "Reimport required; select Import to refresh the selected source";
for (const auto& reason : freshness.value("reasons", Json::array()))
report(reason.value("message", "Input changed") + ": " +
reason.value("path", ""));
};
keep.insert(row.id);
}
+3 -2
View File
@@ -120,7 +120,8 @@ Json Session::assets_list() const {
try {
const auto id = path_to_utf8(entry.path().filename());
const auto manifest = assets_.current_manifest(id);
list.push_back({{"id", id}, {"manifest", manifest}});
list.push_back(
{{"id", id}, {"manifest", manifest}, {"freshness", assets_.freshness(id)}});
} catch (const std::exception& error) {
list.push_back(
{{"id", path_to_utf8(entry.path().filename())}, {"error", error.what()}});
@@ -375,7 +376,7 @@ void Session::register_commands() {
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
});
commands_.add(
"faset_assets", "List imported asset manifests and resource identities.",
"faset_assets", "List imported assets with source/dependency/recipe freshness and reasons.",
schema(Json::object()), [&](const Json&) { return assets_list(); }, true);
commands_.add(
"faset_import",