diff --git a/.github/workflows/windows-graphics.yml b/.github/workflows/windows-graphics.yml index b14e064..e481222 100644 --- a/.github/workflows/windows-graphics.yml +++ b/.github/workflows/windows-graphics.yml @@ -63,7 +63,7 @@ jobs: - name: Fetch checksum-verified Slang compiler run: python tools/fetch_slang.py - name: Configure full editor and Player - run: cmake --preset windows-debug -DFASET_BUILD_RENDERER=ON -DFASET_BUILD_EDITOR=ON + run: cmake --preset windows-debug -DFASET_BUILD_RENDERER=ON -DFASET_BUILD_EDITOR=ON -DFASET_DEBUG_IMGUI=ON - name: Build full editor and tests run: cmake --build --preset windows-debug --parallel 2 - name: CPU contracts and software GPU pixel tests diff --git a/CMakeLists.txt b/CMakeLists.txt index bee6674..8b329c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -107,5 +107,6 @@ if(BUILD_TESTING) endif() include(cmake/Tutorials.cmake) +include(cmake/Diagnostics.cmake) include(cmake/WindowsPlatform.cmake) diff --git a/README.md b/README.md index dd8812d..46a15ed 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ Open [localhost:4178](http://localhost:4178). The map is a documentation viewer, ## Repository contents -Documentation, studies, the source manifest, and the map's code are tracked in Git. Third-party engine source trees, installed dependencies, build outputs, and caches are excluded. Source links are pinned to the commits examined during research; Unreal Engine links may require access through Epic. +Faset's C++ source, tests, sample games, Blender add-on, Manual, architecture, studies, +selected validation evidence, and research map are tracked in Git. Third-party engine +source trees, installed dependencies, build outputs, and caches are excluded. Research +source links are pinned to the commits examined; Unreal Engine links may require +access through Epic. See the [publication notes](docs/PUBLICATION.md) for publication scope and licensing status. A license for Faset's own content has not yet been selected. Third-party projects retain their own license terms. diff --git a/apps/editor_gui.cpp b/apps/editor_gui.cpp index 998d76c..9f5c3bd 100644 --- a/apps/editor_gui.cpp +++ b/apps/editor_gui.cpp @@ -3,6 +3,9 @@ #include #include #include +#ifdef FASET_HAS_DEBUG_OVERLAY +#include +#endif #include #define STB_IMAGE_WRITE_IMPLEMENTATION #include @@ -54,6 +57,22 @@ int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames, const auto font = session.config().engine_root / "assets/fonts/NotoSans.ttf"; const auto theme = session.config().engine_root / "assets/ui/dark.json"; EditorUI ui(session, renderer, font, theme); +#ifdef FASET_HAS_DEBUG_OVERLAY + DebugOverlay diagnostics; + auto previous_frame = std::chrono::steady_clock::now(); +#endif + const auto draw_frame = [&] { +#ifdef FASET_HAS_DEBUG_OVERLAY + const auto now = std::chrono::steady_clock::now(); + const auto delta = std::chrono::duration(now - previous_frame).count(); + previous_frame = now; + auto snapshot = ui.snapshot(); + diagnostics.append(snapshot, renderer, delta); + renderer.render(snapshot); +#else + renderer.render(ui.snapshot()); +#endif + }; ui.set_project_switch_enabled(!enable_mcp); McpServer server(session.commands()); StdioTransport transport; @@ -66,7 +85,7 @@ int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames, [&](const Json& arguments) { session.poll(); ui.frame({}); - renderer.render(ui.snapshot()); + draw_frame(); auto region = arguments.value("viewport_only", true) ? ui.snapshot().scene_rect @@ -101,8 +120,12 @@ int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames, } } session.poll(); - ui.frame(renderer.poll_events()); - renderer.render(ui.snapshot()); + auto events = renderer.poll_events(); +#ifdef FASET_HAS_DEBUG_OVERLAY + events = diagnostics.process_events(events); +#endif + ui.frame(events); + draw_frame(); if (ui.project_switch_requested()) return 3; // Application-level request: destroy this Session before opening another. ++frame; diff --git a/cmake/BuildService.cmake b/cmake/BuildService.cmake index 36525ce..a7d4102 100644 --- a/cmake/BuildService.cmake +++ b/cmake/BuildService.cmake @@ -1,7 +1,7 @@ add_library(faset_build_service STATIC ${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp) target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include) target_compile_features(faset_build_service PUBLIC cxx_std_20) -target_link_libraries(faset_build_service PUBLIC faset_core PRIVATE faset_asset_data faset_authoring Threads::Threads) +target_link_libraries(faset_build_service PUBLIC faset_core PRIVATE faset_assets faset_authoring Threads::Threads) if(BUILD_TESTING) add_executable(faset_build_service_tests ${PROJECT_SOURCE_DIR}/tests/build_service_tests.cpp) target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets) @@ -10,7 +10,7 @@ if(BUILD_TESTING) add_executable(faset_build_schema_tool ${PROJECT_SOURCE_DIR}/tests/build_schema_tool.cpp) target_link_libraries(faset_build_schema_tool PRIVATE faset_core) add_executable(faset_build_schema_tests ${PROJECT_SOURCE_DIR}/tests/build_schema_tests.cpp) - target_link_libraries(faset_build_schema_tests PRIVATE faset_build_service faset_authoring) + target_link_libraries(faset_build_schema_tests PRIVATE faset_build_service faset_authoring faset_editor_commands) add_dependencies(faset_build_schema_tests faset_build_schema_tool) add_test(NAME build_schema_publication COMMAND faset_build_schema_tests $ ${PROJECT_SOURCE_DIR}) set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 60) diff --git a/cmake/Diagnostics.cmake b/cmake/Diagnostics.cmake new file mode 100644 index 0000000..5b44f0a --- /dev/null +++ b/cmake/Diagnostics.cmake @@ -0,0 +1,15 @@ +if(FASET_DEBUG_IMGUI AND TARGET faset_imgui AND TARGET faset_render) + add_library(faset_debug_overlay STATIC ${PROJECT_SOURCE_DIR}/src/editor/debug_overlay.cpp) + target_include_directories(faset_debug_overlay PUBLIC ${PROJECT_SOURCE_DIR}/include) + target_link_libraries(faset_debug_overlay PUBLIC faset_render PRIVATE faset_imgui) + if(TARGET faset_editor AND TARGET faset_editor_ui) + target_link_libraries(faset_editor PRIVATE faset_debug_overlay) + target_compile_definitions(faset_editor PRIVATE FASET_HAS_DEBUG_OVERLAY=1) + endif() + if(BUILD_TESTING) + add_executable(faset_debug_overlay_tests ${PROJECT_SOURCE_DIR}/tests/editor_debug_overlay.cpp) + target_link_libraries(faset_debug_overlay_tests PRIVATE faset_debug_overlay) + add_test(NAME editor_debug_overlay COMMAND faset_debug_overlay_tests) + set_tests_properties(editor_debug_overlay PROPERTIES LABELS "gpu") + endif() +endif() diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 0446dcd..b703138 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -228,3 +228,36 @@ the Assets panel, scrolling keyboard focus into view, delivery of explicit gamep schema migrations, GPU pass labels, and wiring optional ImGui diagnostics. These remain under implementation and verification; the research map now links current implementation evidence instead of claiming the engine has not been started. + +## Checkpoint 5 — close the final authoring and diagnostics gaps + +- Asset freshness compares source, bundle payload, external buffer/image, recipe, + importer and profile contents without publishing a generation. GUI and MCP expose + the same state/reasons; selecting a stale imported row prepares Reimport. Cook and + export reject stale referenced sources while keeping the previous successful result. +- Gameplay schemas carry validated declarative migration steps. Inspector and MCP + apply `component.migrate` as an explicit revision-checked Undo transaction. Opening + old data remains possible without rules. Tests cover local components, instance-local + additions, opening inherited sources, missing/manual rules and overflow rollback. + The Manual explains the sparse-override limitation when field units change. +- Keyboard focus scrolls long and nested Inspector/Assets lists into view at 1×/2×, + preserves unfinished text and keeps invalid numeric edits visible. Template preview + and conflicts are also invalidated when schema metadata changes without a scene edit. +- Vulkan passes emit optional debug-utils labels. An optional `FASET_DEBUG_IMGUI=ON` + Editor module shows real renderer diagnostics via F12; the Player remains independent. + GPU tests cover textured/clipped ImGui geometry and event ownership for gestures + crossing the panel in either direction. Offscreen clipboard operations are local + to that renderer and never touch the desktop clipboard. +- The Windows launcher fixture compares canonical filesystem identities, including + hosted-runner short TEMP aliases, and now distinguishes selection/path failures. + +Integrated Linux with optional diagnostics enabled: **34 passed, 1 skipped, 0 failed** +of 35 tests. The skip remains native Wayland programmatic restore; XWayland passed. +ASan/UBSan passed **18/18**, including process cleanup, metadata publication and +migration transactions. Strict MkDocs and local Markdown file-link checks passed. + +Windows run `35299805623` at `e0b9651` passed all **34** tests in its CPU/GPU/UI suite, +including the launcher fix, and proceeded to real native Release exports. That run +predates checkpoint 5's new authoring/diagnostic changes, whose Windows checks remain +separate. The next full Windows build enables the optional diagnostic module too. +No MVP tag is claimed at this checkpoint. diff --git a/docs/manual/editor/assets.md b/docs/manual/editor/assets.md index ab43927..80931e3 100644 --- a/docs/manual/editor/assets.md +++ b/docs/manual/editor/assets.md @@ -26,6 +26,18 @@ With MCP, call `faset_import`, then query `faset_job` using the returned job ID. `faset_job_cancel` requests cancellation. Cancelling an import does not Undo an authoring edit. See [MCP and CLI](mcp.md) for transport setup and errors. +The Assets panel marks an imported asset **Stale** when its source, external glTF +buffer/image, Blender bundle, saved import settings, importer, or target profile +differs from the active generation. Select the imported row, then **Import / Reimport**; +the Console reports the changed input. **Refresh** checks immediately, and the panel +also refreshes periodically. `faset_assets` exposes the same `freshness.state` and +structured `freshness.reasons` to MCP clients. Checks compare contents, including +files whose size and timestamp stayed unchanged. The last good cooked asset remains +visible until reimport succeeds; checking freshness never publishes a generation. +Cook and export refuse referenced stale or unavailable sources and explain which +input needs reimport. A standalone exported game uses only its packaged cooked +generation and never needs the original source files. + ## Import an image PNG and JPEG become image assets. Drag an imported image into a scene to create a diff --git a/docs/manual/editor/diagnostics.md b/docs/manual/editor/diagnostics.md new file mode 100644 index 0000000..59d4c15 --- /dev/null +++ b/docs/manual/editor/diagnostics.md @@ -0,0 +1,19 @@ +# Developer diagnostics + +The optional Dear ImGui overlay shows renderer counters inside the Editor. The Editor's normal interface remains the retained Faset UI. Enable the diagnostic build explicitly: + +```sh +cmake --preset linux-debug -DFASET_DEBUG_IMGUI=ON +cmake --build --preset linux-debug --parallel 4 +build/linux-debug/faset_editor --project examples/projects/collect-3d --gui +``` + +On Windows, use `windows-debug` for both presets and `build/windows-debug/faset_editor.exe`. Press **F12** to show or hide the panel. Drag its title bar to move it; **Freeze counters** holds a completed-frame sample for inspection. Closing the panel does not stop rendering. Pointer gestures inside the overlay are kept out of the authoring UI. + +The panel reports the previous completed frame: renderer wall time, GPU timestamp time where available, synchronous readback time, draw calls, packed vertices, culled meshes, textures, explicit Vulkan allocation sizes, actual validation availability/errors, and GPU pass-label count. Renderer wall time includes waiting for GPU work; it is not thread CPU usage. Memory excludes driver-internal allocations. The overlay itself adds drawing work, so hide it for a baseline performance measurement. + +The Vulkan backend emits `VK_EXT_debug_utils` labels for `ShadowMap`, `ForwardAndUI`, `Readback`, and, when presenting, `Presentation`. A graphics capture tool that supports this extension can identify those command-buffer regions. Labels remain available without the Khronos validation layer when the extension is exposed; unsupported systems continue rendering and report labels unavailable. A submitted-label count confirms calls were emitted, not that an external capture tool was tested. + +This module is disabled by default and is linked only to the graphical Editor and its dedicated test when enabled. Player and exported games do not link ImGui. No overlay control changes authoring documents, gameplay state or export settings. + +Run `ctest --test-dir build/linux-debug -R '^editor_debug_overlay$' --output-on-failure` in an enabled build to check actual ImGui geometry/font rendering, F12 toggling, pointer isolation and restoration of the underlying frame. The regular renderer pixel test also verifies GPU labels and clipped UI triangles. diff --git a/docs/manual/editor/workspace.md b/docs/manual/editor/workspace.md index bde1f97..0cf1c22 100644 --- a/docs/manual/editor/workspace.md +++ b/docs/manual/editor/workspace.md @@ -103,9 +103,17 @@ check **Jobs** and **Console**. A successful build and schema export refresh the Inspector. A failed build retains the previous metadata and reports the failure. A missing schema or unsupported component version appears as read-only raw fields -with **Copy raw fields**. The Editor preserves that data. Restore the matching module -or provide a migration and rebuild before expecting normal field editing or Play. -See [Build, Play, and export](export.md) for the C++ iteration loop. +with **Copy raw fields**. Restore a missing module to make its schema available. +For an older component, declare [data migration rules](../scripting/api.md#editor-data-migrations), +choose **Build C++**, then **Migrate to v…** in the Inspector. This is one undoable +authoring edit; save explicitly afterward. Inherited components show **Open source +to migrate** instead. Top-level local additions migrate in their owning instance. +Missing rules or conversion errors preserve the data and appear in Console. + +Opening or recovering a scene never migrates it automatically. Future versions +stay opaque and cannot be downgraded. Review instance overrides separately when +changing the source field's units or meaning. See [Build, Play, and export](export.md) +for the C++ iteration loop. ## Project settings diff --git a/docs/manual/getting-started/build.md b/docs/manual/getting-started/build.md index 49afce8..40c5b9f 100644 --- a/docs/manual/getting-started/build.md +++ b/docs/manual/getting-started/build.md @@ -6,7 +6,7 @@ ## Linux prerequisites -The selected toolchain is C++20, CMake 3.25 or later, Ninja, and Clang. +The selected toolchain is C++20, CMake 3.25 or later, Ninja, Clang, and Python 3.12+. Graphical builds need Vulkan 1.3 headers/loader and a compatible driver. SDL3, FreeType and HarfBuzz are built from pinned source archives. @@ -64,8 +64,10 @@ disconnecting. Prefetching source archives alone is not a complete offline SDK. ## Windows prerequisites Use an x64 Visual Studio Developer shell with the Windows SDK, MSVC runtime libraries, -LLVM `clang-cl`, Ninja, CMake, and the Vulkan SDK available. Then use the -`windows-debug` or `windows-release` presets. +LLVM `clang-cl`, Ninja, CMake 3.25+, Python 3.12+, and the Vulkan SDK available. +The commands below use the Python `py` launcher; substitute `python` if your +installation exposes that command instead. Use the `windows-debug` or +`windows-release` presets. Enable **Win32 long paths** on the Windows development machine before starting the build shell. Faset's executable manifest declares long-path support, and its direct diff --git a/docs/manual/scripting/api.md b/docs/manual/scripting/api.md index 4456316..33916c4 100644 --- a/docs/manual/scripting/api.md +++ b/docs/manual/scripting/api.md @@ -76,6 +76,82 @@ automatic migrations or authoring-style custom-field constraint validation. `snapshot()` returns a value snapshot for rendering; `snapshotJson()` provides its JSON representation. `diagnostics()` returns a read-only vector of runtime messages. These are native C++ APIs for the Player and tests, **not MCP endpoints**. +## Editor data migrations + +Gameplay schema versions describe saved component data. When a field changes units +or meaning, increase the type's `version` and include declarative `migrations` in +that type returned by `gameplay::schema()`. For example, this type declaration +converts version 1 speed values from centimetres per second to metres per second: + +```json +{ + "id": "game.mover", + "version": 2, + "fields": { + "speed": {"type": "number", "default": 2.5, "min": 0, "max": 10}, + "enabled": {"type": "boolean", "default": true} + }, + "migrations": [ + { + "from_version": 1, + "fields": { + "speed": {"scale": 0.01}, + "enabled": {"default": true} + } + } + ] +} +``` + +Build C++ to export and validate the declaration. The Editor reads these rules +from the same schema manifest as field metadata; it does not load the gameplay +library or execute a migration callback. Invalid metadata or migration rules fail +the build before replacing the last published binary/schema generation. + +Each step upgrades `from_version` to the next integer version. A component at +version 1 needs both steps 1 and 2 to reach version 3. Empty `fields` explicitly +allows a version step with no value conversion. Supported field operations are: + +- `default`: insert a value only when the field is absent. +- `scale`: multiply an existing numeric field by a finite number. +- `require_manual`: when `true`, stop if the field is present, so incompatible data + requires an explicit manual conversion. + +Rules preserve component/entity IDs and fields they do not mention. The final +values must satisfy the current field schema. Unsupported operations, repeated +steps, invalid version ranges, invalid rules and non-finite scale values are +rejected when the schema is loaded. Arithmetic overflow during conversion also +fails without applying the transaction. + +Opening or recovering a scene preserves older component versions as opaque data; +it never migrates them automatically. Missing rules therefore do not prevent +opening the scene. Choose **Migrate to v…** in the Inspector after rebuilding the +schema, or use the same editor's `faset_scene_edit` operation with the current +document revision: + +```json +{ + "document": "document-id", + "revision": 3, + "operations": [ + {"op": "component.migrate", "entity": "entity-id", "component": "component-id"} + ] +} +``` + +The whole batch is one Undo step and is written to the recovery journal. Save +explicitly to update the scene file. Missing steps, a manual-conversion requirement +or a validation error leave the document and revision unchanged. Future versions +cannot be downgraded. The same operation accepts an `instance` ID when `entity` +and `component` identify a top-level instance-local addition using its original +stored IDs, rather than resolved preview IDs. + +Inherited components belong to their source document: open that source to migrate +them. Sparse field overrides in other instances are not automatically converted; +review and explicitly update overrides when changing a field's units or meaning. +The Player performs no migration and still requires the scene version to match its +linked gameplay schema before Play or exported-game validation. + ## Inspect a running Player locally The Player has local development controls in addition to gameplay input: **P** toggles diff --git a/docs/manual/scripting/first-behavior.md b/docs/manual/scripting/first-behavior.md index aecd996..019ac4a 100644 --- a/docs/manual/scripting/first-behavior.md +++ b/docs/manual/scripting/first-behavior.md @@ -44,7 +44,7 @@ Read the callback from top to bottom: The `[](...) { ... }` expression is a C++ lambda: a function stored in `Behavior::update`. Empty brackets mean it captures no local variables. `registerBehavior` takes ownership of the callback object. Register before calling `load`; registration while entities exist or a callback is running is rejected. -The `schema()` function describes editable configuration. It does not create a runtime object. `tutorial.move_x` is the stable `TypeId`; `speed` is a stable `FieldId` within that type. Keep these IDs when changing a display label. Changing a field's meaning or units needs an explicit data migration, not just a new label. +The `schema()` function describes editable configuration. It does not create a runtime object. `tutorial.move_x` is the stable `TypeId`; `speed` is a stable `FieldId` within that type. Keep these IDs when changing a display label. Changing a field's meaning or units needs an [explicit data migration](api.md#editor-data-migrations), not just a new label. ## Attach the behavior diff --git a/include/faset/assets/asset_pipeline.hpp b/include/faset/assets/asset_pipeline.hpp index fe310a2..348cdbb 100644 --- a/include/faset/assets/asset_pipeline.hpp +++ b/include/faset/assets/asset_pipeline.hpp @@ -66,6 +66,9 @@ class AssetPipeline : public AssetStore { explicit AssetPipeline(std::filesystem::path cache_root); ImportResult import_asset(const ImportRequest& request, ImportJob& job); ImportResult import_asset(const ImportRequest& request); + // Inspect source/recipe/dependency hashes without importing or changing the + // active generation. Only editor tools need source freshness; Player does not. + Json freshness(const std::string& asset_id) const; // Overrides are authoring data beside the source, never generated cache contents. Json overrides(const std::string& asset_id) const; void set_overrides(const std::string& asset_id, const Json& overrides); diff --git a/include/faset/editor/debug_overlay.hpp b/include/faset/editor/debug_overlay.hpp new file mode 100644 index 0000000..1df15a6 --- /dev/null +++ b/include/faset/editor/debug_overlay.hpp @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include + +namespace faset::editor { +// Optional developer diagnostics. This module never edits authoring or runtime state. +class DebugOverlay { + public: + DebugOverlay(); + ~DebugOverlay(); + DebugOverlay(const DebugOverlay&) = delete; + DebugOverlay& operator=(const DebugOverlay&) = delete; + bool visible() const; + void set_visible(bool); + // F12 toggles the overlay; events captured by its widgets are removed for this frame. + std::vector process_events(std::span); + void append(render::Snapshot&, const render::Renderer&, float delta_seconds); + + private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace faset::editor diff --git a/include/faset/render/renderer.hpp b/include/faset/render/renderer.hpp index bd03d44..56029ca 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -62,6 +62,12 @@ struct Quad { std::shared_ptr texture{}; std::array uv_rect{0, 0, 1, 1}; }; +struct UiTriangles { + // Non-indexed triangle list in drawable pixels, rendered after UI quads/text. + std::vector vertices; + std::shared_ptr texture{}; + std::array clip_rect{}; // x/y/width/height; zero size uses the full target +}; struct Text { float x{}, y{}; std::string value; @@ -81,6 +87,7 @@ struct Snapshot { // UI coordinates are drawable pixels, top-left origin. Order is preserved per list. std::vector ui_quads; std::vector ui_text; + std::vector ui_triangles; }; // CPU-only validation used before publishing a game or creating Vulkan pipelines. void validate_shader_bundle(const std::filesystem::path& directory); @@ -119,6 +126,8 @@ struct Event { struct FrameStats { std::uint64_t frame{}; bool validation_enabled{}; + bool gpu_labels_enabled{}; + std::uint32_t gpu_label_count{}; // Live VkDeviceMemory allocation sizes, including alignment; excludes driver internals. std::uint64_t gpu_allocated_bytes{}; std::uint32_t texture_count{}; diff --git a/include/faset/ui/ui.hpp b/include/faset/ui/ui.hpp index 9f75617..646911f 100644 --- a/include/faset/ui/ui.hpp +++ b/include/faset/ui/ui.hpp @@ -170,6 +170,8 @@ class Context { void draw(render::Snapshot&); bool update_text(const std::string& id, const std::string& value, bool force = false); bool update_number(const std::string& id, double value, bool force = false); + // Reveal the target in enclosing vertical scrollers without changing text + // edits; a previous field must commit successfully before focus moves. bool focus(const std::string& id); const std::string& focused_id() const; void clear_focus(bool commit = true); diff --git a/mkdocs.yml b/mkdocs.yml index 72f359a..629fb85 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Assets and Blender: editor/assets.md - Build, Play, and export: editor/export.md - Profiling and measurements: editor/profiling.md + - Optional developer diagnostics: editor/diagnostics.md - MCP and command line: editor/mcp.md - Native extensions: editor/extensions.md - Contributing to this manual: contributing.md diff --git a/src/assets/asset_pipeline.cpp b/src/assets/asset_pipeline.cpp index f0e551a..02476c9 100644 --- a/src/assets/asset_pipeline.cpp +++ b/src/assets/asset_pipeline.cpp @@ -844,6 +844,76 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob } return result; } +Json AssetPipeline::freshness(const std::string& id) const { + Json result{{"state", "current"}, {"reasons", Json::array()}}; + auto stale = [&](const std::string& code, const fs::path& path, const std::string& message) { + result["state"] = "stale"; + result["reasons"].push_back( + {{"code", code}, {"path", faset::path_to_utf8(path)}, {"message", message}}); + }; + try { + const auto manifest = current_manifest(id); + result["generation"] = manifest.at("generation"); + const auto source = faset::path_from_utf8(manifest.at("source").get()); + const auto payload = + faset::path_from_utf8(manifest.at("payload_source").get()); + const auto& key = manifest.at("input_key"); + auto check_file = [&](const fs::path& path, const std::string& digest, + const std::string& code) { + try { + if (faset::sha256_file(path) != digest) + stale(code, path, "Input changed; reimport to update the active generation"); + } catch (const std::exception& error) { + stale("input.unavailable", path, error.what()); + } + }; + check_file(payload, manifest.at("source_sha256"), "source.changed"); + if (key.contains("bundle_sha256")) { + check_file(source, key.at("bundle_sha256"), "bundle.changed"); + // The bundle can declare more than its selected GLB. Validate every + // declared payload, as import does, even when the manifest is unchanged. + try { + const auto bundle = read_json(source); + for (const auto& file : bundle.at("files")) { + const auto relative = faset::path_from_utf8(file.at("path").get()); + if (relative.is_absolute() || + faset::generic_path_to_utf8(relative).find("..") != std::string::npos) + throw std::runtime_error("Invalid bundle payload path"); + check_file(source.parent_path() / relative, file.at("sha256"), + "bundle.payload_changed"); + } + } catch (const std::exception& error) { + stale("bundle.unavailable", source, error.what()); + } + } + for (const auto& [uri, digest] : key.at("dependencies").items()) + check_file(external_path(payload, uri), digest, "dependency.changed"); + + const auto sidecar = + faset::path_from_utf8(faset::path_to_utf8(source) + ".faset-import.json"); + try { + const auto metadata = read_json(sidecar); + if (metadata.value("schema_version", 0) != 1 || metadata.value("asset_id", "") != id || + metadata.value("settings", Json::object()) != manifest.at("settings")) + stale("settings.changed", sidecar, + "Import identity or recipe changed; reimport required"); + } catch (const std::exception& error) { + stale("settings.unavailable", sidecar, error.what()); + } + const bool image = manifest.value("kind", "scene") == "image"; + const std::string recipe = image ? "faset-image-1/stb-2.30" : importer_version; + const std::string profile = image ? "desktop-image-v1" : "desktop-static-pbr-v1"; + const Json toolchain{{"cgltf", FASET_CGLTF_COMMIT}, {"stb", FASET_STB_COMMIT}}; + if (key.at("importer") != recipe || key.at("target_profile") != profile || + key.at("toolchain") != toolchain) + stale("importer.changed", source, + "Importer, toolchain or target profile changed; reimport required"); + } catch (const std::exception& error) { + result["state"] = "unavailable"; + result["reasons"].push_back({{"code", "manifest.unavailable"}, {"message", error.what()}}); + } + return result; +} Json AssetPipeline::overrides(const std::string& id) const { const auto source = current_manifest(id).at("source").get(); const auto path = faset::path_from_utf8(source + ".faset-overrides.json"); diff --git a/src/authoring/schema.cpp b/src/authoring/schema.cpp index 38b5ea6..aa42d60 100644 --- a/src/authoring/schema.cpp +++ b/src/authoring/schema.cpp @@ -100,6 +100,25 @@ SchemaRegistry gameplay_schemas(const Json& manifest) { "Gameplay schema duplicates a builtin or gameplay TypeId: " + id); result.register_schema(schema); } + for (const auto& schema : types) { + if (!schema.contains("migrations")) + continue; + require(schema.at("migrations").is_array(), "migration.invalid", + "Migrations must be an array of version steps"); + for (const auto& step : schema.at("migrations")) { + require( + step.is_object() && step.contains("from_version") && + step.at("from_version").is_number_integer() && step.at("from_version") > 0 && + step.at("from_version") < schema.value("version", 1) && + step.contains("fields") && step.at("fields").is_object(), + "migration.invalid", "Migration requires a supported earlier version and fields"); + for (const auto& [key, unused] : step.items()) + require(key == "from_version" || key == "fields", "migration.invalid", + "Unsupported migration step property: " + key); + result.add_migration(schema.at("id"), step.at("from_version").get(), + step.at("fields")); + } + } return result; } bool SchemaRegistry::contains(const std::string& type) const { @@ -139,9 +158,25 @@ void SchemaRegistry::validate_component(const Json& component) const { validate_field(value, metadata["fields"][id]); } void SchemaRegistry::add_migration(const std::string& type, int from_version, Json rules) { - require(from_version > 0 && rules.is_object(), "migration.invalid", "Invalid migration"); + require(contains(type) && from_version > 0 && from_version < schema(type).value("version", 1) && + rules.is_object(), + "migration.invalid", "Migration requires a registered type and an earlier version"); require(!migrations_.contains({type, from_version}), "migration.duplicate", "Migration already exists"); + for (const auto& [field, rule] : rules.items()) { + require(!field.empty() && rule.is_object(), "migration.invalid", + "Migration fields require nonempty IDs and rule objects"); + for (const auto& [operation, value] : rule.items()) { + require(operation == "default" || operation == "scale" || operation == "require_manual", + "migration.invalid", "Unsupported migration operation: " + operation); + if (operation == "scale") + require(value.is_number() && std::isfinite(value.get()), + "migration.invalid", "Migration scale must be a finite number"); + else if (operation == "require_manual") + require(value.is_boolean(), "migration.invalid", + "Migration require_manual must be a boolean"); + } + } migrations_[{type, from_version}] = std::move(rules); } Json SchemaRegistry::migrate_component(const Json& source) const { @@ -150,6 +185,10 @@ Json SchemaRegistry::migrate_component(const Json& source) const { if (!contains(type)) return result; const auto current = schema(type).value("version", 1); + if (result.contains("version")) + require(result.at("version").is_number_integer() && result.at("version") > 0 && + result.at("version") <= std::numeric_limits::max(), + "migration.invalid", "Component version must be a positive supported integer"); auto version = result.value("version", 1); if (version > current) return result; @@ -163,8 +202,11 @@ Json SchemaRegistry::migrate_component(const Json& source) const { if (rule.contains("scale") && result["fields"].contains(field)) { require(result["fields"][field].is_number(), "migration.type", "Cannot scale a nonnumeric field"); - result["fields"][field] = + const auto scaled = result["fields"][field].get() * rule["scale"].get(); + require(std::isfinite(scaled), "migration.nonfinite", + "Migration produced a non-finite field value"); + result["fields"][field] = scaled; } if (rule.value("require_manual", false) && result["fields"].contains(field)) throw Error("migration.manual", "Field requires explicit manual migration", diff --git a/src/authoring/service.cpp b/src/authoring/service.cpp index bcfee46..e099f23 100644 --- a/src/authoring/service.cpp +++ b/src/authoring/service.cpp @@ -288,9 +288,6 @@ Json AuthoringService::open(const std::filesystem::path& relative, bool recover) value.data = recovered.at("scene"); value.revision = recovered.value("revision", 0u); } - for (auto& item : value.data["entities"]) - for (auto& component : item["components"]) - component = schemas_.migrate_component(component); documents_.emplace(id, std::move(value)); return summary(state(id)); } @@ -375,6 +372,24 @@ void AuthoringService::apply(Json& scene, const Json& command) { [&](const Json& value) { return value.at("id") == id; }); require(found != values.end(), "component.missing", "Component does not exist"); values.erase(found); + } else if (op == "component.migrate") { + Json* object = nullptr; + if (command.contains("instance")) { + const auto instance_id = command.at("instance").get(); + for (auto& instance : scene.at("instances")) + if (instance.at("id") == instance_id && instance.contains("additions")) + for (auto& addition : instance.at("additions")) + if (addition.at("id") == command.at("entity")) + object = &addition; + require(object, "template.addition_missing", "Instance-local entity is unavailable"); + } else + object = &entity(scene, command.at("entity").get()); + auto& value = component(*object, command.at("component").get()); + const auto type = value.at("type").get(); + require(schemas_.contains(type), "schema.missing", "Component schema unavailable: " + type); + require(value.value("version", 1) <= schemas_.schema(type).value("version", 1), + "migration.future", "Cannot migrate a component from a newer schema"); + value = schemas_.migrate_component(value); } else if (op == "component.set") { auto& value = component(entity(scene, command.at("entity").get()), command.at("component").get()); @@ -601,9 +616,6 @@ Json AuthoringService::recover(const std::string& id, "recovery.disk_conflict", "Scene file changed or disappeared since recovery was written"); } - for (auto& item : candidate.data["entities"]) - for (auto& component : item["components"]) - component = schemas_.migrate_component(component); validate_scene(candidate.data, schemas_); if (documents_.contains(id)) { const auto& current = state(id); diff --git a/src/authoring/templates.cpp b/src/authoring/templates.cpp index a2d9250..c5f9efb 100644 --- a/src/authoring/templates.cpp +++ b/src/authoring/templates.cpp @@ -17,7 +17,7 @@ struct Resolver { const SceneLoader& loader; std::string root; Json conflicts = Json::array(); - std::set sources; + std::set sources{}; void conflict(const Json& path, std::string code, const Json& record) { conflicts.push_back( {{"instance_path", path}, {"code", std::move(code)}, {"record", record}}); diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index d599029..5975ba5 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -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; diff --git a/src/editor/commands.cpp b/src/editor/commands.cpp index 57e14bb..b7fcf0b 100644 --- a/src/editor/commands.cpp +++ b/src/editor/commands.cpp @@ -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 " diff --git a/src/editor/debug_overlay.cpp b/src/editor/debug_overlay.cpp new file mode 100644 index 0000000..4c2fe4a --- /dev/null +++ b/src/editor/debug_overlay.cpp @@ -0,0 +1,265 @@ +#include +#include +#include +#include +#include +#include + +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_A + name[0] - 'A'); + if (name.size() == 1 && name[0] >= '0' && name[0] <= '9') + return static_cast(ImGuiKey_0 + name[0] - '0'); + const std::pair 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 pointer{-1, -1}; + float scale{}; + std::array window_rect{}; + render::FrameStats displayed; + std::shared_ptr 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(); + 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()) {} +DebugOverlay::~DebugOverlay() = default; +bool DebugOverlay::visible() const { + return impl_->visible; +} +void DebugOverlay::set_visible(bool value) { + impl_->visible = value; +} +std::vector DebugOverlay::process_events(std::span events) { + CurrentContext current(impl_->context); + auto& io = ImGui::GetIO(); + std::vector 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(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 diff --git a/src/editor/editor_ui.cpp b/src/editor/editor_ui.cpp index 8d18a89..7186cb4 100644 --- a/src/editor/editor_ui.cpp +++ b/src/editor/editor_ui.cpp @@ -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() || 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()), + 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); } diff --git a/src/editor/session.cpp b/src/editor/session.cpp index 71c3fe6..2ce25bc 100644 --- a/src/editor/session.cpp +++ b/src/editor/session.cpp @@ -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", diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 1bfa522..f5e72e9 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -55,12 +55,14 @@ struct Image { struct Batch { std::uint32_t first{}, count{}; const Texture* texture{}; + std::array clip_rect{}; }; constexpr std::uint32_t shadow_size = 1024; } // namespace struct Renderer::Impl { RendererConfig config; SDL_Window* window{}; + std::string offscreen_clipboard; bool sdl{}, close{}, dirty_swapchain{}; std::uint32_t width{}, height{}; VkInstance instance{}; @@ -90,6 +92,8 @@ struct Renderer::Impl { VkSampler shadow_sampler{}, color_sampler{}; VkPipelineLayout pipeline_layout{}; VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}, sprite_pipeline{}; + PFN_vkCmdBeginDebugUtilsLabelEXT begin_gpu_label{}; + PFN_vkCmdEndDebugUtilsLabelEXT end_gpu_label{}; struct GpuTexture { Image image; VkDescriptorSet descriptor{}; @@ -359,13 +363,24 @@ struct Renderer::Impl { check(vkEnumerateInstanceLayerProperties(&count, nullptr), "Enumerate layers"); std::vector layers(count); check(vkEnumerateInstanceLayerProperties(&count, layers.data()), "Enumerate layers"); - bool validation = c.validation && std::any_of(layers.begin(), layers.end(), [](auto& p) { - return std::strcmp(p.layerName, "VK_LAYER_KHRONOS_validation") == 0; - }); + check(vkEnumerateInstanceExtensionProperties(nullptr, &count, nullptr), + "Enumerate instance extensions"); + std::vector instance_extensions(count); + check(vkEnumerateInstanceExtensionProperties(nullptr, &count, instance_extensions.data()), + "Enumerate instance extensions"); + const bool debug_utils = + std::any_of(instance_extensions.begin(), instance_extensions.end(), [](const auto& p) { + return std::strcmp(p.extensionName, VK_EXT_DEBUG_UTILS_EXTENSION_NAME) == 0; + }); + bool validation = + c.validation && debug_utils && std::any_of(layers.begin(), layers.end(), [](auto& p) { + return std::strcmp(p.layerName, "VK_LAYER_KHRONOS_validation") == 0; + }); statistics.validation_enabled = validation; if (c.validation && !validation) - std::cerr << "[Faset] Vulkan validation layer not installed; diagnostics disabled.\n"; - if (validation) + std::cerr << "[Faset] Vulkan validation layer/debug-utils unavailable; validation " + "disabled.\n"; + if (debug_utils) extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME); VkApplicationInfo app{}; app.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; @@ -473,6 +488,13 @@ struct Renderer::Impl { } check(vkCreateDevice(physical, &di, nullptr, &device), "Create Vulkan device"); vkGetDeviceQueue(device, queue_family, 0, &queue); + if (debug_utils) { + begin_gpu_label = reinterpret_cast( + vkGetDeviceProcAddr(device, "vkCmdBeginDebugUtilsLabelEXT")); + end_gpu_label = reinterpret_cast( + vkGetDeviceProcAddr(device, "vkCmdEndDebugUtilsLabelEXT")); + } + statistics.gpu_labels_enabled = begin_gpu_label && end_gpu_label; VkCommandPoolCreateInfo pi{}; pi.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; pi.queueFamilyIndex = queue_family; @@ -1000,7 +1022,7 @@ struct Renderer::Impl { } void render(const Snapshot& snapshot) { auto start = std::chrono::steady_clock::now(); - statistics.draw_calls = statistics.culled_meshes = 0; + statistics.draw_calls = statistics.culled_meshes = statistics.gpu_label_count = 0; bool can_present = surface != VK_NULL_HANDLE; if (surface) { // A capture may render between normal event-loop iterations. Keep the window @@ -1033,6 +1055,9 @@ struct Renderer::Impl { for (const auto& sprite : snapshot.sprites) if (sprite.texture) upload_texture(sprite.texture); + for (const auto& triangles : snapshot.ui_triangles) + if (triangles.texture) + upload_texture(triangles.texture); std::vector data; std::vector scene_batches, shadow_batches, sprite_batches, ui_batches; for (const auto& item : snapshot.draws) { @@ -1123,6 +1148,24 @@ struct Renderer::Impl { if (data.size() > text_first) ui_batches.push_back( {text_first, static_cast(data.size() - text_first), white.get()}); + for (const auto& triangles : snapshot.ui_triangles) { + if (triangles.vertices.size() % 3 != 0) + throw std::invalid_argument("UI triangle list must contain complete triangles"); + const auto first = static_cast(data.size()); + for (const auto& source : triangles.vertices) { + GpuVertex vertex{}; + vertex.clip[0] = source.position[0] / float(width) * 2 - 1; + vertex.clip[1] = source.position[1] / float(height) * 2 - 1; + vertex.clip[3] = 1; + std::copy(source.color.begin(), source.color.end(), vertex.color); + std::copy(source.uv.begin(), source.uv.end(), vertex.uv); + vertex.material[0] = triangles.texture && triangles.texture->srgb ? 1.f : 0.f; + data.push_back(vertex); + } + ui_batches.push_back({first, static_cast(triangles.vertices.size()), + triangles.texture ? triangles.texture.get() : white.get(), + triangles.clip_rect}); + } statistics.vertices = static_cast(data.size()); auto byte_count = std::max(sizeof(GpuVertex), data.size() * sizeof(GpuVertex)); if (vertices.size < byte_count) { @@ -1185,7 +1228,33 @@ struct Renderer::Impl { vkCmdSetScissor(command, 0, 1, &scissor); }; RenderGraph graph; - graph.add("ShadowMap", {}, {"shadow"}, [&] { + auto add_pass = [&](std::string name, std::vector reads, + std::vector writes, RenderGraph::Callback callback) { + const auto label_name = name; + graph.add(std::move(name), std::move(reads), std::move(writes), + [this, label_name, callback = std::move(callback)] { + if (statistics.gpu_labels_enabled) { + VkDebugUtilsLabelEXT label{}; + label.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_LABEL_EXT; + label.pLabelName = label_name.c_str(); + label.color[0] = .25f; + label.color[1] = .65f; + label.color[2] = .9f; + label.color[3] = 1.f; + begin_gpu_label(command, &label); + ++statistics.gpu_label_count; + } + struct EndLabel { + Impl& renderer; + ~EndLabel() { + if (renderer.statistics.gpu_labels_enabled) + renderer.end_gpu_label(renderer.command); + } + } end{*this}; + callback(); + }); + }; + add_pass("ShadowMap", {}, {"shadow"}, [&] { transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, VK_IMAGE_ASPECT_DEPTH_BIT); VkRenderingAttachmentInfo attachment{}; @@ -1214,7 +1283,7 @@ struct Renderer::Impl { transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL, VK_IMAGE_ASPECT_DEPTH_BIT); }); - graph.add("ForwardAndUI", {"shadow"}, {"color", "depth"}, [&] { + add_pass("ForwardAndUI", {"shadow"}, {"color", "depth"}, [&] { transition(command, color, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_ASPECT_COLOR_BIT); transition(command, depth, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, @@ -1281,6 +1350,20 @@ struct Renderer::Impl { VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(push), &push); for (auto batch : ui_batches) { + VkRect2D scissor{{0, 0}, {width, height}}; + if (batch.clip_rect[2] > 0 && batch.clip_rect[3] > 0) { + const auto& clip = batch.clip_rect; + const float x = std::clamp(clip[0], 0.f, float(width)); + const float y = std::clamp(clip[1], 0.f, float(height)); + const float right = std::clamp(clip[0] + clip[2], x, float(width)); + const float bottom = std::clamp(clip[1] + clip[3], y, float(height)); + scissor.offset = {static_cast(x), static_cast(y)}; + scissor.extent = {static_cast(right) - static_cast(x), + static_cast(bottom) - static_cast(y)}; + } + if (!scissor.extent.width || !scissor.extent.height) + continue; + vkCmdSetScissor(command, 0, 1, &scissor); auto descriptor = textures.at(batch.texture).descriptor; vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, 0, 1, &descriptor, 0, nullptr); @@ -1289,7 +1372,7 @@ struct Renderer::Impl { } vkCmdEndRendering(command); }); - graph.add("Readback", {"color"}, {"capture"}, [&] { + add_pass("Readback", {"color"}, {"capture"}, [&] { transition(command, color, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_ASPECT_COLOR_BIT); VkBufferImageCopy copy{}; @@ -1299,7 +1382,7 @@ struct Renderer::Impl { readback.handle, 1, ©); }); if (swap_index) - graph.add("Presentation", {"color"}, {"swapchain"}, [&] { + add_pass("Presentation", {"color"}, {"swapchain"}, [&] { auto index = *swap_index; transition(command, swap_images[index], swap_layouts[index], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_ASPECT_COLOR_BIT); @@ -1487,10 +1570,16 @@ void Renderer::set_text_input_area(float x, float y, float width, float height) throw std::runtime_error(SDL_GetError()); } void Renderer::set_clipboard(const std::string& text) { + if (!impl_->window) { + impl_->offscreen_clipboard = text; + return; + } if (!SDL_SetClipboardText(text.c_str())) throw std::runtime_error(SDL_GetError()); } std::string Renderer::clipboard() const { + if (!impl_->window) + return impl_->offscreen_clipboard; char* text = SDL_GetClipboardText(); if (!text) return {}; diff --git a/src/ui/ui.cpp b/src/ui/ui.cpp index a74c188..99e79b1 100644 --- a/src/ui/ui.cpp +++ b/src/ui/ui.cpp @@ -420,15 +420,49 @@ struct Context::Impl { if (ime_enabled) ime_enabled(false); } + void reveal(Widget& widget) { + // Reveal inside-out: an offscreen nested scroller still needs its own + // contents positioned before an outer scroller brings it into view. + auto* branch = &widget; + for (auto* parent = widget.parent; parent; branch = parent, parent = parent->parent) { + if (!parent->layout.scroll || parent->kind == Kind::Row || branch->layout.absolute) + continue; + const float padding = parent->layout.padding * scale; + const float top = parent->rect.y + padding; + const float extent = std::max(0.f, parent->rect.height - 2 * padding); + if (extent <= 0) + continue; + float delta = 0; + if (widget.rect.y < top || widget.rect.height > extent) + delta = widget.rect.y - top; + else if (widget.rect.y + widget.rect.height > top + extent) + delta = widget.rect.y + widget.rect.height - top - extent; + const float scroll = std::clamp(parent->scroll_y + delta, 0.f, + std::max(0.f, parent->content_height - extent)); + if (scroll != parent->scroll_y) { + parent->scroll_y = scroll; + arrange(root, {0, 0, width, height}, {0, 0, width, height}); + } + } + } bool focus(const std::string& id) { auto* widget = find(id); if (!widget || !focusable(*widget)) return false; - if (focused == id) + if (focused == id) { + reveal(*widget); + if (field(*widget) && ime_rectangle) + ime_rectangle(widget->rect); return true; + } if (!commit()) return false; + // A commit callback may reconcile the retained tree. + widget = find(id); + if (!widget || !focusable(*widget)) + return false; focused = id; + reveal(*widget); if (field(*widget)) { auto& edit = edits[id]; const auto text = widget->kind == Kind::NumberField diff --git a/tests/assets_pipeline.cpp b/tests/assets_pipeline.cpp index 01966cb..ba4fbf5 100644 --- a/tests/assets_pipeline.cpp +++ b/tests/assets_pipeline.cpp @@ -115,6 +115,8 @@ int main() { glb(source); auto first = pipeline.import_asset({source}); success(first); + require(pipeline.freshness(first.asset_id).at("state") == "current", + "new import must be current"); require(first.manifest.at("input_key").at("target_profile") == "desktop-static-pbr-v1" && first.manifest.at("input_key") .at("toolchain") @@ -151,8 +153,13 @@ int main() { require(unchanged.cache_hit && unchanged.generation == first.generation, "content cache hit"); glb(source, "Renamed panel", true, false, .25f); + require(pipeline.freshness(first.asset_id).at("state") == "stale" && + pipeline.current_manifest(first.asset_id).at("generation") == first.generation, + "source edit must mark stale without publishing a generation"); auto modified = pipeline.import_asset({source}); success(modified); + require(pipeline.freshness(first.asset_id).at("state") == "current", + "successful reimport must clear stale state"); require(modified.asset_id == first.asset_id && modified.generation != first.generation, "stable asset identity and changed generation"); asset = pipeline.load_asset(first.asset_id); @@ -243,11 +250,36 @@ int main() { save(root / "external.gltf", external.dump()); auto ext = pipeline.import_asset({root / "external.gltf"}); success(ext); + const auto sidecar = root / "external.gltf.faset-import.json"; + const auto recipe = faset::read_json(sidecar); + auto changed_recipe = recipe; + changed_recipe["settings"]["target"] = "changed-profile"; + faset::atomic_write_json(sidecar, changed_recipe); + require(pipeline.freshness(ext.asset_id).at("state") == "stale", + "changed sidecar must mark imported asset stale"); + faset::atomic_write_json(sidecar, recipe); + const auto image_path = root / faset::path_from_utf8("пиксель.png"); + const auto original_time = fs::last_write_time(image_path); + auto altered_image = png; + altered_image.back() ^= 1; + save(image_path, altered_image); + fs::last_write_time(image_path, original_time); + require(pipeline.freshness(ext.asset_id).at("state") == "stale", + "external dependency content change with same size/time must be detected"); + fs::remove(image_path); + require(pipeline.freshness(ext.asset_id).at("state") == "stale", + "missing source dependency must be diagnosed"); + save(image_path, png); + require(pipeline.freshness(ext.asset_id).at("state") == "current", + "restoring exact recipe and dependency must clear stale state"); auto ext_asset = pipeline.load_asset(ext.asset_id); require(ext_asset.textures.size() == 1 && ext_asset.textures[0].bytes.size() == png.size(), "external image not extracted"); require(ext_asset.materials[0].base_color_texture == 0, "material texture reference lost"); save(root / faset::path_from_utf8("геометрия.bin"), geometry(.3f)); + require(pipeline.freshness(ext.asset_id).at("reasons")[0].at("code") == + "dependency.changed", + "external geometry change must expose its reason"); auto dependent = pipeline.import_asset({root / "external.gltf"}); success(dependent); require(dependent.generation != ext.generation, "buffer dependency not invalidated"); @@ -263,12 +295,16 @@ int main() { save(bundle_dir / "manifest.json", bundle.dump()); auto bundle_first = pipeline.import_asset({bundle_dir / "manifest.json"}); success(bundle_first); + require(pipeline.freshness(bundle_first.asset_id).at("state") == "current", + "new Blender bundle must be current"); require(bundle_first.asset_id == "bundle-asset", "bundle identity lost"); glb(bundle_dir / "payload" / "second.glb", "Bundle renamed", true, false, .4f); bundle["files"][0] = { {"path", "payload/second.glb"}, {"sha256", faset::sha256_file(bundle_dir / "payload" / "second.glb")}}; save(bundle_dir / "manifest.json", bundle.dump()); + require(pipeline.freshness(bundle_first.asset_id).at("state") == "stale", + "new bundle publication must invalidate freshness before reimport"); auto bundle_second = pipeline.import_asset({bundle_dir / "manifest.json"}); success(bundle_second); require(bundle_second.asset_id == bundle_first.asset_id && diff --git a/tests/build_schema_tests.cpp b/tests/build_schema_tests.cpp index 41497b8..1799d16 100644 --- a/tests/build_schema_tests.cpp +++ b/tests/build_schema_tests.cpp @@ -2,7 +2,9 @@ #include #include #include +#include #include +#include using namespace faset; namespace fs = std::filesystem; @@ -12,15 +14,120 @@ void check(bool value, std::string_view message) { throw std::runtime_error(std::string(message)); } Json manifest() { - return { - {"format", "faset.schema"}, - {"version", 1}, - {"types", + Json type{ + {"id", "game.mover"}, + {"version", 2}, + {"fields", + {{"speed", {{"type", "number"}, {"default", 2.5}, {"min", 0}, {"max", 10}}}, + {"enabled", {{"type", "boolean"}, {"default", true}}}}}, + {"migrations", Json::array( - {{{"id", "game.mover"}, - {"version", 2}, - {"fields", - {{"speed", {{"type", "number"}, {"default", 2.5}, {"min", 0}, {"max", 10}}}}}}})}}; + {{{"from_version", 1}, + {"fields", {{"speed", {{"scale", 0.01}}}, {"enabled", {{"default", true}}}}}}})}}; + return {{"format", "faset.schema"}, {"version", 1}, {"types", Json::array({type})}}; +} +void migration_contracts(const fs::path& root, const Json& metadata) { + auto old_scene = authoring::make_scene("Legacy movement", 2); + auto object = authoring::make_entity(authoring::builtin_schemas(), "Mover"); + object["id"] = "mover"; + object["components"].push_back({{"id", "movement"}, + {"type", "game.mover"}, + {"version", 1}, + {"fields", {{"speed", 300}, {"unknown", "preserve me"}}}}); + old_scene["entities"].push_back(object); + atomic_write_json(root / "Scenes/legacy.scene.json", old_scene); + const auto original = read_text(root / "Scenes/legacy.scene.json"); + authoring::AuthoringService service(root); + service.replace_external_schemas(metadata); + editor::Commands commands(service); + const auto opened = + commands.call("faset_document_open", {{"path", "Scenes/legacy.scene.json"}}); + const auto id = opened.at("id").get(); + check(opened.at("scene") == old_scene && !opened.at("dirty").get(), + "Opening an older schema preserves opaque data without implicit migration"); + auto apply = [&] { + return commands.call("faset_scene_edit", + {{"document", id}, + {"revision", service.query(id).at("revision")}, + {"operations", Json::array({{{"op", "entity.rename"}, + {"entity", "mover"}, + {"name", "Migrated mover"}}, + {{"op", "component.migrate"}, + {"entity", "mover"}, + {"component", "movement"}}})}}); + }; + const auto migrated = apply(); + const auto& value = migrated.at("scene").at("entities")[0].at("components")[1]; + check(value.at("id") == "movement" && value.at("version") == 2 && + value.at("fields").at("speed") == 3.0 && value.at("fields").at("enabled") == true && + value.at("fields").at("unknown") == "preserve me", + "Exported declarative steps convert values and retain IDs and unknown fields"); + check(read_text(root / "Scenes/legacy.scene.json") == original, + "Explicit migration remains unsaved until Save"); + const auto undone = + commands.call("faset_undo", {{"document", id}, {"revision", migrated.at("revision")}}); + check(undone.at("scene") == old_scene, "One Undo restores the entire migration transaction"); + authoring::AuthoringService recovered(root); + recovered.replace_external_schemas(metadata); + check(recovered.recover(id).at("scene") == old_scene, + "Recovery preserves the journal's older version without automatic migration"); + commands.call("faset_redo", {{"document", id}, {"revision", undone.at("revision")}}); + check(service.query(id).at("scene") == migrated.at("scene"), "Redo restores migrated values"); + commands.call("faset_undo", {{"document", id}, {"revision", service.query(id).at("revision")}}); + for (const auto* failure : {"missing", "manual", "overflow"}) { + auto rules = metadata; + if (std::string_view(failure) == "missing") + rules["types"][0].erase("migrations"); + else if (std::string_view(failure) == "manual") + rules["types"][0]["migrations"][0]["fields"]["speed"] = {{"require_manual", true}}; + else + rules["types"][0]["migrations"][0]["fields"]["speed"]["scale"] = + std::numeric_limits::max(); + service.replace_external_schemas(rules); + const auto before = service.query(id); + bool rejected{}; + try { + (void)apply(); + } catch (const Error&) { + rejected = true; + } + check(rejected && service.query(id) == before && + read_text(root / "Scenes/legacy.scene.json") == original, + "Missing/manual/overflow migration preserves document, revision, Undo and source"); + authoring::AuthoringService reopened(root); + reopened.replace_external_schemas(rules); + check(reopened.open("Scenes/legacy.scene.json").at("scene") == old_scene, + "An unavailable migration never prevents opening old data"); + } + service.replace_external_schemas(metadata); + auto addition = object; + addition["id"] = "local-mover"; + addition["components"][0]["id"] = "local-transform"; + addition["components"][1]["id"] = "local-movement"; + auto current = service.query(id); + current = service.transact(id, current.at("revision"), + Json::array({{{"op", "template.instance"}, + {"instance", + {{"id", "local-instance"}, + {"source", "Scenes/unused.scene.json"}, + {"additions", Json::array({addition})}}}}})); + const auto local_before = current; + current = commands.call("faset_scene_edit", + {{"document", id}, + {"revision", current.at("revision")}, + {"operations", Json::array({{{"op", "component.migrate"}, + {"instance", "local-instance"}, + {"entity", "local-mover"}, + {"component", "local-movement"}}})}}); + check(current.at("scene") + .at("instances")[0] + .at("additions")[0] + .at("components")[1] + .at("fields") + .at("speed") == 3.0, + "Instance-local additions use the same migration command"); + check(service.undo(id, current.at("revision")).at("scene") == local_before.at("scene"), + "Local-addition migration is one undoable edit"); } int test_main(int argc, char** argv) { const auto root = @@ -51,6 +158,7 @@ int test_main(int argc, char** argv) { const auto previous_registry = authoring.schemas().manifest(); check(authoring.schemas().schema("game.mover").at("version") == 2, "Matching custom v2 metadata reaches authoring"); + migration_contracts(config.project_root / "migration-contracts", read_json(schema)); Json scene{ {"format", "faset.scene"}, @@ -100,6 +208,22 @@ int test_main(int argc, char** argv) { candidate = valid; candidate["types"] = Json::object(); invalid.push_back(candidate); + for (const auto& step : Json::array( + {{{"from_version", 2}, {"fields", Json::object()}}, + {{"from_version", 0}, {"fields", Json::object()}}, + {{"from_version", 1.5}, {"fields", Json::object()}}, + {{"from_version", 1}, {"fields", {{"speed", {{"execute", "unsafe"}}}}}}, + {{"from_version", 1}, {"fields", {{"speed", {{"scale", "bad"}}}}}}, + {{"from_version", 1}, {"fields", {{"speed", {{"scale", nullptr}}}}}}, + {{"from_version", 1}, {"fields", {{"speed", {{"require_manual", 1}}}}}}, + {{"from_version", 1}, {"fields", Json::object()}, {"unsupported", true}}})) { + candidate = valid; + candidate["types"][0]["migrations"] = Json::array({step}); + invalid.push_back(candidate); + } + candidate = valid; + candidate["types"][0]["migrations"].push_back(candidate["types"][0]["migrations"][0]); + invalid.push_back(candidate); for (std::size_t index = 0; index < invalid.size(); ++index) { atomic_write_json(config.project_root / "schema-fixture.json", invalid[index]); atomic_write(config.project_root / "Scripts/Gameplay.cpp", diff --git a/tests/build_service_tests.cpp b/tests/build_service_tests.cpp index b965cc1..ea05455 100644 --- a/tests/build_service_tests.cpp +++ b/tests/build_service_tests.cpp @@ -1,3 +1,4 @@ +#include "assets_image_fixtures.hpp" #include #include #include @@ -380,6 +381,41 @@ int test_main(int argc, char** argv) { "Unknown component type blocks cooking"); require(read_text(service.config().cache_root / "last_cook.json") == previous, "Unknown schema preserves last good generation"); + const auto picture = config.project_root / "Assets" / path_from_utf8("Freshness Café.png"); + auto write_picture = [&](const auto& bytes) { + atomic_write(picture, + std::string(reinterpret_cast(bytes.data()), bytes.size())); + }; + write_picture(test_images::png_red_green); + assets::AssetPipeline importer(service.config().cache_root); + const auto imported = importer.import_asset({picture}); + require(imported.ok(), "Freshness fixture imports a real image"); + auto textured = scene(2); + textured["entities"].push_back( + {{"id", "sprite"}, + {"components", Json::array({{{"type", "faset.sprite"}, + {"version", 1}, + {"fields", {{"texture", imported.asset_id}}}}})}}); + require(service.wait(service.start_cook(textured)).state == "succeeded", + "Current referenced assets can be cooked"); + const auto fresh_pointer = read_text(service.config().cache_root / "last_cook.json"); + write_picture(test_images::png_blue_white); + for (const auto& stale : + {service.wait(service.start_cook(textured)), + service.wait(service.start_export(textured, temporary / "export"))}) { + require(stale.state == "failed" && stale.error.find("Reimport") != std::string::npos, + "Stale source blocks cook and export before compilation/publication"); + require(read_text(service.config().cache_root / "last_cook.json") == fresh_pointer, + "Stale source preserves the published cook pointer"); + } + require(!fs::exists(temporary / "export" / "current.json"), + "Stale export never publishes a game pointer"); + require(importer.import_asset({picture}).ok() && + service.wait(service.start_cook(textured)).state == "succeeded", + "Explicit reimport permits cooking the refreshed generation"); + fs::remove(picture); + require(service.wait(service.start_cook(textured)).state == "failed", + "Unavailable source cannot silently cook old cached content"); std::cout << "Literal process arguments, pipes, cancellation, scaffold and atomic cook " "contracts passed\n"; fs::remove_all(temporary); diff --git a/tests/editor_debug_overlay.cpp b/tests/editor_debug_overlay.cpp new file mode 100644 index 0000000..21c6279 --- /dev/null +++ b/tests/editor_debug_overlay.cpp @@ -0,0 +1,80 @@ +#include +#include +#include + +using namespace faset; +void require(bool value, const char* message) { + if (!value) + throw std::runtime_error(message); +} +int main(int argc, char** argv) { + try { + render::Renderer renderer({640, 420, "Faset developer diagnostics test", true, true}); + editor::DebugOverlay overlay; + render::Snapshot scene; + scene.clear_color = {.05f, .06f, .07f, 1}; + overlay.append(scene, renderer, 1.f / 60.f); + require(!overlay.visible() && scene.ui_triangles.empty(), "Diagnostics start hidden"); + renderer.render(scene); + const auto clean = renderer.pixels(); + render::Event toggle; + toggle.type = render::Event::Type::KeyDown; + toggle.key = "F12"; + require(overlay.process_events(std::span(&toggle, 1)).empty() && overlay.visible(), + "F12 is consumed and shows the diagnostic overlay"); + overlay.append(scene, renderer, 1.f / 60.f); + require(!scene.ui_triangles.empty() && scene.ui_triangles.front().texture, + "ImGui draw data reaches Faset's textured UI triangle API"); + renderer.render(scene); + auto shown = renderer.pixels(); + std::size_t changed{}; + for (std::size_t i = 0; i < shown.size(); ++i) + changed += shown[i] != clean[i]; + require(changed > 5000, "Diagnostic panel and text alter actual Vulkan pixels"); + render::Event inside; + inside.type = render::Event::Type::MouseDown; + inside.button = 1; + inside.x = 30; + inside.y = 65; + require(overlay.process_events(std::span(&inside, 1)).empty(), + "Pointer clicks in diagnostics must not edit the underlying scene"); + inside.type = render::Event::Type::MouseUp; + inside.x = 600; + require(overlay.process_events(std::span(&inside, 1)).empty(), + "A diagnostic pointer gesture remains owned through release outside the panel"); + inside.type = render::Event::Type::MouseDown; + require(overlay.process_events(std::span(&inside, 1)).size() == 1, + "Pointer events outside diagnostics reach the retained editor"); + inside.type = render::Event::Type::MouseUp; + inside.x = 30; + require(overlay.process_events(std::span(&inside, 1)).size() == 1, + "Editor-owned release remains forwarded after crossing into diagnostics"); + render::Event move = inside; + move.type = render::Event::Type::MouseMove; + render::Event wheel; + wheel.type = render::Event::Type::Wheel; + wheel.y = 1; + const render::Event inside_wheel[] = {move, wheel}; + require(overlay.process_events(inside_wheel).empty(), + "Move and wheel in one event batch must not scroll the underlying editor"); + move.x = 600; + const render::Event outside_wheel[] = {move, wheel}; + require(overlay.process_events(outside_wheel).size() == 2, + "Move outside and wheel immediately return to the retained editor"); + if (argc > 1) + renderer.capture(argv[1]); + require(overlay.process_events(std::span(&toggle, 1)).empty() && !overlay.visible(), + "F12 hides diagnostics again"); + scene.ui_triangles.clear(); + overlay.append(scene, renderer, 1.f / 60.f); + require(scene.ui_triangles.empty(), "Hidden diagnostics emit no geometry"); + renderer.render(scene); + require(renderer.pixels() == clean, "Hiding diagnostics restores the underlying frame"); + require(renderer.stats().validation_errors == 0, "Diagnostic overlay Vulkan validation"); + std::cout << "ImGui diagnostics, F12, font atlas, clipping and event isolation passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/editor_ui_import_conflicts.cpp b/tests/editor_ui_import_conflicts.cpp index 93a26a1..3510502 100644 --- a/tests/editor_ui_import_conflicts.cpp +++ b/tests/editor_ui_import_conflicts.cpp @@ -100,7 +100,12 @@ int main() { atomic_write_json(source, gltf); click(ui, "tab-assets"); click(ui, "asset-refresh"); - click(ui, "file-Assets/model.gltf"); + const auto freshness = session.commands().call("faset_assets", Json::object()); + check(freshness.at("assets")[0].at("freshness").at("state") == "stale", + "MCP exposes changed-source freshness before reimport"); + check(ui.widgets().find("asset-" + asset_id)->text.starts_with("Stale"), + "Manual asset list marks the changed source stale"); + click(ui, "asset-" + asset_id); auto before = jobs(session); click(ui, "asset-import"); const auto conflict_id = new_job(session, before); @@ -154,6 +159,10 @@ int main() { "Resolved import conflicts leave the review list"); check(session.authoring().query(ui.current_document()) == document_before, "Import approval never rewrites the authoring scene or its Undo revision"); + click(ui, "tab-assets"); + click(ui, "asset-refresh"); + check(ui.widgets().find("asset-" + asset_id)->text.starts_with("Imported"), + "Successful import clears stale status in the same Editor session"); renderer.render(ui.snapshot()); check(renderer.stats().validation_errors == 0, "Vulkan validation"); std::cout << "Import UI: removed IDs/names, stale review rejection, explicit retry and " diff --git a/tests/editor_ui_tests.cpp b/tests/editor_ui_tests.cpp index 2a254d8..3cb7be7 100644 --- a/tests/editor_ui_tests.cpp +++ b/tests/editor_ui_tests.cpp @@ -43,6 +43,176 @@ void text(editor::EditorUI& ui, const std::string& id, const std::string& value, events.push_back(key("Return")); ui.frame(events); } +void migration_workflow(render::Renderer& renderer, const std::filesystem::path& root) { + editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root}); + const auto gameplay_manifest = Json::parse(R"({"types":[ + {"id":"test.migratable","version":2,"name":"Movement","fields":{ + "speed":{"id":"speed","type":"number","default":1.0}, + "enabled":{"id":"enabled","type":"bool","default":true}}, + "migrations":[{"from_version":1,"fields":{"speed":{"scale":0.01},"enabled":{"default":true}}}]}, + {"id":"test.no_migration","version":2,"fields":{ + "speed":{"id":"speed","type":"number","default":1.0}}} + ]})"); + session.authoring().replace_external_schemas(gameplay_manifest); + auto object = authoring::make_entity(session.authoring().schemas(), "Old movement"); + const auto cid = new_id(); + object["components"].push_back({{"id", cid}, + {"type", "test.migratable"}, + {"version", 1}, + {"fields", {{"speed", 250.0}, {"unknown", "retained"}}}}); + const auto old = object["components"].back(); + auto source_scene = authoring::make_scene("Old source"); + source_scene["entities"].push_back(object); + atomic_write_json(root / "Assets/old-source.fscene", source_scene); + const auto doc = session.authoring().create("Migration test").at("id").get(); + const auto instance = new_id(); + session.authoring().transact( + doc, 0, + Json::array({{{"op", "entity.create"}, {"entity", object}}, + {{"op", "template.instance"}, + {"instance", {{"id", instance}, {"source", "Assets/old-source.fscene"}}}}})); + session.authoring().save(doc, "Scenes/migration.fscene"); + editor::EditorUI ui(session, renderer, + path_from_utf8(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf", + path_from_utf8(FASET_TEST_ENGINE) / "assets/ui/dark.json"); + ui.frame({}); + // Explicit keyboard activation also exercises focus reveal in a long Inspector. + auto activate = [&](const std::string& id) { + check(ui.widgets().focus(id), "Migration workflow action must be focusable"); + ui.frame({key("Return")}); + }; + ui.select_entity(object.at("id")); + ui.frame({}); + check(ui.widgets().find("opaque-fields-" + cid) && + !ui.widgets().find("opaque-fields-" + cid)->enabled, + "Older component opens preserved and read-only before explicit migration"); + const auto before = session.authoring().query(doc); + activate("component-migrate-" + cid); + auto after = session.authoring().query(doc); + const auto migrated = after["scene"]["entities"][0]["components"].back(); + check(after["revision"] == before["revision"].get() + 1 && + migrated["id"] == cid && migrated["version"] == 2 && + migrated["fields"]["speed"] == 2.5 && migrated["fields"]["enabled"] == true && + migrated["fields"]["unknown"] == "retained", + "Inspector Migrate applies declared rules atomically while preserving identity and data"); + check(ui.widgets().find("field-" + cid + "-speed-value") && + !ui.widgets().find("component-migrate-" + cid), + "Successful migration replaces opaque presentation with typed fields"); + activate("undo"); + check(session.authoring().query(doc)["scene"]["entities"][0]["components"].back() == old && + ui.widgets().find("component-migrate-" + cid), + "One Undo restores the original version and opaque Inspector"); + auto unresolved = object; + unresolved["id"] = new_id(); + unresolved["name"] = "Missing rule"; + for (auto& c : unresolved["components"]) + c["id"] = new_id(); + unresolved["components"].back()["type"] = "test.no_migration"; + const auto unresolved_cid = unresolved["components"].back()["id"].get(); + auto local = object; + local["id"] = new_id(); + for (auto& c : local["components"]) + c["id"] = new_id(); + after = session.authoring().query(doc); + session.authoring().transact( + doc, after["revision"], + Json::array({{{"op", "entity.create"}, {"entity", unresolved}}, + {{"op", "template.add"}, {"instance", instance}, {"value", local}}})); + ui.select_entity(unresolved.at("id")); + ui.frame({}); + const auto failed_before = session.authoring().query(doc); + activate("component-migrate-" + unresolved_cid); + check(session.authoring().query(doc) == failed_before && + ui.widgets().find("status")->text.find("migration") != std::string::npos, + "Missing migration reports an actionable error without revision or source changes"); + auto resolved = session.commands().resolved_scene(doc).at("scene").at("entities"); + for (const auto& e : resolved) { + if (!e.contains("origin") || e["origin"].value("path", Json::array()).empty()) + continue; + if (e["origin"].value("local", false)) { + ui.select_entity(e.at("id")); + ui.frame({}); + activate("component-migrate-" + e["components"].back()["id"].get()); + const auto record = + session.authoring().query(doc)["scene"]["instances"][0]["additions"][0]; + check( + record["id"] == local["id"] && + record["components"].back()["id"] == local["components"].back()["id"] && + record["components"].back()["version"] == 2, + "Local-addition migration addresses original source IDs, not resolved runtime IDs"); + activate("undo"); + check(session.authoring().query(doc)["scene"]["instances"][0]["additions"][0] == local, + "Local-addition migration is one undoable transaction"); + } + } + for (const auto& e : resolved) { + if (!e.contains("origin") || e["origin"].value("path", Json::array()).empty() || + e["origin"].value("local", false)) + continue; + ui.select_entity(e.at("id")); + ui.frame({}); + const auto action = "component-migrate-" + e["components"].back()["id"].get(); + check(ui.widgets().find(action)->text == "Open source to migrate", + "Inherited migration explicitly navigates to the owning source"); + const auto main_before = session.authoring().query(doc); + activate(action); + check(ui.current_document() == source_scene.at("id").get() && + session.authoring().query(doc) == main_before && + session.authoring() + .query(ui.current_document())["scene"]["entities"][0]["components"] + .back() == old && + ui.widgets().find("component-migrate-" + cid), + "Opening source keeps old data unchanged and offers explicit migration there"); + break; + } + // A metadata rebuild must invalidate the cached resolved scene even when + // neither the document nor its open source has changed revision. + auto v1 = gameplay_manifest; + v1["types"][0]["version"] = 1; + v1["types"][0].erase("migrations"); + v1["types"][0]["fields"].erase("enabled"); + session.authoring().replace_external_schemas(v1); + auto main = session.authoring().query(doc); + session.authoring().transact(doc, main.at("revision"), + Json::array({{{"op", "template.override"}, + {"instance", instance}, + {"address", + {{"path", Json::array()}, + {"object", object.at("id")}, + {"component", cid}, + {"field", "speed"}}}, + {"value", 900.0}}})); + ui.select_document(doc); + std::string inherited_id, inherited_cid; + for (const auto& e : resolved) + if (e.contains("origin") && !e["origin"].value("path", Json::array()).empty() && + !e["origin"].value("local", false)) { + inherited_id = e.at("id"); + inherited_cid = e["components"].back().at("id"); + break; + } + check(!inherited_id.empty(), "Migration schema-cache fixture has an inherited object"); + ui.select_entity(inherited_id); + ui.frame({}); + const auto field_id = "field-" + inherited_cid + "-speed-value"; + check(ui.widgets().find(field_id) && ui.widgets().find(field_id)->value == 900.0 && + !ui.widgets().find("conflict-0-text"), + "V1 schema applies and caches the inherited field override"); + const auto documents_before = session.authoring().documents(); + session.authoring().replace_external_schemas(gameplay_manifest); + ui.frame({}); + const auto* conflict = ui.widgets().find("conflict-0-text"); + check(session.authoring().documents() == documents_before && conflict && + conflict->text.starts_with("override.schema_version") && + !ui.widgets().find(field_id) && ui.widgets().find("opaque-fields-" + inherited_cid), + "Schema-only rebuild refreshes resolved conflicts and opaque fields without authoring " + "edits"); + session.authoring().replace_external_schemas(v1); + ui.frame({}); + check(!ui.widgets().find("conflict-0-text") && ui.widgets().find(field_id) && + ui.widgets().find(field_id)->value == 900.0, + "Restoring compatible metadata clears cached conflicts and reapplies retained override"); +} int main() { auto root = std::filesystem::temp_directory_path() / path_from_utf8("faset-ui-проект-" + new_id()); @@ -253,6 +423,7 @@ int main() { check(ui.project_switch_requested(), "Confirmed project switch is exposed to application"); renderer.render(ui.snapshot()); renderer.capture(root / "editor-ui.ppm"); + migration_workflow(renderer, root / "migration-workflow"); check(renderer.stats().validation_errors == 0, "Vulkan validation errors"); std::cout << "Editor UI: actual events create/select/rename/typed " "fields/Undo/Redo/conflict/one drag transaction passed. " diff --git a/tests/render_tests.cpp b/tests/render_tests.cpp index 1e276ec..3aaacc0 100644 --- a/tests/render_tests.cpp +++ b/tests/render_tests.cpp @@ -41,6 +41,11 @@ int main(int argc, char** argv) { } bool visible = argc > 1 && std::string(argv[1]) == "--visible"; Renderer renderer({320, 240, "Faset render validation", !visible, true}); + if (!visible) { + renderer.set_clipboard("Offscreen Café 世界"); + require(renderer.clipboard() == "Offscreen Café 世界", + "Offscreen clipboard is local and does not require SDL video"); + } Snapshot scene; scene.eye = {4, 3, 5}; scene.view_projection = @@ -58,14 +63,27 @@ int main(int argc, char** argv) { texture->width = texture->height = 1; texture->rgba = {20, 220, 40, 255}; scene.ui_quads.push_back({260, 8, 40, 20, {1, 1, 1, 1}, texture}); + UiTriangles diagnostic; + diagnostic.vertices = {{{80, 8, 0}, {}, {0, 0, 1, 1}}, + {{150, 8, 0}, {}, {0, 0, 1, 1}}, + {{80, 40, 0}, {}, {0, 0, 1, 1}}}; + diagnostic.clip_rect = {90, 8, 30, 32}; + scene.ui_triangles.push_back(diagnostic); renderer.render(scene); require(renderer.stats().validation_errors == 0, "Vulkan validation reported an error"); + if (renderer.stats().validation_enabled) + require(renderer.stats().gpu_labels_enabled, + "Validation context exposes GPU pass labels"); + require(!renderer.stats().gpu_labels_enabled || renderer.stats().gpu_label_count >= 3, + "Every submitted render graph pass receives a GPU label"); auto pixels = renderer.pixels(); require(pixels.size() == 320 * 240 * 4, "Readback dimensions"); auto index = (10 * 320 + 10) * 4; require(pixels[index] > 190 && pixels[index + 1] < 50, "Colored UI pixel"); index = (10 * 320 + 270) * 4; require(pixels[index] < 30 && pixels[index + 1] > 200, "Textured UI pixel"); + require(pixels[(12 * 320 + 95) * 4 + 2] > 240 && pixels[(12 * 320 + 85) * 4 + 2] < 200, + "UI diagnostic triangles obey their per-command clip rectangle"); texture->srgb = true; ++texture->revision; renderer.render(scene); diff --git a/tests/ui_tests.cpp b/tests/ui_tests.cpp index 7dea7d2..e7913b0 100644 --- a/tests/ui_tests.cpp +++ b/tests/ui_tests.cpp @@ -113,10 +113,113 @@ void display_scale_contract() { check(panel.scroll_y == 60 && panel.children[1]->rect.y == panel.rect.y, "DPI changes retain the logical scroll position"); } +void keyboard_scroll_contract() { + for (float scale : {1.f, 2.f}) { + ui::Context context(path_from_utf8(FASET_TEST_FONT)); + auto& columns = context.root().add(ui::Kind::Row, "panels"); + columns.layout.height = 150; + auto& inspector = columns.add(ui::Kind::Column, "inspector"); + inspector.layout.width = 200; + inspector.layout.padding = 8; + inspector.layout.gap = 4; + inspector.layout.scroll = true; + auto& assets = columns.add(ui::Kind::Column, "assets"); + assets.layout.width = 200; + assets.layout.padding = 8; + assets.layout.gap = 3; + assets.layout.scroll = true; + int commits = 0, activations = 0; + for (int i = 0; i < 30; ++i) { + auto& field = inspector.add(ui::Kind::TextField, "property-" + std::to_string(i), + "Property " + std::to_string(i)); + field.layout.height = 30; + field.on_commit = [&](ui::Widget&) { ++commits; }; + auto& asset = assets.add(ui::Kind::Button, "asset-" + std::to_string(i), "Model.glb"); + asset.layout.height = 28; + asset.on_click = [&](ui::Widget&) { ++activations; }; + } + ui::Rect ime; + context.set_ime([](bool) {}, [&](ui::Rect rect) { ime = rect; }); + context.layout(440 * scale, 200 * scale, scale); + check(context.find("property-29")->clip.height == 0 && + context.find("asset-29")->clip.height == 0, + "Long Inspector and Assets start with offscreen controls"); + auto visible = [&](const std::string& id) { + const auto* widget = context.find(id); + const auto r = widget->rect.intersection(widget->clip); + check(context.focused_id() == id && std::abs(r.height - widget->rect.height) < .01f && + std::abs(r.width - widget->rect.width) < .01f, + "Keyboard focus must reveal the entire control in its clipped panel"); + }; + for (int i = 0; i < 30; ++i) { + context.handle(key("Tab")); + visible("property-" + std::to_string(i)); + check(ime.y == context.find(context.focused_id())->rect.y, + "IME rectangle follows the newly scrolled field"); + } + context.handle(key("A", true)); + context.handle(text("Изменено")); + const float inspector_scroll = inspector.scroll_y; + for (int i = 0; i < 30; ++i) { + context.handle(key("Tab")); + visible("asset-" + std::to_string(i)); + context.handle(key("Return")); + } + check(commits == 1 && activations == 30 && inspector.scroll_y == inspector_scroll, + "Focus scroll commits one draft, activates assets and leaves unrelated panel alone"); + for (int i = 28; i >= 0; --i) { + context.handle(key("Tab", false, true)); + visible("asset-" + std::to_string(i)); + } + for (int i = 29; i >= 0; --i) { + context.handle(key("Tab", false, true)); + visible("property-" + std::to_string(i)); + } + check(inspector.scroll_y == 0 && assets.scroll_y == 0 && commits == 1, + "Reverse keyboard traversal returns both panels to the top without edits"); + context.handle(text(" draft")); + inspector.scroll_y = inspector_scroll; + context.layout(440 * scale, 200 * scale, scale); + check(context.find("property-0")->clip.height == 0, + "A manual scroll may move an active field out of view"); + check(context.focus("property-0"), "Refocus active field"); + visible("property-0"); + check(context.find("property-0")->text == "Property 0 draft" && commits == 1, + "Revealing the current focus preserves its uncommitted edit"); + } + ui::Context nested(path_from_utf8(FASET_TEST_FONT)); + auto& outer = nested.root().add(ui::Kind::Column, "outer"); + outer.layout.height = 120; + outer.layout.padding = 6; + outer.layout.scroll = true; + outer.add(ui::Kind::Label, "spacer").layout.height = 240; + auto& inner = outer.add(ui::Kind::Column, "inner"); + inner.layout.height = 85; + inner.layout.padding = 5; + inner.layout.scroll = true; + for (int i = 0; i < 15; ++i) + inner.add(ui::Kind::TextField, "nested-" + std::to_string(i)).layout.height = 30; + nested.layout(260, 160); + check(nested.focus("nested-14"), "Focus deeply clipped control"); + auto* target = nested.find("nested-14"); + check(outer.scroll_y > 0 && inner.scroll_y > 0 && target->clip.height == target->rect.height, + "Focus reveals a control through both initially clipped scroll ancestors"); + auto& invalid = inner.add(ui::Kind::NumberField, "invalid-number"); + invalid.layout.height = 30; + nested.layout(260, 160); + nested.focus("invalid-number"); + nested.handle(text("bad number")); + const auto old_outer = outer.scroll_y, old_inner = inner.scroll_y; + nested.handle(key("Tab")); + check(nested.focused_id() == "invalid-number" && !invalid.error.empty() && + outer.scroll_y == old_outer && inner.scroll_y == old_inner, + "Rejected numeric commit retains focus and scroll instead of hiding the error"); +} } // namespace int main() { try { display_scale_contract(); + keyboard_scroll_contract(); ui::TextBuffer buffer("Привет"); check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint"); check(buffer.undo() && buffer.text() == "Привет", "text undo");