Checkpoint 5: complete asset freshness, schema migrations and editor diagnostics
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user