diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..5f4d143 --- /dev/null +++ b/.clang-format @@ -0,0 +1,9 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +ColumnLimit: 100 +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +BreakBeforeBraces: Attach +PointerAlignment: Left +SortIncludes: CaseSensitive diff --git a/.gitattributes b/.gitattributes index ac0fa13..bce4344 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,5 @@ licenses/*.txt -whitespace *.png binary *.ttf binary + +assets/fonts/OFL.txt -whitespace diff --git a/CMakeLists.txt b/CMakeLists.txt index ce1bbb5..7fca85d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,7 +27,7 @@ else() endif() include(cmake/Dependencies.cmake) -add_library(faset_core STATIC src/core/hash.cpp src/core/io.cpp) +add_library(faset_core STATIC src/core/hash.cpp src/core/io.cpp src/core/process.cpp) target_include_directories(faset_core PUBLIC include) target_link_libraries(faset_core PUBLIC nlohmann_json::nlohmann_json Threads::Threads) target_compile_definitions(faset_core PUBLIC FASET_VERSION="${PROJECT_VERSION}") @@ -50,7 +50,21 @@ endforeach() if(FASET_BUILD_RENDERER AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/Renderer.cmake") include(cmake/Renderer.cmake) endif() -foreach(module UI Editor Applications) +if(TARGET faset_gameplay AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/Player.cmake") + include(cmake/Player.cmake) +endif() +if(TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/EditorCommands.cmake") + include(cmake/EditorCommands.cmake) +endif() +if(TARGET faset_assets AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/BuildService.cmake") + include(cmake/BuildService.cmake) +endif() +if(TARGET faset_editor_commands AND TARGET faset_build_service) + include(cmake/Plugins.cmake) + add_library(faset_editor_session STATIC src/editor/session.cpp) + target_link_libraries(faset_editor_session PUBLIC faset_editor_commands faset_build_service faset_assets faset_editor_plugins) +endif() +foreach(module UI EditorUI Editor Applications) if(NOT FASET_BUILD_EDITOR) continue() endif() @@ -59,8 +73,27 @@ foreach(module UI Editor Applications) endif() endforeach() +if(TARGET faset_editor_session) + add_executable(faset_editor apps/editor_main.cpp) + target_link_libraries(faset_editor PRIVATE faset_editor_session) + target_compile_definitions(faset_editor PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}") + if(BUILD_TESTING) + find_package(Python3 COMPONENTS Interpreter REQUIRED) + add_test(NAME editor_mcp_stdio COMMAND ${Python3_EXECUTABLE} ${PROJECT_SOURCE_DIR}/tests/mcp_stdio_test.py $) + set_tests_properties(editor_mcp_stdio PROPERTIES TIMEOUT 60) + endif() + if(TARGET faset_editor_ui) + target_link_libraries(faset_editor PRIVATE faset_editor_ui) + target_link_libraries(faset_editor PRIVATE faset_stb) + target_compile_definitions(faset_editor PRIVATE FASET_HAS_EDITOR_UI=1) + target_sources(faset_editor PRIVATE apps/editor_gui.cpp) + endif() +endif() + if(BUILD_TESTING) add_executable(faset_core_tests tests/core_tests.cpp) target_link_libraries(faset_core_tests PRIVATE faset_core) add_test(NAME core COMMAND faset_core_tests) endif() + +include(cmake/Tutorials.cmake) diff --git a/README.md b/README.md index 7cf7403..5f99c7a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Faset is an independent engine project for desktop **2D and 3D games on Linux and Windows**. Its priorities are a custom editor that is comfortable to use by hand and through MCP, integration with Blender, and a path toward advanced graphics. -**Current status: MVP implementation is in progress.** Native core, authoring, physics runtime, asset import, and renderer subsystems are being integrated and tested. The complete editor and game export workflow are not yet ready. See the [implementation checkpoints](docs/IMPLEMENTATION.md) for observed results; a technology appearing in the plan does not mean it is complete or benchmarked. +**Current status: MVP implementation is in progress.** The native Editor, shared GUI/MCP authoring, C++ gameplay builds, Vulkan Player, and standalone export are integrated and under acceptance testing. Linux GPU workflows work; complete cross-platform acceptance and the two finished example games remain in progress. See the [implementation checkpoints](docs/IMPLEMENTATION.md) for observed results; a technology appearing in the plan does not mean it is complete or benchmarked. ## Start here diff --git a/apps/editor_gui.cpp b/apps/editor_gui.cpp new file mode 100644 index 0000000..05705a1 --- /dev/null +++ b/apps/editor_gui.cpp @@ -0,0 +1,110 @@ +#include +#include +#include +#include +#include +#include +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include + +namespace faset::editor { +namespace { +std::string base64(const std::vector& bytes) { + constexpr char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + out.reserve(((bytes.size() + 2) / 3) * 4); + for (std::size_t i = 0; i < bytes.size(); i += 3) { + const auto a = bytes[i]; + const auto b = i + 1 < bytes.size() ? bytes[i + 1] : 0, + c = i + 2 < bytes.size() ? bytes[i + 2] : 0; + out += alphabet[a >> 2]; + out += alphabet[((a & 3) << 4) | (b >> 4)]; + out += i + 1 < bytes.size() ? alphabet[((b & 15) << 2) | (c >> 6)] : '='; + out += i + 2 < bytes.size() ? alphabet[c & 63] : '='; + } + return out; +} +std::vector png(render::Renderer& renderer, std::array rectangle) { + const auto width = renderer.width(), height = renderer.height(); + const auto pixels = renderer.pixels(); + int x = std::clamp(static_cast(rectangle[0]), 0, static_cast(width) - 1), + y = std::clamp(static_cast(rectangle[1]), 0, static_cast(height) - 1); + const int w = std::clamp(static_cast(rectangle[2]), 1, static_cast(width) - x), + h = std::clamp(static_cast(rectangle[3]), 1, static_cast(height) - y); + std::vector cropped(static_cast(w) * h * 4), encoded; + for (int row = 0; row < h; ++row) + std::copy_n(pixels.begin() + (static_cast(row + y) * width + x) * 4, + static_cast(w) * 4, + cropped.begin() + static_cast(row) * w * 4); + const auto writer = [](void* context, void* data, int count) { + auto& out = *static_cast*>(context); + auto* begin = static_cast(data); + out.insert(out.end(), begin, begin + count); + }; + require(stbi_write_png_to_func(writer, &encoded, w, h, 4, cropped.data(), w * 4) != 0, + "capture.encode", "Cannot encode editor screenshot"); + return encoded; +} +} // namespace +int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames, + const std::filesystem::path& capture) { + render::Renderer renderer({1440, 900, + "Faset — " + session.project().value("name", std::string("Project")), + false, true}); + 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); + McpServer server(session.commands()); + StdioTransport transport; + session.commands().add( + "faset_editor_capture", + "Capture the Editor or its authoring viewport as a PNG image. Requires the graphical " + "Editor and Vulkan; never captures a Player process.", + Commands::object_schema( + {{"path", {{"type", "string"}}}, {"viewport_only", {{"type", "boolean"}}}}), + [&](const Json& arguments) { + session.poll(); + ui.frame({}); + renderer.render(ui.snapshot()); + auto region = + arguments.value("viewport_only", true) + ? ui.snapshot().scene_rect + : std::array{0, 0, float(renderer.width()), float(renderer.height())}; + if (region[2] <= 0 || region[3] <= 0) + region = {0, 0, float(renderer.width()), float(renderer.height())}; + auto encoded = png(renderer, region); + const auto relative = + arguments.value("path", std::string(".faset/screenshots/editor.png")); + atomic_write( + project_path(session.config().project_root, relative), + std::string_view(reinterpret_cast(encoded.data()), encoded.size())); + return Json{{"path", relative}, + {"mimeType", "image/png"}, + {"width", static_cast(region[2])}, + {"height", static_cast(region[3])}, + {"image_base64", base64(encoded)}}; + }, + true); + std::uint64_t frame = 0; + while (!renderer.should_close() && (max_frames == 0 || frame < max_frames)) { + if (enable_mcp) + for (const auto& line : transport.poll()) { + try { + const auto reply = server.handle(Json::parse(line)); + if (reply) + transport.send(*reply); + } catch (const Json::exception&) { + transport.send(server.parse_error()); + } + } + session.poll(); + ui.frame(renderer.poll_events()); + renderer.render(ui.snapshot()); + ++frame; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + if (!capture.empty()) + renderer.capture(capture); + return renderer.stats().validation_errors == 0 ? 0 : 2; +} +} // namespace faset::editor diff --git a/apps/editor_main.cpp b/apps/editor_main.cpp new file mode 100644 index 0000000..547a437 --- /dev/null +++ b/apps/editor_main.cpp @@ -0,0 +1,172 @@ +#include +#include +#include +#include +#include +#include +#include +#ifdef FASET_HAS_EDITOR_UI +#include +namespace faset::editor { +int run_editor_ui(Session&, bool, std::uint64_t, const std::filesystem::path&); +} +#endif +#ifdef _WIN32 +#define NOMINMAX +#include +#endif + +namespace { +volatile std::sig_atomic_t interrupted = 0; +void interrupt(int) { + interrupted = 1; +} +std::filesystem::path executable_directory(const char* argument) { +#ifdef _WIN32 + std::wstring path(32768, L'\0'); + const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) + throw std::runtime_error("Cannot locate Editor executable"); + path.resize(length); + return std::filesystem::path(path).parent_path(); +#else + std::error_code error; + const auto path = std::filesystem::read_symlink("/proc/self/exe", error); + return error ? std::filesystem::absolute(argument).parent_path() : path.parent_path(); +#endif +} +void help() { + std::cout + << "Faset Editor\n" + " faset_editor --project PATH [--new NAME --dimension 2|3] [--scene RELATIVE_PATH]\n" + " faset_editor --project PATH --mcp [--gui]\n" + " faset_editor --project PATH --command JSON [--wait]\n" + "Options: --engine SDK_SOURCE, --headless, --frames N, --capture PATH.ppm\n" + "MCP uses JSON-RPC over stdio and only exposes authoring/editor services.\n"; +} +} // namespace +int main(int argc, char** argv) { + using namespace faset; + using namespace faset::editor; + try { + std::filesystem::path project, engine = FASET_ENGINE_SOURCE, scene, capture; + std::string new_name, command; + int dimension = 3; + bool mcp = false, gui = true, explicit_gui = false, wait = false; + std::uint64_t frames = 0; + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + auto value = [&]() { + require(i + 1 < argc, "cli.argument", "Missing value for " + arg); + return std::string(argv[++i]); + }; + if (arg == "--help" || arg == "-h") { + help(); + return 0; + } + if (arg == "--project") + project = value(); + else if (arg == "--engine") + engine = value(); + else if (arg == "--new") + new_name = value(); + else if (arg == "--dimension") + dimension = std::stoi(value()); + else if (arg == "--scene") + scene = value(); + else if (arg == "--mcp") + mcp = true; + else if (arg == "--gui") { + gui = true; + explicit_gui = true; + } else if (arg == "--headless") + gui = false; + else if (arg == "--command") { + command = value(); + gui = false; + } else if (arg == "--wait") + wait = true; + else if (arg == "--frames") { + const auto text = value(); + require(!text.empty() && text.find_first_not_of("0123456789") == std::string::npos, + "cli.frames", "Frame count must be positive"); + frames = std::stoull(text); + require(frames > 0 && frames <= 10000000, "cli.frames", "Frame count out of range"); + } else if (arg == "--capture") + capture = value(); + else + throw Error("cli.option", "Unknown option: " + arg); + } + require(!project.empty(), "cli.project", "Use --project PATH to select a project"); + require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command"); + if (mcp && !explicit_gui) + gui = false; + Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine), + executable_directory(argv[0])}); + if (!new_name.empty()) + session.scaffold(new_name, dimension); + const auto settings = session.project(); + if (scene.empty()) + scene = settings.value("start_scene", std::string()); + if (!scene.empty() && + std::filesystem::exists(project_path(session.config().project_root, scene))) + session.authoring().open(scene); + if (!command.empty()) { + const auto request = Json::parse(command); + auto result = session.commands().call(request.at("name"), + request.value("arguments", Json::object())); + if (wait && result.contains("job")) { + const auto id = result.at("job"); + const auto started = std::chrono::steady_clock::now(); + for (;;) { + session.poll(); + result = session.commands().call("faset_job", {{"id", id}}); + const auto state = result.value("state", std::string()); + if (state == "succeeded" || state == "failed" || state == "cancelled" || + state == "conflict") + break; + require(std::chrono::steady_clock::now() - started < std::chrono::minutes(30), + "job.timeout", "Command wait exceeded 30 minutes"); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + } + std::cout << result.dump(2) << '\n'; + const auto state = result.value("state", std::string()); + return state == "failed" || state == "cancelled" || state == "conflict" ? 1 : 0; + } + if (gui) { +#ifdef FASET_HAS_EDITOR_UI + return run_editor_ui(session, mcp, frames, capture); +#else + throw Error("editor.gui_unavailable", + "This build has no graphical editor; use --mcp or --command, or build with " + "FASET_BUILD_EDITOR=ON"); +#endif + } + require(mcp, "cli.mode", "Headless mode requires --mcp or --command"); + std::signal(SIGINT, interrupt); + std::signal(SIGTERM, interrupt); + McpServer server(session.commands()); + StdioTransport transport; + while (!interrupted && !transport.closed()) { + for (const auto& line : transport.poll()) { + try { + const auto reply = server.handle(Json::parse(line)); + if (reply) + transport.send(*reply); + } catch (const Json::exception&) { + transport.send(server.parse_error()); + } + } + session.poll(); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + return 0; + } catch (const Error& error) { + std::cerr << error.json().dump() << '\n'; + return 1; + } catch (const std::exception& error) { + std::cerr << Json{{"code", "editor.failure"}, {"message", error.what()}}.dump() << '\n'; + return 1; + } +} diff --git a/apps/player_main.cpp b/apps/player_main.cpp new file mode 100644 index 0000000..ec5c60d --- /dev/null +++ b/apps/player_main.cpp @@ -0,0 +1,296 @@ +#include "Gameplay.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(_WIN32) +#define NOMINMAX +#include +#endif + +namespace { +std::filesystem::path executableDirectory(const char* argument) { +#if defined(_WIN32) + std::wstring path(32768, L'\0'); + const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) + throw std::runtime_error("Cannot locate Player executable"); + path.resize(length); + return std::filesystem::path(path).parent_path(); +#else + std::error_code error; + auto executable = std::filesystem::read_symlink("/proc/self/exe", error); + return error ? std::filesystem::absolute(argument).parent_path() : executable.parent_path(); +#endif +} +std::uint64_t count(const std::string& value) { + if (value.empty() || value.find_first_not_of("0123456789") != std::string::npos) + throw std::invalid_argument("Frame count must be a positive integer"); + auto result = std::stoull(value); + if (result == 0 || result > 10000000) + throw std::invalid_argument("Frame count out of range"); + return result; +} +faset::runtime::RuntimeConfig simulationConfig(const nlohmann::json& scene) { + faset::runtime::RuntimeConfig config; + if (!scene.contains("simulation")) + return config; + const auto& settings = scene.at("simulation"); + if (!settings.is_object()) + throw std::invalid_argument("simulation must be an object"); + config.fixedDelta = settings.value("fixed_delta", config.fixedDelta); + auto boundedInteger = [&](const char* key, int fallback, int maximum) { + if (!settings.contains(key)) + return fallback; + const auto& value = settings.at(key); + if (!value.is_number_integer()) + throw std::invalid_argument(std::string(key) + " must be an integer"); + // Check before narrowing, so very large unsigned values cannot wrap into + // a valid configuration on a platform with a narrower unsigned type. + const auto numeric = value.get(); + if (numeric < 1 || numeric > maximum) + throw std::invalid_argument(std::string(key) + " is out of range"); + return value.get(); + }; + config.maxCatchUpTicks = static_cast( + boundedInteger("max_catch_up_ticks", static_cast(config.maxCatchUpTicks), 1024)); + config.physicsSubsteps = boundedInteger("physics_substeps", config.physicsSubsteps, 128); + if (settings.contains("gravity")) { + const auto& gravity = settings.at("gravity"); + if (!gravity.is_array() || gravity.size() != 3) + throw std::invalid_argument("gravity must contain three numbers"); + config.gravity = gravity.get(); + } + return config; +} +void validatePackagedShaders(const std::filesystem::path& directory) { + for (const auto* name : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"}) { + const auto path = directory / "shaders" / name; + if (!std::filesystem::is_regular_file(path)) + throw std::runtime_error("Packaged shader is missing: " + path.string()); + const auto bytes = faset::read_text(path); + if (bytes.size() < 20 || bytes.size() % 4 != 0 || + static_cast(bytes[0]) != 0x03 || + static_cast(bytes[1]) != 0x02 || + static_cast(bytes[2]) != 0x23 || + static_cast(bytes[3]) != 0x07) + throw std::runtime_error("Packaged shader is not a SPIR-V module: " + path.string()); + } +} +} // namespace +int main(int argc, char** argv) { + try { + std::filesystem::path scenePath, assetsPath, capturePath, controlPath; + bool headless = false, validateOnly = false; + std::uint64_t maximumFrames = 0; + std::set options; + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "--help") { + std::cout << "faset_player [--scene PATH] [--assets CACHE] [--frames N] " + "[--headless] [--capture PATH.ppm] [--validate] [--control PATH]\n" + "No --scene: open scene.fscene beside the executable. CACHE contains " + "assets//.\n" + "Headless uses offscreen Vulkan; --frames uses the configured fixed " + "simulation delta.\n" + "--validate checks scene/resources on CPU without gameplay callbacks " + "or Vulkan initialization.\n" + "--control is an optional editor mailbox for pause/resume/step/stop, " + "without world queries.\n" + "Keys: A/D horizontal, W/S vertical, Space jump, E interact, P pause, " + "N single-step, Escape quit.\n"; + return 0; + } + if (!options.insert(arg).second) + throw std::invalid_argument("Repeated option: " + arg); + auto value = [&]() -> std::string { + if (i + 1 >= argc) + throw std::invalid_argument("Missing value for " + arg); + return argv[++i]; + }; + if (arg == "--scene") + scenePath = value(); + else if (arg == "--assets") + assetsPath = value(); + else if (arg == "--capture") + capturePath = value(); + else if (arg == "--control") + controlPath = value(); + else if (arg == "--frames") + maximumFrames = count(value()); + else if (arg == "--headless") + headless = true; + else if (arg == "--validate") + validateOnly = true; + else + throw std::invalid_argument("Unknown option: " + arg); + } + if (scenePath.empty()) + scenePath = executableDirectory(argv[0]) / "scene.fscene"; + scenePath = std::filesystem::absolute(scenePath).lexically_normal(); + if (assetsPath.empty()) + assetsPath = scenePath.parent_path(); + if (!std::filesystem::is_directory(assetsPath)) + throw std::invalid_argument("Asset cache directory does not exist: " + + assetsPath.string()); + if (headless && maximumFrames == 0) + maximumFrames = 1; + const auto document = faset::player::readScene(scenePath); + const auto config = simulationConfig(document); + const auto executableRoot = executableDirectory(argv[0]); + if (scenePath.extension() == ".fscene" && + std::filesystem::equivalent(scenePath.parent_path(), executableRoot)) + validatePackagedShaders(executableRoot); + if (validateOnly) { + if (!capturePath.empty() || !controlPath.empty()) + throw std::invalid_argument("--validate cannot capture or control a running game"); + faset::runtime::Runtime validator(config); + validator.load(document); + faset::player::SceneView view(assetsPath); + view.build(document, 16.0f / 9.0f); + for (const auto& diagnostic : view.diagnostics()) { + if (diagnostic.starts_with("error:")) + throw std::runtime_error(diagnostic); + std::cerr << diagnostic << '\n'; + } + std::cout << nlohmann::json{{"validated", true}, + {"dimension", document.value("dimension", 3)}} + .dump() + << '\n'; + return 0; + } + faset::runtime::Runtime world(config); + faset::gameplay::registerGameplay(world); + world.load(document); + faset::player::SceneView view(assetsPath); + faset::render::Renderer renderer( + {1280, 720, document.value("name", std::string("Faset Player")), headless, true}); + std::set held; + bool stop = false; + std::uint64_t frames = 0; + std::size_t logCursor = 0; + std::set reported; + std::uint64_t controlSequence = 0; + std::string previousControl; + auto previous = std::chrono::steady_clock::now(); + while (!stop && !renderer.should_close() && + (maximumFrames == 0 || frames < maximumFrames)) { + faset::runtime::InputState input; + bool singleStep = false; + if (!controlPath.empty() && std::filesystem::is_regular_file(controlPath)) { + try { + if (std::filesystem::file_size(controlPath) > 65536) + throw std::runtime_error("control message exceeds 64 KiB"); + auto content = faset::read_text(controlPath); + if (content != previousControl) { + previousControl = content; + const auto message = nlohmann::json::parse(content); + const auto& sequence = message.at("sequence"); + if (!(sequence.is_number_unsigned() || + (sequence.is_number_integer() && sequence.get() >= 0))) + throw std::invalid_argument( + "control sequence must be a nonnegative integer"); + const auto value = sequence.get(); + if (value > controlSequence) { + const auto command = message.at("command").get(); + if (command == "pause") + world.setPaused(true); + else if (command == "resume") + world.setPaused(false); + else if (command == "step") { + world.setPaused(true); + singleStep = true; + } else if (command == "stop") + stop = true; + else + throw std::invalid_argument("unsupported control command"); + controlSequence = value; + } + } + } catch (const std::exception& error) { + const std::string message = + std::string("Player control ignored: ") + error.what(); + if (reported.insert(message).second) + std::cerr << message << '\n'; + } + } + for (const auto& event : renderer.poll_events()) { + using Type = faset::render::Event::Type; + if (event.type == Type::Quit) + stop = true; + if (event.type == Type::FocusLost) + held.clear(); + std::string key = event.key; + std::transform(key.begin(), key.end(), key.begin(), + [](unsigned char c) { return static_cast(std::toupper(c)); }); + if (event.type == Type::KeyUp) + held.erase(key); + if (event.type == Type::KeyDown) { + held.insert(key); + if (key == "ESCAPE") + stop = true; + if (!event.repeat) { + if (key == "SPACE") + input.jumpPressed = true; + if (key == "E") + input.interactPressed = true; + if (key == "P") + world.setPaused(!world.paused()); + if (key == "N") + singleStep = true; + } + } + } + if (stop) + break; + input.horizontal = float(held.contains("D") || held.contains("RIGHT")) - + float(held.contains("A") || held.contains("LEFT")); + input.vertical = float(held.contains("W") || held.contains("UP")) - + float(held.contains("S") || held.contains("DOWN")); + const auto now = std::chrono::steady_clock::now(); + const double elapsed = maximumFrames + ? config.fixedDelta + : std::chrono::duration(now - previous).count(); + previous = now; + if (singleStep && world.paused()) + world.singleStep(input); + else + world.advance(elapsed, input); + auto snapshot = view.build(world.snapshotJson(), static_cast(renderer.width()) / + std::max(1u, renderer.height())); + for (const auto& diagnostic : view.diagnostics()) { + if (diagnostic.starts_with("error:")) + throw std::runtime_error(diagnostic); + if (reported.insert(diagnostic).second) + std::cerr << diagnostic << '\n'; + } + while (logCursor < world.diagnostics().size()) + std::cerr << world.diagnostics()[logCursor++] << '\n'; + renderer.render(snapshot); + ++frames; + } + if (!capturePath.empty()) { + if (frames == 0) + throw std::runtime_error("No frame was rendered for capture"); + renderer.capture(capturePath); + } + const auto stats = renderer.stats(); + std::cout << nlohmann::json{{"frames", frames}, + {"ticks", world.snapshot().tick}, + {"dimension", document.value("dimension", 3)}, + {"device", stats.device}, + {"validation_errors", stats.validation_errors}} + .dump() + << '\n'; + return stats.validation_errors == 0 ? 0 : 2; + } catch (const std::exception& error) { + std::cerr << "Player failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/apps/schema_exporter_main.cpp b/apps/schema_exporter_main.cpp new file mode 100644 index 0000000..396816c --- /dev/null +++ b/apps/schema_exporter_main.cpp @@ -0,0 +1,35 @@ +#include "Gameplay.hpp" +#include +#include +#include + +int main(int argc, char** argv) { + try { + std::filesystem::path output; + for (int i = 1; i < argc; ++i) { + const std::string argument = argv[i]; + if (argument == "--help") { + std::cout << "faset_schema_exporter [--output PATH]\nExports declarative gameplay " + "schemas without creating a world.\n"; + return 0; + } + if (argument == "--output" && i + 1 < argc && output.empty()) + output = argv[++i]; + else + throw std::invalid_argument("Unknown, repeated or incomplete argument: " + + argument); + } + const auto types = faset::gameplay::schema(); + if (!types.is_array()) + throw std::runtime_error("Gameplay schema() must return a type array"); + const nlohmann::json manifest{{"format", "faset.schema"}, {"version", 1}, {"types", types}}; + if (output.empty()) + std::cout << manifest.dump(2) << '\n'; + else + faset::atomic_write_json(output, manifest); + return 0; + } catch (const std::exception& error) { + std::cerr << "Schema export failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/assets/fonts/NOTICE.md b/assets/fonts/NOTICE.md new file mode 100644 index 0000000..41c351b --- /dev/null +++ b/assets/fonts/NOTICE.md @@ -0,0 +1,8 @@ +# Noto Sans + +The Faset Editor bundles Noto Sans under the SIL Open Font License 1.1. +The unmodified font and complete license are `NotoSans.ttf` and `OFL.txt`. + +Source: [google/fonts, Noto Sans](https://github.com/google/fonts/tree/a54f7446f84a1125ef6bf08baa46f3639e8905e0/ofl/notosans). +The source path, exact commit and SHA-256 are recorded in `source.json`. +This font is an Editor asset; exported Players do not require it. diff --git a/assets/fonts/NotoSans.ttf b/assets/fonts/NotoSans.ttf new file mode 100644 index 0000000..7557504 Binary files /dev/null and b/assets/fonts/NotoSans.ttf differ diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..6843f31 --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/source.json b/assets/fonts/source.json new file mode 100644 index 0000000..ca532d9 --- /dev/null +++ b/assets/fonts/source.json @@ -0,0 +1,10 @@ +{ + "name": "Noto Sans", + "repository": "https://github.com/google/fonts", + "commit": "a54f7446f84a1125ef6bf08baa46f3639e8905e0", + "path": "ofl/notosans/NotoSans[wdth,wght].ttf", + "sha256": "bfb7bb691513f12e734dc346c03a03f784912432d7e3fa8e56efcf906fe86b3d", + "license": "SIL Open Font License 1.1", + "license_file": "OFL.txt", + "license_sha256": "cee9892f9f0cc8fe882c9e9537ee6a89621d86ee7ceaf70b02e2b2b1c25c061a" +} diff --git a/assets/ui/dark.json b/assets/ui/dark.json new file mode 100644 index 0000000..0828d82 --- /dev/null +++ b/assets/ui/dark.json @@ -0,0 +1,16 @@ +{ + "background": [0.073, 0.078, 0.090, 1], + "surface": [0.102, 0.108, 0.122, 1], + "raised": [0.145, 0.151, 0.169, 1], + "hover": [0.19, 0.197, 0.219, 1], + "border": [0.24, 0.25, 0.278, 1], + "text": [0.88, 0.889, 0.91, 1], + "muted": [0.59, 0.61, 0.65, 1], + "accent": [0.65, 0.60, 0.88, 1], + "selection": [0.26, 0.245, 0.35, 1], + "danger": [0.92, 0.39, 0.38, 1], + "font_size": 14, + "row_height": 28, + "padding": 8, + "gap": 4 +} diff --git a/assets/ui/editor-layout.json b/assets/ui/editor-layout.json new file mode 100644 index 0000000..09c8d08 --- /dev/null +++ b/assets/ui/editor-layout.json @@ -0,0 +1,19 @@ +{ + "root": { + "id": "root", "kind": "column", "layout": {"gap": 1}, + "children": [ + {"id": "menubar", "kind": "panel", "layout": {"height": 34, "padding": 3}}, + {"id": "toolbar", "kind": "panel", "layout": {"height": 40, "padding": 5}}, + {"id": "workspace", "kind": "row", "layout": {"flex": 1, "gap": 1}, "children": [ + {"id": "scene_panel", "kind": "panel", "layout": {"width": 224, "min_width": 150, "gap": 0}}, + {"id": "left_divider", "kind": "divider", "layout": {"width": 5}}, + {"id": "viewport", "kind": "viewport", "layout": {"flex": 1, "min_width": 180}}, + {"id": "right_divider", "kind": "divider", "layout": {"width": 5}}, + {"id": "inspector_panel", "kind": "panel", "layout": {"width": 300, "min_width": 210, "gap": 0}} + ]}, + {"id": "bottom_divider", "kind": "divider", "layout": {"height": 5}}, + {"id": "bottom_panel", "kind": "panel", "layout": {"height": 184, "min_height": 90, "gap": 0}}, + {"id": "statusbar", "kind": "panel", "layout": {"height": 26, "padding": 0}} + ] + } +} diff --git a/cmake/Assets.cmake b/cmake/Assets.cmake index f4f0764..29ea1d0 100644 --- a/cmake/Assets.cmake +++ b/cmake/Assets.cmake @@ -1,9 +1,13 @@ +add_library(faset_asset_data STATIC ${PROJECT_SOURCE_DIR}/src/assets/asset_data.cpp) +target_compile_features(faset_asset_data PUBLIC cxx_std_20) +target_include_directories(faset_asset_data PUBLIC ${PROJECT_SOURCE_DIR}/include) +target_link_libraries(faset_asset_data PUBLIC faset_core nlohmann_json::nlohmann_json) add_library(faset_assets ${PROJECT_SOURCE_DIR}/src/assets/asset_pipeline.cpp ${PROJECT_SOURCE_DIR}/src/assets/cgltf.cpp) target_compile_features(faset_assets PUBLIC cxx_std_20) target_include_directories(faset_assets PUBLIC ${PROJECT_SOURCE_DIR}/include) -target_link_libraries(faset_assets PUBLIC faset_core nlohmann_json::nlohmann_json PRIVATE faset_cgltf) +target_link_libraries(faset_assets PUBLIC faset_asset_data PRIVATE faset_cgltf faset_stb) if(BUILD_TESTING) add_executable(faset_assets_tests ${PROJECT_SOURCE_DIR}/tests/assets_pipeline.cpp) target_link_libraries(faset_assets_tests PRIVATE faset_assets) diff --git a/cmake/Authoring.cmake b/cmake/Authoring.cmake index 22c4cc1..2704c9c 100644 --- a/cmake/Authoring.cmake +++ b/cmake/Authoring.cmake @@ -1,4 +1,4 @@ -add_library(faset_authoring STATIC src/authoring/schema.cpp src/authoring/service.cpp src/authoring/templates.cpp) +add_library(faset_authoring STATIC src/authoring/schema.cpp src/authoring/service.cpp src/authoring/templates.cpp src/authoring/transforms.cpp) target_include_directories(faset_authoring PUBLIC "${PROJECT_SOURCE_DIR}/include") target_link_libraries(faset_authoring PUBLIC faset_core) if(BUILD_TESTING) diff --git a/cmake/BuildService.cmake b/cmake/BuildService.cmake new file mode 100644 index 0000000..808c666 --- /dev/null +++ b/cmake/BuildService.cmake @@ -0,0 +1,10 @@ +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 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) + target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}") + add_test(NAME process_and_cook COMMAND faset_build_service_tests) +endif() diff --git a/cmake/EditorCommands.cmake b/cmake/EditorCommands.cmake new file mode 100644 index 0000000..3c03cb1 --- /dev/null +++ b/cmake/EditorCommands.cmake @@ -0,0 +1,8 @@ +add_library(faset_editor_commands STATIC src/editor/commands.cpp src/editor/mcp.cpp) +target_link_libraries(faset_editor_commands PUBLIC faset_authoring) +target_include_directories(faset_editor_commands PUBLIC include) +if(BUILD_TESTING) + add_executable(faset_mcp_tests tests/mcp_tests.cpp) + target_link_libraries(faset_mcp_tests PRIVATE faset_editor_commands) + add_test(NAME editor_mcp COMMAND faset_mcp_tests) +endif() diff --git a/cmake/EditorUI.cmake b/cmake/EditorUI.cmake new file mode 100644 index 0000000..3f7fddd --- /dev/null +++ b/cmake/EditorUI.cmake @@ -0,0 +1,15 @@ +if(TARGET faset_ui AND TARGET faset_editor_session AND TARGET faset_scene_view) + add_library(faset_editor_ui STATIC ${PROJECT_SOURCE_DIR}/src/editor/editor_ui.cpp) + target_link_libraries(faset_editor_ui PUBLIC faset_ui faset_editor_session faset_scene_view) + if(BUILD_TESTING) + add_executable(faset_editor_ui_tests ${PROJECT_SOURCE_DIR}/tests/editor_ui_tests.cpp) + target_link_libraries(faset_editor_ui_tests PRIVATE faset_editor_ui) + target_compile_definitions(faset_editor_ui_tests PRIVATE FASET_TEST_ENGINE="${PROJECT_SOURCE_DIR}") + if(TARGET faset_example_plugin) + add_dependencies(faset_editor_ui_tests faset_example_plugin) + target_compile_definitions(faset_editor_ui_tests PRIVATE FASET_TEST_PLUGIN_DIRECTORY="${PROJECT_BINARY_DIR}/example-plugin") + endif() + add_test(NAME editor_ui_authoring COMMAND faset_editor_ui_tests) + set_tests_properties(editor_ui_authoring PROPERTIES LABELS "gpu") + endif() +endif() diff --git a/cmake/Player.cmake b/cmake/Player.cmake new file mode 100644 index 0000000..3d7978d --- /dev/null +++ b/cmake/Player.cmake @@ -0,0 +1,24 @@ +if(TARGET faset_gameplay) + add_executable(faset_schema_exporter ${PROJECT_SOURCE_DIR}/apps/schema_exporter_main.cpp) + target_link_libraries(faset_schema_exporter PRIVATE faset_core faset_gameplay) +endif() + +if(TARGET faset_runtime AND TARGET faset_render AND TARGET faset_assets) + add_library(faset_scene_view STATIC + ${PROJECT_SOURCE_DIR}/src/player/SceneView.cpp + ${PROJECT_SOURCE_DIR}/src/player/scene_io.cpp) + target_include_directories(faset_scene_view PUBLIC ${PROJECT_SOURCE_DIR}/include) + target_link_libraries(faset_scene_view PUBLIC faset_render faset_core PRIVATE faset_asset_data) + if(TARGET faset_stb) + target_link_libraries(faset_scene_view PRIVATE faset_stb) + target_compile_definitions(faset_scene_view PRIVATE FASET_HAS_STB=1) + endif() + add_executable(faset_player ${PROJECT_SOURCE_DIR}/apps/player_main.cpp) + target_link_libraries(faset_player PRIVATE faset_scene_view faset_runtime faset_gameplay) + install(TARGETS faset_player RUNTIME DESTINATION .) + if(BUILD_TESTING) + add_executable(faset_player_tests ${PROJECT_SOURCE_DIR}/tests/runtime_player_tests.cpp) + target_link_libraries(faset_player_tests PRIVATE faset_scene_view faset_runtime faset_assets) + add_test(NAME player_scene_contracts COMMAND faset_player_tests) + endif() +endif() diff --git a/cmake/Plugins.cmake b/cmake/Plugins.cmake new file mode 100644 index 0000000..cef9222 --- /dev/null +++ b/cmake/Plugins.cmake @@ -0,0 +1,33 @@ +# Native plugins deliberately require the exact SDK, compiler, CRT and build. +file(GLOB_RECURSE FASET_SDK_INPUTS CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/include/faset/*.hpp" + "${PROJECT_SOURCE_DIR}/include/faset/*.h" + "${PROJECT_SOURCE_DIR}/src/editor/*.cpp" + "${PROJECT_SOURCE_DIR}/src/authoring/*.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${FASET_SDK_INPUTS} "${PROJECT_SOURCE_DIR}/dependencies.lock.json") +set(FASET_SDK_SIGNATURE "${PROJECT_VERSION};${CMAKE_SYSTEM_NAME};${CMAKE_SYSTEM_PROCESSOR};${CMAKE_SIZEOF_VOID_P};${CMAKE_CXX_COMPILER_ID};${CMAKE_CXX_COMPILER_VERSION};${CMAKE_CXX_COMPILER_FRONTEND_VARIANT};${CMAKE_MSVC_RUNTIME_LIBRARY};${CMAKE_BUILD_TYPE};${CMAKE_CXX_FLAGS};${FASET_SANITIZERS}") +foreach(source IN LISTS FASET_SDK_INPUTS) + file(SHA256 "${source}" source_hash) + string(APPEND FASET_SDK_SIGNATURE ";${source_hash}") +endforeach() +file(SHA256 "${PROJECT_SOURCE_DIR}/dependencies.lock.json" dependency_hash) +string(SHA256 FASET_EDITOR_SDK_FINGERPRINT "${FASET_SDK_SIGNATURE};${dependency_hash}") +file(MAKE_DIRECTORY "${PROJECT_BINARY_DIR}/generated/faset/editor") +file(WRITE "${PROJECT_BINARY_DIR}/generated/faset/editor/sdk_build.h" + "#pragma once\n#define FASET_EDITOR_SDK_FINGERPRINT \"${FASET_EDITOR_SDK_FINGERPRINT}\"\n") +add_library(faset_editor_sdk INTERFACE) +target_include_directories(faset_editor_sdk INTERFACE "${PROJECT_SOURCE_DIR}/include" "${PROJECT_BINARY_DIR}/generated") +add_library(faset_editor_plugins STATIC src/editor/plugins.cpp) +target_link_libraries(faset_editor_plugins PUBLIC faset_editor_commands faset_editor_sdk PRIVATE ${CMAKE_DL_LIBS}) +add_library(faset_example_plugin MODULE examples/extensions/beacon/Editor.cpp) +target_link_libraries(faset_example_plugin PRIVATE faset_editor_sdk nlohmann_json::nlohmann_json) +set_target_properties(faset_example_plugin PROPERTIES PREFIX "" LIBRARY_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/example-plugin" RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/example-plugin") +file(GENERATE OUTPUT "${PROJECT_BINARY_DIR}/example-plugin/beacon.faset-plugin.json" CONTENT + "{\n \"format\":\"faset.editor_plugin\",\n \"version\":1,\n \"id\":\"example.beacon\",\n \"module_version\":\"1.0.0\",\n \"kind\":\"editor\",\n \"api_version\":1,\n \"build_fingerprint\":\"${FASET_EDITOR_SDK_FINGERPRINT}\",\n \"library\":\"$\",\n \"dependencies\":[]\n}\n") +if(BUILD_TESTING) + add_executable(faset_plugin_tests tests/plugin_tests.cpp) + target_link_libraries(faset_plugin_tests PRIVATE faset_editor_plugins faset_runtime) + target_compile_definitions(faset_plugin_tests PRIVATE FASET_TEST_PLUGIN_DIRECTORY="${PROJECT_BINARY_DIR}/example-plugin") + add_dependencies(faset_plugin_tests faset_example_plugin) + add_test(NAME editor_plugins COMMAND faset_plugin_tests) +endif() diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 1f27025..51cbdb5 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -6,13 +6,24 @@ target_compile_features(faset_runtime PUBLIC cxx_std_20) target_include_directories(faset_runtime PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../include) target_link_libraries(faset_runtime PUBLIC nlohmann_json::nlohmann_json PRIVATE EnTT::EnTT box2d box3d) -add_library(faset_gameplay STATIC ${CMAKE_CURRENT_LIST_DIR}/../examples/gameplay/Gameplay.cpp) +set(FASET_GAMEPLAY_SOURCE_DIR "${PROJECT_SOURCE_DIR}/examples/gameplay" CACHE PATH "Directory containing the game's Gameplay.cpp and Gameplay.hpp") +if(NOT EXISTS "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.cpp" OR NOT EXISTS "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.hpp") + message(FATAL_ERROR "FASET_GAMEPLAY_SOURCE_DIR must contain Gameplay.cpp and Gameplay.hpp") +endif() +add_library(faset_gameplay STATIC "${FASET_GAMEPLAY_SOURCE_DIR}/Gameplay.cpp") add_library(Faset::Gameplay ALIAS faset_gameplay) -target_include_directories(faset_gameplay PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../examples/gameplay) +target_include_directories(faset_gameplay PUBLIC "${FASET_GAMEPLAY_SOURCE_DIR}") target_link_libraries(faset_gameplay PUBLIC faset_runtime) if(BUILD_TESTING) add_executable(faset_runtime_tests ${CMAKE_CURRENT_LIST_DIR}/../tests/runtime_tests.cpp) - target_link_libraries(faset_runtime_tests PRIVATE faset_runtime faset_gameplay) + if(FASET_GAMEPLAY_SOURCE_DIR STREQUAL "${PROJECT_SOURCE_DIR}/examples/gameplay") + target_link_libraries(faset_runtime_tests PRIVATE faset_runtime faset_gameplay) + else() + add_library(faset_gameplay_test_fixture STATIC ${CMAKE_CURRENT_LIST_DIR}/../examples/gameplay/Gameplay.cpp) + target_include_directories(faset_gameplay_test_fixture PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../examples/gameplay) + target_link_libraries(faset_gameplay_test_fixture PUBLIC faset_runtime) + target_link_libraries(faset_runtime_tests PRIVATE faset_runtime faset_gameplay_test_fixture) + endif() add_test(NAME runtime_contracts COMMAND faset_runtime_tests) endif() diff --git a/cmake/Tutorials.cmake b/cmake/Tutorials.cmake new file mode 100644 index 0000000..64e4733 --- /dev/null +++ b/cmake/Tutorials.cmake @@ -0,0 +1,14 @@ +if(BUILD_TESTING AND TARGET faset_runtime) + foreach(tutorial moving following spawning physics) + set(tutorial_dir "${PROJECT_SOURCE_DIR}/examples/tutorials/${tutorial}") + add_library(faset_tutorial_${tutorial} STATIC "${tutorial_dir}/Gameplay.cpp") + target_include_directories(faset_tutorial_${tutorial} PUBLIC "${tutorial_dir}") + target_link_libraries(faset_tutorial_${tutorial} PUBLIC faset_runtime) + add_executable(faset_tutorial_${tutorial}_tests "${PROJECT_SOURCE_DIR}/tests/runtime_tutorials.cpp") + target_link_libraries(faset_tutorial_${tutorial}_tests PRIVATE faset_tutorial_${tutorial}) + target_compile_definitions(faset_tutorial_${tutorial}_tests PRIVATE + FASET_TUTORIAL_NAME="${tutorial}" + FASET_TUTORIAL_SCENE="${tutorial_dir}/scene.json") + add_test(NAME tutorial_${tutorial} COMMAND faset_tutorial_${tutorial}_tests) + endforeach() +endif() diff --git a/cmake/UI.cmake b/cmake/UI.cmake new file mode 100644 index 0000000..c634534 --- /dev/null +++ b/cmake/UI.cmake @@ -0,0 +1,59 @@ +# FreeType rasterization + HarfBuzz shaping. The default uses verified pinned archives. +option(FASET_USE_SYSTEM_TEXT_LIBRARIES "Use system FreeType/HarfBuzz instead of pinned SDK versions" OFF) +if(FASET_USE_SYSTEM_TEXT_LIBRARIES) + find_package(Freetype QUIET) +endif() +if(NOT TARGET Freetype::Freetype) + include(FetchContent) + set(FT_DISABLE_ZLIB ON CACHE BOOL "" FORCE) + set(FT_DISABLE_BZIP2 ON CACHE BOOL "" FORCE) + set(FT_DISABLE_PNG ON CACHE BOOL "" FORCE) + set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE) + set(FT_DISABLE_HARFBUZZ ON CACHE BOOL "" FORCE) + faset_dependency(freetype) + if(NOT TARGET Freetype::Freetype) + add_library(Freetype::Freetype ALIAS freetype) + endif() +endif() +if(FASET_USE_SYSTEM_TEXT_LIBRARIES) + find_package(harfbuzz CONFIG QUIET) +endif() +if(TARGET harfbuzz::harfbuzz) + set(FASET_HARFBUZZ_TARGET harfbuzz::harfbuzz) +else() + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND AND FASET_USE_SYSTEM_TEXT_LIBRARIES) + pkg_check_modules(FASET_HARFBUZZ QUIET IMPORTED_TARGET harfbuzz) + endif() + if(TARGET PkgConfig::FASET_HARFBUZZ) + set(FASET_HARFBUZZ_TARGET PkgConfig::FASET_HARFBUZZ) + else() + include(FetchContent) + set(HB_HAVE_FREETYPE ON CACHE BOOL "" FORCE) + set(HB_BUILD_UTILS OFF CACHE BOOL "" FORCE) + set(HB_BUILD_SUBSET OFF CACHE BOOL "" FORCE) + faset_dependency(harfbuzz) + set(FASET_HARFBUZZ_TARGET harfbuzz) + endif() +endif() +add_library(faset_ui + ${PROJECT_SOURCE_DIR}/src/ui/ui.cpp + ${PROJECT_SOURCE_DIR}/src/ui/text.cpp + ${PROJECT_SOURCE_DIR}/src/ui/font.cpp) +target_compile_features(faset_ui PUBLIC cxx_std_20) +target_include_directories(faset_ui PUBLIC ${PROJECT_SOURCE_DIR}/include) +target_link_libraries(faset_ui PUBLIC faset_core PRIVATE Freetype::Freetype ${FASET_HARFBUZZ_TARGET}) +if(BUILD_TESTING) + add_executable(faset_ui_tests ${PROJECT_SOURCE_DIR}/tests/ui_tests.cpp) + target_link_libraries(faset_ui_tests PRIVATE faset_ui) + target_compile_definitions(faset_ui_tests PRIVATE FASET_TEST_FONT="${PROJECT_SOURCE_DIR}/assets/fonts/NotoSans.ttf") + add_test(NAME ui_widgets COMMAND faset_ui_tests) + if(TARGET faset_render) + add_executable(faset_ui_render_test ${PROJECT_SOURCE_DIR}/tests/ui_render_test.cpp) + target_link_libraries(faset_ui_render_test PRIVATE faset_ui faset_render) + target_compile_definitions(faset_ui_render_test PRIVATE FASET_TEST_FONT="${PROJECT_SOURCE_DIR}/assets/fonts/NotoSans.ttf" FASET_UI_THEME="${PROJECT_SOURCE_DIR}/assets/ui/dark.json") + add_test(NAME ui_render COMMAND faset_ui_render_test ${CMAKE_BINARY_DIR}/ui-test.ppm) + set_tests_properties(ui_render PROPERTIES LABELS "gpu") + endif() +endif() +install(DIRECTORY ${PROJECT_SOURCE_DIR}/assets/fonts ${PROJECT_SOURCE_DIR}/assets/ui DESTINATION assets) diff --git a/dependencies.lock.json b/dependencies.lock.json index 7e9b1a7..39aaf6f 100644 --- a/dependencies.lock.json +++ b/dependencies.lock.json @@ -64,6 +64,22 @@ "url": "https://codeload.github.com/nothings/stb/tar.gz/2c980bb59875b0d32144a71867fbdebb2f77cd20", "sha256": "9a955b1b49a4410088a2e0ee2a9c057c3c907d0c1d75454144cb980aca0ba515", "license": "MIT OR Unlicense" + }, + "freetype": { + "repository": "https://github.com/freetype/freetype", + "version": "2.13.3", + "commit": "42608f77f20749dd6ddc9e0536788eaad70ea4b5", + "url": "https://codeload.github.com/freetype/freetype/tar.gz/42608f77f20749dd6ddc9e0536788eaad70ea4b5", + "sha256": "68ce87bb59ea209eb7350f41a94a27519ce16b37011b475a1e62d4abee154b66", + "license": "FTL" + }, + "harfbuzz": { + "repository": "https://github.com/harfbuzz/harfbuzz", + "version": "10.4.0", + "commit": "3ef8709829a5884517ad91a97b32b9435b2f20d1", + "url": "https://codeload.github.com/harfbuzz/harfbuzz/tar.gz/3ef8709829a5884517ad91a97b32b9435b2f20d1", + "sha256": "61757682efefaa93a6eab7d988d139827e8f35071327cd93e23dc7fd77e0162b", + "license": "MIT" } } } diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 8cb77d8..444e3dc 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -28,9 +28,60 @@ Observed validation on Linux: - MkDocs strict build passed with MkDocs 1.6.1 and Material 9.7.7. The full editor, user-project build pipeline, MCP integration, standalone exports, -and Windows acceptance are still being implemented. This checkpoint is not the MVP release. +and Windows acceptance were still being implemented at that checkpoint. Later progress is recorded below. Known intermediate constraints include box-only physics colliders, root-level physics -objects, static glTF triangles/UV0, a conservative serial renderer, and unfinished -world-preserving authoring reparent operations. These remain implementation work or +objects, static glTF triangles/UV0, a conservative serial renderer, and then-unfinished +world-preserving authoring reparent operations (completed in checkpoint 2). These remain implementation work or explicit profile limits to review during final acceptance. + +## Checkpoint 2 — integrated Editor, gameplay iteration and export + +Implemented and integrated: + +- Separate Player and SchemaExporter executables; statically linked project gameplay, + configurable simulation settings, real contact-based grounded queries, and four + compiled scripting tutorials embedded directly into the English MkDocs manual. +- Shared authoring commands and a real stdio MCP server, optimistic revisions, + transactional retries, jobs/cancellation, recovery of unsaved documents, and + viewport capture in graphical sessions only. +- Cancellable native subprocess execution, incremental project builds, schema export, + binary scene/resource packaging, distinct Debug development and Release export + directories, immutable published generations and retained last-good builds. +- A native retained UI with pinned FreeType/HarfBuzz and bundled Noto Sans: + scene tree, schema Inspector, viewport camera/picking/gizmos, assets, diagnostics, + build/play controls, commands, recovery and startup-loaded extension action panels. +- Exact-build native Editor SDK, dependency validation, owner-bound registrations, + example Beacon runtime component/editor command/panel, and unknown-data preservation. +- Full world-preserving TRS reparent with explicit rejection of unsupported shear. +- Read-only cooked asset target for Player. Import/build/editor/MCP services remain + outside the shipping runtime dependency graph. + +Observed validation: + +- Integrated Linux Clang 21 build: 19 CTests pass, including GPU rendering, retained + widgets, Editor authoring interaction, actual plugin loading, real MCP stdio, + process/cook contracts, and all four compiled gameplay tutorials. +- Real 2D and imported-glTF 3D standalone exports render with zero Vulkan validation + errors on NVIDIA RTX 2080 Ti. Changing gameplay rebuilds metadata; failed C++ + compilation preserves the last successful published build. +- Runtime/tutorial checks also pass with Clang 18; runtime ASan/UBSan checks pass. +- Unmodified Blender 4.5.3 exports through the optional add-on. Real Editor imports + preserve output IDs after rename/geometry edits, report deleted-output conflicts, + retain the last generation on failure, and preserve separate scene placement/color. + Reproduce with `tools/verify_blender_roundtrip.py --blender PATH --editor PATH`. +- Strict MkDocs build passes. Tutorial source snippets are compiled by CTest. +- Previous checkpoint headless Linux and Windows GitHub CI passed after portability + fixes. New checkpoint and full Windows graphical/export checks are separate work; + Linux validation does not imply Windows validation. + +This is an implementation checkpoint, not an MVP release. Finished playable sample +projects, complete Windows graphical/export acceptance, fresh-install checks, +performance measurements, and final UX review remain. Standalone image import is +integrated but its dedicated PNG/JPEG edge-case checks are the next asset task. + +The baseline profile currently uses box colliders, root-level rigid bodies, static +triangle glTF meshes/UV0, basic PBR/directional shadows and a conservative serial +Vulkan renderer. Advanced rendering and broader content profiles remain later work. +The generated UI reference determines visual direction only; architecture, behavior +and acceptance criteria remain authoritative. diff --git a/docs/manual/editor/extensions.md b/docs/manual/editor/extensions.md new file mode 100644 index 0000000..231a168 --- /dev/null +++ b/docs/manual/editor/extensions.md @@ -0,0 +1,58 @@ +# Native Editor extensions + +Editor extensions are startup-loaded `.so`/`.dll` modules. They are trusted native +code inside the Editor process. Gameplay remains statically linked into the separate +Player; an Editor extension is never required by the exported game. + +The initial SDK registers commands and small action panels. Inspector fields for +runtime components come from the separate gameplay SchemaExporter. Rich custom +widgets and a general marketplace/package manager are later work. + +## Example: Beacon + +`examples/extensions/beacon` contains: + +- `Beacon.hpp`: a runtime component schema and rotating-object behavior. +- `Editor.cpp`: an Editor command and a panel that creates a Beacon in one transaction. + +Include `Beacon.hpp` from your project's `Scripts/Gameplay.cpp`. Call +`beacon::register_behavior(runtime)` from `registerGameplay`, and append +`beacon::schema()` to the array returned by `schema()`. Build gameplay so the Editor +can load the new metadata. Read [the first behavior tutorial](../scripting/first-behavior.md) +for the complete gameplay registration convention. + +The normal engine build produces `example-plugin/` inside its build directory. +Copy its `beacon.faset-plugin.json` and native library into your project's +`Plugins/` directory, then restart the Editor. The **Beacon tools** panel offers +**Add Beacon**. Its command is also discoverable through MCP as +`plugin_example_beacon_create` and requires a document ID. + +Disabling the Editor extension removes its panel and command after restart. It does +not erase its saved component data. Removing the runtime registration leaves an +unknown component preserved by authoring; exporting that scene fails until the +runtime dependency is restored or the component is deliberately removed. + +## Compatibility and ownership + +A manifest includes module ID/version, `kind: editor`, API version, native library, +build fingerprint and dependencies with exact versions. The loader validates the +complete graph for missing dependencies and cycles before calling entry points. +A failed dependency prevents loading its dependents. + +The fingerprint includes the SDK sources, dependency lock, platform, architecture, +compiler version, configuration and CRT settings. Rebuild a plugin for the exact +Editor SDK. Compatibility across arbitrary C++ builds is not promised. Reloading an +updated native module requires restarting the Editor. + +`include/faset/editor/plugin_api.h` defines a small C interface. Borrowed UTF-8 JSON +strings are valid during a call; responses are copied through a receiving callback. +Each side frees its own allocations. Register only during startup and invoke the SDK +on the Editor thread. Commands must use their owning module's name prefix. Panels +can invoke owned commands; a `$document` argument resolves to the active authoring +document. No EnTT registry, live runtime world, or arbitrary C++ object pointer is +exposed as a scripting API. + +The native plugin test loads the actual example module, creates a component, +checks Undo, runs its separate runtime behavior, unloads command registrations, +opens the saved scene without the package, and rejects incompatible/cyclic/missing +plugin dependencies. diff --git a/docs/manual/editor/mcp.md b/docs/manual/editor/mcp.md new file mode 100644 index 0000000..fc02753 --- /dev/null +++ b/docs/manual/editor/mcp.md @@ -0,0 +1,85 @@ +# Use the Editor through MCP + +Faset exposes the same authoring commands to its native interface and to MCP. MCP +runs in the **Editor**. It never provides access to a running game's entities, and +is not linked into the Player or SchemaExporter. + +Start a headless server after building Faset: + +```sh +build/linux-debug/faset_editor --project /absolute/path/to/game --mcp +``` + +Configure your MCP client to launch that executable with those arguments using a +stdio transport. On Windows select `build/windows-debug/faset_editor.exe`. Use +absolute paths, including the project path. Add `--gui` when the same process +should display the native Editor; a graphical session and supported GPU are then +required. A separately launched Editor process owns a separate in-memory session. + +The transport uses newline-delimited JSON-RPC and the MCP `2025-06-18` lifecycle. +Initialize, send `notifications/initialized`, then discover commands with +`tools/list`. Standard output is reserved for protocol messages. See the official +[MCP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) +and [tool contracts](https://modelcontextprotocol.io/specification/2025-06-18/server/tools). + +## Authoring workflow + +1. Use `faset_documents` or create/open a document. +2. Query `faset_schema` for stable type and field IDs and their constraints. +3. Query the document for its persistent ID and current revision. +4. Submit one `faset_scene_edit` batch with that revision. +5. Save with `faset_document_save`. Use Undo/Redo for authoring changes. + +For example, the arguments to `faset_scene_edit` can be: + +```json +{ + "document": "REPLACE_WITH_DOCUMENT_ID", + "revision": 0, + "idempotency_key": "create-first-object", + "operations": [{"op": "entity.create", "name": "Player"}] +} +``` + +The command returns the new revision and scene with generated IDs. Repeating the +same batch and retry key in the same session returns its previous result. Reusing +the key for a different payload is an error. An outdated revision produces +`revision.conflict`; query the new state and decide how to apply the intended change. +Do not blindly retry a write using a fresh revision. + +Each successful batch is one Undo step. A failed operation rejects the whole batch. +Manual Inspector edits use this same service, including revision checks. + +## Jobs and capabilities + +`faset_import`, `faset_build`, `faset_export`, and `faset_play` return job IDs. +Use `faset_job` or `faset_jobs` for progress, diagnostics and results. +`faset_job_cancel` requests cancellation; cancelling a build is separate from +undoing a document change. Failed compilation/import preserves the last successful +published generation. + +`faset_capabilities` reports available services. `faset_editor_capture` is present +only in a graphical Editor. It returns an MCP image and can save a project-relative +PNG. A headless server has no screenshot tool. `faset_play_control` provides pause, +resume and single-step process controls; it cannot read or modify game entities. + +`faset_recovery_list` and `faset_recovery_restore` expose crash recovery, including +scenes that have never been saved. Restoring an already open document requires its +current revision. Recovery refuses to overwrite an externally changed scene file. + +## Single-command CLI + +The CLI is useful for scripts that do not need an MCP session: + +```sh +build/linux-debug/faset_editor --project /absolute/path/to/game \ + --command '{"name":"faset_import","arguments":{"path":"Assets/door/manifest.json"}}' --wait +``` + +`--wait` follows a returned job until completion. Failed, cancelled and conflicting +jobs produce a nonzero exit code. This shell quoting example targets Bash; use the +appropriate argument quoting for your Windows shell. + +Validation: unit tests cover protocol errors and shared authoring semantics. +`editor_mcp_stdio` launches the real Editor and verifies initialization, clean JSON +stdout, conflicts, retry behavior, Undo/Redo, saving, reopening, and orderly EOF. diff --git a/docs/manual/getting-started/build.md b/docs/manual/getting-started/build.md index e7aee02..6144c98 100644 --- a/docs/manual/getting-started/build.md +++ b/docs/manual/getting-started/build.md @@ -1,14 +1,14 @@ # Build from source -!!! warning "Foundation checkpoint" - These instructions initially cover the build foundation. The integrated editor, - sample projects, and packaging steps are being added and verified during MVP implementation. +!!! note "Implementation checkpoint" + Linux Editor, Player and export integration are tested. Final Windows graphics/export + acceptance is tracked separately in the implementation report. ## Linux prerequisites The selected toolchain is C++20, CMake 3.25 or later, Ninja, and Clang. Graphical builds need Vulkan 1.3 headers/loader and a compatible driver. -SDL3 is built from a pinned source archive. +SDL3, FreeType and HarfBuzz are built from pinned source archives. On Ubuntu, install the native build tools before configuring: @@ -16,7 +16,7 @@ On Ubuntu, install the native build tools before configuring: sudo apt install clang ninja-build cmake python3 python3-venv pkg-config \ libvulkan-dev vulkan-validationlayers libx11-dev libxext-dev libxrandr-dev \ libxcursor-dev libxi-dev libxfixes-dev libxkbcommon-dev libwayland-dev \ - libfreetype-dev libharfbuzz-dev xvfb + xvfb ``` `xvfb` is used for automated window tests. A normal desktop session does not need it. @@ -30,6 +30,16 @@ cmake --build --preset linux-debug --parallel ctest --preset linux-debug ``` +Create a project and open the native Editor: + +```sh +build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3 +``` + +Use **Build C++** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**. +The Player runs separately. Stop it before changing and rebuilding C++ gameplay. +See [MCP and CLI](../editor/mcp.md) for headless authoring and automation. + For an optimized build use `linux-release`. The `linux-sanitize` preset enables AddressSanitizer and UndefinedBehaviorSanitizer for tests without the graphics backend. diff --git a/docs/manual/index.md b/docs/manual/index.md index db14450..bc5ac4e 100644 --- a/docs/manual/index.md +++ b/docs/manual/index.md @@ -7,7 +7,7 @@ and how those functions interact with scenes, physics, and the editor. !!! warning "Development status" MVP implementation is in progress. A planned feature is not a working feature. Individual guides state their prerequisites and validation status. The current - foundation can be built and tested; a complete editor and game export are not yet available. + Editor, gameplay tutorials and Linux export can be built and tested. Final Windows graphics/export acceptance and complete sample games are still in progress. Start with [how C++ gameplay works](scripting/index.md), then read [frame and physics updates](scripting/lifecycle.md). See diff --git a/docs/manual/scripting/api.md b/docs/manual/scripting/api.md new file mode 100644 index 0000000..e4603b7 --- /dev/null +++ b/docs/manual/scripting/api.md @@ -0,0 +1,70 @@ +# Runtime API reference + +Include `` and use namespace `faset::runtime`. This is the implemented C++ surface used by the compiled tutorials. The runtime is sequential; call it from its owning thread. + +## Register behavior + +`void Runtime::registerBehavior(std::string componentType, Behavior behavior)` registers callbacks before scene loading. An empty or duplicate type, registration during a callback, and registration after entities have loaded are rejected. + +`Behavior::Callback` is `std::function`. Assign it to any of `onStart`, `fixedUpdate`, `update`, `lateUpdate`, and `onDestroy`. Unassigned members do nothing. `Behavior::onCollision` instead accepts `(Runtime&, EntityHandle, const CollisionEvent&)`. + +See [callback order](lifecycle.md) and the complete [registration example](first-behavior.md). + +## Resolve identity + +- `EntityHandle find(const std::string& persistentId) const` returns a handle, or an empty handle when absent. +- `bool valid(EntityHandle) const noexcept` checks session, entity validity, and generation. +- `std::uint64_t session() const noexcept` identifies this runtime session, not the saved scene. + +A handle contains `session`, `slot`, and `generation`. Its Boolean conversion says it is nonempty; it **does not** prove that the object is still alive. Call `valid` before using a retained handle. A handle from another runtime or an earlier `load` is rejected. + +## Read configuration and poses + +- `nlohmann::json fields(EntityHandle, const std::string& componentType) const` returns a configuration copy. It throws if the type is absent. +- `Transform transform(EntityHandle) const` returns a simulation-pose copy. +- `Transform presentation(EntityHandle) const` returns the pose prepared for display. +- `void setTransform(EntityHandle, const Transform&)` updates a non-physical object. +- `void setPresentation(EntityHandle, const Transform&)` updates presentation only, during `lateUpdate`. + +`Transform` has `position`, `rotation`, and `scale`, each a `std::array`. Position is in metres; rotation is XYZ Euler radians with matrix composition `Rz * Ry * Rx`; scale is a multiplier. The pose is local to its scene parent. The renderer composes the hierarchy. The initial physics adapters require root objects. + +These are values, not borrowed component pointers. Changing a returned copy has no effect until an appropriate setter is called. Presentation writes do not alter physics or the saved scene. + +## Control a rigid body + +- `Vec3 velocity(EntityHandle) const` reads metres per second. A 2D body returns Z = 0. +- `void setVelocity(EntityHandle, Vec3)` sets linear velocity; it does not take a displacement. +- `void applyImpulse(EntityHandle, Vec3)` applies an impulse at the centre and wakes the body. +- `void teleport(EntityHandle, const Transform&)` changes the pose discontinuously, wakes the body, and resets interpolation/contact-query history. It does not zero velocity. +- `bool grounded(EntityHandle) const` tests support using recent native contact normals. + +These methods require a valid handle and a physics body. Ordinary `setTransform` is rejected for physical objects, including static and kinematic bodies. See [physics](physics.md) for dimensions, tolerances, and collider limits. + +## Input and collision events + +`InputState input() const noexcept` returns `horizontal`, `vertical`, `jumpPressed`, and `interactPressed`. The Player maps A/D and left/right arrows to horizontal input, W/S and up/down arrows to vertical input, Space to jump, and E to interact. The gameplay module decides what these actions do. + +`CollisionEvent` contains `first`, `second`, and `began`. `onCollision` receives an event during post-physics delivery. Its reference lasts for that callback; copy the event if you need to retain it, then recheck retained handles before later use. + +`const std::vector& collisions() const noexcept` exposes the most recently completed tick's events. The vector is replaced on a later tick or scene replacement. Polling only once per rendered frame can miss an earlier tick in a multi-tick frame; use callbacks when every delivered event matters. This is contact begin/end notification, not a general event bus or a contact-normal query. + +## Queue structural changes + +- `void spawn(nlohmann::json entity)` queues an entity record with `id`, `name`, `parent`, and `components`. +- `void destroy(EntityHandle)` queues removal of the object and its descendants. +- `void addComponent(EntityHandle, nlohmann::json component)` queues a full component record. +- `void removeComponent(EntityHandle, const std::string& componentType)` queues removal by type. + +Changes are applied in FIFO order at the next fixed-tick barrier. `spawn` does not return an immediately usable handle; use `find(id)` after application. A spawned child's parent must already exist when its command is applied. These commands are individual runtime operations, not an atomic authoring batch with Undo. + +Immediate validation errors throw. Deferred failures are recorded in diagnostics; a stale command does not revive an object. IDs and component types must not collide. The [spawning example](examples.md#spawn-and-destroy-on-safe-boundaries) demonstrates the timing. + +## Drive a world or a test + +`Runtime(RuntimeConfig = {})` constructs the controller. `load(const nlohmann::json&)` validates and prepares a scene, creates its entities, then calls initial callbacks. Invalid scene data leaves the preceding world intact. `clear()` destroys the current entities and invalidates their session handles. + +`FrameStats advance(double elapsedSeconds, InputState = {})` advances fixed ticks, frame callbacks, interpolation, and late callbacks. `singleStep(InputState = {})` advances one fixed tick. `setPaused(bool)` clears accumulated time and resets presentation history; `paused()` reports this local state. Do not call load/clear/advance recursively from a callback. + +`RuntimeConfig` defaults to `fixedDelta = 1.0 / 60.0`, `maxCatchUpTicks = 4`, `physicsSubsteps = 4`, and `gravity = {0, -9.81f, 0}`. `FrameStats` reports fixed ticks performed, dropped time, interpolation fraction, and total tick count. + +`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**. diff --git a/docs/manual/scripting/examples.md b/docs/manual/scripting/examples.md new file mode 100644 index 0000000..69e275f --- /dev/null +++ b/docs/manual/scripting/examples.md @@ -0,0 +1,50 @@ +# More complete examples + +Each folder below is a separate, buildable gameplay module with `Gameplay.hpp`, `Gameplay.cpp`, and `scene.json`. Select one using `FASET_GAMEPLAY_SOURCE_DIR`, as shown in [the first tutorial](first-behavior.md). The Player links that module statically. + +## Follow an interpolated object + +`examples/tutorials/following` moves an object during fixed updates. A camera follows its presentation pose during `lateUpdate`: + +```cpp +--8<-- "examples/tutorials/following/Gameplay.cpp" +``` + +The camera resolves the saved target ID on each frame, handles its disappearance, reads `presentation(target)`, and writes only its own presentation pose. In this small scene both objects are roots, so their coordinate frames match. For objects under different parents, transform between coordinate frames explicitly; adding local positions from unrelated parents is incorrect. + +The `tutorial_following` test advances one and a half fixed intervals. It checks that the camera follows the halfway presentation position, while its simulation pose is unchanged. It also removes the target to exercise missing-handle behavior. + +## Spawn and destroy on safe boundaries + +`examples/tutorials/spawning` creates a temporary sprite and removes it after its lifetime: + +```cpp +--8<-- "examples/tutorials/spawning/Gameplay.cpp" +``` + +The spawner's `onStart` queues creation. The new object's `onStart` runs only when that command is applied. Its elapsed time is ordinary C++ state owned by the callbacks. A key contains all three handle fields, so a recycled slot or restarted session cannot accidentally reuse an older object's timer. + +When the timer expires, `destroy` queues removal; the handle remains valid until the next barrier. `onDestroy` removes the stored timer entry. The example's `spawned_id` must be unique in the runtime scene: using the same value on several spawners produces a duplicate-ID diagnostic. A production spawner should choose an appropriate runtime ID policy. + +This does not save the spawned object into the authoring document. The test checks deferred creation, eventual invalidation, and a fresh spawn after reloading the scene. + +## Use a contact-based jump + +`examples/tutorials/physics` contains the [complete physics controller](physics.md). Its test checks movement, a supported jump, rejection of a jump at the apex, and landing. The default example gameplay module also uses the same grounded query. + +The query's slope/separation thresholds are intentionally small and explicit. Extend the gameplay controller when your game needs coyote time, jump buffering, climbing steps, or moving platforms; these are not automatically provided by naming a component “character”. + +## Run the tutorial checks + +After configuring a normal build with `BUILD_TESTING=ON`: + +```bash +cmake --build build/linux-debug --target \ + faset_tutorial_moving_tests faset_tutorial_following_tests \ + faset_tutorial_spawning_tests faset_tutorial_physics_tests +ctest --test-dir build/linux-debug -R '^tutorial_' --output-on-failure +``` + +On Windows use the chosen Windows build directory. The four tests compile separate gameplay libraries and execute their actual callbacks. They require neither a graphics window nor the Editor. Running a tutorial through `faset_player` additionally checks rendering and platform integration and requires the documented Vulkan setup. + +Schema declarations are tested for stable map-key/FieldId matching and defaults. They remain ordinary C++ source; the engine does not scan arbitrary C++ classes to create this API automatically. diff --git a/docs/manual/scripting/first-behavior.md b/docs/manual/scripting/first-behavior.md new file mode 100644 index 0000000..cd34471 --- /dev/null +++ b/docs/manual/scripting/first-behavior.md @@ -0,0 +1,67 @@ +# Your first behavior + +This example moves a sprite along the X axis at two metres per second. It has no rigid body: the behavior owns its simulation pose. + +## Build and run the complete example + +First complete [the build setup](../getting-started/build.md), including the renderer dependencies. From the repository root on Linux: + +```bash +cmake -S . -B build/tutorial-moving -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + -DFASET_GAMEPLAY_SOURCE_DIR="$PWD/examples/tutorials/moving" +cmake --build build/tutorial-moving --target faset_player faset_schema_exporter +build/tutorial-moving/faset_schema_exporter --output build/tutorial-moving/schema.json +build/tutorial-moving/faset_player --scene examples/tutorials/moving/scene.json +``` + +On Windows, use a Developer shell with `clang-cl`, the Windows SDK and the documented dependencies. Use `clang-cl` for both compiler options and an absolute path for `FASET_GAMEPLAY_SOURCE_DIR`; run `faset_player.exe` from the selected build directory. + +The same directory contract is used by a project's `Scripts` folder. These commands select a complete gameplay module; they do not add its behavior to an unrelated module automatically. + +## The module interface + +```cpp +--8<-- "examples/tutorials/moving/Gameplay.hpp" +``` + +The header declares the two entry functions. `Runtime` is the game world API; its implementation and EnTT storage remain inside the engine. + +## The implementation + +```cpp +--8<-- "examples/tutorials/moving/Gameplay.cpp" +``` + +Read the callback from top to bottom: + +1. `game` is the current runtime, and `self` is the object carrying `tutorial.move_x`. +2. `delta` is this frame's elapsed time in **seconds**. +3. `settings.value("speed", 2.0f)` reads configuration and supplies a fallback if the field is absent. +4. `transform` gives a pose copy. Multiplying metres per second by seconds gives a displacement in metres. +5. `setTransform` publishes the changed non-physical pose. + +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 after a world has loaded 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. + +## Attach the behavior + +The example scene is a complete, loadable document: + +```json +--8<-- "examples/tutorials/moving/scene.json" +``` + +The sprite is visible because it has `faset.sprite`. It moves because it also has `tutorial.move_x`. The configuration field is `speed`; the type string must match the registration exactly. `rotation` uses radians, and the default coordinate system is Y-up. + +After your project's schema is exported and loaded by the Editor, the type can be described through the same authoring schema used by the Inspector. The direct Player command above is useful before building an Editor workflow around your component. + +## Make a change and verify it + +Change the scene's `speed` to `-2`: the object moves left. Change the C++ callback or schema: stop the Player, rebuild, regenerate the schema, then launch a new session. There is no automatic C++ hot reload. + +The `tutorial_moving` CTest checks that both 30 Hz and 60 Hz frame sequences move the object two metres in one second. It checks the resulting pose, rather than only checking that the program starts. + +Common mistakes are forgetting `setTransform` after editing the copy, writing the wrong component type string, and attaching a rigid body while still using `setTransform`. The last case produces a runtime diagnostic: physics owns that body's pose. Continue with [physics movement](physics.md) for the correct API. diff --git a/docs/manual/scripting/index.md b/docs/manual/scripting/index.md index 75d1a98..ac0ef4f 100644 --- a/docs/manual/scripting/index.md +++ b/docs/manual/scripting/index.md @@ -1,41 +1,40 @@ # C++ gameplay -In the first version of Faset, a "script" is C++ gameplay code compiled into your game. -It is not an interpreted text file. The gameplay library is statically linked into -a separate Player executable. +In Faset, a gameplay script is **C++ compiled into the Player**. You write ordinary functions and register the callbacks an object needs. There is no C++ interpreter or live replacement of compiled classes. Stop Play, rebuild, export the schema, and start a new Player session. -The intended iteration cycle is: +Lua is planned for a later stage. The APIs and tutorials in this section describe the C++ implementation available now. -1. Stop Play. -2. Edit your C++ behavior or system. -3. Build the changed code and export its property schema. -4. Start a new Player session. +## Start here -The Editor reads a schema generated by a separate SchemaExporter. It does not load -your gameplay library into its own process. A gameplay crash therefore does not -automatically crash the Editor. Editor native extensions have a different lifecycle -and run inside the Editor process. +1. Read [Your first behavior](first-behavior.md) and run the moving-object example. +2. Learn [when callbacks run](lifecycle.md) before mixing frame updates and physics. +3. Build a [physics character](physics.md) that can move and jump from the floor. +4. Try [following, spawning, and timed destruction](examples.md). +5. Keep the [runtime API reference](api.md) nearby while writing code. -!!! note "API examples are added with implementation" - This page describes the accepted execution model. Exact function signatures and - complete examples will be documented alongside compiling runtime examples, rather - than presenting proposed APIs as available functions. +The complete tutorial modules are compiled and executed by CTest. The code blocks include those source files directly, so the manual does not maintain separate, untested copies. -## Behaviors and systems +## What belongs to your module -A behavior gives an individual object lifecycle callbacks. A system operates on a -set of objects with matching components. Both use the same runtime state; the visual -scene and Inspector are the authoring view of that state. +A gameplay directory contains `Gameplay.hpp` and `Gameplay.cpp`. It provides two functions in `faset::gameplay`: -Persistent scene IDs and runtime handles are different. A scene ID survives saving -and reopening. A runtime handle belongs to a particular world/session and can become -invalid after an object is removed. Do not store raw component pointers across -structural changes or treat a runtime handle as a save-file ID. +- `registerGameplay(runtime::Runtime&)` registers executable behavior callbacks. +- `schema()` returns a JSON array of component descriptions: stable type and field IDs, versions, defaults, constraints, and Inspector hints. -## Physics ownership +The Player calls registration before loading a scene. The separate SchemaExporter calls `schema()` without creating a game world or running gameplay callbacks. The Editor reads the resulting declaration; it does not load the gameplay binary into the Editor process. -Physics owns the position of a dynamic rigid body. Move it with the supported physics -commands instead of writing its presentation transform. A camera or other visual-only -object can follow the interpolated result without modifying the simulation. +A scene object receives a behavior by containing a component whose `type` matches the string passed to `registerBehavior`. Registering a behavior does not attach it to every object. A component can contain data without having any callbacks. -Continue with [Frame and physics updates](lifecycle.md). +## Data, poses, and state + +`fields(self, type)` returns a **copy of component configuration**. Editing that copy changes neither the saved scene nor the runtime configuration. `transform(self)` returns a copy of the current simulation pose; pass the changed copy to `setTransform` for a non-physical object. A rigid body uses `setVelocity`, `applyImpulse`, or an explicit `teleport` instead. + +Ordinary C++ state can be captured by callbacks. The spawning tutorial shows state indexed by the full runtime handle and cleaned up in `onDestroy`. Do not capture a reference to a local variable that will disappear after `registerGameplay` returns. Use owned state, or ensure the referenced object outlives the runtime. + +Scene IDs are saved strings. `EntityHandle` is a temporary reference containing a session, slot, and generation. It must not be written into a save file. Resolve a scene ID with `find`, check `valid`, and expect old handles to stop working after removal or a new Play session. + +## Current boundaries + +These examples use per-object callbacks and the implemented typed pose/physics API. They do not provide a universal binding for arbitrary C++ classes, a public EnTT registry, or a general parallel-system scheduler. The runtime is sequential and has one owning thread. + +MCP belongs to the Editor's authoring, import, build, and process-control services. It does not invoke runtime methods or inspect the live game world. A C++ gameplay change requires the same rebuild whether a person or an agent edited the source. diff --git a/docs/manual/scripting/lifecycle.md b/docs/manual/scripting/lifecycle.md index 5aabe01..4e7fbff 100644 --- a/docs/manual/scripting/lifecycle.md +++ b/docs/manual/scripting/lifecycle.md @@ -1,47 +1,61 @@ # Frame and physics updates -!!! note "Execution contract" - This page describes the accepted runtime contract. The runnable callback examples - and test results are added as the runtime implementation becomes available. +The C++ member names are `onStart`, `fixedUpdate`, `update`, `lateUpdate`, and `onDestroy`. Design discussions may call the corresponding phases OnStart, FixedUpdate, Update, LateUpdate, and OnDestroy. Use the **camelCase member names** in code. -## Choose the right callback +Each ordinary callback receives `(Runtime& game, EntityHandle self, double delta)`. Register only the callbacks you need. `onStart` and `onDestroy` receive a zero `delta`; update callbacks receive seconds. `onCollision` has a separate event signature described in the [API reference](api.md). -- `OnStart`: initialize a behavior once its object and components exist. -- `FixedUpdate`: update simulation logic before a physics step. -- `Update`: run frame-based gameplay once per rendered frame. -- `LateUpdate`: update cameras and dependent visual objects after presentation interpolation. -- `OnDestroy`: release subscriptions and other behavior-owned state before its handle is invalidated. +## Object lifetime -The default simulation interval is 1/60 second. A rendered frame may contain zero, -one, or several fixed ticks. Frame rate and physics rate are not the same quantity. +`onStart` runs once after an object and its components exist. All objects in the initial scene are created before their initial callbacks run. A spawn queued by `onStart` becomes visible at the next fixed-tick barrier, not during the callback that requested it. -## Fixed tick order +`onDestroy` runs while that object's handle and allowed component data are still valid. Clean up subscriptions or external C++ state there. After removal, `valid(oldHandle)` returns false. Removing a behavior component also runs that component's `onDestroy`. Clearing or replacing a scene runs destruction callbacks; a new scene uses a new session identity. -1. Apply structural commands queued by earlier work. -2. Deliver tick input and call `FixedUpdate`. -3. Apply physics commands and step the 2D and 3D worlds. -4. Read back transforms and queue collision events. -5. Run reactions after physics. +Registering a callback does not make captured pointers safe. A lambda that stores a reference to a stack variable in `registerGameplay` will outlive that variable. The [timed-despawn example](examples.md#spawn-and-destroy-on-safe-boundaries) uses shared ownership for captured state and removes each object's entry on destruction. -Object creation/removal and component addition/removal are deferred to the beginning -of the next fixed tick. This prevents a callback from invalidating the collection -currently being processed. New objects follow the same initialization rules as objects -loaded from a scene. +## One fixed tick -After the fixed ticks, the frame runs `Update`, prepares interpolated presentation -transforms, calls `LateUpdate`, and produces the render snapshot. +The default interval is 1/60 second. A rendered frame may contain zero, one, or several fixed ticks. For each tick the runtime: -## Avoid frame-rate-dependent movement +1. Applies structural commands queued by earlier work, in FIFO order. +2. Makes tick input available and calls `fixedUpdate`. +3. Steps the scene's Box2D or Box3D world with its configured substeps. +4. Reads physical poses back and delivers collision events to `onCollision` callbacks. -A speed is a distance per second. Multiply it by the callback's elapsed seconds when -calculating a displacement. Do not multiply a velocity by elapsed time before assigning -it to a physics velocity API; the physics step performs that integration. +`spawn`, `destroy`, `addComponent`, and `removeComponent` queue structural changes. A command queued while this barrier or a callback runs waits until the **next** tick. This avoids invalidating the entity collection currently being visited. Runtime structural commands do not create an Editor Undo action or modify a saved scene. -## Overload and pause +A velocity is metres per second: assign it directly. A manually calculated displacement is speed multiplied by `delta`. The [physics controller](physics.md) demonstrates this distinction. -The initial catch-up limit is four fixed ticks per frame. Excess whole intervals are -dropped with a diagnostic rather than making the physics step arbitrarily large. -This is a local-game policy, not a guarantee of deterministic network simulation. +## One rendered frame -Pausing clears accumulated time. Single-step advances exactly one simulation tick. -Interpolation history is reset for a new session, spawn, or teleport. +After its fixed ticks, the runtime calls `update` once. It then prepares presentation transforms, calls `lateUpdate`, and makes the final snapshot available to rendering. + +For interpolation, the runtime blends the previous and current completed simulation poses using the accumulator fraction. Position and scale are interpolated linearly; rotation follows the shortest quaternion path. This normally displays a pose up to one fixed tick behind the latest simulated state. It is not prediction of a future physics pose. + +Use `presentation(target)` in `lateUpdate` when a camera follows a physical object. Following `transform(target)` instead would follow the discrete simulation pose and can cause visible judder. Use `setPresentation` for the camera's visual pose; this method is permitted only during `lateUpdate` and does not write back into physics. + +A non-physical pose changed in `update` is presented directly for that frame. Physical bodies reject ordinary `setTransform`; use an explicit teleport when discontinuous motion is intended. Spawn, teleport, scene replacement, and pause transitions reset the relevant interpolation history. + +## Input, pause, and overload + +`input()` supplies held horizontal/vertical axes and one-shot jump/interact edges. In `fixedUpdate`, an edge survives a rendered frame with no fixed tick and is consumed once, even when the next frame catches up several ticks. In `update`, input is the current frame's input. Consume a gameplay action in one chosen phase so your own code does not apply it twice. + +The default catch-up limit is four ticks per frame. Excess whole intervals are dropped and reported as `dropped_time`; the fractional remainder is retained. Physics `delta` is not enlarged to compensate. This is a local-game policy, not a lockstep or rollback guarantee. + +Player keys `P` and `N` pause and single-step. Pause clears accumulated wall time. One step advances one fixed tick and produces a current presentation pose. Resuming does not simulate the time spent paused. + +## Configure simulation + +The Player reads this optional object from the scene document. The settings are used by ordinary Play and `--validate`: + +```json +{ + "simulation": { + "fixed_delta": 0.016666666666666666, + "max_catch_up_ticks": 4, + "physics_substeps": 4, + "gravity": [0, -9.81, 0] + } +} +``` + +This is an excerpt, not a complete scene. The tutorial scenes contain complete examples. `fixed_delta` is seconds, gravity is metres per second squared, and substeps are solver subdivisions inside one fixed tick. These do not create additional gameplay callbacks. Invalid configuration is rejected before the Player starts. Editing these JSON settings is implemented; an Editor settings panel should only be relied on where the current UI exposes it. diff --git a/docs/manual/scripting/physics.md b/docs/manual/scripting/physics.md new file mode 100644 index 0000000..8fe5c6b --- /dev/null +++ b/docs/manual/scripting/physics.md @@ -0,0 +1,49 @@ +# Move and jump with physics + +A physical object's final pose belongs to Box2D or Box3D. Your behavior supplies intent through velocity, impulse, or an explicit teleport. It must not write a presentation position back into a rigid body each frame. + +## Run the controller + +Select `examples/tutorials/physics` as `FASET_GAMEPLAY_SOURCE_DIR`, build `faset_player`, and launch it with `--scene examples/tutorials/physics/scene.json`. Use the commands from [the first tutorial](first-behavior.md), changing the folder and build directory. + +Press **A/D** or the left/right arrows to move, and **Space** to jump. The built-in Player supplies W/S as a vertical input axis too; this particular 2D controller deliberately uses only the horizontal axis. `P` pauses, `N` advances one tick while paused, and Escape closes the Player. + +## Complete controller code + +```cpp +--8<-- "examples/tutorials/physics/Gameplay.cpp" +``` + +The callback starts with the current velocity so it preserves the solver's vertical motion. It replaces only the horizontal component. An accepted jump replaces vertical velocity with `jump_speed`. + +Do **not** multiply the assigned velocity by `delta`. The solver integrates metres per second over the fixed interval. `applyImpulse` is different: it applies a momentum impulse and its effect depends on mass. The adapter uses the body's centre and wakes it. + +The collision callback is delivered after the physics step on the runtime's owning thread. It receives copied handles and a begin/end flag, not pointers into the native solver. This example prints a message when contact begins. Both objects can receive their own callback if both have registered behaviors. + +## What grounded means + +`grounded(self)` examines contact manifolds from the last completed physics step. A contact counts as support when its normal points sufficiently against gravity (dot product greater than 0.6) and at least one contact point is within 0.02 metres. With zero gravity, the query uses Y-up. The query supports both physics adapters. + +This distinguishes a floor from a wall and from the top of a jump. **Zero vertical speed is not a ground test**: vertical speed is also near zero at the apex. The tests explicitly try to jump there and check that another upward impulse is not created. + +New bodies have no support result before a physics step. Teleporting invalidates the old contact result until the next step. The query is a small support test; it is not a full character motor with step climbing, coyote time, jump buffering, moving-platform attachment, or a capsule controller. + +## Scene components and units + +The controller's scene includes a static floor and a dynamic box: + +```json +--8<-- "examples/tutorials/physics/scene.json" +``` + +Use `faset.rigid_body_2d` in a 2D scene and `faset.rigid_body_3d` in a 3D scene. Each currently creates a box collider. `body_type` accepts `static`, `dynamic`, or `kinematic`. `half_extents` contains half the box dimensions in metres: two values for 2D, three for 3D. The object's absolute scale multiplies those extents at creation. + +Density must be positive; friction is nonnegative; restitution is between zero and one. `linear_velocity` uses metres per second, `gravity_scale` scales world gravity for that body, and `category_bits`/`mask_bits` filter collisions. Rotation uses radians; a 2D rigid body rotates only around Z. + +Initial adapters require physical bodies to be **root scene objects**. A parent transform is not silently baked into a rigid body's simulation frame. Collider scale cannot be changed through teleport; remove/re-add the body through the deferred component API when rebuilding its shape. More collider types and articulated character motors are separate work. + +## Teleport and failure handling + +For an intentional discontinuity, get a pose copy, change its position, and call `teleport(self, pose)`. It resets interpolation and wakes the body. It preserves the body's velocity; call `setVelocity(self, {0, 0, 0})` as well when resetting motion is intended. + +A missing body or stale handle makes the physics accessor throw. Exceptions raised inside gameplay callbacks are recorded in `Runtime::diagnostics()` and printed by the Player; later phases continue. Fix the error instead of using exceptions as a normal ground test. A behavior that controls physics should be attached only to an object with the matching rigid-body component. diff --git a/examples/extensions/beacon/Beacon.hpp b/examples/extensions/beacon/Beacon.hpp new file mode 100644 index 0000000..4126395 --- /dev/null +++ b/examples/extensions/beacon/Beacon.hpp @@ -0,0 +1,28 @@ +#pragma once +#include + +namespace beacon { +inline nlohmann::json schema() { + return {{"id", "example.beacon"}, + {"name", "Beacon"}, + {"version", 1}, + {"fields", + {{"speed", + {{"id", "speed"}, + {"name", "Rotation speed"}, + {"type", "number"}, + {"default", 1.0}, + {"units", "rad/s"}}}}}}; +} +inline void register_behavior(faset::runtime::Runtime& runtime) { + faset::runtime::Behavior behavior; + behavior.fixedUpdate = [](faset::runtime::Runtime& world, faset::runtime::EntityHandle self, + double dt) { + auto pose = world.transform(self); + pose.rotation[1] += + world.fields(self, "example.beacon").value("speed", 1.0f) * static_cast(dt); + world.setTransform(self, pose); + }; + runtime.registerBehavior("example.beacon", std::move(behavior)); +} +} // namespace beacon diff --git a/examples/extensions/beacon/Editor.cpp b/examples/extensions/beacon/Editor.cpp new file mode 100644 index 0000000..2262281 --- /dev/null +++ b/examples/extensions/beacon/Editor.cpp @@ -0,0 +1,119 @@ +#include +#include +#include +#include +#include +#include +#include + +namespace { +using Json = nlohmann::json; +struct State { + const FasetEditorHost* host; +}; +void collect(void* target, const char* data, uint64_t size) { + static_cast(target)->append(data, size); +} +Json invoke(State& state, const char* command, const Json& arguments) { + std::string output; + const auto input = arguments.dump(); + const auto status = + state.host->invoke_command(state.host->context, command, input.c_str(), collect, &output); + auto result = Json::parse(output); + if (status != 0) + throw std::runtime_error(result.value("message", std::string("Editor command failed"))); + return result; +} +std::string id() { + std::random_device random; + std::ostringstream stream; + stream << std::hex << std::setfill('0'); + for (int i = 0; i < 4; ++i) + stream << std::setw(8) << random(); + return stream.str(); +} +int create(void* user, const char* input, FasetWrite write, void* receiver) { + try { + auto& state = *static_cast(user); + const auto arguments = Json::parse(input); + const auto document = + invoke(state, "faset_document_query", {{"document", arguments.at("document")}}); + const auto schema = invoke(state, "faset_schema", Json::object()); + bool known = false; + for (const auto& type : schema.at("types")) + if (type.at("id") == "example.beacon") + known = true; + if (!known) + throw std::runtime_error("Include Beacon.hpp in gameplay, register its schema and " + "behavior, then Build before adding a Beacon"); + const auto entity = id(); + Json record = { + {"id", entity}, + {"name", "Beacon"}, + {"parent", nullptr}, + {"components", + Json::array( + {{{"id", id()}, + {"type", "faset.transform"}, + {"version", 1}, + {"fields", + {{"position", {0, 1, 0}}, {"rotation", {0, 0, 0}}, {"scale", {1, 1, 1}}}}}, + {{"id", id()}, + {"type", "faset.mesh"}, + {"version", 1}, + {"fields", + {{"asset", ""}, {"primitive", "cube"}, {"color", {0.68, 0.55, 0.95, 1}}}}}, + {{"id", id()}, + {"type", "example.beacon"}, + {"version", 1}, + {"fields", {{"speed", 1.0}}}}})}}; + const auto result = + invoke(state, "faset_scene_edit", + {{"document", document.at("id")}, + {"revision", document.at("revision")}, + {"operations", Json::array({{{"op", "entity.create"}, {"entity", record}}})}}) + .dump(); + write(receiver, result.data(), result.size()); + return 0; + } catch (const std::exception& error) { + const auto output = Json{{"code", "beacon.create"}, {"message", error.what()}}.dump(); + write(receiver, output.data(), output.size()); + return 1; + } +} +void shutdown(void* user) { + delete static_cast(user); +} +} // namespace +extern "C" FASET_PLUGIN_EXPORT int faset_editor_plugin(const FasetEditorHost* host, + FasetEditorPlugin* plugin) { + if (!host || !plugin || host->api_version != FASET_EDITOR_API_VERSION || + host->struct_size != sizeof(FasetEditorHost) || + std::string(host->build_fingerprint) != FASET_EDITOR_SDK_FINGERPRINT) + return 1; + try { + auto* state = new State{host}; + *plugin = {FASET_EDITOR_API_VERSION, sizeof(FasetEditorPlugin), + FASET_EDITOR_SDK_FINGERPRINT, state, shutdown}; + const auto descriptor = Json{ + {"name", "plugin_example_beacon_create"}, + {"description", "Create a rotating Beacon using one authoring transaction."}, + {"inputSchema", + {{"type", "object"}, + {"properties", {{"document", {{"type", "string"}}}}}, + {"required", {"document"}}, + {"additionalProperties", + false}}}}.dump(); + if (host->register_command(host->context, descriptor.c_str(), create, state) != 0) + return 1; + const auto panel = Json{{"id", "example.beacon.tools"}, + {"title", "Beacon tools"}, + {"action", "Add Beacon"}, + {"command", "plugin_example_beacon_create"}, + {"arguments", {{"document", "$document"}}}} + .dump(); + return host->register_panel(host->context, panel.c_str()); + } catch (...) { + return 1; + } +} diff --git a/examples/gameplay/Gameplay.cpp b/examples/gameplay/Gameplay.cpp index f9c63be..5e90024 100644 --- a/examples/gameplay/Gameplay.cpp +++ b/examples/gameplay/Gameplay.cpp @@ -6,46 +6,65 @@ namespace faset::gameplay { void registerGameplay(runtime::Runtime& engine) { runtime::Behavior character; - character.fixedUpdate=[](runtime::Runtime& world,runtime::EntityHandle self,double) { - const auto fields=world.fields(self,"gameplay.character"); - auto velocity=world.velocity(self);const auto input=world.input(); - velocity[0]=input.horizontal*fields.value("speed",4.0f); - // A minimal demo controller: jump only near zero vertical velocity. - // A production grounded controller needs contact normals / a ground query. - if(input.jumpPressed&&std::abs(velocity[1])<0.1f)velocity[1]=fields.value("jump_speed",5.0f); - world.setVelocity(self,velocity); + character.fixedUpdate = [](runtime::Runtime& world, runtime::EntityHandle self, double) { + const auto fields = world.fields(self, "gameplay.character"); + auto velocity = world.velocity(self); + const auto input = world.input(); + velocity[0] = input.horizontal * fields.value("speed", 4.0f); + if (input.jumpPressed && world.grounded(self)) + velocity[1] = fields.value("jump_speed", 5.0f); + world.setVelocity(self, velocity); }; - engine.registerBehavior("gameplay.character",std::move(character)); + engine.registerBehavior("gameplay.character", std::move(character)); runtime::Behavior door; - auto open=std::make_shared,bool>>(); - door.onStart=[open](runtime::Runtime&,runtime::EntityHandle self,double){(*open)[{self.session,self.slot}]=false;}; - door.onDestroy=[open](runtime::Runtime&,runtime::EntityHandle self,double){open->erase({self.session,self.slot});}; - door.fixedUpdate=[open](runtime::Runtime& world,runtime::EntityHandle self,double dt) { - const auto fields=world.fields(self,"gameplay.door"); - auto pose=world.transform(self); - auto& opened=(*open)[{self.session,self.slot}]; - if(world.input().interactPressed)opened=!opened; - const float target=opened?fields.value("open_angle",1.5707963f):fields.value("closed_angle",0.0f); - const float distance=target-pose.rotation[1]; - const float amount=std::max(0.0f,fields.value("speed",1.5f))*static_cast(dt); - pose.rotation[1]+=std::clamp(distance,-amount,amount); - world.setTransform(self,pose); + auto open = std::make_shared, bool>>(); + door.onStart = [open](runtime::Runtime&, runtime::EntityHandle self, double) { + (*open)[{self.session, self.slot}] = false; }; - engine.registerBehavior("gameplay.door",std::move(door)); + door.onDestroy = [open](runtime::Runtime&, runtime::EntityHandle self, double) { + open->erase({self.session, self.slot}); + }; + door.fixedUpdate = [open](runtime::Runtime& world, runtime::EntityHandle self, double dt) { + const auto fields = world.fields(self, "gameplay.door"); + auto pose = world.transform(self); + auto& opened = (*open)[{self.session, self.slot}]; + if (world.input().interactPressed) + opened = !opened; + const float target = + opened ? fields.value("open_angle", 1.5707963f) : fields.value("closed_angle", 0.0f); + const float distance = target - pose.rotation[1]; + const float amount = std::max(0.0f, fields.value("speed", 1.5f)) * static_cast(dt); + pose.rotation[1] += std::clamp(distance, -amount, amount); + world.setTransform(self, pose); + }; + engine.registerBehavior("gameplay.door", std::move(door)); } nlohmann::json schema() { // Explicit declarations shared by Player and SchemaExporter. This function // constructs descriptions only: no Runtime, physics world or lifecycle. - return nlohmann::json::array({ - {{"id","gameplay.character"},{"version",1},{"name","Character"},{"fields",{ - {"speed",{{"id","speed"},{"type","number"},{"default",4.0},{"min",0.0}}}, - {"jump_speed",{{"id","jump_speed"},{"type","number"},{"default",5.0},{"min",0.0}}}}}}, - {{"id","gameplay.door"},{"version",1},{"name","Door"},{"fields",{ - {"open_angle",{{"id","open_angle"},{"type","number"},{"default",1.5707963},{"units","rad"}}}, - {"closed_angle",{{"id","closed_angle"},{"type","number"},{"default",0.0},{"units","rad"}}}, - {"speed",{{"id","speed"},{"type","number"},{"default",1.5},{"min",0.0},{"units","rad/s"}}}}}} - }); -} + return nlohmann::json::array( + {{{"id", "gameplay.character"}, + {"version", 1}, + {"name", "Character"}, + {"fields", + {{"speed", {{"id", "speed"}, {"type", "number"}, {"default", 4.0}, {"min", 0.0}}}, + {"jump_speed", + {{"id", "jump_speed"}, {"type", "number"}, {"default", 5.0}, {"min", 0.0}}}}}}, + {{"id", "gameplay.door"}, + {"version", 1}, + {"name", "Door"}, + {"fields", + {{"open_angle", + {{"id", "open_angle"}, {"type", "number"}, {"default", 1.5707963}, {"units", "rad"}}}, + {"closed_angle", + {{"id", "closed_angle"}, {"type", "number"}, {"default", 0.0}, {"units", "rad"}}}, + {"speed", + {{"id", "speed"}, + {"type", "number"}, + {"default", 1.5}, + {"min", 0.0}, + {"units", "rad/s"}}}}}}}); } +} // namespace faset::gameplay diff --git a/examples/gameplay/Gameplay.hpp b/examples/gameplay/Gameplay.hpp index 18adf46..536ab72 100644 --- a/examples/gameplay/Gameplay.hpp +++ b/examples/gameplay/Gameplay.hpp @@ -4,4 +4,4 @@ namespace faset::gameplay { void registerGameplay(runtime::Runtime& runtime); nlohmann::json schema(); -} +} // namespace faset::gameplay diff --git a/examples/tutorials/following/Gameplay.cpp b/examples/tutorials/following/Gameplay.cpp new file mode 100644 index 0000000..3c666ab --- /dev/null +++ b/examples/tutorials/following/Gameplay.cpp @@ -0,0 +1,43 @@ +#include "Gameplay.hpp" + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& world) { + runtime::Behavior motion; + motion.fixedUpdate = [](runtime::Runtime& game, runtime::EntityHandle self, double delta) { + auto pose = game.transform(self); + pose.position[0] += game.fields(self, "tutorial.fixed_move").value("speed", 2.0f) * + static_cast(delta); + game.setTransform(self, pose); + }; + world.registerBehavior("tutorial.fixed_move", std::move(motion)); + + runtime::Behavior follow; + follow.lateUpdate = [](runtime::Runtime& game, runtime::EntityHandle self, double) { + const auto settings = game.fields(self, "tutorial.follow"); + const auto target = game.find(settings.value("target", std::string{})); + if (!game.valid(target)) + return; // Target may be absent or destroyed. + const auto targetPose = game.presentation(target); // Already interpolated. + const auto offset = settings.at("offset").get(); + auto cameraPose = game.presentation(self); + for (int axis = 0; axis < 3; ++axis) + cameraPose.position[axis] = targetPose.position[axis] + offset[axis]; + game.setPresentation(self, cameraPose); // Does not write the simulation pose. + }; + world.registerBehavior("tutorial.follow", std::move(follow)); +} + +nlohmann::json schema() { + return nlohmann::json::array( + {{{"id", "tutorial.fixed_move"}, + {"version", 1}, + {"name", "Fixed movement"}, + {"fields", {{"speed", {{"id", "speed"}, {"type", "number"}, {"default", 2.0}}}}}}, + {{"id", "tutorial.follow"}, + {"version", 1}, + {"name", "Follow presentation"}, + {"fields", + {{"target", {{"id", "target"}, {"type", "entity_ref"}, {"default", "actor"}}}, + {"offset", {{"id", "offset"}, {"type", "vec3"}, {"default", {0, 0, 10}}}}}}}}); +} +} // namespace faset::gameplay diff --git a/examples/tutorials/following/Gameplay.hpp b/examples/tutorials/following/Gameplay.hpp new file mode 100644 index 0000000..d9caafe --- /dev/null +++ b/examples/tutorials/following/Gameplay.hpp @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& world); +nlohmann::json schema(); +} // namespace faset::gameplay diff --git a/examples/tutorials/following/scene.json b/examples/tutorials/following/scene.json new file mode 100644 index 0000000..72187d7 --- /dev/null +++ b/examples/tutorials/following/scene.json @@ -0,0 +1,126 @@ +{ + "format": "faset.scene", + "version": 1, + "id": "tutorial-following", + "name": "Following tutorial", + "dimension": 2, + "simulation": { + "fixed_delta": 0.016666666666666666, + "max_catch_up_ticks": 4, + "physics_substeps": 4, + "gravity": [ + 0, + -9.81, + 0 + ] + }, + "entities": [ + { + "id": "actor", + "name": "actor", + "parent": null, + "components": [ + { + "id": "actor-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "actor-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.2, + 0.7, + 0.9, + 1 + ], + "size": [ + 1, + 1 + ] + } + }, + { + "id": "actor-tutorial.fixed_move", + "type": "tutorial.fixed_move", + "version": 1, + "fields": { + "speed": 2 + } + } + ] + }, + { + "id": "camera", + "name": "camera", + "parent": null, + "components": [ + { + "id": "camera-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + 0, + 10 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "camera-faset.camera", + "type": "faset.camera", + "version": 1, + "fields": { + "fov": 60, + "near": 0.1, + "far": 100 + } + }, + { + "id": "camera-tutorial.follow", + "type": "tutorial.follow", + "version": 1, + "fields": { + "target": "actor", + "offset": [ + 0, + 0, + 10 + ] + } + } + ] + } + ], + "instances": [] +} diff --git a/examples/tutorials/moving/Gameplay.cpp b/examples/tutorials/moving/Gameplay.cpp new file mode 100644 index 0000000..2b14bae --- /dev/null +++ b/examples/tutorials/moving/Gameplay.cpp @@ -0,0 +1,31 @@ +#include "Gameplay.hpp" + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& world) { + runtime::Behavior mover; + mover.update = [](runtime::Runtime& game, runtime::EntityHandle self, double delta) { + // fields() returns a configuration copy. transform() returns a live pose copy. + const auto settings = game.fields(self, "tutorial.move_x"); + const float speed = settings.value("speed", 2.0f); // metres per second + auto pose = game.transform(self); + pose.position[0] += speed * static_cast(delta); + game.setTransform(self, pose); // This object has no rigid body. + }; + world.registerBehavior("tutorial.move_x", std::move(mover)); +} + +nlohmann::json schema() { + // The FieldId is the map key. Keep it stable if you change a display name. + return nlohmann::json::array({{{"id", "tutorial.move_x"}, + {"version", 1}, + {"name", "Move along X"}, + {"fields", + {{"speed", + {{"id", "speed"}, + {"type", "number"}, + {"default", 2.0}, + {"min", -20.0}, + {"max", 20.0}, + {"units", "m/s"}}}}}}}); +} +} // namespace faset::gameplay diff --git a/examples/tutorials/moving/Gameplay.hpp b/examples/tutorials/moving/Gameplay.hpp new file mode 100644 index 0000000..d9caafe --- /dev/null +++ b/examples/tutorials/moving/Gameplay.hpp @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& world); +nlohmann::json schema(); +} // namespace faset::gameplay diff --git a/examples/tutorials/moving/scene.json b/examples/tutorials/moving/scene.json new file mode 100644 index 0000000..0158309 --- /dev/null +++ b/examples/tutorials/moving/scene.json @@ -0,0 +1,74 @@ +{ + "format": "faset.scene", + "version": 1, + "id": "tutorial-moving", + "name": "Moving tutorial", + "dimension": 2, + "simulation": { + "fixed_delta": 0.016666666666666666, + "max_catch_up_ticks": 4, + "physics_substeps": 4, + "gravity": [ + 0, + -9.81, + 0 + ] + }, + "entities": [ + { + "id": "actor", + "name": "actor", + "parent": null, + "components": [ + { + "id": "actor-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "actor-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.2, + 0.7, + 0.9, + 1 + ], + "size": [ + 1, + 1 + ] + } + }, + { + "id": "actor-tutorial.move_x", + "type": "tutorial.move_x", + "version": 1, + "fields": { + "speed": 2 + } + } + ] + } + ], + "instances": [] +} diff --git a/examples/tutorials/physics/Gameplay.cpp b/examples/tutorials/physics/Gameplay.cpp new file mode 100644 index 0000000..e734728 --- /dev/null +++ b/examples/tutorials/physics/Gameplay.cpp @@ -0,0 +1,43 @@ +#include "Gameplay.hpp" +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& world) { + runtime::Behavior character; + character.fixedUpdate = [](runtime::Runtime& game, runtime::EntityHandle self, double) { + const auto settings = game.fields(self, "tutorial.character"); + const auto input = game.input(); + auto velocity = game.velocity(self); // Metres per second; preserve the Y component. + velocity[0] = input.horizontal * settings.value("speed", 4.0f); + if (input.jumpPressed && game.grounded(self)) + velocity[1] = settings.value("jump_speed", 5.0f); + // Do not multiply velocity by delta. The physics solver integrates it. + game.setVelocity(self, velocity); + }; + character.onCollision = [](runtime::Runtime&, runtime::EntityHandle, + const runtime::CollisionEvent& event) { + if (event.began) + std::cout << "Character contact began\n"; + }; + world.registerBehavior("tutorial.character", std::move(character)); +} + +nlohmann::json schema() { + return nlohmann::json::array({{{"id", "tutorial.character"}, + {"version", 1}, + {"name", "Physics character"}, + {"fields", + {{"speed", + {{"id", "speed"}, + {"type", "number"}, + {"default", 4.0}, + {"min", 0.0}, + {"units", "m/s"}}}, + {"jump_speed", + {{"id", "jump_speed"}, + {"type", "number"}, + {"default", 5.0}, + {"min", 0.0}, + {"units", "m/s"}}}}}}}); +} +} // namespace faset::gameplay diff --git a/examples/tutorials/physics/Gameplay.hpp b/examples/tutorials/physics/Gameplay.hpp new file mode 100644 index 0000000..d9caafe --- /dev/null +++ b/examples/tutorials/physics/Gameplay.hpp @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& world); +nlohmann::json schema(); +} // namespace faset::gameplay diff --git a/examples/tutorials/physics/scene.json b/examples/tutorials/physics/scene.json new file mode 100644 index 0000000..6e58dab --- /dev/null +++ b/examples/tutorials/physics/scene.json @@ -0,0 +1,148 @@ +{ + "format": "faset.scene", + "version": 1, + "id": "tutorial-physics", + "name": "Physics tutorial", + "dimension": 2, + "simulation": { + "fixed_delta": 0.016666666666666666, + "max_catch_up_ticks": 4, + "physics_substeps": 4, + "gravity": [ + 0, + -9.81, + 0 + ] + }, + "entities": [ + { + "id": "floor", + "name": "floor", + "parent": null, + "components": [ + { + "id": "floor-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + -0.5, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "floor-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.4, + 0.5, + 0.6, + 1 + ], + "size": [ + 12, + 1 + ] + } + }, + { + "id": "floor-faset.rigid_body_2d", + "type": "faset.rigid_body_2d", + "version": 1, + "fields": { + "body_type": "static", + "half_extents": [ + 6, + 0.5 + ] + } + } + ] + }, + { + "id": "actor", + "name": "actor", + "parent": null, + "components": [ + { + "id": "actor-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + 0.5, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "actor-faset.sprite", + "type": "faset.sprite", + "version": 1, + "fields": { + "color": [ + 0.2, + 0.7, + 0.9, + 1 + ], + "size": [ + 1, + 1 + ] + } + }, + { + "id": "actor-faset.rigid_body_2d", + "type": "faset.rigid_body_2d", + "version": 1, + "fields": { + "body_type": "dynamic", + "half_extents": [ + 0.5, + 0.5 + ], + "density": 1, + "friction": 0.3, + "gravity_scale": 1 + } + }, + { + "id": "actor-tutorial.character", + "type": "tutorial.character", + "version": 1, + "fields": { + "speed": 4, + "jump_speed": 5 + } + } + ] + } + ], + "instances": [] +} diff --git a/examples/tutorials/spawning/Gameplay.cpp b/examples/tutorials/spawning/Gameplay.cpp new file mode 100644 index 0000000..eb70193 --- /dev/null +++ b/examples/tutorials/spawning/Gameplay.cpp @@ -0,0 +1,70 @@ +#include "Gameplay.hpp" +#include +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& world) { + runtime::Behavior spawner; + spawner.onStart = [](runtime::Runtime& game, runtime::EntityHandle self, double) { + const auto settings = game.fields(self, "tutorial.spawn_once"); + const auto id = settings.at("spawned_id").get(); + // This queues a runtime object; it does not change the saved scene. + game.spawn( + {{"id", id}, + {"name", "Temporary box"}, + {"parent", nullptr}, + {"components", nlohmann::json::array( + {{{"id", id + "/transform"}, + {"type", "faset.transform"}, + {"version", 1}, + {"fields", {{"position", {0, 0, 0}}}}}, + {{"id", id + "/sprite"}, + {"type", "faset.sprite"}, + {"version", 1}, + {"fields", {{"color", {0.9, 0.6, 0.2, 1}}, {"size", {1, 1}}}}}, + {{"id", id + "/lifetime"}, + {"type", "tutorial.timed_despawn"}, + {"version", 1}, + {"fields", {{"seconds", settings.value("lifetime", 1.0)}}}}})}}); + }; + world.registerBehavior("tutorial.spawn_once", std::move(spawner)); + + // Ordinary C++ state belongs to the gameplay module. Every handle part matters. + using Key = std::tuple; + auto ages = std::make_shared>(); + auto key = [](runtime::EntityHandle h) { return Key{h.session, h.slot, h.generation}; }; + runtime::Behavior lifetime; + lifetime.onStart = [ages, key](runtime::Runtime&, runtime::EntityHandle self, double) { + (*ages)[key(self)] = 0.0; + }; + lifetime.fixedUpdate = [ages, key](runtime::Runtime& game, runtime::EntityHandle self, + double delta) { + auto& age = ages->at(key(self)); + age += delta; + if (age >= game.fields(self, "tutorial.timed_despawn").value("seconds", 1.0)) + game.destroy(self); // Still valid until the next fixed-tick barrier. + }; + lifetime.onDestroy = [ages, key](runtime::Runtime&, runtime::EntityHandle self, double) { + ages->erase(key(self)); + }; + world.registerBehavior("tutorial.timed_despawn", std::move(lifetime)); +} + +nlohmann::json schema() { + return nlohmann::json::array( + {{{"id", "tutorial.spawn_once"}, + {"version", 1}, + {"name", "Spawn once"}, + {"fields", + {{"spawned_id", + {{"id", "spawned_id"}, {"type", "string"}, {"default", "temporary-box"}}}, + {"lifetime", + {{"id", "lifetime"}, {"type", "number"}, {"default", 1.0}, {"min", 0.0}}}}}}, + {{"id", "tutorial.timed_despawn"}, + {"version", 1}, + {"name", "Timed despawn"}, + {"fields", + {{"seconds", + {{"id", "seconds"}, {"type", "number"}, {"default", 1.0}, {"min", 0.0}}}}}}}); +} +} // namespace faset::gameplay diff --git a/examples/tutorials/spawning/Gameplay.hpp b/examples/tutorials/spawning/Gameplay.hpp new file mode 100644 index 0000000..d9caafe --- /dev/null +++ b/examples/tutorials/spawning/Gameplay.hpp @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& world); +nlohmann::json schema(); +} // namespace faset::gameplay diff --git a/examples/tutorials/spawning/scene.json b/examples/tutorials/spawning/scene.json new file mode 100644 index 0000000..63d76e9 --- /dev/null +++ b/examples/tutorials/spawning/scene.json @@ -0,0 +1,58 @@ +{ + "format": "faset.scene", + "version": 1, + "id": "tutorial-spawning", + "name": "Spawning tutorial", + "dimension": 2, + "simulation": { + "fixed_delta": 0.016666666666666666, + "max_catch_up_ticks": 4, + "physics_substeps": 4, + "gravity": [ + 0, + -9.81, + 0 + ] + }, + "entities": [ + { + "id": "spawner", + "name": "spawner", + "parent": null, + "components": [ + { + "id": "spawner-faset.transform", + "type": "faset.transform", + "version": 1, + "fields": { + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "spawner-tutorial.spawn_once", + "type": "tutorial.spawn_once", + "version": 1, + "fields": { + "spawned_id": "temporary-box", + "lifetime": 1.0 + } + } + ] + } + ], + "instances": [] +} diff --git a/include/faset/assets/asset_data.hpp b/include/faset/assets/asset_data.hpp new file mode 100644 index 0000000..6d10e34 --- /dev/null +++ b/include/faset/assets/asset_data.hpp @@ -0,0 +1,71 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace faset::assets { +using Json = nlohmann::json; +struct Vertex { + std::array position{}; + std::array normal{0, 0, 1}; + std::array uv{}; +}; +struct Primitive { + std::vector vertices; + std::vector indices; + int material = -1; +}; +struct Mesh { + std::string id, name; + std::vector primitives; +}; +struct Node { + std::string id, name, parent_id; + int mesh = -1; + // glTF right-handed, Y-up, metres; column-major matrix. + std::array local_transform{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; + bool stable_source_id = false; +}; +struct Material { + std::string id, name; + std::array base_color{1, 1, 1, 1}; + std::array emissive{}; + float metallic = 1, roughness = 1, alpha_cutoff = 0.5f; + std::string alpha_mode = "OPAQUE"; + bool double_sided = false, unlit = false; + int base_color_texture = -1, metallic_roughness_texture = -1; + int normal_texture = -1, occlusion_texture = -1, emissive_texture = -1; +}; +struct Texture { + std::string id, name, mime_type; + // Encoded image bytes, owned by this value. Renderer selects an image decoder. + std::vector bytes; + int wrap_s = 10497, wrap_t = 10497, min_filter = 0, mag_filter = 0; +}; +struct CookedAsset { + std::string asset_id, generation; + std::vector meshes; + std::vector nodes; + std::vector materials; + std::vector textures; +}; + +// Read-only cooked storage. No source parser, import jobs or publication operations. +class AssetStore { + public: + explicit AssetStore(std::filesystem::path cache_root); + Json current_manifest(const std::string& asset_id) const; + CookedAsset load_asset(const std::string& asset_id) const; + std::filesystem::path generation_directory(const std::string& asset_id) const; + const std::filesystem::path& cache_root() const noexcept { + return cache_root_; + } + + protected: + std::filesystem::path cache_root_; +}; +} // namespace faset::assets diff --git a/include/faset/assets/asset_pipeline.hpp b/include/faset/assets/asset_pipeline.hpp index aa46ad1..e0a5186 100644 --- a/include/faset/assets/asset_pipeline.hpp +++ b/include/faset/assets/asset_pipeline.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -7,69 +8,28 @@ #include #include #include +#include #include #include -#include namespace faset::assets { using Json = nlohmann::json; inline constexpr const char* importer_version = "faset-gltf-1/cgltf-1.15"; -struct Vertex { - std::array position{}; - std::array normal{0, 0, 1}; - std::array uv{}; +struct ImportProgress { + float fraction = 0; + std::string stage; }; -struct Primitive { - std::vector vertices; - std::vector indices; - int material = -1; -}; -struct Mesh { - std::string id, name; - std::vector primitives; -}; -struct Node { - std::string id, name, parent_id; - int mesh = -1; - // glTF right-handed, Y-up, metres; column-major matrix. - std::array local_transform{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; - bool stable_source_id = false; -}; -struct Material { - std::string id, name; - std::array base_color{1,1,1,1}; - std::array emissive{}; - float metallic = 1, roughness = 1, alpha_cutoff = 0.5f; - std::string alpha_mode = "OPAQUE"; - bool double_sided = false, unlit = false; - int base_color_texture = -1, metallic_roughness_texture = -1; - int normal_texture = -1, occlusion_texture = -1, emissive_texture = -1; -}; -struct Texture { - std::string id, name, mime_type; - // Encoded image bytes, owned by this value. Renderer selects an image decoder. - std::vector bytes; - int wrap_s = 10497, wrap_t = 10497, min_filter = 0, mag_filter = 0; -}; -struct CookedAsset { - std::string asset_id, generation; - std::vector meshes; - std::vector nodes; - std::vector materials; - std::vector textures; -}; - -struct ImportProgress { float fraction = 0; std::string stage; }; class ImportJob { -public: + public: using Observer = std::function; explicit ImportJob(Observer observer = {}); void cancel() noexcept; bool cancelled() const noexcept; ImportProgress progress() const; void report(float fraction, std::string stage); -private: + + private: std::atomic cancelled_{false}; mutable std::mutex mutex_; ImportProgress progress_; @@ -79,7 +39,7 @@ private: enum class ImportStatus { succeeded, failed, cancelled, conflict }; struct ImportRequest { std::filesystem::path source; - std::string asset_id{}; // Empty: restore/create source.faset-import.json identity. + std::string asset_id{}; // Empty: restore/create source.faset-import.json identity. Json settings = nullptr; // Null restores the sidecar recipe; an object replaces it. // Explicit conflict resolution; false keeps the previous generation active. bool allow_removed_outputs = false; @@ -91,24 +51,20 @@ struct ImportResult { std::vector removed_output_ids; Json manifest; bool cache_hit = false; - bool ok() const noexcept { return status == ImportStatus::succeeded; } + bool ok() const noexcept { + return status == ImportStatus::succeeded; + } }; // A pipeline is an authoring service. Player only needs read-only cooked data. // Writers in one process serialize publication; a cache root has one service owner. -class AssetPipeline { -public: +class AssetPipeline : public AssetStore { + public: explicit AssetPipeline(std::filesystem::path cache_root); ImportResult import_asset(const ImportRequest& request, ImportJob& job); ImportResult import_asset(const ImportRequest& request); - Json current_manifest(const std::string& asset_id) const; - CookedAsset load_asset(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); - std::filesystem::path generation_directory(const std::string& asset_id) const; - const std::filesystem::path& cache_root() const noexcept { return cache_root_; } -private: - std::filesystem::path cache_root_; }; } // namespace faset::assets diff --git a/include/faset/authoring/schema.hpp b/include/faset/authoring/schema.hpp index 594012e..c2287bd 100644 --- a/include/faset/authoring/schema.hpp +++ b/include/faset/authoring/schema.hpp @@ -1,13 +1,13 @@ #pragma once -#include #include +#include #include #include #include namespace faset::authoring { class SchemaRegistry { -public: + public: void register_schema(const Json& schema); void register_schemas(const Json& schemas); bool contains(const std::string& type) const; @@ -18,29 +18,43 @@ public: void validate_component(const Json& component) const; Json migrate_component(const Json& component) const; void add_migration(const std::string& type, int from_version, Json field_rules); -private: - std::map schemas_; - std::map,Json> migrations_; + + private: + std::map schemas_; + std::map, Json> migrations_; }; -template class TypeRegistration { -public: - TypeRegistration(SchemaRegistry& registry, std::string id, std::string name, int version=1) - : registry_(registry),schema_{{"id",std::move(id)},{"name",std::move(name)},{"version",version},{"fields",Json::object()}} {} - template - TypeRegistration& field(std::string id, std::string name, Value T::*member, Value default_value, - std::string kind, Json constraints=Json::object()) { +template class TypeRegistration { + public: + TypeRegistration(SchemaRegistry& registry, std::string id, std::string name, int version = 1) + : registry_(registry), schema_{{"id", std::move(id)}, + {"name", std::move(name)}, + {"version", version}, + {"fields", Json::object()}} {} + template + TypeRegistration& field(std::string id, std::string name, Value T::* member, + Value default_value, std::string kind, + Json constraints = Json::object()) { static_assert(std::is_member_object_pointer_v); - require(!schema_["fields"].contains(id),"schema.duplicate_field","Duplicate stable FieldId"); + require(!schema_["fields"].contains(id), "schema.duplicate_field", + "Duplicate stable FieldId"); // Converting the typed default checks supported JSON serialization at compile time. - Json descriptor={{"id",id},{"name",std::move(name)},{"type",std::move(kind)},{"default",Json(default_value)}}; - descriptor.update(constraints); schema_["fields"][id]=std::move(descriptor); return *this; + Json descriptor = {{"id", id}, + {"name", std::move(name)}, + {"type", std::move(kind)}, + {"default", Json(default_value)}}; + descriptor.update(constraints); + schema_["fields"][id] = std::move(descriptor); + return *this; } - void commit() { registry_.register_schema(schema_); } -private: + void commit() { + registry_.register_schema(schema_); + } + + private: SchemaRegistry& registry_; Json schema_; }; SchemaRegistry builtin_schemas(); -void validate_field(const Json& value,const Json& descriptor); -} +void validate_field(const Json& value, const Json& descriptor); +} // namespace faset::authoring diff --git a/include/faset/authoring/service.hpp b/include/faset/authoring/service.hpp index c6569df..461414f 100644 --- a/include/faset/authoring/service.hpp +++ b/include/faset/authoring/service.hpp @@ -3,47 +3,58 @@ #include #include #include +#include #include #include namespace faset::authoring { -Json make_scene(std::string name,int dimension=3); -Json make_entity(const SchemaRegistry& schemas,std::string name,const std::string& parent=""); -void validate_scene(const Json& scene,const SchemaRegistry& schemas); +Json make_scene(std::string name, int dimension = 3); +Json make_entity(const SchemaRegistry& schemas, std::string name, const std::string& parent = ""); +void validate_scene(const Json& scene, const SchemaRegistry& schemas); class AuthoringService { -public: - explicit AuthoringService(std::filesystem::path project_root,SchemaRegistry schemas=builtin_schemas()); - Json create(std::string name,int dimension=3); - Json open(const std::filesystem::path& relative,bool recover=false); + public: + explicit AuthoringService(std::filesystem::path project_root, + SchemaRegistry schemas = builtin_schemas()); + Json create(std::string name, int dimension = 3); + Json open(const std::filesystem::path& relative, bool recover = false); Json query(const std::string& document) const; Json documents() const; - Json transact(const std::string& document,std::uint64_t expected_revision,const Json& operations,const std::string& idempotency_key=""); - Json undo(const std::string& document,std::uint64_t expected_revision); - Json redo(const std::string& document,std::uint64_t expected_revision); - Json save(const std::string& document,const std::filesystem::path& relative={}); + Json transact(const std::string& document, std::uint64_t expected_revision, + const Json& operations, const std::string& idempotency_key = ""); + Json undo(const std::string& document, std::uint64_t expected_revision); + Json redo(const std::string& document, std::uint64_t expected_revision); + Json save(const std::string& document, const std::filesystem::path& relative = {}); Json recovery_documents() const; - const SchemaRegistry& schemas() const {return schemas_;} + Json recover(const std::string& document, + std::optional expected_revision = std::nullopt); + const SchemaRegistry& schemas() const { + return schemas_; + } void register_schemas(const Json& manifest); - const std::filesystem::path& root() const {return root_;} -private: + void replace_external_schemas(const Json& manifest); + const std::filesystem::path& root() const { + return root_; + } + + private: struct State { Json data; - std::uint64_t revision=0; + std::uint64_t revision = 0; std::filesystem::path path; - std::string saved_hash,disk_hash; - std::vector undo,redo; - std::map> requests; + std::string saved_hash, disk_hash; + std::vector undo, redo; + std::map> requests; }; - Json summary(const State& state,bool include_data=true) const; + Json summary(const State& state, bool include_data = true) const; State& state(const std::string& document); const State& state(const std::string& document) const; void journal(const State& state) const; - void apply(Json& scene,const Json& operation); - Json history(const std::string& document,std::uint64_t revision,bool redo); + void apply(Json& scene, const Json& operation); + Json history(const std::string& document, std::uint64_t revision, bool redo); std::filesystem::path root_; SchemaRegistry schemas_; - std::map documents_; + std::map documents_; mutable std::recursive_mutex mutex_; }; -} +} // namespace faset::authoring diff --git a/include/faset/authoring/templates.hpp b/include/faset/authoring/templates.hpp index 04c129b..2bf3b55 100644 --- a/include/faset/authoring/templates.hpp +++ b/include/faset/authoring/templates.hpp @@ -4,8 +4,12 @@ #include namespace faset::authoring { -struct ResolvedScene {Json scene;Json conflicts=Json::array();}; -using SceneLoader=std::function; +struct ResolvedScene { + Json scene; + Json conflicts = Json::array(); +}; +using SceneLoader = std::function; // Source documents are immutable inputs. Conflicting records stay in the authoring file. -ResolvedScene resolve_templates(const Json& scene,const SchemaRegistry& schemas,const SceneLoader& loader); -} +ResolvedScene resolve_templates(const Json& scene, const SchemaRegistry& schemas, + const SceneLoader& loader); +} // namespace faset::authoring diff --git a/include/faset/authoring/transforms.hpp b/include/faset/authoring/transforms.hpp new file mode 100644 index 0000000..41d8f2d --- /dev/null +++ b/include/faset/authoring/transforms.hpp @@ -0,0 +1,8 @@ +#pragma once +#include +#include +namespace faset::authoring { +// Reparents authored TRS while optionally preserving the complete world transform. +// Non-invertible parents and transforms requiring shear are rejected atomically. +void reparent_entity(Json& scene, const std::string& entity, const Json& parent, bool keep_world); +} // namespace faset::authoring diff --git a/include/faset/core/error.hpp b/include/faset/core/error.hpp index 98f5c2a..cee331b 100644 --- a/include/faset/core/error.hpp +++ b/include/faset/core/error.hpp @@ -5,16 +5,23 @@ namespace faset { class Error : public std::runtime_error { -public: + public: Error(std::string code, std::string message, Json details = Json::object()) - : std::runtime_error(std::move(message)), code_(std::move(code)), details_(std::move(details)) {} - const std::string& code() const noexcept { return code_; } - Json json() const { return {{"code", code_}, {"message", what()}, {"details", details_}}; } -private: + : std::runtime_error(std::move(message)), code_(std::move(code)), + details_(std::move(details)) {} + const std::string& code() const noexcept { + return code_; + } + Json json() const { + return {{"code", code_}, {"message", what()}, {"details", details_}}; + } + + private: std::string code_; Json details_; }; inline void require(bool condition, const std::string& code, const std::string& message) { - if (!condition) throw Error(code, message); -} + if (!condition) + throw Error(code, message); } +} // namespace faset diff --git a/include/faset/core/hash.hpp b/include/faset/core/hash.hpp index d7a1307..b9f24fa 100644 --- a/include/faset/core/hash.hpp +++ b/include/faset/core/hash.hpp @@ -11,4 +11,4 @@ inline std::string sha256(std::string_view text) { return sha256(std::as_bytes(std::span(text.data(), text.size()))); } std::string sha256_file(const std::filesystem::path& path); -} +} // namespace faset diff --git a/include/faset/core/io.hpp b/include/faset/core/io.hpp index e99f75c..af17402 100644 --- a/include/faset/core/io.hpp +++ b/include/faset/core/io.hpp @@ -11,5 +11,6 @@ Json read_json(const std::filesystem::path& path); void atomic_write(const std::filesystem::path& path, std::string_view bytes); void atomic_write_json(const std::filesystem::path& path, const Json& value); // Rejects traversal and symlink escapes before project-scoped file operations. -std::filesystem::path project_path(const std::filesystem::path& root, const std::filesystem::path& relative); -} +std::filesystem::path project_path(const std::filesystem::path& root, + const std::filesystem::path& relative); +} // namespace faset diff --git a/include/faset/core/json.hpp b/include/faset/core/json.hpp index a0c2df2..b6287b3 100644 --- a/include/faset/core/json.hpp +++ b/include/faset/core/json.hpp @@ -1,3 +1,5 @@ #pragma once #include -namespace faset { using Json = nlohmann::json; } +namespace faset { +using Json = nlohmann::json; +} diff --git a/include/faset/core/process.hpp b/include/faset/core/process.hpp new file mode 100644 index 0000000..8f8fb89 --- /dev/null +++ b/include/faset/core/process.hpp @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace faset { +struct ProcessOptions { + // First element is the executable. Arguments are passed directly, never through a shell. + std::vector arguments; + std::filesystem::path working_directory; + std::map environment; +}; +struct ProcessPoll { + bool running{}; + std::optional exit_code; + std::string output; // Newly available stdout and stderr, combined. +}; +class Process { + public: + explicit Process(const ProcessOptions&); + ~Process(); + Process(Process&&) noexcept; + Process& operator=(Process&&) noexcept; + Process(const Process&) = delete; + Process& operator=(const Process&) = delete; + ProcessPoll poll(); + // Terminates the process group/job, including its compiler children. + void cancel(); + + private: + struct Impl; + std::unique_ptr impl_; +}; +std::filesystem::path find_executable(const std::string& name); +} // namespace faset diff --git a/include/faset/editor/build_service.hpp b/include/faset/editor/build_service.hpp new file mode 100644 index 0000000..0a12cc3 --- /dev/null +++ b/include/faset/editor/build_service.hpp @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace faset::editor { +struct BuildConfig { + std::filesystem::path project_root; + std::filesystem::path engine_root; + std::filesystem::path build_directory; + std::filesystem::path cache_root; + // Separate CMake caches prevent an export from changing the active development build. + std::string configuration{"Debug"}; + std::string export_configuration{"Release"}; + std::string cmake{"cmake"}; + std::string generator{"Ninja"}; + std::vector configure_arguments; +}; +struct JobStatus { + std::string id, kind, state{"queued"}, stage{"queued"}; + double progress{}; + std::string log, error; + Json result = Json::object(); + bool finished() const { + return state == "succeeded" || state == "failed" || state == "cancelled"; + } + Json json() const; +}; +// One serialized build/cook worker per project. GUI and MCP use the same service. +// Input scenes must be resolved authoring snapshots, independent of live runtime state. +class BuildService { + public: + explicit BuildService(BuildConfig); + ~BuildService(); + BuildService(const BuildService&) = delete; + BuildService& operator=(const BuildService&) = delete; + void scaffold(const std::string& name, int dimension); + std::string start_build(); + std::string start_cook(Json resolved_scene); + // Publishes output/generations/; current.json changes only after all validation succeeds. + std::string start_export(Json resolved_scene, const std::filesystem::path& output_directory); + JobStatus job(const std::string& id) const; + std::vector jobs() const; + void cancel(const std::string& id); + JobStatus wait(const std::string& id); + const BuildConfig& config() const; + + private: + struct Impl; + std::unique_ptr impl_; +}; +void write_cooked_scene(const std::filesystem::path& path, const Json& resolved_scene); +} // namespace faset::editor diff --git a/include/faset/editor/commands.hpp b/include/faset/editor/commands.hpp new file mode 100644 index 0000000..5befd5a --- /dev/null +++ b/include/faset/editor/commands.hpp @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include +#include + +namespace faset::editor { +class Commands { + public: + using Handler = std::function; + explicit Commands(authoring::AuthoringService& authoring); + void add(std::string name, std::string description, Json input_schema, Handler handler, + bool read_only = false); + Json list() const; + void remove(const std::string& name); + Json call(const std::string& name, const Json& arguments); + Json resolved_scene(const std::string& document) const; + authoring::AuthoringService& authoring() { + return authoring_; + } + static Json object_schema(Json properties, Json required = Json::array()); + + private: + struct Command { + Json descriptor; + Handler handler; + }; + authoring::AuthoringService& authoring_; + std::map commands_; +}; +} // namespace faset::editor diff --git a/include/faset/editor/editor_ui.hpp b/include/faset/editor/editor_ui.hpp new file mode 100644 index 0000000..e0e984e --- /dev/null +++ b/include/faset/editor/editor_ui.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#include + +namespace faset::editor { +// Owns authoring presentation only. Player execution and MCP transport stay in +// the application. +class EditorUI { + public: + EditorUI(Session&, render::Renderer&, const std::filesystem::path& font, + const std::filesystem::path& styles); + ~EditorUI(); + EditorUI(const EditorUI&) = delete; + EditorUI& operator=(const EditorUI&) = delete; + void frame(const std::vector&); + const render::Snapshot& snapshot() const; + ui::Context& widgets(); + const std::string& current_document() const; + void select_document(const std::string& document); + const std::string& selected_entity() const; + void select_entity(const std::string& entity); + + private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace faset::editor diff --git a/include/faset/editor/mcp.hpp b/include/faset/editor/mcp.hpp new file mode 100644 index 0000000..931e834 --- /dev/null +++ b/include/faset/editor/mcp.hpp @@ -0,0 +1,32 @@ +#pragma once +#include +#include +#include +#include + +namespace faset::editor { +// MCP is compiled exclusively into the Editor / headless authoring executable. +class McpServer { + public: + explicit McpServer(Commands& commands) : commands_(commands) {} + std::optional handle(const Json& message); + Json parse_error() const; + + private: + Commands& commands_; + bool initialized_ = false, ready_ = false; +}; +class StdioTransport { + public: + // Nonblocking: GUI and MCP can share the same authoring session and event loop. + std::vector poll(); + bool closed() const noexcept { + return closed_; + } + void send(const Json& message); + + private: + std::string buffer_; + bool closed_ = false; +}; +} // namespace faset::editor diff --git a/include/faset/editor/plugin_api.h b/include/faset/editor/plugin_api.h new file mode 100644 index 0000000..a1b49d8 --- /dev/null +++ b/include/faset/editor/plugin_api.h @@ -0,0 +1,42 @@ +#pragma once +/* Exact-build native Editor SDK. No STL types or ownership cross this ABI. */ +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +#define FASET_EDITOR_API_VERSION 1u +#if defined(_WIN32) +#define FASET_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FASET_PLUGIN_EXPORT __attribute__((visibility("default"))) +#endif +/* UTF-8 JSON is borrowed for the duration of a call. write() copies output in + the receiving module. Return zero on success; errors use {code,message}. */ +typedef void (*FasetWrite)(void* receiver, const char* utf8, uint64_t length); +typedef int (*FasetCommand)(void* user, const char* arguments_json, FasetWrite write, + void* receiver); +typedef struct FasetEditorHost { + uint32_t api_version; + uint32_t struct_size; + const char* build_fingerprint; + void* context; + int (*register_command)(void* context, const char* descriptor_json, FasetCommand callback, + void* user); + int (*register_panel)(void* context, const char* panel_json); + int (*invoke_command)(void* context, const char* name, const char* arguments_json, + FasetWrite write, void* receiver); + void (*log)(void* context, const char* utf8); +} FasetEditorHost; +typedef struct FasetEditorPlugin { + uint32_t api_version; + uint32_t struct_size; + const char* build_fingerprint; + void* user; + void (*shutdown)(void* user); +} FasetEditorPlugin; +/* Required exported symbol. Called once at startup, after dependencies load. */ +typedef int (*FasetPluginEntry)(const FasetEditorHost* host, FasetEditorPlugin* plugin); +#ifdef __cplusplus +} +#endif diff --git a/include/faset/editor/plugins.hpp b/include/faset/editor/plugins.hpp new file mode 100644 index 0000000..87c695c --- /dev/null +++ b/include/faset/editor/plugins.hpp @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include +namespace faset::editor { +class PluginManager { + public: + using Logger = std::function; + explicit PluginManager(Commands&, Logger); + ~PluginManager(); + PluginManager(const PluginManager&) = delete; + PluginManager& operator=(const PluginManager&) = delete; + // Discover and validate the complete dependency graph before loading code. + // Individual failures are reported; dependents are never loaded after one. + void load(const std::filesystem::path& directory); + Json status() const; + Json panels() const; + static std::string fingerprint(); + + private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace faset::editor diff --git a/include/faset/editor/session.hpp b/include/faset/editor/session.hpp new file mode 100644 index 0000000..93dc2ad --- /dev/null +++ b/include/faset/editor/session.hpp @@ -0,0 +1,69 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace faset::editor { +struct SessionConfig { + std::filesystem::path project_root, engine_root, binary_directory; +}; +class Session { + public: + explicit Session(SessionConfig); + ~Session(); + Session(const Session&) = delete; + Session& operator=(const Session&) = delete; + authoring::AuthoringService& authoring() { + return authoring_; + } + Commands& commands() { + return commands_; + } + void poll(); + const std::vector& logs() const { + return logs_; + } + Json project() const; + Json plugin_panels() const { + return plugins_ ? plugins_->panels() : Json::array(); + } + void scaffold(const std::string& name, int dimension); + const SessionConfig& config() const { + return config_; + } + bool playing() const { + return bool(player_); + } + void log(std::string message); + + private: + struct ImportTask; + void register_commands(); + Json assets_list() const; + Json jobs() const; + Json job(const std::string& id) const; + void load_schema(const std::filesystem::path& path); + void launch_player(Json scene, const std::filesystem::path& executable); + void stop_player(); + SessionConfig config_; + authoring::AuthoringService authoring_; + Commands commands_; + assets::AssetPipeline assets_; + BuildService builds_; + std::map> imports_; + std::vector workers_; + std::vector logs_; + std::map observed_jobs_; + std::unique_ptr player_; + std::filesystem::path control_path_; + std::uint64_t control_sequence_ = 0; + std::string pending_play_job_; + Json pending_play_scene_; + std::unique_ptr plugins_; +}; +} // namespace faset::editor diff --git a/include/faset/player/SceneView.hpp b/include/faset/player/SceneView.hpp new file mode 100644 index 0000000..6fac052 --- /dev/null +++ b/include/faset/player/SceneView.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include + +namespace faset::player { +struct CameraSettings { + // Defaults frame the small demo scenes. An explicit editor camera overrides + // scene camera components without changing the authoring document. + bool overrideSceneCamera{false}; + render::Vec3 eye{8, 6, 10}; + render::Vec3 target{0, 0, 0}; + float verticalFovDegrees{60}; + float orthographicHeight{12}; + float nearPlane{0.1f}, farPlane{1000}; +}; +class SceneView { + public: + // cacheRoot contains assets//current.json and generation folders. + explicit SceneView(std::filesystem::path cacheRoot); + ~SceneView(); + SceneView(const SceneView&) = delete; + SceneView& operator=(const SceneView&) = delete; + render::Snapshot build(const nlohmann::json& flatSceneOrRuntimeSnapshot, float aspect, + CameraSettings camera = {}); + void clearCache(); + const std::vector& diagnostics() const; + + private: + struct Impl; + std::unique_ptr impl_; +}; + +// Reads JSON development scenes or a strict FASETSCN v1 CBOR envelope. +nlohmann::json readScene(const std::filesystem::path& path); +} // namespace faset::player diff --git a/include/faset/render/render_graph.hpp b/include/faset/render/render_graph.hpp index 9a21d55..2ac92d5 100644 --- a/include/faset/render/render_graph.hpp +++ b/include/faset/render/render_graph.hpp @@ -6,15 +6,21 @@ namespace faset::render { // Ordered single-queue graph. Reads must be imported or produced by an earlier pass. // The Vulkan executor performs barriers at each resource state transition. class RenderGraph { -public: + public: using Callback = std::function; void import(std::string resource); - void add(std::string name, std::vector reads, std::vector writes, Callback execute); + void add(std::string name, std::vector reads, std::vector writes, + Callback execute); void execute() const; std::vector pass_names() const; -private: - struct Pass {std::string name; std::vector reads, writes; Callback callback;}; + + private: + struct Pass { + std::string name; + std::vector reads, writes; + Callback callback; + }; std::vector imports_; std::vector passes_; }; -} +} // namespace faset::render diff --git a/include/faset/render/renderer.hpp b/include/faset/render/renderer.hpp index 1b06b6e..d3dec8b 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -11,37 +11,68 @@ using Vec2 = std::array; using Vec3 = std::array; using Color = std::array; using Mat4 = std::array; -inline constexpr Mat4 identity{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; +inline constexpr Mat4 identity{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; // Matrices are column-major, vectors are columns; clip depth is Vulkan's [0,1]. Mat4 multiply(const Mat4&, const Mat4&); -Mat4 transform(Vec3 position, Vec3 rotation = {}, Vec3 scale = {1,1,1}); +Mat4 transform(Vec3 position, Vec3 rotation = {}, Vec3 scale = {1, 1, 1}); Mat4 perspective(float vertical_fov_radians, float aspect, float near_plane, float far_plane); -Mat4 orthographic(float left, float right, float bottom, float top, float near_plane, float far_plane); -Mat4 look_at(Vec3 eye, Vec3 target, Vec3 up = {0,1,0}); -struct Vertex { Vec3 position{}; Vec3 normal{0,0,1}; Color color{1,1,1,1}; Vec2 uv{}; }; -struct Mesh { std::vector vertices; std::vector indices; }; +Mat4 orthographic(float left, float right, float bottom, float top, float near_plane, + float far_plane); +Mat4 look_at(Vec3 eye, Vec3 target, Vec3 up = {0, 1, 0}); +struct Vertex { + Vec3 position{}; + Vec3 normal{0, 0, 1}; + Color color{1, 1, 1, 1}; + Vec2 uv{}; +}; +struct Mesh { + std::vector vertices; + std::vector indices; +}; std::shared_ptr cube_mesh(); struct Texture; struct DrawItem { std::shared_ptr mesh; Mat4 model{identity}; - Color color{1,1,1,1}; + Color color{1, 1, 1, 1}; float roughness{0.65f}; float metallic{0.0f}; bool cast_shadow{true}; std::shared_ptr texture; }; -struct Sprite { Vec3 position{}; Vec2 size{1,1}; Color color{1,1,1,1}; float rotation{}; std::shared_ptr texture; }; -struct Texture { std::uint32_t width{}, height{}; std::vector rgba; std::uint64_t revision{}; bool srgb{false}; }; -struct Quad { float x{}, y{}, width{}, height{}; Color color{1,1,1,1}; std::shared_ptr texture; std::array uv_rect{0,0,1,1}; }; -struct Text { float x{}, y{}; std::string value; Color color{0.85f,0.87f,0.90f,1}; float size{14}; }; +struct Sprite { + Vec3 position{}; + Vec2 size{1, 1}; + Color color{1, 1, 1, 1}; + float rotation{}; + std::shared_ptr texture; +}; +struct Texture { + std::uint32_t width{}, height{}; + std::vector rgba; + std::uint64_t revision{}; + bool srgb{false}; +}; +struct Quad { + float x{}, y{}, width{}, height{}; + Color color{1, 1, 1, 1}; + std::shared_ptr texture; + std::array uv_rect{0, 0, 1, 1}; +}; +struct Text { + float x{}, y{}; + std::string value; + Color color{0.85f, 0.87f, 0.90f, 1}; + float size{14}; +}; struct Snapshot { - // Optional scene viewport in drawable pixels (x, y, width, height); zero size uses the full target. - std::array scene_rect{}; + // Optional scene viewport in drawable pixels (x, y, width, height); zero size uses the full + // target. + std::array scene_rect{}; Mat4 view_projection{identity}; - Vec3 eye{4,3,5}; - Vec3 light_direction{-0.5f,-1,-0.3f}; - Color clear_color{0.055f,0.065f,0.085f,1}; + Vec3 eye{4, 3, 5}; + Vec3 light_direction{-0.5f, -1, -0.3f}; + Color clear_color{0.055f, 0.065f, 0.085f, 1}; std::vector draws; std::vector sprites; // UI coordinates are drawable pixels, top-left origin. Order is preserved per list. @@ -55,7 +86,20 @@ struct RendererConfig { bool validation{true}; }; struct Event { - enum class Type { Quit, Resize, FocusGained, FocusLost, MouseMove, MouseDown, MouseUp, Wheel, KeyDown, KeyUp, TextInput, TextEditing }; + enum class Type { + Quit, + Resize, + FocusGained, + FocusLost, + MouseMove, + MouseDown, + MouseUp, + Wheel, + KeyDown, + KeyUp, + TextInput, + TextEditing + }; Type type{}; float x{}, y{}; int button{}; @@ -71,7 +115,7 @@ struct FrameStats { std::string device; }; class Renderer { -public: + public: explicit Renderer(const RendererConfig& = {}); ~Renderer(); Renderer(Renderer&&) noexcept; @@ -95,8 +139,9 @@ public: void set_text_input_area(float x, float y, float width, float height); void set_clipboard(const std::string&); std::string clipboard() const; -private: + + private: struct Impl; std::unique_ptr impl_; }; -} +} // namespace faset::render diff --git a/include/faset/runtime/Runtime.hpp b/include/faset/runtime/Runtime.hpp index 0a2da7c..cc39562 100644 --- a/include/faset/runtime/Runtime.hpp +++ b/include/faset/runtime/Runtime.hpp @@ -4,10 +4,10 @@ #include #include #include +#include #include #include #include -#include namespace faset::runtime { @@ -26,7 +26,9 @@ struct EntityHandle { std::uint64_t session{}; std::uint32_t slot{}; std::uint64_t generation{}; - explicit operator bool() const noexcept { return session != 0; } + explicit operator bool() const noexcept { + return session != 0; + } bool operator==(const EntityHandle&) const = default; }; @@ -37,8 +39,17 @@ struct InputState { bool interactPressed{}; }; -struct Sprite { Vec4 color{1, 1, 1, 1}; Vec2 size{1, 1}; std::string texture; int layer{}; }; -struct Mesh { std::string asset; Vec4 color{1, 1, 1, 1}; std::string primitive{"cube"}; }; +struct Sprite { + Vec4 color{1, 1, 1, 1}; + Vec2 size{1, 1}; + std::string texture; + int layer{}; +}; +struct Mesh { + std::string asset; + Vec4 color{1, 1, 1, 1}; + std::string primitive{"cube"}; +}; struct RenderEntity { std::string id; std::string name; @@ -86,7 +97,7 @@ struct CollisionEvent { // Single-owner sequential runtime. Gameplay callbacks run on the caller's thread. // No Editor, MCP, renderer or platform service is linked by this API. class Runtime { -public: + public: explicit Runtime(RuntimeConfig config = {}); ~Runtime(); Runtime(const Runtime&) = delete; @@ -111,6 +122,9 @@ public: // Configuration copy. Live poses and velocities have their own typed accessors. nlohmann::json fields(EntityHandle handle, const std::string& componentType) const; Vec3 velocity(EntityHandle handle) const; + // Support from the last completed physics step. Checks actual contact normals + // against opposite gravity (Y-up if gravity is zero), not vertical speed. + bool grounded(EntityHandle handle) const; InputState input() const noexcept; // Valid until the next fixed tick or scene replacement. No native solver pointers. const std::vector& collisions() const noexcept; @@ -135,7 +149,7 @@ public: std::uint64_t session() const noexcept; const std::vector& diagnostics() const noexcept; -private: + private: struct Impl; std::unique_ptr impl_; }; diff --git a/include/faset/ui/ui.hpp b/include/faset/ui/ui.hpp new file mode 100644 index 0000000..4768f88 --- /dev/null +++ b/include/faset/ui/ui.hpp @@ -0,0 +1,183 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace faset::ui { +using Json = nlohmann::json; +using Color = render::Color; +struct Rect { + float x{}, y{}, width{}, height{}; + bool contains(float px, float py) const noexcept; + Rect intersection(const Rect&) const noexcept; +}; +struct Theme { + Color background{.075f, .078f, .086f, 1}, surface{.10f, .105f, .115f, 1}; + Color raised{.135f, .14f, .15f, 1}, hover{.175f, .18f, .20f, 1}, border{.23f, .235f, .255f, 1}; + Color text{.86f, .875f, .90f, 1}, muted{.55f, .575f, .62f, 1}, accent{.65f, .60f, .88f, 1}; + Color selection{.26f, .245f, .35f, 1}, danger{.92f, .39f, .38f, 1}; + float font_size = 14, row_height = 28, padding = 8, gap = 4; + static Theme from_json(const Json&); + static Theme load(const std::filesystem::path&); + Json to_json() const; +}; + +// Byte offsets always remain valid UTF-8 boundaries. Undo is local to an +// unfinished edit. +class TextBuffer { + public: + explicit TextBuffer(std::string text = {}); + const std::string& text() const noexcept { + return text_; + } + std::size_t cursor() const noexcept { + return cursor_; + } + std::size_t anchor() const noexcept { + return anchor_; + } + bool has_selection() const noexcept { + return cursor_ != anchor_; + } + std::string selected_text() const; + void reset(std::string text); + void set_cursor(std::size_t byte_offset, bool select = false); + void select_all(); + void left(bool select = false, bool by_word = false); + void right(bool select = false, bool by_word = false); + void home(bool select = false); + void end(bool select = false); + bool insert(std::string_view utf8); + bool backspace(); + bool delete_forward(); + bool undo(); + bool redo(); + static bool valid_utf8(std::string_view); + + private: + struct State { + std::string text; + std::size_t cursor, anchor; + }; + std::string text_; + std::size_t cursor_{}, anchor_{}; + std::vector undo_, redo_; + void remember(); + void erase_selection(); +}; +class FontAtlas { + public: + explicit FontAtlas(const std::filesystem::path& font); + ~FontAtlas(); + FontAtlas(const FontAtlas&) = delete; + FontAtlas& operator=(const FontAtlas&) = delete; + float measure(std::string_view utf8, float pixels); + // y is the top of the line, not the baseline; clipping also adjusts glyph + // UVs. + void draw(render::Snapshot&, std::string_view utf8, float x, float y, float pixels, Color, + const Rect& clip); + std::shared_ptr texture() const; + + private: + struct Impl; + std::unique_ptr impl_; +}; + +enum class Kind { + Panel, + Row, + Column, + Label, + Button, + Tab, + TreeRow, + TextField, + NumberField, + Checkbox, + Divider, + Viewport +}; +struct Layout { + float width = -1, height = -1, flex = 0; + float min_width = 0, min_height = 0, max_width = 100000, max_height = 100000; + float padding = 0, gap = 4; + bool absolute = false, scroll = false, clip = true; + float x = 0, y = 0; +}; +struct Widget { + Kind kind = Kind::Panel; + std::string id, text; + Layout layout; + Rect rect, clip; + bool visible = true, enabled = true, selected = false, checked = false; + double value = 0, step = .01; + int precision = 3, indent = 0; + float scroll_y = 0, content_height = 0; + std::string error, tooltip; + Json drag_payload; + std::string dock_area, dock_panel; + std::function on_click, on_preview, on_commit, on_cancel; + std::function on_drop; + std::vector> children; + Widget* parent = nullptr; + Widget& add(Kind, const std::string& stable_id, const std::string& text = {}); + Widget* find(std::string_view stable_id); + const Widget* find(std::string_view stable_id) const; + void remove(std::string_view stable_id); +}; +// Small persistent docking model: named areas, ordered tabs/panels and explicit +// sizes. +class DockLayout { + public: + void move(const std::string& panel, const std::string& area, std::size_t index); + std::vector panels(const std::string& area) const; + void set_size(const std::string& panel, float size); + float size(const std::string& panel, float fallback) const; + Json to_json() const; + void from_json(const Json&); + void save(const std::filesystem::path&) const; + void load(const std::filesystem::path&); + + private: + std::unordered_map> areas_; + std::unordered_map sizes_; +}; + +class Context { + public: + explicit Context(const std::filesystem::path& font); + ~Context(); + Widget& root(); + Widget* find(std::string_view id); + void set_theme(Theme); + const Theme& theme() const; + // Reloads declarative widget properties; matching IDs retain callbacks and + // edit state. + void apply_layout(const Json&); + void layout(float drawable_width, float drawable_height, float dpi_scale = 1); + bool handle(const render::Event&); + 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); + bool focus(const std::string& id); + const std::string& focused_id() const; + void clear_focus(bool commit = true); + bool editing() const; + FontAtlas& font(); + void set_clipboard(std::function read, + std::function write); + // Hook to Renderer::set_text_input and set_text_input_area. + void set_ime(std::function enabled, std::function rectangle); + void set_docking(DockLayout*, std::function changed = {}); + + private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace faset::ui diff --git a/licenses/README.md b/licenses/README.md index 26e3e25..e4f7f36 100644 --- a/licenses/README.md +++ b/licenses/README.md @@ -10,3 +10,12 @@ Faset source dependencies are pinned in `dependencies.lock.json`. These notices - **cgltf** (MIT): [360db1a95480](https://github.com/jkuhlmann/cgltf/tree/360db1a95480fe102ae9c69b27c5d101167ff5ba), notice in `cgltf.txt`. - **stb** (MIT OR Unlicense): [2c980bb59875](https://github.com/nothings/stb/tree/2c980bb59875b0d32144a71867fbdebb2f77cd20), notice in `stb.txt`. - **nlohmann/json** (MIT): pinned in the dependency lock, notice in `json.txt`. + +- **FreeType** (FreeType License): pinned 2.13.3, notice in `freetype.txt`. Portions of this software are copyright © The FreeType Project (www.freetype.org). All rights reserved. +- **HarfBuzz** (MIT-style): pinned 10.4.0, copyright and permissions in `harfbuzz.txt`. +- **Noto Sans** (SIL Open Font License 1.1): editor font only; see `../assets/fonts/OFL.txt` and `../assets/fonts/NOTICE.md`. + +The default Editor build uses the pinned FreeType/HarfBuzz archives. An explicit +`FASET_USE_SYSTEM_TEXT_LIBRARIES=ON` uses installed versions; distributors must retain +the notices appropriate to those installations. These font libraries and the editor +font are not linked into or required by exported games. diff --git a/licenses/freetype.txt b/licenses/freetype.txt new file mode 100644 index 0000000..c406d15 --- /dev/null +++ b/licenses/freetype.txt @@ -0,0 +1,169 @@ + The FreeType Project LICENSE + ---------------------------- + + 2006-Jan-27 + + Copyright 1996-2002, 2006 by + David Turner, Robert Wilhelm, and Werner Lemberg + + + +Introduction +============ + + The FreeType Project is distributed in several archive packages; + some of them may contain, in addition to the FreeType font engine, + various tools and contributions which rely on, or relate to, the + FreeType Project. + + This license applies to all files found in such packages, and + which do not fall under their own explicit license. The license + affects thus the FreeType font engine, the test programs, + documentation and makefiles, at the very least. + + This license was inspired by the BSD, Artistic, and IJG + (Independent JPEG Group) licenses, which all encourage inclusion + and use of free software in commercial and freeware products + alike. As a consequence, its main points are that: + + o We don't promise that this software works. However, we will be + interested in any kind of bug reports. (`as is' distribution) + + o You can use this software for whatever you want, in parts or + full form, without having to pay us. (`royalty-free' usage) + + o You may not pretend that you wrote this software. If you use + it, or only parts of it, in a program, you must acknowledge + somewhere in your documentation that you have used the + FreeType code. (`credits') + + We specifically permit and encourage the inclusion of this + software, with or without modifications, in commercial products. + We disclaim all warranties covering The FreeType Project and + assume no liability related to The FreeType Project. + + + Finally, many people asked us for a preferred form for a + credit/disclaimer to use in compliance with this license. We thus + encourage you to use the following text: + + """ + Portions of this software are copyright © The FreeType + Project (www.freetype.org). All rights reserved. + """ + + Please replace with the value from the FreeType version you + actually use. + + +Legal Terms +=========== + +0. Definitions +-------------- + + Throughout this license, the terms `package', `FreeType Project', + and `FreeType archive' refer to the set of files originally + distributed by the authors (David Turner, Robert Wilhelm, and + Werner Lemberg) as the `FreeType Project', be they named as alpha, + beta or final release. + + `You' refers to the licensee, or person using the project, where + `using' is a generic term including compiling the project's source + code as well as linking it to form a `program' or `executable'. + This program is referred to as `a program using the FreeType + engine'. + + This license applies to all files distributed in the original + FreeType Project, including all source code, binaries and + documentation, unless otherwise stated in the file in its + original, unmodified form as distributed in the original archive. + If you are unsure whether or not a particular file is covered by + this license, you must contact us to verify this. + + The FreeType Project is copyright (C) 1996-2000 by David Turner, + Robert Wilhelm, and Werner Lemberg. All rights reserved except as + specified below. + +1. No Warranty +-------------- + + THE FREETYPE PROJECT IS PROVIDED `AS IS' WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. IN NO EVENT WILL ANY OF THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY DAMAGES CAUSED BY THE USE OR THE INABILITY TO + USE, OF THE FREETYPE PROJECT. + +2. Redistribution +----------------- + + This license grants a worldwide, royalty-free, perpetual and + irrevocable right and license to use, execute, perform, compile, + display, copy, create derivative works of, distribute and + sublicense the FreeType Project (in both source and object code + forms) and derivative works thereof for any purpose; and to + authorize others to exercise some or all of the rights granted + herein, subject to the following conditions: + + o Redistribution of source code must retain this license file + (`FTL.TXT') unaltered; any additions, deletions or changes to + the original files must be clearly indicated in accompanying + documentation. The copyright notices of the unaltered, + original files must be preserved in all copies of source + files. + + o Redistribution in binary form must provide a disclaimer that + states that the software is based in part of the work of the + FreeType Team, in the distribution documentation. We also + encourage you to put an URL to the FreeType web page in your + documentation, though this isn't mandatory. + + These conditions apply to any software derived from or based on + the FreeType Project, not just the unmodified files. If you use + our work, you must acknowledge us. However, no fee need be paid + to us. + +3. Advertising +-------------- + + Neither the FreeType authors and contributors nor you shall use + the name of the other for commercial, advertising, or promotional + purposes without specific prior written permission. + + We suggest, but do not require, that you use one or more of the + following phrases to refer to this software in your documentation + or advertising materials: `FreeType Project', `FreeType Engine', + `FreeType library', or `FreeType Distribution'. + + As you have not signed this license, you are not required to + accept it. However, as the FreeType Project is copyrighted + material, only this license, or another one contracted with the + authors, grants you the right to use, distribute, and modify it. + Therefore, by using, distributing, or modifying the FreeType + Project, you indicate that you understand and accept all the terms + of this license. + +4. Contacts +----------- + + There are two mailing lists related to FreeType: + + o freetype@nongnu.org + + Discusses general use and applications of FreeType, as well as + future and wanted additions to the library and distribution. + If you are looking for support, start in this list if you + haven't found anything to help you in the documentation. + + o freetype-devel@nongnu.org + + Discusses bugs, as well as engine internals, design issues, + specific licenses, porting, etc. + + Our home page can be found at + + https://www.freetype.org + + +--- end of FTL.TXT --- diff --git a/licenses/harfbuzz.txt b/licenses/harfbuzz.txt new file mode 100644 index 0000000..1dd917e --- /dev/null +++ b/licenses/harfbuzz.txt @@ -0,0 +1,42 @@ +HarfBuzz is licensed under the so-called "Old MIT" license. Details follow. +For parts of HarfBuzz that are licensed under different licenses see individual +files names COPYING in subdirectories where applicable. + +Copyright © 2010-2022 Google, Inc. +Copyright © 2015-2020 Ebrahim Byagowi +Copyright © 2019,2020 Facebook, Inc. +Copyright © 2012,2015 Mozilla Foundation +Copyright © 2011 Codethink Limited +Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies) +Copyright © 2009 Keith Stribley +Copyright © 2011 Martin Hosken and SIL International +Copyright © 2007 Chris Wilson +Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod +Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc. +Copyright © 1998-2005 David Turner and Werner Lemberg +Copyright © 2016 Igalia S.L. +Copyright © 2022 Matthias Clasen +Copyright © 2018,2021 Khaled Hosny +Copyright © 2018,2019,2020 Adobe, Inc +Copyright © 2013-2015 Alexei Podtelezhnikov + +For full copyright notices consult the individual files in the package. + + +Permission is hereby granted, without written agreement and without +license or royalty fees, to use, copy, modify, and distribute this +software and its documentation for any purpose, provided that the +above copyright notice and the following two paragraphs appear in +all copies of this software. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN +IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO +PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. diff --git a/licenses/json.txt b/licenses/json.txt index 2071b23..5bfa8b2 100644 --- a/licenses/json.txt +++ b/licenses/json.txt @@ -1,3 +1,9 @@ +nlohmann/json v3.12.0 +SPDX-FileCopyrightText: 2013 - 2025 Niels Lohmann +SPDX-License-Identifier: MIT + +The following license text is reproduced from upstream LICENSES/MIT.txt. + MIT License Copyright (c) diff --git a/mkdocs.yml b/mkdocs.yml index 5c018bb..2b04696 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,9 @@ plugins: - search markdown_extensions: - admonition + - pymdownx.snippets: + base_path: ["."] + check_paths: true - pymdownx.details - pymdownx.superfences - pymdownx.highlight: @@ -31,5 +34,12 @@ nav: - Build from source: getting-started/build.md - C++ gameplay: - How gameplay works: scripting/index.md + - Write your first behavior: scripting/first-behavior.md - Frame and physics updates: scripting/lifecycle.md + - Physics and grounded movement: scripting/physics.md + - Runtime API: scripting/api.md + - Compiled examples: scripting/examples.md + - Editor automation: + - MCP and command line: editor/mcp.md + - Native extensions: editor/extensions.md - Contributing to this manual: contributing.md diff --git a/shaders/baseline.slang b/shaders/baseline.slang index af39aa2..92cf8da 100644 --- a/shaders/baseline.slang +++ b/shaders/baseline.slang @@ -34,8 +34,13 @@ VertexOutput vertexMain(VertexInput v) { float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); } [shader("fragment")] float4 fragmentMain(VertexOutput v) : SV_Target { - float4 base = v.color * colorMap.Sample(colorSampler, v.uv); - if (dot(v.normal,v.normal) < 0.01) return base; + float4 sampled = colorMap.Sample(colorSampler, v.uv); + if (dot(v.normal,v.normal) < 1e-12) { + // UI/sprite tint is in display space; sRGB textures were decoded by Vulkan. + if (v.material.x > 0.5) sampled.rgb = lerp(sampled.rgb * 12.92, 1.055 * pow(max(sampled.rgb,0),float3(1.0/2.4)) - 0.055, step(0.0031308, sampled.rgb)); + return v.color * sampled; + } + float4 base = v.color * sampled; const float pi = 3.14159265; float3 n=normalize(v.normal), l=normalize(-frame.lightDirection.xyz), view=normalize(frame.eye.xyz-v.world), h=normalize(l+view); float nl=max(dot(n,l),0.0), nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0); diff --git a/src/assets/asset_data.cpp b/src/assets/asset_data.cpp new file mode 100644 index 0000000..e57f48a --- /dev/null +++ b/src/assets/asset_data.cpp @@ -0,0 +1,203 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace faset::assets { +namespace fs = std::filesystem; +namespace { +void valid_id(const std::string& id) { + if (id.empty() || id.size() > 128 || !std::all_of(id.begin(), id.end(), [](unsigned char c) { + return std::isalnum(c) || c == '-' || c == '_'; + })) + throw std::runtime_error("Invalid AssetId"); +} +std::vector read_bytes(const fs::path& path) { + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file) + throw std::runtime_error("Cannot read cooked file: " + path.string()); + auto length = file.tellg(); + if (length < 0 || static_cast(length) > 1024ull * 1024 * 1024) + throw std::runtime_error("Cooked file exceeds 1 GiB limit"); + std::vector bytes(static_cast(length)); + file.seekg(0); + if (!bytes.empty() && !file.read(reinterpret_cast(bytes.data()), + static_cast(bytes.size()))) + throw std::runtime_error("Truncated cooked file"); + return bytes; +} +Json read_json(const fs::path& path) { + auto bytes = read_bytes(path); + if (bytes.empty()) + throw std::runtime_error("Empty cooked JSON manifest"); + return Json::parse(reinterpret_cast(bytes.data()), + reinterpret_cast(bytes.data() + bytes.size())); +} +fs::path cooked_path(const fs::path& directory, const std::string& name) { + if (name.empty()) + throw std::runtime_error("Empty cooked path"); + return faset::project_path(directory, fs::path(name)); +} +void validate_generation(const fs::path& directory, const Json& manifest) { + if (manifest.at("schema_version") != 1) + throw std::runtime_error("Unsupported asset manifest version"); + std::set files; + for (const auto& file : manifest.at("files")) { + auto name = file.at("path").get(); + if (!files.insert(name).second) + throw std::runtime_error("Duplicate cooked file path"); + auto bytes = read_bytes(cooked_path(directory, name)); + if (bytes.size() != file.at("size").get() || + faset::sha256(std::span(bytes)) != + file.at("sha256").get()) + throw std::runtime_error("Corrupt cooked file: " + name); + } + for (const auto& mesh : manifest.at("meshes")) + for (const auto& primitive : mesh.at("primitives")) + if (!files.contains(primitive.at("path").get())) + throw std::runtime_error("Mesh payload is missing from its manifest"); + for (const auto& texture : manifest.at("textures")) + if (!files.contains(texture.at("path").get())) + throw std::runtime_error("Texture payload is missing from its manifest"); +} +struct BinaryReader { + const std::vector& bytes; + std::size_t cursor = 0; + std::uint32_t u32() { + if (bytes.size() - cursor < 4) + throw std::runtime_error("Truncated cooked mesh"); + std::uint32_t v = 0; + for (int i = 0; i < 4; ++i) + v |= std::to_integer(bytes[cursor++]) << (8 * i); + return v; + } + float number() { + auto v = std::bit_cast(u32()); + if (!std::isfinite(v)) + throw std::runtime_error("Invalid cooked float"); + return v; + } +}; +Primitive decode_primitive(const std::vector& bytes, int material) { + BinaryReader in{bytes}; + if (in.u32() != 0x48534d46 || in.u32() != 1) + throw std::runtime_error("Unsupported cooked mesh format"); + const auto nv = in.u32(), ni = in.u32(); + if (static_cast(nv) * 32 + static_cast(ni) * 4 + 16 != + bytes.size()) + throw std::runtime_error("Invalid cooked mesh size"); + Primitive p; + p.material = material; + p.vertices.resize(nv); + p.indices.resize(ni); + for (auto& v : p.vertices) { + for (auto& x : v.position) + x = in.number(); + for (auto& x : v.normal) + x = in.number(); + for (auto& x : v.uv) + x = in.number(); + } + for (auto& i : p.indices) { + i = in.u32(); + if (i >= nv) + throw std::runtime_error("Cooked mesh index out of range"); + } + return p; +} +} // namespace +AssetStore::AssetStore(fs::path root) + : cache_root_(fs::absolute(std::move(root)).lexically_normal()) {} +fs::path AssetStore::generation_directory(const std::string& id) const { + valid_id(id); + const auto root = faset::project_path(cache_root_, fs::path("assets") / id); + const auto pointer = read_json(root / "current.json"); + const auto generation = pointer.at("generation").get(); + valid_id(generation); + return faset::project_path(root, fs::path("generations") / generation); +} +Json AssetStore::current_manifest(const std::string& id) const { + valid_id(id); + const auto root = faset::project_path(cache_root_, fs::path("assets") / id); + const auto pointer = read_json(root / "current.json"); + const auto generation = pointer.at("generation").get(); + valid_id(generation); + auto manifest = read_json( + faset::project_path(root, fs::path("generations") / generation / "manifest.json")); + if (manifest.at("asset_id").get() != id || + manifest.at("generation").get() != generation) + throw std::runtime_error("Cooked manifest identity does not match its generation"); + // One pointer snapshot prevents mixing two concurrently published generations. + manifest["source"] = pointer.at("source"); + return manifest; +} +CookedAsset AssetStore::load_asset(const std::string& id) const { + const auto directory = generation_directory(id); + const auto m = read_json(directory / "manifest.json"); + validate_generation(directory, m); + if (m.at("asset_id").get() != id || + m.at("generation").get() != directory.filename().string()) + throw std::runtime_error("Cooked asset identity does not match its generation"); + CookedAsset asset; + asset.asset_id = m.at("asset_id"); + asset.generation = m.at("generation"); + for (const auto& n : m.at("nodes")) { + Node node; + node.id = n.at("id"); + node.name = n.at("name"); + node.parent_id = n.at("parent_id"); + node.mesh = n.at("mesh"); + node.local_transform = n.at("local_transform").get>(); + node.stable_source_id = n.at("stable_source_id"); + asset.nodes.push_back(std::move(node)); + } + for (const auto& j : m.at("meshes")) { + Mesh mesh; + mesh.id = j.at("id"); + mesh.name = j.at("name"); + for (const auto& primitive : j.at("primitives")) + mesh.primitives.push_back(decode_primitive( + read_bytes(cooked_path(directory, primitive.at("path").get())), + primitive.at("material"))); + asset.meshes.push_back(std::move(mesh)); + } + for (const auto& j : m.at("materials")) { + Material material; + material.id = j.at("id"); + material.name = j.at("name"); + material.base_color = j.at("base_color").get>(); + material.emissive = j.at("emissive").get>(); + material.metallic = j.at("metallic"); + material.roughness = j.at("roughness"); + material.alpha_mode = j.at("alpha_mode"); + material.alpha_cutoff = j.at("alpha_cutoff"); + material.double_sided = j.at("double_sided"); + material.unlit = j.at("unlit"); + material.base_color_texture = j.at("base_color_texture"); + material.metallic_roughness_texture = j.at("metallic_roughness_texture"); + material.normal_texture = j.at("normal_texture"); + material.occlusion_texture = j.at("occlusion_texture"); + material.emissive_texture = j.at("emissive_texture"); + asset.materials.push_back(std::move(material)); + } + for (const auto& j : m.at("textures")) { + Texture texture; + texture.id = j.at("id"); + texture.name = j.at("name"); + texture.mime_type = j.at("mime_type"); + texture.bytes = read_bytes(cooked_path(directory, j.at("path").get())); + texture.wrap_s = j.at("wrap_s"); + texture.wrap_t = j.at("wrap_t"); + texture.min_filter = j.at("min_filter"); + texture.mag_filter = j.at("mag_filter"); + asset.textures.push_back(std::move(texture)); + } + return asset; +} +} // namespace faset::assets diff --git a/src/assets/asset_pipeline.cpp b/src/assets/asset_pipeline.cpp index ca18cc7..aec3e62 100644 --- a/src/assets/asset_pipeline.cpp +++ b/src/assets/asset_pipeline.cpp @@ -1,6 +1,23 @@ +#include #include #include -#include +// Import validation owns a private decoder; Player's decoder remains a separate +// binary boundary. +#define STB_IMAGE_IMPLEMENTATION +#define STB_IMAGE_STATIC +#define STBI_ONLY_PNG +#define STBI_ONLY_JPEG +#define STBI_NO_STDIO +#define STBI_NO_HDR +#define STBI_NO_LINEAR +#if defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-function" +#endif +#include +#if defined(__GNUC__) +#pragma GCC diagnostic pop +#endif #include #include #include @@ -29,360 +46,775 @@ std::mutex writer_mutex; struct Cancelled {}; void checkpoint(ImportJob& job, float fraction, const std::string& stage) { job.report(fraction, stage); - if (job.cancelled()) throw Cancelled{}; + if (job.cancelled()) + throw Cancelled{}; } std::string uuid() { std::random_device random; std::ostringstream out; - for (int i=0; i<4; ++i) out << std::hex << std::setw(8) << std::setfill('0') << random(); + for (int i = 0; i < 4; ++i) + out << std::hex << std::setw(8) << std::setfill('0') << random(); return out.str(); } void valid_id(const std::string& id) { - if (id.empty() || id.size()>128 || !std::all_of(id.begin(),id.end(),[](unsigned char c){return std::isalnum(c)||c=='-'||c=='_';})) + if (id.empty() || id.size() > 128 || !std::all_of(id.begin(), id.end(), [](unsigned char c) { + return std::isalnum(c) || c == '-' || c == '_'; + })) throw std::runtime_error("Invalid AssetId"); } std::vector read_bytes(const fs::path& path) { - std::ifstream file(path, std::ios::binary|std::ios::ate); - if (!file) throw std::runtime_error("Cannot read: "+path.string()); - const auto length=file.tellg(); - if (length<0 || static_cast(length)>1024ull*1024*1024) throw std::runtime_error("Input exceeds 1 GiB limit: "+path.string()); - std::vector data(static_cast(length)); file.seekg(0); - if (!data.empty()&&!file.read(reinterpret_cast(data.data()),static_cast(data.size()))) - throw std::runtime_error("Short read: "+path.string()); + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file) + throw std::runtime_error("Cannot read: " + path.string()); + const auto length = file.tellg(); + if (length < 0 || static_cast(length) > 1024ull * 1024 * 1024) + throw std::runtime_error("Input exceeds 1 GiB limit: " + path.string()); + std::vector data(static_cast(length)); + file.seekg(0); + if (!data.empty() && + !file.read(reinterpret_cast(data.data()), static_cast(data.size()))) + throw std::runtime_error("Short read: " + path.string()); return data; } void write_bytes(const fs::path& path, const std::vector& data) { - fs::create_directories(path.parent_path()); std::ofstream out(path,std::ios::binary|std::ios::trunc); - if (!out || (!data.empty()&&!out.write(reinterpret_cast(data.data()),static_cast(data.size())))) throw std::runtime_error("Cannot write: "+path.string()); - out.close(); if (!out) throw std::runtime_error("Cannot close: "+path.string()); + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out || (!data.empty() && !out.write(reinterpret_cast(data.data()), + static_cast(data.size())))) + throw std::runtime_error("Cannot write: " + path.string()); + out.close(); + if (!out) + throw std::runtime_error("Cannot close: " + path.string()); } Json read_json(const fs::path& path) { - std::ifstream in(path); if(!in)throw std::runtime_error("Cannot read JSON: "+path.string()); + std::ifstream in(path); + if (!in) + throw std::runtime_error("Cannot read JSON: " + path.string()); return Json::parse(in); } -void write_json(const fs::path& path,const Json& value) { - const auto text=value.dump(2)+"\n"; - write_bytes(path,std::vector(reinterpret_cast(text.data()),reinterpret_cast(text.data()+text.size()))); +void write_json(const fs::path& path, const Json& value) { + const auto text = value.dump(2) + "\n"; + write_bytes(path, std::vector( + reinterpret_cast(text.data()), + reinterpret_cast(text.data() + text.size()))); } -void atomic_json(const fs::path& path,const Json& value) { - fs::create_directories(path.parent_path()); auto temporary=path; temporary+=".tmp-"+uuid(); +void atomic_json(const fs::path& path, const Json& value) { + fs::create_directories(path.parent_path()); + auto temporary = path; + temporary += ".tmp-" + uuid(); try { - write_json(temporary,value); + write_json(temporary, value); #ifdef _WIN32 - if(!MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)) throw std::runtime_error("Atomic replace failed: "+path.string()); + if (!MoveFileExW(temporary.c_str(), path.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + throw std::runtime_error("Atomic replace failed: " + path.string()); #else - fs::rename(temporary,path); + fs::rename(temporary, path); #endif - } catch(...) { std::error_code ec;fs::remove(temporary,ec);throw; } + } catch (...) { + std::error_code ec; + fs::remove(temporary, ec); + throw; + } +} +std::string hash_bytes(const std::vector& bytes) { + return faset::sha256(std::span(bytes)); +} +std::string stable_id(const std::string& kind, const std::string& key) { + return kind + "-" + faset::sha256(kind + ":" + key).substr(0, 32); +} +std::string safe_name(const char* name) { + return name ? name : ""; } -std::string hash_bytes(const std::vector& bytes) { return faset::sha256(std::span(bytes)); } -std::string stable_id(const std::string& kind,const std::string& key) { return kind+"-"+faset::sha256(kind+":"+key).substr(0,32); } -std::string safe_name(const char* name) { return name?name:""; } std::string source_id(const cgltf_extras& extras) { - if(!extras.data)return {}; - auto value=Json::parse(extras.data,nullptr,false); - if(value.is_object()&&value.contains("faset_id")&&value["faset_id"].is_string()) return value["faset_id"].get(); + if (!extras.data) + return {}; + auto value = Json::parse(extras.data, nullptr, false); + if (value.is_object() && value.contains("faset_id") && value["faset_id"].is_string()) + return value["faset_id"].get(); return {}; } std::string uri_decode(std::string value) { std::string result; - for(std::size_t i=0;i(c);i+=2; - } else result+=value[i]; + for (std::size_t i = 0; i < value.size(); ++i) { + if (value[i] == '%' && i + 2 < value.size()) { + const auto hex = value.substr(i + 1, 2); + std::size_t count = 0; + const int c = std::stoi(hex, &count, 16); + if (count != 2 || c == 0) + throw std::runtime_error("Invalid URI escape"); + result += static_cast(c); + i += 2; + } else + result += value[i]; } return result; } std::vector decode_data_uri(const std::string& uri) { - const auto comma=uri.find(','); - if(comma==std::string::npos||uri.substr(0,comma).find(";base64")==std::string::npos) throw std::runtime_error("Only base64 data URIs are supported"); - constexpr std::string_view alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - std::vector data; std::uint32_t bits=0;int count=0; - for(std::size_t i=comma+1;i(v);count+=6; - if(count>=8){count-=8;data.push_back(static_cast((bits>>count)&255));} + const auto comma = uri.find(','); + if (comma == std::string::npos || uri.substr(0, comma).find(";base64") == std::string::npos) + throw std::runtime_error("Only base64 data URIs are supported"); + constexpr std::string_view alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::vector data; + std::uint32_t bits = 0; + int count = 0; + for (std::size_t i = comma + 1; i < uri.size(); ++i) { + if (uri[i] == '=') + break; + const auto v = alphabet.find(uri[i]); + if (v == std::string_view::npos) + throw std::runtime_error("Invalid base64 image"); + bits = (bits << 6) | static_cast(v); + count += 6; + if (count >= 8) { + count -= 8; + data.push_back(static_cast((bits >> count) & 255)); + } } return data; } -fs::path external_path(const fs::path& source,const std::string& uri) { - if(uri.find("://")!=std::string::npos)throw std::runtime_error("Network URI is not an import dependency: "+uri); - const fs::path relative=uri_decode(uri); - if(relative.is_absolute())throw std::runtime_error("glTF URI must be relative"); - return (source.parent_path()/relative).lexically_normal(); +fs::path external_path(const fs::path& source, const std::string& uri) { + if (uri.find("://") != std::string::npos) + throw std::runtime_error("Network URI is not an import dependency: " + uri); + const fs::path relative = uri_decode(uri); + if (relative.is_absolute()) + throw std::runtime_error("glTF URI must be relative"); + return (source.parent_path() / relative).lexically_normal(); } -struct Dependency { fs::path path; std::string digest; std::vector bytes; }; -using Dependencies=std::map; -std::vector dependency_bytes(const fs::path& source,const std::string& uri,Dependencies& dependencies) { - if(auto found=dependencies.find(uri);found!=dependencies.end())return found->second.bytes; - auto path=external_path(source,uri);auto bytes=read_bytes(path);dependencies[uri]={path,hash_bytes(bytes),bytes};return bytes; +struct Dependency { + fs::path path; + std::string digest; + std::vector bytes; +}; +using Dependencies = std::map; +std::vector dependency_bytes(const fs::path& source, const std::string& uri, + Dependencies& dependencies) { + if (auto found = dependencies.find(uri); found != dependencies.end()) + return found->second.bytes; + auto path = external_path(source, uri); + auto bytes = read_bytes(path); + dependencies[uri] = {path, hash_bytes(bytes), bytes}; + return bytes; } -std::string image_mime(const cgltf_image& image,const std::vector& bytes) { - if(image.mime_type)return image.mime_type; - if(bytes.size()>=4&&bytes[0]==std::byte{0x89}&&bytes[1]==std::byte{'P'})return "image/png"; - if(bytes.size()>=2&&bytes[0]==std::byte{0xff}&&bytes[1]==std::byte{0xd8})return "image/jpeg"; +std::string image_mime(const cgltf_image& image, const std::vector& bytes) { + if (image.mime_type) + return image.mime_type; + if (bytes.size() >= 4 && bytes[0] == std::byte{0x89} && bytes[1] == std::byte{'P'}) + return "image/png"; + if (bytes.size() >= 2 && bytes[0] == std::byte{0xff} && bytes[1] == std::byte{0xd8}) + return "image/jpeg"; return "application/octet-stream"; } -std::vector image_bytes(const cgltf_image& image,const fs::path& source,Dependencies& dependencies) { - if(image.uri) { - const std::string uri=image.uri; - return uri.starts_with("data:")?decode_data_uri(uri):dependency_bytes(source,uri,dependencies); +std::vector image_bytes(const cgltf_image& image, const fs::path& source, + Dependencies& dependencies) { + if (image.uri) { + const std::string uri = image.uri; + return uri.starts_with("data:") ? decode_data_uri(uri) + : dependency_bytes(source, uri, dependencies); } - if(image.buffer_view&&image.buffer_view->buffer&&image.buffer_view->buffer->data) { - const auto& view=*image.buffer_view; - if(view.offset>view.buffer->size||view.size>view.buffer->size-view.offset)throw std::runtime_error("Image buffer view out of bounds"); - const auto* begin=static_cast(view.buffer->data)+view.offset; - return {begin,begin+view.size}; + if (image.buffer_view && image.buffer_view->buffer && image.buffer_view->buffer->data) { + const auto& view = *image.buffer_view; + if (view.offset > view.buffer->size || view.size > view.buffer->size - view.offset) + throw std::runtime_error("Image buffer view out of bounds"); + const auto* begin = static_cast(view.buffer->data) + view.offset; + return {begin, begin + view.size}; } throw std::runtime_error("Texture has no supported image payload"); } -void put_u32(std::vector& out,std::uint32_t v) {for(int i=0;i<4;++i)out.push_back(static_cast((v>>(8*i))&255));} -void put_float(std::vector& out,float v) {if(!std::isfinite(v))throw std::runtime_error("Non-finite mesh value");put_u32(out,std::bit_cast(v));} -struct BinaryReader { - const std::vector& bytes;std::size_t cursor=0; - std::uint32_t u32(){if(bytes.size()-cursor<4)throw std::runtime_error("Truncated cooked mesh");std::uint32_t v=0;for(int i=0;i<4;++i)v|=std::to_integer(bytes[cursor++])<<(8*i);return v;} - float number(){auto v=std::bit_cast(u32());if(!std::isfinite(v))throw std::runtime_error("Invalid cooked float");return v;} -}; +void put_u32(std::vector& out, std::uint32_t v) { + for (int i = 0; i < 4; ++i) + out.push_back(static_cast((v >> (8 * i)) & 255)); +} +void put_float(std::vector& out, float v) { + if (!std::isfinite(v)) + throw std::runtime_error("Non-finite mesh value"); + put_u32(out, std::bit_cast(v)); +} std::vector encode_primitive(const Primitive& p) { - std::vector out;put_u32(out,0x48534d46);put_u32(out,1);put_u32(out,static_cast(p.vertices.size()));put_u32(out,static_cast(p.indices.size())); - for(const auto& v:p.vertices){for(auto f:v.position)put_float(out,f);for(auto f:v.normal)put_float(out,f);for(auto f:v.uv)put_float(out,f);} - for(auto i:p.indices)put_u32(out,i);return out; + std::vector out; + put_u32(out, 0x48534d46); + put_u32(out, 1); + put_u32(out, static_cast(p.vertices.size())); + put_u32(out, static_cast(p.indices.size())); + for (const auto& v : p.vertices) { + for (auto f : v.position) + put_float(out, f); + for (auto f : v.normal) + put_float(out, f); + for (auto f : v.uv) + put_float(out, f); + } + for (auto i : p.indices) + put_u32(out, i); + return out; } -Primitive decode_primitive(const std::vector& bytes,int material) { - BinaryReader in{bytes};if(in.u32()!=0x48534d46||in.u32()!=1)throw std::runtime_error("Unsupported cooked mesh format"); - const auto nv=in.u32(),ni=in.u32(); - if(static_cast(nv)*32+static_cast(ni)*4+16!=bytes.size())throw std::runtime_error("Invalid cooked mesh size"); - Primitive p;p.material=material;p.vertices.resize(nv);p.indices.resize(ni); - for(auto& v:p.vertices){for(auto& x:v.position)x=in.number();for(auto& x:v.normal)x=in.number();for(auto& x:v.uv)x=in.number();} - for(auto& i:p.indices){i=in.u32();if(i>=nv)throw std::runtime_error("Cooked mesh index out of range");}return p; -} -std::vector unpack(const cgltf_accessor* accessor,std::size_t elements) { - if(!accessor || cgltf_num_components(accessor->type)!=elements)throw std::runtime_error("Unexpected vertex attribute type"); - if(accessor->count>10000000)throw std::runtime_error("Mesh exceeds vertex limit"); - std::vector values(accessor->count*elements); - if(cgltf_accessor_unpack_floats(accessor,values.data(),values.size())!=values.size())throw std::runtime_error("Cannot unpack vertex attribute"); - if(!std::all_of(values.begin(),values.end(),[](float v){return std::isfinite(v);}))throw std::runtime_error("Non-finite vertex attribute");return values; +std::vector unpack(const cgltf_accessor* accessor, std::size_t elements) { + if (!accessor || cgltf_num_components(accessor->type) != elements) + throw std::runtime_error("Unexpected vertex attribute type"); + if (accessor->count > 10000000) + throw std::runtime_error("Mesh exceeds vertex limit"); + std::vector values(accessor->count * elements); + if (cgltf_accessor_unpack_floats(accessor, values.data(), values.size()) != values.size()) + throw std::runtime_error("Cannot unpack vertex attribute"); + if (!std::all_of(values.begin(), values.end(), [](float v) { return std::isfinite(v); })) + throw std::runtime_error("Non-finite vertex attribute"); + return values; } void calculate_normals(Primitive& primitive) { - for(auto& v:primitive.vertices)v.normal={0,0,0}; - for(std::size_t i=0;i u{},v{},n{};for(int j=0;j<3;++j){u[j]=b.position[j]-a.position[j];v[j]=c.position[j]-a.position[j];} - n={u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]}; - for(auto* vertex:{&a,&b,&c})for(int j=0;j<3;++j)vertex->normal[j]+=n[j]; + for (auto& v : primitive.vertices) + v.normal = {0, 0, 0}; + for (std::size_t i = 0; i < primitive.indices.size(); i += 3) { + auto& a = primitive.vertices[primitive.indices[i]]; + auto& b = primitive.vertices[primitive.indices[i + 1]]; + auto& c = primitive.vertices[primitive.indices[i + 2]]; + std::array u{}, v{}, n{}; + for (int j = 0; j < 3; ++j) { + u[j] = b.position[j] - a.position[j]; + v[j] = c.position[j] - a.position[j]; + } + n = {u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]}; + for (auto* vertex : {&a, &b, &c}) + for (int j = 0; j < 3; ++j) + vertex->normal[j] += n[j]; + } + for (auto& v : primitive.vertices) { + const auto length = + std::sqrt(std::inner_product(v.normal.begin(), v.normal.end(), v.normal.begin(), 0.f)); + if (length > 1e-12f) + for (auto& n : v.normal) + n /= length; + else + v.normal = {0, 0, 1}; } - for(auto& v:primitive.vertices){const auto length=std::sqrt(std::inner_product(v.normal.begin(),v.normal.end(),v.normal.begin(),0.f));if(length>1e-12f)for(auto& n:v.normal)n/=length;else v.normal={0,0,1};} } -int texture_index(const cgltf_texture_view& view,const cgltf_data& data) { - if(!view.texture)return -1; - if(view.texcoord!=0||view.has_transform)throw std::runtime_error("Only TEXCOORD_0 without texture transform is supported by this import profile"); - return static_cast(view.texture-data.textures); +int texture_index(const cgltf_texture_view& view, const cgltf_data& data) { + if (!view.texture) + return -1; + if (view.texcoord != 0 || view.has_transform) + throw std::runtime_error("Only TEXCOORD_0 without texture transform is " + "supported by this import profile"); + return static_cast(view.texture - data.textures); } -void add_file(Json& manifest,const fs::path& stage,const std::string& path,const std::vector& bytes) { - write_bytes(stage/path,bytes);manifest["files"].push_back({{"path",path},{"sha256",hash_bytes(bytes)},{"size",bytes.size()}}); +void add_file(Json& manifest, const fs::path& stage, const std::string& path, + const std::vector& bytes) { + write_bytes(stage / path, bytes); + manifest["files"].push_back( + {{"path", path}, {"sha256", hash_bytes(bytes)}, {"size", bytes.size()}}); } -void validate_generation(const fs::path& directory,const Json& manifest) { - if(manifest.at("schema_version")!=1)throw std::runtime_error("Unsupported asset manifest version"); - for(const auto& file:manifest.at("files")) { - const fs::path relative=file.at("path").get(); - if(relative.is_absolute()||relative.string().find("..")!=std::string::npos)throw std::runtime_error("Invalid cooked file path"); - auto bytes=read_bytes(directory/relative); - if(bytes.size()!=file.at("size").get()||hash_bytes(bytes)!=file.at("sha256").get())throw std::runtime_error("Corrupt cooked file: "+relative.string()); +void validate_generation(const fs::path& directory, const Json& manifest) { + if (manifest.at("schema_version") != 1) + throw std::runtime_error("Unsupported asset manifest version"); + for (const auto& file : manifest.at("files")) { + const fs::path relative = file.at("path").get(); + if (relative.is_absolute() || relative.string().find("..") != std::string::npos) + throw std::runtime_error("Invalid cooked file path"); + auto bytes = read_bytes(directory / relative); + if (bytes.size() != file.at("size").get() || + hash_bytes(bytes) != file.at("sha256").get()) + throw std::runtime_error("Corrupt cooked file: " + relative.string()); } } Json material_json(const Material& m) { - return {{"id",m.id},{"name",m.name},{"base_color",m.base_color},{"metallic",m.metallic},{"roughness",m.roughness},{"emissive",m.emissive},{"alpha_mode",m.alpha_mode},{"alpha_cutoff",m.alpha_cutoff},{"double_sided",m.double_sided},{"unlit",m.unlit},{"base_color_texture",m.base_color_texture},{"metallic_roughness_texture",m.metallic_roughness_texture},{"normal_texture",m.normal_texture},{"occlusion_texture",m.occlusion_texture},{"emissive_texture",m.emissive_texture}}; -} + return {{"id", m.id}, + {"name", m.name}, + {"base_color", m.base_color}, + {"metallic", m.metallic}, + {"roughness", m.roughness}, + {"emissive", m.emissive}, + {"alpha_mode", m.alpha_mode}, + {"alpha_cutoff", m.alpha_cutoff}, + {"double_sided", m.double_sided}, + {"unlit", m.unlit}, + {"base_color_texture", m.base_color_texture}, + {"metallic_roughness_texture", m.metallic_roughness_texture}, + {"normal_texture", m.normal_texture}, + {"occlusion_texture", m.occlusion_texture}, + {"emissive_texture", m.emissive_texture}}; } +} // namespace -ImportJob::ImportJob(Observer observer):observer_(std::move(observer)){} -void ImportJob::cancel() noexcept {cancelled_.store(true);} -bool ImportJob::cancelled() const noexcept {return cancelled_.load();} -ImportProgress ImportJob::progress() const {std::lock_guard lock(mutex_);return progress_;} -void ImportJob::report(float fraction,std::string stage) { - ImportProgress progress{fraction,std::move(stage)};{std::lock_guard lock(mutex_);progress_=progress;} - if(observer_) { try { observer_(progress); } catch(...) { /* Observers cannot roll back a published result. */ } } +ImportJob::ImportJob(Observer observer) : observer_(std::move(observer)) {} +void ImportJob::cancel() noexcept { + cancelled_.store(true); } -AssetPipeline::AssetPipeline(fs::path root):cache_root_(fs::absolute(std::move(root)).lexically_normal()){} -ImportResult AssetPipeline::import_asset(const ImportRequest& request){ImportJob job;return import_asset(request,job);} -ImportResult AssetPipeline::import_asset(const ImportRequest& request,ImportJob& job) { - std::lock_guard writer(writer_mutex);ImportResult result;fs::path stage; +bool ImportJob::cancelled() const noexcept { + return cancelled_.load(); +} +ImportProgress ImportJob::progress() const { + std::lock_guard lock(mutex_); + return progress_; +} +void ImportJob::report(float fraction, std::string stage) { + ImportProgress progress{fraction, std::move(stage)}; + { + std::lock_guard lock(mutex_); + progress_ = progress; + } + if (observer_) { + try { + observer_(progress); + } catch (...) { /* Observers cannot roll back a published result. */ + } + } +} +AssetPipeline::AssetPipeline(fs::path root) : AssetStore(std::move(root)) {} +ImportResult AssetPipeline::import_asset(const ImportRequest& request) { + ImportJob job; + return import_asset(request, job); +} +ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob& job) { + std::lock_guard writer(writer_mutex); + ImportResult result; + fs::path stage; try { - checkpoint(job,0,"reading source"); - const auto logical_source=fs::absolute(request.source).lexically_normal(); - auto source=logical_source; - const auto logical_bytes=read_bytes(logical_source);const auto logical_hash=hash_bytes(logical_bytes); + checkpoint(job, 0, "reading source"); + const auto logical_source = fs::absolute(request.source).lexically_normal(); + auto source = logical_source; + const auto logical_bytes = read_bytes(logical_source); + const auto logical_hash = hash_bytes(logical_bytes); std::string bundle_asset_id; std::vector payload_snapshot; - if(logical_source.extension()==".json") { - auto bundle=Json::parse(reinterpret_cast(logical_bytes.data()),reinterpret_cast(logical_bytes.data()+logical_bytes.size())); - if(bundle.at("schema_version")!=1||bundle.at("files").empty())throw std::runtime_error("Invalid bundle manifest"); - bundle_asset_id=bundle.at("asset_id").get();valid_id(bundle_asset_id); - bool found_payload=false; - for(const auto& file:bundle.at("files")) { - const auto relative=fs::path(file.at("path").get()); - if(relative.is_absolute()||relative.string().find("..")!=std::string::npos)throw std::runtime_error("Invalid bundle payload path"); - const auto candidate=logical_source.parent_path()/relative;const auto bytes=read_bytes(candidate); - if(hash_bytes(bytes)!=file.at("sha256").get())throw std::runtime_error("Bundle payload digest mismatch"); - if(file.contains("size")&&file.at("size").get()!=bytes.size())throw std::runtime_error("Bundle payload size mismatch"); - if(!found_payload&&relative.extension()==".glb"){source=candidate;payload_snapshot=bytes;found_payload=true;} + if (logical_source.extension() == ".json") { + auto bundle = Json::parse( + reinterpret_cast(logical_bytes.data()), + reinterpret_cast(logical_bytes.data() + logical_bytes.size())); + if (bundle.at("schema_version") != 1 || bundle.at("files").empty()) + throw std::runtime_error("Invalid bundle manifest"); + bundle_asset_id = bundle.at("asset_id").get(); + valid_id(bundle_asset_id); + bool found_payload = false; + for (const auto& file : bundle.at("files")) { + const auto relative = fs::path(file.at("path").get()); + if (relative.is_absolute() || relative.string().find("..") != std::string::npos) + throw std::runtime_error("Invalid bundle payload path"); + const auto candidate = logical_source.parent_path() / relative; + const auto bytes = read_bytes(candidate); + if (hash_bytes(bytes) != file.at("sha256").get()) + throw std::runtime_error("Bundle payload digest mismatch"); + if (file.contains("size") && file.at("size").get() != bytes.size()) + throw std::runtime_error("Bundle payload size mismatch"); + if (!found_payload && relative.extension() == ".glb") { + source = candidate; + payload_snapshot = bytes; + found_payload = true; + } } - if(!found_payload)throw std::runtime_error("Bundle has no GLB payload"); + if (!found_payload) + throw std::runtime_error("Bundle has no GLB payload"); } - const auto source_bytes=source==logical_source?logical_bytes:payload_snapshot;const auto source_hash=hash_bytes(source_bytes); - const auto sidecar=fs::path(logical_source.string()+".faset-import.json"); - Json metadata=fs::exists(sidecar)?read_json(sidecar):Json::object(); - const auto settings=request.settings.is_null()?metadata.value("settings",Json::object()):request.settings; - if(!settings.is_object())throw std::runtime_error("Import settings must be an object"); - result.asset_id=request.asset_id.empty()?metadata.value("asset_id",bundle_asset_id.empty()?uuid():bundle_asset_id):request.asset_id;valid_id(result.asset_id); - if(!bundle_asset_id.empty()&&bundle_asset_id!=result.asset_id)throw std::runtime_error("Bundle AssetId disagrees with import identity"); - if(metadata.contains("asset_id")&&metadata.at("asset_id")!=result.asset_id)throw std::runtime_error("Explicit AssetId disagrees with source sidecar"); - const auto asset_root=cache_root_/"assets"/result.asset_id; - Json previous=fs::exists(asset_root/"current.json")?current_manifest(result.asset_id):Json(); - if(!previous.is_null()) { - const auto previous_source=fs::path(previous.at("source").get()); - if(previous_source!=logical_source&&fs::exists(previous_source))throw std::runtime_error("Duplicate AssetId: previous source still exists"); + const auto source_bytes = source == logical_source ? logical_bytes : payload_snapshot; + const auto source_hash = hash_bytes(source_bytes); + const auto sidecar = fs::path(logical_source.string() + ".faset-import.json"); + Json metadata = fs::exists(sidecar) ? read_json(sidecar) : Json::object(); + const auto settings = request.settings.is_null() + ? metadata.value("settings", Json::object()) + : request.settings; + if (!settings.is_object()) + throw std::runtime_error("Import settings must be an object"); + result.asset_id = + request.asset_id.empty() + ? metadata.value("asset_id", bundle_asset_id.empty() ? uuid() : bundle_asset_id) + : request.asset_id; + valid_id(result.asset_id); + if (!bundle_asset_id.empty() && bundle_asset_id != result.asset_id) + throw std::runtime_error("Bundle AssetId disagrees with import identity"); + if (metadata.contains("asset_id") && metadata.at("asset_id") != result.asset_id) + throw std::runtime_error("Explicit AssetId disagrees with source sidecar"); + const auto asset_root = cache_root_ / "assets" / result.asset_id; + Json previous = + fs::exists(asset_root / "current.json") ? current_manifest(result.asset_id) : Json(); + if (!previous.is_null()) { + const auto previous_source = fs::path(previous.at("source").get()); + if (previous_source != logical_source && fs::exists(previous_source)) + throw std::runtime_error("Duplicate AssetId: previous source still exists"); } - cgltf_options options{};cgltf_data* raw=nullptr; - auto parse=cgltf_parse(&options,source_bytes.data(),source_bytes.size(),&raw); - if(parse!=cgltf_result_success)throw std::runtime_error("Invalid glTF/GLB (parse "+std::to_string(parse)+")"); - std::unique_ptr data(raw,cgltf_free); - for(std::size_t i=0;iextensions_required_count;++i) - if(std::string(data->extensions_required[i])!="KHR_materials_unlit")throw std::runtime_error("Unsupported required extension: "+std::string(data->extensions_required[i])); Dependencies dependencies; - for(std::size_t i=0;ibuffers_count;++i) { - const auto* uri=data->buffers[i].uri; - if(uri&&!std::string_view(uri).starts_with("data:")) { - dependency_bytes(source,uri,dependencies); - auto& snapshot=dependencies.at(uri).bytes; - if(snapshot.size()buffers[i].size)throw std::runtime_error("External buffer shorter than declared"); - data->buffers[i].data=snapshot.data(); - data->buffers[i].data_free_method=cgltf_data_free_method_none; + CookedAsset asset; + asset.asset_id = result.asset_id; + auto extension = source.extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + const bool standalone_image = + extension == ".png" || extension == ".jpg" || extension == ".jpeg"; + const std::string recipe_version = + standalone_image ? "faset-image-1/stb-2.30" : importer_version; + Json image_info; + if (standalone_image) { + checkpoint(job, .15f, "validating image"); + if (source_bytes.size() > static_cast(std::numeric_limits::max())) + throw std::runtime_error("Encoded image exceeds decoder limit"); + const auto* encoded = reinterpret_cast(source_bytes.data()); + const auto length = static_cast(source_bytes.size()); + int width = 0, height = 0, channels = 0; + if (!stbi_info_from_memory(encoded, length, &width, &height, &channels)) + throw std::runtime_error("Invalid PNG/JPEG image header"); + if (width <= 0 || height <= 0 || width > 16384 || height > 16384 || + std::uint64_t(width) * std::uint64_t(height) > 64 * 1024 * 1024) + throw std::runtime_error("Image exceeds 16384 dimension or 64 megapixel limit"); + auto* pixels = stbi_load_from_memory(encoded, length, &width, &height, &channels, 4); + if (!pixels) + throw std::runtime_error( + "Cannot decode PNG/JPEG image: " + + std::string(stbi_failure_reason() ? stbi_failure_reason() : "invalid image")); + stbi_image_free(pixels); + const auto pixels_per_unit = settings.value("pixels_per_unit", 100.0); + if (!std::isfinite(pixels_per_unit) || pixels_per_unit <= 0 || + pixels_per_unit > 1000000) + throw std::runtime_error("pixels_per_unit must be a finite positive " + "number no greater than 1000000"); + image_info = {{"width", width}, + {"height", height}, + {"channels", channels}, + {"pixels_per_unit", pixels_per_unit}}; + Texture texture; + texture.id = stable_id("texture", "image:" + result.asset_id); + texture.name = "Image"; + texture.mime_type = extension == ".png" ? "image/png" : "image/jpeg"; + texture.bytes = source_bytes; + texture.wrap_s = texture.wrap_t = 33071; + asset.textures.push_back(std::move(texture)); + checkpoint(job, .5f, "preparing image payload"); + } else { + cgltf_options options{}; + cgltf_data* raw = nullptr; + auto parse = cgltf_parse(&options, source_bytes.data(), source_bytes.size(), &raw); + if (parse != cgltf_result_success) + throw std::runtime_error("Invalid glTF/GLB (parse " + std::to_string(parse) + ")"); + std::unique_ptr data(raw, cgltf_free); + for (std::size_t i = 0; i < data->extensions_required_count; ++i) + if (std::string(data->extensions_required[i]) != "KHR_materials_unlit") + throw std::runtime_error("Unsupported required extension: " + + std::string(data->extensions_required[i])); + for (std::size_t i = 0; i < data->buffers_count; ++i) { + const auto* uri = data->buffers[i].uri; + if (uri && !std::string_view(uri).starts_with("data:")) { + dependency_bytes(source, uri, dependencies); + auto& snapshot = dependencies.at(uri).bytes; + if (snapshot.size() < data->buffers[i].size) + throw std::runtime_error("External buffer shorter than declared"); + data->buffers[i].data = snapshot.data(); + data->buffers[i].data_free_method = cgltf_data_free_method_none; + } } - } - if(cgltf_load_buffers(&options,data.get(),source.string().c_str())!=cgltf_result_success)throw std::runtime_error("Cannot load glTF buffers"); - if(cgltf_validate(data.get())!=cgltf_result_success)throw std::runtime_error("Invalid glTF buffer/accessor layout"); - checkpoint(job,.15f,"extracting geometry"); - CookedAsset asset;asset.asset_id=result.asset_id; - std::set identifiers; - auto identify=[&](const std::string& kind,const cgltf_extras& extras,const std::string& fallback){ - const auto source_identity=source_id(extras);const auto id=stable_id(kind,source_identity.empty()?"fallback:"+fallback:"source:"+source_identity); - if(!identifiers.insert(id).second)throw std::runtime_error("DuplicateSourceId: "+kind+" "+source_identity); - return id; - }; - for(std::size_t mi=0;mimeshes_count;++mi) { - checkpoint(job,.15f+.3f*static_cast(mi)/std::max(1,data->meshes_count),"extracting meshes"); - const auto& mesh=data->meshes[mi];Mesh cooked;cooked.name=safe_name(mesh.name);cooked.id=identify("mesh",mesh.extras,std::to_string(mi)+":"+cooked.name); - for(std::size_t pi=0;pi{};const auto tex=uv?unpack(uv,2):std::vector{}; - const auto count=xyz.size()/3;if((normals&&normal.size()!=count*3)||(uv&&tex.size()!=count*2))throw std::runtime_error("Vertex attribute counts differ"); - Primitive output;output.material=primitive.material?static_cast(primitive.material-data->materials):-1;output.vertices.resize(count); - for(std::size_t i=0;iis_sparse||primitive.indices->type!=cgltf_type_scalar|| - (primitive.indices->component_type!=cgltf_component_type_r_8u&&primitive.indices->component_type!=cgltf_component_type_r_16u&&primitive.indices->component_type!=cgltf_component_type_r_32u))) - throw std::runtime_error("Indices require a dense unsigned integer accessor"); - const auto index_count=primitive.indices?primitive.indices->count:count; - if(index_count%3||index_count>30000000)throw std::runtime_error("Invalid triangle index count"); - output.indices.resize(index_count); - for(std::size_t i=0;i=count)throw std::runtime_error("Index outside vertex array");output.indices[i]=static_cast(index);} - if(!normals)calculate_normals(output);cooked.primitives.push_back(std::move(output)); - if(primitive.targets_count)result.diagnostics.push_back("Morph targets imported as static base geometry"); - } - asset.meshes.push_back(std::move(cooked)); - } - for(std::size_t ni=0;ninodes_count;++ni) { - const auto& node=data->nodes[ni];Node output;output.name=safe_name(node.name);output.stable_source_id=!source_id(node.extras).empty(); - output.id=identify("node",node.extras,std::to_string(ni)+":"+output.name);output.mesh=node.mesh?static_cast(node.mesh-data->meshes):-1; - cgltf_node_transform_local(&node,output.local_transform.data()); - if(!std::all_of(output.local_transform.begin(),output.local_transform.end(),[](float v){return std::isfinite(v);}))throw std::runtime_error("Non-finite node transform"); - asset.nodes.push_back(std::move(output));if(node.skin)result.diagnostics.push_back("Skinned node imported in static rest pose; animation playback is not cooked"); - } - for(std::size_t ni=0;ninodes_count;++ni)if(data->nodes[ni].parent)asset.nodes[ni].parent_id=asset.nodes[static_cast(data->nodes[ni].parent-data->nodes)].id; - // Only instantiate the selected/default scene. Unused resources remain reusable outputs. - if(data->scenes_count) { - const auto* selected=data->scene?data->scene:&data->scenes[0]; - std::set active_nodes; - std::function visit=[&](const cgltf_node* n){ - auto index=static_cast(n-data->nodes);if(!active_nodes.insert(index).second)return; - for(std::size_t i=0;ichildren_count;++i)visit(n->children[i]); + if (cgltf_load_buffers(&options, data.get(), source.string().c_str()) != + cgltf_result_success) + throw std::runtime_error("Cannot load glTF buffers"); + if (cgltf_validate(data.get()) != cgltf_result_success) + throw std::runtime_error("Invalid glTF buffer/accessor layout"); + checkpoint(job, .15f, "extracting geometry"); + std::set identifiers; + auto identify = [&](const std::string& kind, const cgltf_extras& extras, + const std::string& fallback) { + const auto source_identity = source_id(extras); + const auto id = + stable_id(kind, source_identity.empty() ? "fallback:" + fallback + : "source:" + source_identity); + if (!identifiers.insert(id).second) + throw std::runtime_error("DuplicateSourceId: " + kind + " " + source_identity); + return id; }; - for(std::size_t i=0;inodes_count;++i)visit(selected->nodes[i]); - std::vector active;for(std::size_t i=0;imeshes_count; ++mi) { + checkpoint(job, + .15f + .3f * static_cast(mi) / + std::max(1, data->meshes_count), + "extracting meshes"); + const auto& mesh = data->meshes[mi]; + Mesh cooked; + cooked.name = safe_name(mesh.name); + cooked.id = identify("mesh", mesh.extras, std::to_string(mi) + ":" + cooked.name); + for (std::size_t pi = 0; pi < mesh.primitives_count; ++pi) { + const auto& primitive = mesh.primitives[pi]; + if (primitive.type != cgltf_primitive_type_triangles || + primitive.has_draco_mesh_compression) + throw std::runtime_error( + "Only uncompressed triangle primitives are supported"); + const cgltf_accessor *positions = nullptr, *normals = nullptr, *uv = nullptr; + for (std::size_t ai = 0; ai < primitive.attributes_count; ++ai) { + const auto& a = primitive.attributes[ai]; + if (a.type == cgltf_attribute_type_position) + positions = a.data; + if (a.type == cgltf_attribute_type_normal) + normals = a.data; + if (a.type == cgltf_attribute_type_texcoord && a.index == 0) + uv = a.data; + } + const auto xyz = unpack(positions, 3); + const auto normal = normals ? unpack(normals, 3) : std::vector{}; + const auto tex = uv ? unpack(uv, 2) : std::vector{}; + const auto count = xyz.size() / 3; + if ((normals && normal.size() != count * 3) || (uv && tex.size() != count * 2)) + throw std::runtime_error("Vertex attribute counts differ"); + Primitive output; + output.material = primitive.material + ? static_cast(primitive.material - data->materials) + : -1; + output.vertices.resize(count); + for (std::size_t i = 0; i < count; ++i) { + if (i % 4096 == 0 && job.cancelled()) + throw Cancelled{}; + std::copy_n(xyz.data() + i * 3, 3, output.vertices[i].position.begin()); + if (normals) + std::copy_n(normal.data() + i * 3, 3, + output.vertices[i].normal.begin()); + if (uv) + std::copy_n(tex.data() + i * 2, 2, output.vertices[i].uv.begin()); + } + if (primitive.indices && + (primitive.indices->is_sparse || + primitive.indices->type != cgltf_type_scalar || + (primitive.indices->component_type != cgltf_component_type_r_8u && + primitive.indices->component_type != cgltf_component_type_r_16u && + primitive.indices->component_type != cgltf_component_type_r_32u))) + throw std::runtime_error( + "Indices require a dense unsigned integer accessor"); + const auto index_count = primitive.indices ? primitive.indices->count : count; + if (index_count % 3 || index_count > 30000000) + throw std::runtime_error("Invalid triangle index count"); + output.indices.resize(index_count); + for (std::size_t i = 0; i < index_count; ++i) { + if (i % 4096 == 0 && job.cancelled()) + throw Cancelled{}; + const auto index = + primitive.indices ? cgltf_accessor_read_index(primitive.indices, i) : i; + if (index >= count) + throw std::runtime_error("Index outside vertex array"); + output.indices[i] = static_cast(index); + } + if (!normals) + calculate_normals(output); + cooked.primitives.push_back(std::move(output)); + if (primitive.targets_count) + result.diagnostics.push_back( + "Morph targets imported as static base geometry"); + } + asset.meshes.push_back(std::move(cooked)); + } + for (std::size_t ni = 0; ni < data->nodes_count; ++ni) { + const auto& node = data->nodes[ni]; + Node output; + output.name = safe_name(node.name); + output.stable_source_id = !source_id(node.extras).empty(); + output.id = identify("node", node.extras, std::to_string(ni) + ":" + output.name); + output.mesh = node.mesh ? static_cast(node.mesh - data->meshes) : -1; + cgltf_node_transform_local(&node, output.local_transform.data()); + if (!std::all_of(output.local_transform.begin(), output.local_transform.end(), + [](float v) { return std::isfinite(v); })) + throw std::runtime_error("Non-finite node transform"); + asset.nodes.push_back(std::move(output)); + if (node.skin) + result.diagnostics.push_back( + "Skinned node imported in static rest pose; animation playback " + "is not cooked"); + } + for (std::size_t ni = 0; ni < data->nodes_count; ++ni) + if (data->nodes[ni].parent) + asset.nodes[ni].parent_id = + asset.nodes[static_cast(data->nodes[ni].parent - data->nodes)] + .id; + // Only instantiate the selected/default scene. Unused resources remain + // reusable outputs. + if (data->scenes_count) { + const auto* selected = data->scene ? data->scene : &data->scenes[0]; + std::set active_nodes; + std::function visit = [&](const cgltf_node* n) { + auto index = static_cast(n - data->nodes); + if (!active_nodes.insert(index).second) + return; + for (std::size_t i = 0; i < n->children_count; ++i) + visit(n->children[i]); + }; + for (std::size_t i = 0; i < selected->nodes_count; ++i) + visit(selected->nodes[i]); + std::vector active; + for (std::size_t i = 0; i < asset.nodes.size(); ++i) + if (active_nodes.contains(i)) + active.push_back(std::move(asset.nodes[i])); + asset.nodes = std::move(active); + } + checkpoint(job, .5f, "extracting materials and textures"); + for (std::size_t mi = 0; mi < data->materials_count; ++mi) { + const auto& m = data->materials[mi]; + Material out; + out.name = safe_name(m.name); + out.id = identify("material", m.extras, std::to_string(mi) + ":" + out.name); + if (m.has_pbr_metallic_roughness) { + const auto& p = m.pbr_metallic_roughness; + std::copy_n(p.base_color_factor, 4, out.base_color.begin()); + out.metallic = p.metallic_factor; + out.roughness = p.roughness_factor; + out.base_color_texture = texture_index(p.base_color_texture, *data); + out.metallic_roughness_texture = + texture_index(p.metallic_roughness_texture, *data); + } + std::copy_n(m.emissive_factor, 3, out.emissive.begin()); + out.alpha_mode = m.alpha_mode == cgltf_alpha_mode_blend ? "BLEND" + : m.alpha_mode == cgltf_alpha_mode_mask ? "MASK" + : "OPAQUE"; + out.alpha_cutoff = m.alpha_cutoff; + out.double_sided = m.double_sided; + out.unlit = m.unlit; + out.normal_texture = texture_index(m.normal_texture, *data); + out.occlusion_texture = texture_index(m.occlusion_texture, *data); + out.emissive_texture = texture_index(m.emissive_texture, *data); + asset.materials.push_back(std::move(out)); + } + for (std::size_t ti = 0; ti < data->textures_count; ++ti) { + const auto& texture = data->textures[ti]; + if (!texture.image) + throw std::runtime_error("Texture extension has no supported fallback image"); + Texture out; + out.name = safe_name(texture.name); + out.id = identify("texture", texture.extras, std::to_string(ti) + ":" + out.name); + out.bytes = image_bytes(*texture.image, source, dependencies); + out.mime_type = image_mime(*texture.image, out.bytes); + if (texture.sampler) { + out.wrap_s = texture.sampler->wrap_s; + out.wrap_t = texture.sampler->wrap_t; + out.min_filter = texture.sampler->min_filter; + out.mag_filter = texture.sampler->mag_filter; + } + asset.textures.push_back(std::move(out)); + } } - checkpoint(job,.5f,"extracting materials and textures"); - for(std::size_t mi=0;mimaterials_count;++mi) { - const auto& m=data->materials[mi];Material out;out.name=safe_name(m.name);out.id=identify("material",m.extras,std::to_string(mi)+":"+out.name); - if(m.has_pbr_metallic_roughness){const auto& p=m.pbr_metallic_roughness;std::copy_n(p.base_color_factor,4,out.base_color.begin());out.metallic=p.metallic_factor;out.roughness=p.roughness_factor;out.base_color_texture=texture_index(p.base_color_texture,*data);out.metallic_roughness_texture=texture_index(p.metallic_roughness_texture,*data);} - std::copy_n(m.emissive_factor,3,out.emissive.begin());out.alpha_mode=m.alpha_mode==cgltf_alpha_mode_blend?"BLEND":m.alpha_mode==cgltf_alpha_mode_mask?"MASK":"OPAQUE";out.alpha_cutoff=m.alpha_cutoff;out.double_sided=m.double_sided;out.unlit=m.unlit; - out.normal_texture=texture_index(m.normal_texture,*data);out.occlusion_texture=texture_index(m.occlusion_texture,*data);out.emissive_texture=texture_index(m.emissive_texture,*data);asset.materials.push_back(std::move(out)); + Json key{{"source", source_hash}, + {"settings", settings}, + {"importer", recipe_version}, + {"dependencies", Json::object()}}; + if (source != logical_source) + key["bundle_sha256"] = logical_hash; + for (const auto& [name, item] : dependencies) + key["dependencies"][name] = item.digest; + result.generation = faset::sha256(key.dump()); + asset.generation = result.generation; + Json manifest{{"schema_version", 1}, + {"asset_id", result.asset_id}, + {"generation", result.generation}, + {"source", logical_source.string()}, + {"payload_source", source.string()}, + {"source_sha256", source_hash}, + {"importer", recipe_version}, + {"settings", settings}, + {"input_key", key}, + {"nodes", Json::array()}, + {"meshes", Json::array()}, + {"materials", Json::array()}, + {"textures", Json::array()}, + {"files", Json::array()}, + {"outputs", Json::array()}}; + manifest["kind"] = standalone_image ? "image" : "scene"; + if (standalone_image) + manifest["image"] = image_info; + stage = cache_root_ / "staging" / uuid(); + fs::create_directories(stage); + for (const auto& node : asset.nodes) { + manifest["nodes"].push_back({{"id", node.id}, + {"name", node.name}, + {"parent_id", node.parent_id}, + {"mesh", node.mesh}, + {"local_transform", node.local_transform}, + {"stable_source_id", node.stable_source_id}}); + manifest["outputs"].push_back(node.id); } - for(std::size_t ti=0;titextures_count;++ti) { - const auto& texture=data->textures[ti];if(!texture.image)throw std::runtime_error("Texture extension has no supported fallback image"); - Texture out;out.name=safe_name(texture.name);out.id=identify("texture",texture.extras,std::to_string(ti)+":"+out.name);out.bytes=image_bytes(*texture.image,source,dependencies);out.mime_type=image_mime(*texture.image,out.bytes); - if(texture.sampler){out.wrap_s=texture.sampler->wrap_s;out.wrap_t=texture.sampler->wrap_t;out.min_filter=texture.sampler->min_filter;out.mag_filter=texture.sampler->mag_filter;}asset.textures.push_back(std::move(out)); + for (const auto& mesh : asset.meshes) { + Json m{{"id", mesh.id}, {"name", mesh.name}, {"primitives", Json::array()}}; + for (std::size_t i = 0; i < mesh.primitives.size(); ++i) { + const auto file = "meshes/" + mesh.id + "-" + std::to_string(i) + ".fmesh"; + add_file(manifest, stage, file, encode_primitive(mesh.primitives[i])); + m["primitives"].push_back( + {{"path", file}, {"material", mesh.primitives[i].material}}); + } + manifest["meshes"].push_back(m); + manifest["outputs"].push_back(mesh.id); } - Json key{{"source",source_hash},{"settings",settings},{"importer",importer_version},{"dependencies",Json::object()}}; - if(source!=logical_source)key["bundle_sha256"]=logical_hash; - for(const auto& [name,item]:dependencies)key["dependencies"][name]=item.digest; - result.generation=faset::sha256(key.dump());asset.generation=result.generation; - Json manifest{{"schema_version",1},{"asset_id",result.asset_id},{"generation",result.generation},{"source",logical_source.string()},{"payload_source",source.string()},{"source_sha256",source_hash},{"importer",importer_version},{"settings",settings},{"input_key",key},{"nodes",Json::array()},{"meshes",Json::array()},{"materials",Json::array()},{"textures",Json::array()},{"files",Json::array()},{"outputs",Json::array()}}; - stage=cache_root_/"staging"/uuid();fs::create_directories(stage); - for(const auto& node:asset.nodes){manifest["nodes"].push_back({{"id",node.id},{"name",node.name},{"parent_id",node.parent_id},{"mesh",node.mesh},{"local_transform",node.local_transform},{"stable_source_id",node.stable_source_id}});manifest["outputs"].push_back(node.id);} - for(const auto& mesh:asset.meshes){Json m{{"id",mesh.id},{"name",mesh.name},{"primitives",Json::array()}};for(std::size_t i=0;i>(); - if(!previous.is_null())for(const auto& old:previous.at("outputs"))if(!published_ids.contains(old.get()))result.removed_output_ids.push_back(old.get()); - result.manifest=manifest; - if(!result.removed_output_ids.empty()&&!request.allow_removed_outputs){result.status=ImportStatus::conflict;result.diagnostics.push_back("Removed or renamed outputs require explicit remap/removal approval; active generation preserved");fs::remove_all(stage);return result;} - if(hash_bytes(read_bytes(source))!=source_hash)throw std::runtime_error("Source changed during import; retry"); - if(source!=logical_source&&hash_bytes(read_bytes(logical_source))!=logical_hash)throw std::runtime_error("Bundle manifest changed during import; retry"); - for(const auto& [name,item]:dependencies)if(hash_bytes(read_bytes(item.path))!=item.digest)throw std::runtime_error("Dependency changed during import: "+name); - write_json(stage/"manifest.json",manifest); - checkpoint(job,.9f,"publishing generation"); - const auto destination=asset_root/"generations"/result.generation;fs::create_directories(destination.parent_path()); - if(fs::exists(destination)){auto existing=read_json(destination/"manifest.json");validate_generation(destination,existing);if(existing.at("input_key")!=key)throw std::runtime_error("Digest collision detected");result.cache_hit=true;fs::remove_all(stage);}else fs::rename(stage,destination); - // Persist identity outside the disposable cache, then atomically publish one pointer. - metadata={{"schema_version",1},{"asset_id",result.asset_id},{"settings",settings}};atomic_json(sidecar,metadata); - if(job.cancelled())throw Cancelled{}; - atomic_json(asset_root/"current.json",{{"schema_version",1},{"generation",result.generation},{"source",logical_source.string()}}); - result.status=ImportStatus::succeeded;job.report(1,"complete"); - } catch(const Cancelled&) {result.status=ImportStatus::cancelled;result.diagnostics.push_back("Import cancelled; active generation unchanged");} - catch(const std::exception& e){result.status=ImportStatus::failed;result.diagnostics.push_back(e.what());} - if(!stage.empty()){std::error_code ec;fs::remove_all(stage,ec);}return result; -} -fs::path AssetPipeline::generation_directory(const std::string& id) const { - valid_id(id);const auto root=cache_root_/"assets"/id;const auto pointer=read_json(root/"current.json");const auto generation=pointer.at("generation").get();valid_id(generation);return root/"generations"/generation; -} -Json AssetPipeline::current_manifest(const std::string& id) const { - valid_id(id);const auto root=cache_root_/"assets"/id;const auto pointer=read_json(root/"current.json"); - const auto generation=pointer.at("generation").get();valid_id(generation); - auto manifest=read_json(root/"generations"/generation/"manifest.json"); - // One pointer snapshot prevents mixing two concurrently published generations. - manifest["source"]=pointer.at("source");return manifest; -} -CookedAsset AssetPipeline::load_asset(const std::string& id) const { - const auto directory=generation_directory(id);const auto m=read_json(directory/"manifest.json");validate_generation(directory,m); - CookedAsset asset;asset.asset_id=m.at("asset_id");asset.generation=m.at("generation"); - for(const auto& n:m.at("nodes")){Node node;node.id=n.at("id");node.name=n.at("name");node.parent_id=n.at("parent_id");node.mesh=n.at("mesh");node.local_transform=n.at("local_transform").get>();node.stable_source_id=n.at("stable_source_id");asset.nodes.push_back(std::move(node));} - for(const auto& j:m.at("meshes")){Mesh mesh;mesh.id=j.at("id");mesh.name=j.at("name");for(const auto& primitive:j.at("primitives"))mesh.primitives.push_back(decode_primitive(read_bytes(directory/primitive.at("path").get()),primitive.at("material")));asset.meshes.push_back(std::move(mesh));} - for(const auto& j:m.at("materials")){Material material;material.id=j.at("id");material.name=j.at("name");material.base_color=j.at("base_color").get>();material.emissive=j.at("emissive").get>();material.metallic=j.at("metallic");material.roughness=j.at("roughness");material.alpha_mode=j.at("alpha_mode");material.alpha_cutoff=j.at("alpha_cutoff");material.double_sided=j.at("double_sided");material.unlit=j.at("unlit");material.base_color_texture=j.at("base_color_texture");material.metallic_roughness_texture=j.at("metallic_roughness_texture");material.normal_texture=j.at("normal_texture");material.occlusion_texture=j.at("occlusion_texture");material.emissive_texture=j.at("emissive_texture");asset.materials.push_back(std::move(material));} - for(const auto& j:m.at("textures")){Texture texture;texture.id=j.at("id");texture.name=j.at("name");texture.mime_type=j.at("mime_type");texture.bytes=read_bytes(directory/j.at("path").get());texture.wrap_s=j.at("wrap_s");texture.wrap_t=j.at("wrap_t");texture.min_filter=j.at("min_filter");texture.mag_filter=j.at("mag_filter");asset.textures.push_back(std::move(texture));}return asset; + for (const auto& material : asset.materials) { + manifest["materials"].push_back(material_json(material)); + manifest["outputs"].push_back(material.id); + } + for (const auto& texture : asset.textures) { + const auto file = "textures/" + texture.id + ".image"; + add_file(manifest, stage, file, texture.bytes); + manifest["textures"].push_back({{"id", texture.id}, + {"name", texture.name}, + {"mime_type", texture.mime_type}, + {"path", file}, + {"wrap_s", texture.wrap_s}, + {"wrap_t", texture.wrap_t}, + {"min_filter", texture.min_filter}, + {"mag_filter", texture.mag_filter}}); + manifest["outputs"].push_back(texture.id); + } + checkpoint(job, .75f, "validating candidate generation"); + validate_generation(stage, manifest); + const auto published_ids = manifest.at("outputs").get>(); + if (!previous.is_null()) + for (const auto& old : previous.at("outputs")) + if (!published_ids.contains(old.get())) + result.removed_output_ids.push_back(old.get()); + result.manifest = manifest; + if (!result.removed_output_ids.empty() && !request.allow_removed_outputs) { + result.status = ImportStatus::conflict; + result.diagnostics.push_back( + "Removed or renamed outputs require explicit remap/removal approval; " + "active generation preserved"); + fs::remove_all(stage); + return result; + } + if (hash_bytes(read_bytes(source)) != source_hash) + throw std::runtime_error("Source changed during import; retry"); + if (source != logical_source && hash_bytes(read_bytes(logical_source)) != logical_hash) + throw std::runtime_error("Bundle manifest changed during import; retry"); + for (const auto& [name, item] : dependencies) + if (hash_bytes(read_bytes(item.path)) != item.digest) + throw std::runtime_error("Dependency changed during import: " + name); + write_json(stage / "manifest.json", manifest); + checkpoint(job, .9f, "publishing generation"); + const auto destination = asset_root / "generations" / result.generation; + fs::create_directories(destination.parent_path()); + if (fs::exists(destination)) { + auto existing = read_json(destination / "manifest.json"); + validate_generation(destination, existing); + if (existing.at("input_key") != key) + throw std::runtime_error("Digest collision detected"); + result.cache_hit = true; + fs::remove_all(stage); + } else + fs::rename(stage, destination); + // Persist identity outside the disposable cache, then atomically publish + // one pointer. + metadata = {{"schema_version", 1}, {"asset_id", result.asset_id}, {"settings", settings}}; + atomic_json(sidecar, metadata); + if (job.cancelled()) + throw Cancelled{}; + atomic_json(asset_root / "current.json", {{"schema_version", 1}, + {"generation", result.generation}, + {"source", logical_source.string()}}); + result.status = ImportStatus::succeeded; + job.report(1, "complete"); + } catch (const Cancelled&) { + result.status = ImportStatus::cancelled; + result.diagnostics.push_back("Import cancelled; active generation unchanged"); + } catch (const std::exception& e) { + result.status = ImportStatus::failed; + result.diagnostics.push_back(e.what()); + } + if (!stage.empty()) { + std::error_code ec; + fs::remove_all(stage, ec); + } + return result; } Json AssetPipeline::overrides(const std::string& id) const { - const auto source=current_manifest(id).at("source").get();const auto path=fs::path(source+".faset-overrides.json");return fs::exists(path)?read_json(path):Json::object(); + const auto source = current_manifest(id).at("source").get(); + const auto path = fs::path(source + ".faset-overrides.json"); + return fs::exists(path) ? read_json(path) : Json::object(); } -void AssetPipeline::set_overrides(const std::string& id,const Json& values) { - if(!values.is_object())throw std::runtime_error("Overrides must be an object keyed by stable output IDs");std::lock_guard lock(writer_mutex); - atomic_json(fs::path(current_manifest(id).at("source").get()+".faset-overrides.json"),values); +void AssetPipeline::set_overrides(const std::string& id, const Json& values) { + if (!values.is_object()) + throw std::runtime_error("Overrides must be an object keyed by stable output IDs"); + std::lock_guard lock(writer_mutex); + atomic_json( + fs::path(current_manifest(id).at("source").get() + ".faset-overrides.json"), + values); } } // namespace faset::assets diff --git a/src/authoring/schema.cpp b/src/authoring/schema.cpp index 5870cea..057e53f 100644 --- a/src/authoring/schema.cpp +++ b/src/authoring/schema.cpp @@ -1,120 +1,207 @@ -#include #include #include +#include #include namespace faset::authoring { -void validate_field(const Json& value,const Json& descriptor) { - const auto kind=descriptor.value("type",std::string("any")); - bool valid=true; - if(kind=="number"||kind=="float") valid=value.is_number()&&std::isfinite(value.get()); - else if(kind=="integer"||kind=="int") valid=value.is_number_integer(); - else if(kind=="boolean"||kind=="bool") valid=value.is_boolean(); - else if(kind=="string"||kind=="asset_ref"||kind=="entity_ref") valid=value.is_string(); - else if(kind=="vec2"||kind=="vec3"||kind=="vec4"||kind=="color") { - const auto size=kind=="vec2"?2u:(kind=="vec3"?3u:4u); - valid=value.is_array()&&value.size()==size; - if(valid) for(const auto& entry:value) valid=valid&&entry.is_number()&&std::isfinite(entry.get()); - } else if(kind=="array") valid=value.is_array(); - else if(kind=="object") valid=value.is_object(); - else require(kind=="any","schema.field_type","Unsupported schema field type: "+kind); - require(valid,"validation.field_type","Invalid value for field "+descriptor.value("id",std::string("?"))+" (expected "+kind+")"); - if(value.is_number()) { - if(descriptor.contains("min")) require(value.get()>=descriptor["min"].get(),"validation.minimum","Field is below its minimum"); - if(descriptor.contains("max")) require(value.get()<=descriptor["max"].get(),"validation.maximum","Field exceeds its maximum"); +void validate_field(const Json& value, const Json& descriptor) { + const auto kind = descriptor.value("type", std::string("any")); + bool valid = true; + if (kind == "number" || kind == "float") + valid = value.is_number() && std::isfinite(value.get()); + else if (kind == "integer" || kind == "int") + valid = value.is_number_integer(); + else if (kind == "boolean" || kind == "bool") + valid = value.is_boolean(); + else if (kind == "string" || kind == "asset_ref" || kind == "entity_ref") + valid = value.is_string(); + else if (kind == "vec2" || kind == "vec3" || kind == "vec4" || kind == "color") { + const auto size = kind == "vec2" ? 2u : (kind == "vec3" ? 3u : 4u); + valid = value.is_array() && value.size() == size; + if (valid) + for (const auto& entry : value) + valid = valid && entry.is_number() && std::isfinite(entry.get()); + } else if (kind == "array") + valid = value.is_array(); + else if (kind == "object") + valid = value.is_object(); + else + require(kind == "any", "schema.field_type", "Unsupported schema field type: " + kind); + require(valid, "validation.field_type", + "Invalid value for field " + descriptor.value("id", std::string("?")) + " (expected " + + kind + ")"); + if (value.is_number()) { + if (descriptor.contains("min")) + require(value.get() >= descriptor["min"].get(), "validation.minimum", + "Field is below its minimum"); + if (descriptor.contains("max")) + require(value.get() <= descriptor["max"].get(), "validation.maximum", + "Field exceeds its maximum"); } - if(descriptor.contains("enum")) { - bool found=false;for(const auto& option:descriptor["enum"])found=found||option==value; - require(found,"validation.enum","Field value is not an allowed choice"); + if (descriptor.contains("enum")) { + bool found = false; + for (const auto& option : descriptor["enum"]) + found = found || option == value; + require(found, "validation.enum", "Field value is not an allowed choice"); } } void SchemaRegistry::register_schema(const Json& value) { - require(value.is_object()&&value.contains("id")&&value["id"].is_string()&&value.contains("fields")&&value["fields"].is_object(),"schema.invalid","Invalid component schema"); - Json normalized=value; - const auto id=value.at("id").get(); - require(!id.empty(),"schema.invalid","TypeId cannot be empty"); - require(value.value("version",1)>0,"schema.invalid","Schema version must be positive"); - for(auto& [key,field]:normalized["fields"].items()) { - require(field.is_object()&&field.contains("default"),"schema.invalid","Each field requires a typed default"); - require(field.value("id",key)==key,"schema.field_id","Field map keys must be stable FieldIds"); - field["id"]=key;validate_field(field["default"],field); + require(value.is_object() && value.contains("id") && value["id"].is_string() && + value.contains("fields") && value["fields"].is_object(), + "schema.invalid", "Invalid component schema"); + Json normalized = value; + const auto id = value.at("id").get(); + require(!id.empty(), "schema.invalid", "TypeId cannot be empty"); + require(value.value("version", 1) > 0, "schema.invalid", "Schema version must be positive"); + for (auto& [key, field] : normalized["fields"].items()) { + require(field.is_object() && field.contains("default"), "schema.invalid", + "Each field requires a typed default"); + require(field.value("id", key) == key, "schema.field_id", + "Field map keys must be stable FieldIds"); + field["id"] = key; + validate_field(field["default"], field); } - if(auto found=schemas_.find(id);found!=schemas_.end()) require(found->second==normalized,"schema.duplicate_type","A different schema is already registered for "+id); - schemas_[id]=std::move(normalized); + if (auto found = schemas_.find(id); found != schemas_.end()) + require(found->second == normalized, "schema.duplicate_type", + "A different schema is already registered for " + id); + schemas_[id] = std::move(normalized); } void SchemaRegistry::register_schemas(const Json& values) { - const auto& array=values.is_array()?values:values.at("types"); - auto candidate=*this;for(const auto& schema:array)candidate.register_schema(schema);*this=std::move(candidate); + const auto& array = values.is_array() ? values : values.at("types"); + auto candidate = *this; + for (const auto& schema : array) + candidate.register_schema(schema); + *this = std::move(candidate); } -bool SchemaRegistry::contains(const std::string& type)const{return schemas_.contains(type);} -Json SchemaRegistry::schema(const std::string& type)const { - const auto found=schemas_.find(type);require(found!=schemas_.end(),"schema.missing","Component schema unavailable: "+type);return found->second; +bool SchemaRegistry::contains(const std::string& type) const { + return schemas_.contains(type); } -Json SchemaRegistry::manifest()const {Json types=Json::array();for(const auto& [id,type]:schemas_)types.push_back(type);return {{"format","faset.schema"},{"version",1},{"types",types}};} -Json SchemaRegistry::default_fields(const std::string& type)const {Json fields=Json::object();const auto metadata=schema(type);for(const auto& [id,field]:metadata["fields"].items())fields[id]=field["default"];return fields;} -void SchemaRegistry::validate_component(const Json& component)const { - require(component.is_object()&&component.contains("type")&&component["type"].is_string()&&component.contains("fields")&&component["fields"].is_object(),"component.invalid","Invalid component record"); - const auto type=component.at("type").get(); - if(!contains(type))return; - const auto metadata=schema(type); +Json SchemaRegistry::schema(const std::string& type) const { + const auto found = schemas_.find(type); + require(found != schemas_.end(), "schema.missing", "Component schema unavailable: " + type); + return found->second; +} +Json SchemaRegistry::manifest() const { + Json types = Json::array(); + for (const auto& [id, type] : schemas_) + types.push_back(type); + return {{"format", "faset.schema"}, {"version", 1}, {"types", types}}; +} +Json SchemaRegistry::default_fields(const std::string& type) const { + Json fields = Json::object(); + const auto metadata = schema(type); + for (const auto& [id, field] : metadata["fields"].items()) + fields[id] = field["default"]; + return fields; +} +void SchemaRegistry::validate_component(const Json& component) const { + require(component.is_object() && component.contains("type") && component["type"].is_string() && + component.contains("fields") && component["fields"].is_object(), + "component.invalid", "Invalid component record"); + const auto type = component.at("type").get(); + if (!contains(type)) + return; + const auto metadata = schema(type); // Future or missing-module schemas are preserved, not interpreted with the wrong version. - if(component.value("version",1)!=metadata.value("version",1))return; - for(const auto& [id,value]:component["fields"].items())if(metadata["fields"].contains(id))validate_field(value,metadata["fields"][id]); + if (component.value("version", 1) != metadata.value("version", 1)) + return; + for (const auto& [id, value] : component["fields"].items()) + if (metadata["fields"].contains(id)) + 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(!migrations_.contains({type,from_version}),"migration.duplicate","Migration already exists"); - migrations_[{type,from_version}]=std::move(rules); +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(!migrations_.contains({type, from_version}), "migration.duplicate", + "Migration already exists"); + migrations_[{type, from_version}] = std::move(rules); } -Json SchemaRegistry::migrate_component(const Json& source)const { - Json result=source;const auto type=result.at("type").get(); - if(!contains(type))return result; - const auto current=schema(type).value("version",1); - auto version=result.value("version",1); - if(version>current)return result; - while(versionsecond.items()) { - if(rule.contains("default")&&!result["fields"].contains(field))result["fields"][field]=rule["default"]; - if(rule.contains("scale")&&result["fields"].contains(field)) { - require(result["fields"][field].is_number(),"migration.type","Cannot scale a nonnumeric field"); - result["fields"][field]=result["fields"][field].get()*rule["scale"].get(); +Json SchemaRegistry::migrate_component(const Json& source) const { + Json result = source; + const auto type = result.at("type").get(); + if (!contains(type)) + return result; + const auto current = schema(type).value("version", 1); + auto version = result.value("version", 1); + if (version > current) + return result; + while (version < current) { + const auto found = migrations_.find({type, version}); + require(found != migrations_.end(), "migration.required", + "Explicit migration required for " + type); + for (const auto& [field, rule] : found->second.items()) { + if (rule.contains("default") && !result["fields"].contains(field)) + result["fields"][field] = rule["default"]; + if (rule.contains("scale") && result["fields"].contains(field)) { + require(result["fields"][field].is_number(), "migration.type", + "Cannot scale a nonnumeric field"); + result["fields"][field] = + result["fields"][field].get() * rule["scale"].get(); } - if(rule.value("require_manual",false)&&result["fields"].contains(field))throw Error("migration.manual","Field requires explicit manual migration",{{"type",type},{"field",field}}); + if (rule.value("require_manual", false) && result["fields"].contains(field)) + throw Error("migration.manual", "Field requires explicit manual migration", + {{"type", type}, {"field", field}}); } - result["version"]=++version; + result["version"] = ++version; } - const auto metadata=schema(type); - for(const auto& [field,descriptor]:metadata["fields"].items())if(!result["fields"].contains(field))result["fields"][field]=descriptor["default"]; - validate_component(result);return result; + const auto metadata = schema(type); + for (const auto& [field, descriptor] : metadata["fields"].items()) + if (!result["fields"].contains(field)) + result["fields"][field] = descriptor["default"]; + validate_component(result); + return result; } SchemaRegistry builtin_schemas() { SchemaRegistry registry; - struct Transform {std::array position,rotation,scale;}; - TypeRegistration(registry,"faset.transform","Transform") - .field("position","Position",&Transform::position,std::array{0,0,0},"vec3") - .field("rotation","Rotation",&Transform::rotation,std::array{0,0,0},"vec3",{{"unit","radians"}}) - .field("scale","Scale",&Transform::scale,std::array{1,1,1},"vec3").commit(); - auto add=[&](std::string id,std::string name,Json fields){registry.register_schema({{"id",id},{"name",name},{"version",1},{"fields",fields}});}; - auto field=[](std::string type,Json value){return Json{{"type",type},{"default",value}};}; - add("faset.sprite","Sprite",{{"color",field("color",{0.65,0.6,0.85,1.0})},{"size",field("vec2",{1,1})},{"texture",field("asset_ref","")},{"layer",field("integer",0)}}); - add("faset.mesh","Mesh",{{"asset",field("asset_ref","")},{"color",field("color",{0.65,0.65,0.68,1.0})},{"primitive",Json{{"type","string"},{"default","cube"},{"enum",{"cube","plane","asset"}}}}}); - add("faset.camera","Camera",{{"fov",Json{{"type","number"},{"default",60.0},{"min",1.0},{"max",179.0}}},{"near",Json{{"type","number"},{"default",0.1},{"min",0.001}}},{"far",Json{{"type","number"},{"default",1000.0},{"min",0.01}}}}); - add("faset.light","Directional Light",{{"color",field("color",{1,1,1,1})},{"intensity",Json{{"type","number"},{"default",1.0},{"min",0.0}}}}); - for(int dimension:{2,3}) { - Json vector=dimension==2?Json{0,0}:Json{0,0,0};Json extents=dimension==2?Json{0.5,0.5}:Json{0.5,0.5,0.5}; - add("faset.rigid_body_"+std::to_string(dimension)+"d","Rigid Body "+std::to_string(dimension)+"D",{ - {"body_type",Json{{"type","string"},{"default","dynamic"},{"enum",{"static","dynamic","kinematic"}}}}, - {"half_extents",field(dimension==2?"vec2":"vec3",extents)}, - {"linear_velocity",field(dimension==2?"vec2":"vec3",vector)}, - {"density",Json{{"type","number"},{"default",1.0},{"min",0.001}}}, - {"friction",Json{{"type","number"},{"default",0.5},{"min",0.0}}}, - {"restitution",Json{{"type","number"},{"default",0.0},{"min",0.0},{"max",1.0}}}, - {"gravity_scale",field("number",1.0)}, - {"category_bits",Json{{"type","integer"},{"default",1},{"min",0}}}, - {"mask_bits",Json{{"type","integer"},{"default",65535},{"min",0}}}}); + struct Transform { + std::array position, rotation, scale; + }; + TypeRegistration(registry, "faset.transform", "Transform") + .field("position", "Position", &Transform::position, std::array{0, 0, 0}, "vec3") + .field("rotation", "Rotation", &Transform::rotation, std::array{0, 0, 0}, "vec3", + {{"unit", "radians"}}) + .field("scale", "Scale", &Transform::scale, std::array{1, 1, 1}, "vec3") + .commit(); + auto add = [&](std::string id, std::string name, Json fields) { + registry.register_schema({{"id", id}, {"name", name}, {"version", 1}, {"fields", fields}}); + }; + auto field = [](std::string type, Json value) { + return Json{{"type", type}, {"default", value}}; + }; + add("faset.sprite", "Sprite", + {{"color", field("color", {0.65, 0.6, 0.85, 1.0})}, + {"size", field("vec2", {1, 1})}, + {"texture", field("asset_ref", "")}, + {"layer", field("integer", 0)}}); + add("faset.mesh", "Mesh", + {{"asset", field("asset_ref", "")}, + {"color", field("color", {0.65, 0.65, 0.68, 1.0})}, + {"primitive", + Json{{"type", "string"}, {"default", "cube"}, {"enum", {"cube", "plane", "asset"}}}}}); + add("faset.camera", "Camera", + {{"fov", Json{{"type", "number"}, {"default", 60.0}, {"min", 1.0}, {"max", 179.0}}}, + {"near", Json{{"type", "number"}, {"default", 0.1}, {"min", 0.001}}}, + {"far", Json{{"type", "number"}, {"default", 1000.0}, {"min", 0.01}}}}); + add("faset.light", "Directional Light", + {{"color", field("color", {1, 1, 1, 1})}, + {"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}}}); + for (int dimension : {2, 3}) { + Json vector = dimension == 2 ? Json{0, 0} : Json{0, 0, 0}; + Json extents = dimension == 2 ? Json{0.5, 0.5} : Json{0.5, 0.5, 0.5}; + add("faset.rigid_body_" + std::to_string(dimension) + "d", + "Rigid Body " + std::to_string(dimension) + "D", + {{"body_type", Json{{"type", "string"}, + {"default", "dynamic"}, + {"enum", {"static", "dynamic", "kinematic"}}}}, + {"half_extents", field(dimension == 2 ? "vec2" : "vec3", extents)}, + {"linear_velocity", field(dimension == 2 ? "vec2" : "vec3", vector)}, + {"density", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.001}}}, + {"friction", Json{{"type", "number"}, {"default", 0.5}, {"min", 0.0}}}, + {"restitution", + Json{{"type", "number"}, {"default", 0.0}, {"min", 0.0}, {"max", 1.0}}}, + {"gravity_scale", field("number", 1.0)}, + {"category_bits", Json{{"type", "integer"}, {"default", 1}, {"min", 0}}}, + {"mask_bits", Json{{"type", "integer"}, {"default", 65535}, {"min", 0}}}}); } return registry; } -} +} // namespace faset::authoring diff --git a/src/authoring/service.cpp b/src/authoring/service.cpp index 6f4b7fb..4688b71 100644 --- a/src/authoring/service.cpp +++ b/src/authoring/service.cpp @@ -1,201 +1,511 @@ -#include -#include -#include #include #include +#include +#include +#include +#include #include namespace faset::authoring { namespace { -Json& entity(Json& scene,const std::string& id) { - for(auto& item:scene["entities"])if(item.at("id")==id)return item; - throw Error("entity.missing","Entity does not exist",{{"entity",id}}); +Json& entity(Json& scene, const std::string& id) { + for (auto& item : scene["entities"]) + if (item.at("id") == id) + return item; + throw Error("entity.missing", "Entity does not exist", {{"entity", id}}); } -Json& component(Json& item,const std::string& id) { - for(auto& value:item["components"])if(value.at("id")==id)return value; - throw Error("component.missing","Component does not exist",{{"component",id}}); +Json& component(Json& item, const std::string& id) { + for (auto& value : item["components"]) + if (value.at("id") == id) + return value; + throw Error("component.missing", "Component does not exist", {{"component", id}}); } -std::string parent_id(const Json& item) {return item.contains("parent")&&!item["parent"].is_null()?item["parent"].get():"";} -void check_revision(std::uint64_t current,std::uint64_t expected) { - if(current!=expected)throw Error("revision.conflict","Document changed since it was read",{{"expected",expected},{"current",current}}); +std::string parent_id(const Json& item) { + return item.contains("parent") && !item["parent"].is_null() ? item["parent"].get() + : ""; +} +void check_revision(std::uint64_t current, std::uint64_t expected) { + if (current != expected) + throw Error("revision.conflict", "Document changed since it was read", + {{"expected", expected}, {"current", current}}); } bool finite_json(const Json& value) { - if(value.is_number_float())return std::isfinite(value.get()); - if(value.is_structured())for(const auto& child:value)if(!finite_json(child))return false; + if (value.is_number_float()) + return std::isfinite(value.get()); + if (value.is_structured()) + for (const auto& child : value) + if (!finite_json(child)) + return false; return true; } +bool valid_id(const Json& value) { + if (!value.is_string()) + return false; + const auto& text = value.get_ref(); + return !text.empty() && text.size() <= 128 && + text.find_first_not_of( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.:") == + std::string::npos; } -Json make_scene(std::string name,int dimension) { - require(dimension==2||dimension==3,"scene.dimension","Scene dimension must be 2 or 3"); - return {{"format","faset.scene"},{"version",1},{"id",new_id()},{"name",std::move(name)},{"dimension",dimension},{"entities",Json::array()},{"instances",Json::array()}}; +} // namespace +Json make_scene(std::string name, int dimension) { + require(dimension == 2 || dimension == 3, "scene.dimension", "Scene dimension must be 2 or 3"); + return {{"format", "faset.scene"}, {"version", 1}, {"id", new_id()}, + {"name", std::move(name)}, {"dimension", dimension}, {"entities", Json::array()}, + {"instances", Json::array()}}; } -Json make_entity(const SchemaRegistry& schemas,std::string name,const std::string& parent) { - Json transform={{"id",new_id()},{"type","faset.transform"},{"version",1},{"fields",schemas.default_fields("faset.transform")}}; - return {{"id",new_id()},{"name",std::move(name)},{"parent",parent.empty()?Json(nullptr):Json(parent)},{"components",Json::array({transform})}}; +Json make_entity(const SchemaRegistry& schemas, std::string name, const std::string& parent) { + Json transform = {{"id", new_id()}, + {"type", "faset.transform"}, + {"version", 1}, + {"fields", schemas.default_fields("faset.transform")}}; + return {{"id", new_id()}, + {"name", std::move(name)}, + {"parent", parent.empty() ? Json(nullptr) : Json(parent)}, + {"components", Json::array({transform})}}; } -void validate_scene(const Json& scene,const SchemaRegistry& schemas) { - require(scene.is_object()&&scene.value("format",std::string())=="faset.scene","scene.format","Expected a Faset scene"); - require(scene.value("version",0)==1,"scene.version","Unsupported scene format version"); - require(scene.contains("id")&&scene["id"].is_string()&&!scene["id"].get().empty(),"scene.id","Scene requires a stable ID"); - require(scene.contains("name")&&scene["name"].is_string(),"scene.name","Scene name must be text"); - require(scene.value("dimension",0)==2||scene.value("dimension",0)==3,"scene.dimension","Scene dimension must be 2 or 3"); - require(scene.contains("entities")&&scene["entities"].is_array(),"scene.entities","Scene entities must be an array"); - require(finite_json(scene),"validation.finite","Scene contains a non-finite number"); - std::set ids;std::map parents; - auto insert_id=[&](const Json& value) {require(value.is_string()&&!value.get().empty(),"id.invalid","ID must be nonempty text");require(ids.insert(value.get()).second,"id.duplicate","Duplicate document ID");}; - for(const auto& item:scene["entities"]) { - require(item.is_object()&&item.contains("id")&&item.contains("name")&&item["name"].is_string(),"entity.invalid","Invalid entity record"); - insert_id(item["id"]);parents[item["id"].get()]=parent_id(item); - require(item.contains("components")&&item["components"].is_array(),"entity.components","Entity components must be an array"); +void validate_scene(const Json& scene, const SchemaRegistry& schemas) { + require(scene.is_object() && scene.value("format", std::string()) == "faset.scene", + "scene.format", "Expected a Faset scene"); + require(scene.value("version", 0) == 1, "scene.version", "Unsupported scene format version"); + require(scene.contains("id") && valid_id(scene["id"]), "scene.id", + "Scene requires a safe stable ID"); + require(scene.contains("name") && scene["name"].is_string(), "scene.name", + "Scene name must be text"); + require(scene.value("dimension", 0) == 2 || scene.value("dimension", 0) == 3, "scene.dimension", + "Scene dimension must be 2 or 3"); + require(scene.contains("entities") && scene["entities"].is_array(), "scene.entities", + "Scene entities must be an array"); + require(finite_json(scene), "validation.finite", "Scene contains a non-finite number"); + std::set ids; + std::map parents; + auto insert_id = [&](const Json& value) { + require(valid_id(value), "id.invalid", + "ID must contain at most 128 ASCII identifier characters"); + require(ids.insert(value.get()).second, "id.duplicate", + "Duplicate document ID"); + }; + for (const auto& item : scene["entities"]) { + require(item.is_object() && item.contains("id") && item.contains("name") && + item["name"].is_string(), + "entity.invalid", "Invalid entity record"); + insert_id(item["id"]); + parents[item["id"].get()] = parent_id(item); + require(item.contains("components") && item["components"].is_array(), "entity.components", + "Entity components must be an array"); std::set types; - for(const auto& value:item["components"]) { - require(value.contains("id"),"component.id","Component requires stable ID");insert_id(value["id"]);schemas.validate_component(value); - require(types.insert(value.at("type").get()).second,"component.duplicate_type","One component of each type is supported per entity"); + for (const auto& value : item["components"]) { + require(value.contains("id"), "component.id", "Component requires stable ID"); + insert_id(value["id"]); + schemas.validate_component(value); + require(types.insert(value.at("type").get()).second, + "component.duplicate_type", + "One component of each type is supported per entity"); } } - for(const auto& [id,parent]:parents) { - std::set visited{id};auto current=parent; - while(!current.empty()) {require(parents.contains(current),"entity.parent_missing","Parent entity is missing");require(visited.insert(current).second,"entity.cycle","Hierarchy contains a cycle");current=parents.at(current);} + for (const auto& [id, parent] : parents) { + std::set visited{id}; + auto current = parent; + while (!current.empty()) { + require(parents.contains(current), "entity.parent_missing", "Parent entity is missing"); + require(visited.insert(current).second, "entity.cycle", "Hierarchy contains a cycle"); + current = parents.at(current); + } } - if(scene.contains("instances")) { - require(scene["instances"].is_array(),"template.instances","Template instances must be an array"); - for(const auto& instance:scene["instances"]) { - require(instance.contains("id")&&instance.contains("source")&&instance["source"].is_string(),"template.instance","Invalid template instance"); + if (scene.contains("instances")) { + require(scene["instances"].is_array(), "template.instances", + "Template instances must be an array"); + for (const auto& instance : scene["instances"]) { + require(instance.contains("id") && instance.contains("source") && + instance["source"].is_string(), + "template.instance", "Invalid template instance"); insert_id(instance["id"]); } } } -AuthoringService::AuthoringService(std::filesystem::path root,SchemaRegistry schemas):root_(std::filesystem::absolute(std::move(root)).lexically_normal()),schemas_(std::move(schemas)) {std::filesystem::create_directories(root_);} -AuthoringService::State& AuthoringService::state(const std::string& id) {auto found=documents_.find(id);require(found!=documents_.end(),"document.missing","Document is not open");return found->second;} -const AuthoringService::State& AuthoringService::state(const std::string& id)const {auto found=documents_.find(id);require(found!=documents_.end(),"document.missing","Document is not open");return found->second;} -Json AuthoringService::summary(const State& value,bool include_data)const { - Json result={{"id",value.data.at("id")},{"name",value.data.at("name")},{"revision",value.revision},{"dirty",sha256(value.data.dump())!=value.saved_hash},{"path",value.path.generic_string()},{"can_undo",!value.undo.empty()},{"can_redo",!value.redo.empty()}}; - if(include_data) result["scene"]=value.data; +AuthoringService::AuthoringService(std::filesystem::path root, SchemaRegistry schemas) + : root_(std::filesystem::absolute(std::move(root)).lexically_normal()), + schemas_(std::move(schemas)) { + std::filesystem::create_directories(root_); +} +AuthoringService::State& AuthoringService::state(const std::string& id) { + auto found = documents_.find(id); + require(found != documents_.end(), "document.missing", "Document is not open"); + return found->second; +} +const AuthoringService::State& AuthoringService::state(const std::string& id) const { + auto found = documents_.find(id); + require(found != documents_.end(), "document.missing", "Document is not open"); + return found->second; +} +Json AuthoringService::summary(const State& value, bool include_data) const { + Json result = {{"id", value.data.at("id")}, + {"name", value.data.at("name")}, + {"revision", value.revision}, + {"dirty", sha256(value.data.dump()) != value.saved_hash}, + {"path", value.path.generic_string()}, + {"can_undo", !value.undo.empty()}, + {"can_redo", !value.redo.empty()}}; + if (include_data) + result["scene"] = value.data; return result; } -void AuthoringService::journal(const State& value)const { - atomic_write_json(project_path(root_,std::filesystem::path(".faset/recovery")/(value.data.at("id").get()+".json")),{{"format","faset.recovery"},{"version",1},{"path",value.path.generic_string()},{"revision",value.revision},{"saved_hash",value.saved_hash},{"disk_hash",value.disk_hash},{"scene",value.data}}); +void AuthoringService::journal(const State& value) const { + atomic_write_json(project_path(root_, std::filesystem::path(".faset/recovery") / + (value.data.at("id").get() + ".json")), + {{"format", "faset.recovery"}, + {"version", 1}, + {"path", value.path.generic_string()}, + {"revision", value.revision}, + {"saved_hash", value.saved_hash}, + {"disk_hash", value.disk_hash}, + {"scene", value.data}}); } -Json AuthoringService::create(std::string name,int dimension) { - std::lock_guard lock(mutex_);State value;value.data=make_scene(std::move(name),dimension);journal(value); - const auto id=value.data["id"].get();documents_.emplace(id,std::move(value));return summary(state(id)); +Json AuthoringService::create(std::string name, int dimension) { + std::lock_guard lock(mutex_); + State value; + value.data = make_scene(std::move(name), dimension); + journal(value); + const auto id = value.data["id"].get(); + documents_.emplace(id, std::move(value)); + return summary(state(id)); } -Json AuthoringService::open(const std::filesystem::path& relative,bool recover) { - std::lock_guard lock(mutex_);auto path=project_path(root_,relative);Json data=read_json(path);validate_scene(data,schemas_); - const auto id=data.at("id").get(); - if(documents_.contains(id)) {require(state(id).path==relative.lexically_normal(),"document.id_collision","Another open file has the same document ID");return summary(state(id));} - State value;value.data=data;value.path=relative.lexically_normal();value.saved_hash=sha256(data.dump());value.disk_hash=sha256_file(path); - const auto recovery=project_path(root_,std::filesystem::path(".faset/recovery")/(id+".json")); - if(recover&&std::filesystem::exists(recovery)) { - const auto recovered=read_json(recovery); - require(recovered.value("disk_hash",std::string())==value.disk_hash,"recovery.disk_conflict","Scene file changed since recovery was written"); - validate_scene(recovered.at("scene"),schemas_);value.data=recovered.at("scene");value.revision=recovered.value("revision",0u); +Json AuthoringService::open(const std::filesystem::path& relative, bool recover) { + std::lock_guard lock(mutex_); + auto path = project_path(root_, relative); + Json data = read_json(path); + validate_scene(data, schemas_); + const auto id = data.at("id").get(); + if (documents_.contains(id)) { + require(state(id).path == relative.lexically_normal(), "document.id_collision", + "Another open file has the same document ID"); + return recover ? this->recover(id, state(id).revision) : summary(state(id)); } - 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)); + State value; + value.data = data; + value.path = relative.lexically_normal(); + value.saved_hash = sha256(data.dump()); + value.disk_hash = sha256_file(path); + const auto recovery = + project_path(root_, std::filesystem::path(".faset/recovery") / (id + ".json")); + if (recover && std::filesystem::exists(recovery)) { + const auto recovered = read_json(recovery); + require(recovered.value("disk_hash", std::string()) == value.disk_hash, + "recovery.disk_conflict", "Scene file changed since recovery was written"); + validate_scene(recovered.at("scene"), schemas_); + require(recovered.at("scene").at("id") == id, "recovery.id", + "Recovery ID does not match its document"); + 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)); } -Json AuthoringService::query(const std::string& id)const {std::lock_guard lock(mutex_);return summary(state(id));} -Json AuthoringService::documents()const {std::lock_guard lock(mutex_);Json result=Json::array();for(const auto& [id,value]:documents_)result.push_back(summary(value,false));return result;} -void AuthoringService::register_schemas(const Json& manifest) {std::lock_guard lock(mutex_);schemas_.register_schemas(manifest);} -void AuthoringService::apply(Json& scene,const Json& command) { - require(command.is_object()&&command.contains("op")&&command["op"].is_string(),"command.invalid","Command requires an operation name"); - const auto op=command.at("op").get(); - if(op=="entity.create") { - Json value=command.contains("entity")&&command["entity"].is_object()?command["entity"]:make_entity(schemas_,command.value("name",std::string("Object")),command.value("parent",std::string())); - if(!value.contains("id")) value["id"]=new_id(); +Json AuthoringService::query(const std::string& id) const { + std::lock_guard lock(mutex_); + return summary(state(id)); +} +Json AuthoringService::documents() const { + std::lock_guard lock(mutex_); + Json result = Json::array(); + for (const auto& [id, value] : documents_) + result.push_back(summary(value, false)); + return result; +} +void AuthoringService::register_schemas(const Json& manifest) { + std::lock_guard lock(mutex_); + schemas_.register_schemas(manifest); +} +void AuthoringService::replace_external_schemas(const Json& manifest) { + std::lock_guard lock(mutex_); + auto candidate = builtin_schemas(); + candidate.register_schemas(manifest); + schemas_ = std::move(candidate); +} +void AuthoringService::apply(Json& scene, const Json& command) { + require(command.is_object() && command.contains("op") && command["op"].is_string(), + "command.invalid", "Command requires an operation name"); + const auto op = command.at("op").get(); + if (op == "entity.create") { + Json value = command.contains("entity") && command["entity"].is_object() + ? command["entity"] + : make_entity(schemas_, command.value("name", std::string("Object")), + command.value("parent", std::string())); + if (!value.contains("id")) + value["id"] = new_id(); scene["entities"].push_back(std::move(value)); - } else if(op=="entity.rename") { - entity(scene,command.at("entity").get())["name"]=command.at("name"); - } else if(op=="entity.delete") { - const auto id=command.at("entity").get();entity(scene,id); - std::set removed{id};bool changed=true; - while(changed) {changed=false;for(const auto& item:scene["entities"])if(removed.contains(parent_id(item)))changed=removed.insert(item.at("id").get()).second||changed;} - auto& values=scene["entities"];values.erase(std::remove_if(values.begin(),values.end(),[&](const Json& value){return removed.contains(value.at("id").get());}),values.end()); - } else if(op=="entity.reparent") { - auto& value=entity(scene,command.at("entity").get()); - require(!command.value("keep_world",false),"transform.unsupported","World-preserving reparent requires the transform resolver"); - const auto parent=command.value("parent",Json(nullptr));if(!parent.is_null())entity(scene,parent.get());value["parent"]=parent; - } else if(op=="component.add") { - auto& value=entity(scene,command.at("entity").get());const auto type=command.at("type").get(); - const auto metadata=schemas_.schema(type);Json fields=schemas_.default_fields(type);if(command.contains("fields"))fields.update(command["fields"]); - value["components"].push_back({{"id",command.value("id",new_id())},{"type",type},{"version",metadata.value("version",1)},{"fields",fields}}); - } else if(op=="component.remove") { - auto& values=entity(scene,command.at("entity").get())["components"];const auto id=command.at("component").get(); - auto found=std::find_if(values.begin(),values.end(),[&](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.set") { - auto& value=component(entity(scene,command.at("entity").get()),command.at("component").get()); - const auto field=command.at("field").get();require(!field.empty(),"field.invalid","FieldId cannot be empty");value["fields"][field]=command.at("value"); - } else if(op=="entity.duplicate") { - const auto id=command.at("entity").get();entity(scene,id); - std::set subtree{id};bool changed=true; - while(changed) {changed=false;for(const auto& item:scene["entities"])if(subtree.contains(parent_id(item)))changed=subtree.insert(item.at("id").get()).second||changed;} - std::map mapping; - for(const auto& item:scene["entities"])if(subtree.contains(item.at("id").get())) {mapping[item.at("id")]=new_id();for(const auto& component:item["components"])mapping[component.at("id")]=new_id();} - Json duplicates=Json::array(); - for(const auto& item:scene["entities"])if(subtree.contains(item.at("id").get())) { - auto copy=item;copy["id"]=mapping.at(item.at("id").get());const auto parent=parent_id(item);if(mapping.contains(parent))copy["parent"]=mapping.at(parent); - if(item.at("id")==id)copy["name"]=item.at("name").get()+" Copy"; - for(auto& component:copy["components"]) { - component["id"]=mapping.at(component.at("id").get());const auto type=component.at("type").get(); - if(!schemas_.contains(type))continue; - const auto metadata=schemas_.schema(type); - for(auto& [field,value]:component["fields"].items())if(metadata["fields"].contains(field)&&metadata["fields"][field].value("type",std::string())=="entity_ref"&&value.is_string()&&mapping.contains(value.get()))value=mapping.at(value.get()); - } - duplicates.push_back(std::move(copy)); + } else if (op == "entity.rename") { + entity(scene, command.at("entity").get())["name"] = command.at("name"); + } else if (op == "entity.delete") { + const auto id = command.at("entity").get(); + entity(scene, id); + std::set removed{id}; + bool changed = true; + while (changed) { + changed = false; + for (const auto& item : scene["entities"]) + if (removed.contains(parent_id(item))) + changed = removed.insert(item.at("id").get()).second || changed; } - for(auto& value:duplicates)scene["entities"].push_back(std::move(value)); - } else if(op=="scene.rename")scene["name"]=command.at("name"); - else if(op=="template.instance") { - Json value=command.at("instance");if(!value.contains("id"))value["id"]=new_id();if(!scene.contains("instances"))scene["instances"]=Json::array();scene["instances"].push_back(std::move(value)); - } else if(op=="template.override"||op=="template.revert"||op=="template.suppress"||op=="template.add"||op=="template.reparent") { - auto& instances=scene["instances"];const auto id=command.at("instance").get(); - auto found=std::find_if(instances.begin(),instances.end(),[&](const Json& value){return value.at("id")==id;});require(found!=instances.end(),"template.missing","Instance not found"); - const std::string key=op=="template.suppress"?"suppressed":op=="template.add"?"additions":op=="template.reparent"?"reparents":"overrides"; - if(!found->contains(key)) (*found)[key]=Json::array(); - auto& records=(*found)[key]; - if(key=="overrides") { - const auto address=command.at("address"); - auto old=std::find_if(records.begin(),records.end(),[&](const Json& value){return value.at("address")==address;}); - if(old!=records.end())records.erase(old); - if(op!="template.revert")records.push_back({{"address",address},{"value",command.at("value")}}); - } else records.push_back(command.at("value")); - } else throw Error("command.unknown","Unknown authoring command: "+op); + auto& values = scene["entities"]; + values.erase(std::remove_if(values.begin(), values.end(), + [&](const Json& value) { + return removed.contains(value.at("id").get()); + }), + values.end()); + } else if (op == "entity.reparent") { + reparent_entity(scene, command.at("entity").get(), + command.value("parent", Json(nullptr)), command.value("keep_world", false)); + } else if (op == "component.add") { + auto& value = entity(scene, command.at("entity").get()); + const auto type = command.at("type").get(); + const auto metadata = schemas_.schema(type); + Json fields = schemas_.default_fields(type); + if (command.contains("fields")) + fields.update(command["fields"]); + value["components"].push_back({{"id", command.value("id", new_id())}, + {"type", type}, + {"version", metadata.value("version", 1)}, + {"fields", fields}}); + } else if (op == "component.remove") { + auto& values = entity(scene, command.at("entity").get())["components"]; + const auto id = command.at("component").get(); + auto found = std::find_if(values.begin(), values.end(), + [&](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.set") { + auto& value = component(entity(scene, command.at("entity").get()), + command.at("component").get()); + const auto field = command.at("field").get(); + require(!field.empty(), "field.invalid", "FieldId cannot be empty"); + value["fields"][field] = command.at("value"); + } else if (op == "entity.duplicate") { + const auto id = command.at("entity").get(); + entity(scene, id); + std::set subtree{id}; + bool changed = true; + while (changed) { + changed = false; + for (const auto& item : scene["entities"]) + if (subtree.contains(parent_id(item))) + changed = subtree.insert(item.at("id").get()).second || changed; + } + std::map mapping; + for (const auto& item : scene["entities"]) + if (subtree.contains(item.at("id").get())) { + mapping[item.at("id")] = new_id(); + for (const auto& component : item["components"]) + mapping[component.at("id")] = new_id(); + } + Json duplicates = Json::array(); + for (const auto& item : scene["entities"]) + if (subtree.contains(item.at("id").get())) { + auto copy = item; + copy["id"] = mapping.at(item.at("id").get()); + const auto parent = parent_id(item); + if (mapping.contains(parent)) + copy["parent"] = mapping.at(parent); + if (item.at("id") == id) + copy["name"] = item.at("name").get() + " Copy"; + for (auto& component : copy["components"]) { + component["id"] = mapping.at(component.at("id").get()); + const auto type = component.at("type").get(); + if (!schemas_.contains(type)) + continue; + const auto metadata = schemas_.schema(type); + for (auto& [field, value] : component["fields"].items()) + if (metadata["fields"].contains(field) && + metadata["fields"][field].value("type", std::string()) == + "entity_ref" && + value.is_string() && mapping.contains(value.get())) + value = mapping.at(value.get()); + } + duplicates.push_back(std::move(copy)); + } + for (auto& value : duplicates) + scene["entities"].push_back(std::move(value)); + } else if (op == "scene.rename") + scene["name"] = command.at("name"); + else if (op == "template.instance") { + Json value = command.at("instance"); + if (!value.contains("id")) + value["id"] = new_id(); + if (!scene.contains("instances")) + scene["instances"] = Json::array(); + scene["instances"].push_back(std::move(value)); + } else if (op == "template.override" || op == "template.revert" || op == "template.suppress" || + op == "template.add" || op == "template.reparent") { + auto& instances = scene["instances"]; + const auto id = command.at("instance").get(); + auto found = std::find_if(instances.begin(), instances.end(), + [&](const Json& value) { return value.at("id") == id; }); + require(found != instances.end(), "template.missing", "Instance not found"); + const std::string key = op == "template.suppress" ? "suppressed" + : op == "template.add" ? "additions" + : op == "template.reparent" ? "reparents" + : "overrides"; + if (!found->contains(key)) + (*found)[key] = Json::array(); + auto& records = (*found)[key]; + if (key == "overrides") { + const auto address = command.at("address"); + auto old = std::find_if(records.begin(), records.end(), [&](const Json& value) { + return value.at("address") == address; + }); + if (old != records.end()) + records.erase(old); + if (op != "template.revert") + records.push_back({{"address", address}, {"value", command.at("value")}}); + } else + records.push_back(command.at("value")); + } else + throw Error("command.unknown", "Unknown authoring command: " + op); } -Json AuthoringService::transact(const std::string& id,std::uint64_t revision,const Json& operations,const std::string& key) { - std::lock_guard lock(mutex_);auto& current=state(id);require(operations.is_array()&&!operations.empty(),"transaction.empty","Transaction requires an array of operations"); - const auto fingerprint=sha256(Json{{"revision",revision},{"operations",operations}}.dump()); - if(!key.empty()&¤t.requests.contains(key)) { - const auto& request=current.requests.at(key);require(request.first==fingerprint,"idempotency.conflict","Idempotency key was used with another payload");return request.second; +Json AuthoringService::transact(const std::string& id, std::uint64_t revision, + const Json& operations, const std::string& key) { + std::lock_guard lock(mutex_); + auto& current = state(id); + require(operations.is_array() && !operations.empty(), "transaction.empty", + "Transaction requires an array of operations"); + const auto fingerprint = + sha256(Json{{"revision", revision}, {"operations", operations}}.dump()); + if (!key.empty() && current.requests.contains(key)) { + const auto& request = current.requests.at(key); + require(request.first == fingerprint, "idempotency.conflict", + "Idempotency key was used with another payload"); + return request.second; } - check_revision(current.revision,revision);State candidate=current; - for(const auto& operation:operations)apply(candidate.data,operation); - validate_scene(candidate.data,schemas_); - candidate.undo.push_back(current.data);if(candidate.undo.size()>100)candidate.undo.erase(candidate.undo.begin());candidate.redo.clear();++candidate.revision; - journal(candidate);auto result=summary(candidate); - if(!key.empty()) {if(candidate.requests.size()>=256)candidate.requests.erase(candidate.requests.begin());candidate.requests[key]={fingerprint,result};} - current=std::move(candidate);return result; -} -Json AuthoringService::history(const std::string& id,std::uint64_t revision,bool forward) { - std::lock_guard lock(mutex_);auto& current=state(id);check_revision(current.revision,revision);State candidate=current; - auto& source=forward?candidate.redo:candidate.undo;auto& target=forward?candidate.undo:candidate.redo; - require(!source.empty(),"history.empty",forward?"Nothing to redo":"Nothing to undo");target.push_back(candidate.data);candidate.data=source.back();source.pop_back();++candidate.revision;journal(candidate);current=std::move(candidate);return summary(current); -} -Json AuthoringService::undo(const std::string& id,std::uint64_t revision){return history(id,revision,false);} -Json AuthoringService::redo(const std::string& id,std::uint64_t revision){return history(id,revision,true);} -Json AuthoringService::save(const std::string& id,const std::filesystem::path& relative) { - std::lock_guard lock(mutex_);auto& current=state(id);const auto selected=relative.empty()?current.path:relative.lexically_normal();require(!selected.empty(),"save.path","Choose a scene path before saving"); - const auto path=project_path(root_,selected); - if(std::filesystem::exists(path)) { - require(selected==current.path&&!current.disk_hash.empty(),"save.exists","Save As will not overwrite another file"); - require(sha256_file(path)==current.disk_hash,"save.disk_conflict","File changed outside the Editor; reload or save to another path"); + check_revision(current.revision, revision); + State candidate = current; + for (const auto& operation : operations) + apply(candidate.data, operation); + validate_scene(candidate.data, schemas_); + candidate.undo.push_back(current.data); + if (candidate.undo.size() > 100) + candidate.undo.erase(candidate.undo.begin()); + candidate.redo.clear(); + ++candidate.revision; + journal(candidate); + auto result = summary(candidate); + if (!key.empty()) { + if (candidate.requests.size() >= 256) + candidate.requests.erase(candidate.requests.begin()); + candidate.requests[key] = {fingerprint, result}; } - atomic_write_json(path,current.data);current.path=selected;current.saved_hash=sha256(current.data.dump());current.disk_hash=sha256_file(path); - journal(current);return summary(current); + current = std::move(candidate); + return result; } -Json AuthoringService::recovery_documents()const { - std::lock_guard lock(mutex_);Json result=Json::array();const auto path=project_path(root_,".faset/recovery");if(!std::filesystem::exists(path))return result; - for(const auto& entry:std::filesystem::directory_iterator(path))if(entry.is_regular_file()&&entry.path().extension()==".json") { - try {const auto value=read_json(entry.path());result.push_back({{"id",value.at("scene").at("id")},{"name",value.at("scene").at("name")},{"path",value.at("path")},{"dirty",sha256(value.at("scene").dump())!=value.value("saved_hash",std::string())}});}catch(const std::exception&) {result.push_back({{"error","Invalid recovery record"},{"file",entry.path().filename().string()}});} - }return result; +Json AuthoringService::history(const std::string& id, std::uint64_t revision, bool forward) { + std::lock_guard lock(mutex_); + auto& current = state(id); + check_revision(current.revision, revision); + State candidate = current; + auto& source = forward ? candidate.redo : candidate.undo; + auto& target = forward ? candidate.undo : candidate.redo; + require(!source.empty(), "history.empty", forward ? "Nothing to redo" : "Nothing to undo"); + target.push_back(candidate.data); + candidate.data = source.back(); + source.pop_back(); + ++candidate.revision; + journal(candidate); + current = std::move(candidate); + return summary(current); } +Json AuthoringService::undo(const std::string& id, std::uint64_t revision) { + return history(id, revision, false); } +Json AuthoringService::redo(const std::string& id, std::uint64_t revision) { + return history(id, revision, true); +} +Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative) { + std::lock_guard lock(mutex_); + auto& current = state(id); + const auto selected = relative.empty() ? current.path : relative.lexically_normal(); + require(!selected.empty(), "save.path", "Choose a scene path before saving"); + const auto path = project_path(root_, selected); + if (std::filesystem::exists(path)) { + require(selected == current.path && !current.disk_hash.empty(), "save.exists", + "Save As will not overwrite another file"); + require(sha256_file(path) == current.disk_hash, "save.disk_conflict", + "File changed outside the Editor; reload or save to another path"); + } + atomic_write_json(path, current.data); + current.path = selected; + current.saved_hash = sha256(current.data.dump()); + current.disk_hash = sha256_file(path); + journal(current); + return summary(current); +} +Json AuthoringService::recover(const std::string& id, + std::optional expected_revision) { + std::lock_guard lock(mutex_); + require(valid_id(Json(id)), "id.invalid", "Invalid recovery document ID"); + const auto record = + read_json(project_path(root_, std::filesystem::path(".faset/recovery") / (id + ".json"))); + require(record.value("format", std::string()) == "faset.recovery" && + record.value("version", 0) == 1, + "recovery.format", "Unsupported recovery record"); + State candidate; + candidate.data = record.at("scene"); + validate_scene(candidate.data, schemas_); + require(candidate.data.at("id") == id, "recovery.id", + "Recovery ID does not match its document"); + candidate.path = record.at("path").get(); + candidate.saved_hash = record.value("saved_hash", std::string()); + candidate.disk_hash = record.value("disk_hash", std::string()); + candidate.revision = record.value("revision", std::uint64_t(0)); + if (!candidate.path.empty()) { + const auto disk = project_path(root_, candidate.path); + require(std::filesystem::exists(disk) && sha256_file(disk) == candidate.disk_hash, + "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); + require(expected_revision.has_value(), "recovery.revision_required", + "Recovering an open document requires its current revision"); + check_revision(current.revision, *expected_revision); + require(current.path == candidate.path, "document.id_collision", + "Recovery path differs from the open document"); + if (candidate.data == current.data) + return summary(current); + candidate.undo = current.undo; + candidate.undo.push_back(current.data); + candidate.revision = std::max(current.revision, candidate.revision) + 1; + } else if (!candidate.path.empty()) + candidate.undo.push_back(read_json(project_path(root_, candidate.path))); + journal(candidate); + documents_[id] = std::move(candidate); + return summary(state(id)); +} +Json AuthoringService::recovery_documents() const { + std::lock_guard lock(mutex_); + Json result = Json::array(); + const auto path = project_path(root_, ".faset/recovery"); + if (!std::filesystem::exists(path)) + return result; + for (const auto& entry : std::filesystem::directory_iterator(path)) + if (entry.is_regular_file() && entry.path().extension() == ".json") { + try { + const auto value = read_json(entry.path()); + result.push_back({{"id", value.at("scene").at("id")}, + {"name", value.at("scene").at("name")}, + {"path", value.at("path")}, + {"dirty", sha256(value.at("scene").dump()) != + value.value("saved_hash", std::string())}}); + } catch (const std::exception&) { + result.push_back({{"error", "Invalid recovery record"}, + {"file", entry.path().filename().string()}}); + } + } + return result; +} +} // namespace faset::authoring diff --git a/src/authoring/templates.cpp b/src/authoring/templates.cpp index a845ad9..4843955 100644 --- a/src/authoring/templates.cpp +++ b/src/authoring/templates.cpp @@ -1,99 +1,199 @@ -#include -#include -#include #include +#include +#include +#include +#include #include namespace faset::authoring { namespace { -std::string scoped_id(const std::string& root,const Json& path,const std::string& source) { - const auto digest=sha256(Json::array({root,path,source}).dump()); - return digest.substr(0,8)+"-"+digest.substr(8,4)+"-5"+digest.substr(13,3)+"-a"+digest.substr(17,3)+"-"+digest.substr(20,12); +std::string scoped_id(const std::string& root, const Json& path, const std::string& source) { + const auto digest = sha256(Json::array({root, path, source}).dump()); + return digest.substr(0, 8) + "-" + digest.substr(8, 4) + "-5" + digest.substr(13, 3) + "-a" + + digest.substr(17, 3) + "-" + digest.substr(20, 12); } struct Resolver { const SchemaRegistry& schemas; const SceneLoader& loader; std::string root; - Json conflicts=Json::array(); + Json conflicts = Json::array(); 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}});} - Json* target(Json& entities,const Json& path,const Json& address) { - Json full=path;for(const auto& entry:address.value("path",Json::array()))full.push_back(entry); - for(auto& item:entities)if(item.at("origin").at("path")==full&&item.at("origin").at("object")==address.at("object"))return &item; + void conflict(const Json& path, std::string code, const Json& record) { + conflicts.push_back( + {{"instance_path", path}, {"code", std::move(code)}, {"record", record}}); + } + Json* target(Json& entities, const Json& path, const Json& address) { + Json full = path; + for (const auto& entry : address.value("path", Json::array())) + full.push_back(entry); + for (auto& item : entities) + if (item.at("origin").at("path") == full && + item.at("origin").at("object") == address.at("object")) + return &item; return nullptr; } - Json expand(const Json& scene,const Json& path) { - require(path.size()<=32,"template.depth","Maximum template nesting depth exceeded"); - validate_scene(scene,schemas); - Json output=Json::array();std::map ids; - for(const auto& item:scene["entities"]) { - const auto id=item.at("id").get();ids[id]=path.empty()?id:scoped_id(root,path,id); - for(const auto& component:item["components"]) {const auto cid=component.at("id").get();ids[cid]=path.empty()?cid:scoped_id(root,path,cid);} + Json expand(const Json& scene, const Json& path) { + require(path.size() <= 32, "template.depth", "Maximum template nesting depth exceeded"); + validate_scene(scene, schemas); + Json output = Json::array(); + std::map ids; + for (const auto& item : scene["entities"]) { + const auto id = item.at("id").get(); + ids[id] = path.empty() ? id : scoped_id(root, path, id); + for (const auto& component : item["components"]) { + const auto cid = component.at("id").get(); + ids[cid] = path.empty() ? cid : scoped_id(root, path, cid); + } } - for(const auto& source:scene["entities"]) { - Json item=source;item["id"]=ids.at(source.at("id").get()); - item["origin"]={{"path",path},{"object",source.at("id")},{"scene",scene.at("id")}}; - if(source.contains("parent")&&!source["parent"].is_null())item["parent"]=ids.at(source["parent"].get()); - for(auto& component:item["components"]) { - const auto source_id=component.at("id").get();component["id"]=ids.at(source_id);component["source_id"]=source_id; - const auto type=component.at("type").get();if(!schemas.contains(type))continue; - const auto metadata=schemas.schema(type); - for(auto& [field,value]:component["fields"].items())if(metadata["fields"].contains(field)&&metadata["fields"][field].value("type",std::string())=="entity_ref"&&value.is_string()&&ids.contains(value.get()))value=ids.at(value.get()); + for (const auto& source : scene["entities"]) { + Json item = source; + item["id"] = ids.at(source.at("id").get()); + item["origin"] = { + {"path", path}, {"object", source.at("id")}, {"scene", scene.at("id")}}; + if (source.contains("parent") && !source["parent"].is_null()) + item["parent"] = ids.at(source["parent"].get()); + for (auto& component : item["components"]) { + const auto source_id = component.at("id").get(); + component["id"] = ids.at(source_id); + component["source_id"] = source_id; + const auto type = component.at("type").get(); + if (!schemas.contains(type)) + continue; + const auto metadata = schemas.schema(type); + for (auto& [field, value] : component["fields"].items()) + if (metadata["fields"].contains(field) && + metadata["fields"][field].value("type", std::string()) == "entity_ref" && + value.is_string() && ids.contains(value.get())) + value = ids.at(value.get()); } output.push_back(std::move(item)); } - for(const auto& instance:scene.value("instances",Json::array())) { - Json nested_path=path;nested_path.push_back(instance.at("id"));const auto source_name=instance.at("source").get(); - Json expanded=Json::array();std::string source_id; + for (const auto& instance : scene.value("instances", Json::array())) { + Json nested_path = path; + nested_path.push_back(instance.at("id")); + const auto source_name = instance.at("source").get(); + Json expanded = Json::array(); + std::string source_id; + bool inserted = false; try { - const auto source=loader(source_name);source_id=source.at("id").get(); - require(sources.insert(source_id).second,"template.cycle","Template source cycle detected"); - expanded=expand(source,nested_path);sources.erase(source_id); - } catch(const std::exception& error) { - if(!source_id.empty())sources.erase(source_id); - conflict(nested_path,"template.source_unavailable",{{"source",source_name},{"message",error.what()}});continue; + const auto source = loader(source_name); + source_id = source.at("id").get(); + inserted = sources.insert(source_id).second; + require(inserted, "template.cycle", "Template source cycle detected"); + expanded = expand(source, nested_path); + sources.erase(source_id); + } catch (const std::exception& error) { + if (inserted) + sources.erase(source_id); + conflict(nested_path, "template.source_unavailable", + {{"source", source_name}, {"message", error.what()}}); + continue; } - for(const auto& addition:instance.value("additions",Json::array())) { - Json item=addition;const auto id=item.at("id").get();item["id"]=scoped_id(root,nested_path,id); - item["origin"]={{"path",nested_path},{"object",id},{"local",true}}; - if(item.contains("parent")&&!item["parent"].is_null())item["parent"]=scoped_id(root,nested_path,item["parent"].get()); - for(auto& component:item["components"]) {const auto cid=component.at("id").get();component["source_id"]=cid;component["id"]=scoped_id(root,nested_path,cid);} + for (const auto& addition : instance.value("additions", Json::array())) { + Json item = addition; + const auto id = item.at("id").get(); + item["id"] = scoped_id(root, nested_path, id); + item["origin"] = {{"path", nested_path}, {"object", id}, {"local", true}}; + if (item.contains("parent") && !item["parent"].is_null()) + item["parent"] = + scoped_id(root, nested_path, item["parent"].get()); + for (auto& component : item["components"]) { + const auto cid = component.at("id").get(); + component["source_id"] = cid; + component["id"] = scoped_id(root, nested_path, cid); + } expanded.push_back(std::move(item)); } - for(const auto& change:instance.value("overrides",Json::array())) { - const auto& address=change.at("address");auto* item=target(expanded,nested_path,address); - if(!item){conflict(nested_path,"override.object_missing",change);continue;} - auto found=std::find_if((*item)["components"].begin(),(*item)["components"].end(),[&](const Json& value){return value.at("source_id")==address.at("component");}); - if(found==(*item)["components"].end()){conflict(nested_path,"override.component_missing",change);continue;} - const auto type=found->at("type").get();const auto field=address.at("field").get(); - if(!schemas.contains(type)||!schemas.schema(type)["fields"].contains(field)){conflict(nested_path,"override.field_unavailable",change);continue;} - try {validate_field(change.at("value"),schemas.schema(type)["fields"][field]);(*found)["fields"][field]=change.at("value");} - catch(const std::exception& error){conflict(nested_path,"override.invalid",{{"change",change},{"message",error.what()}});} + for (const auto& change : instance.value("overrides", Json::array())) { + const auto& address = change.at("address"); + auto* item = target(expanded, nested_path, address); + if (!item) { + conflict(nested_path, "override.object_missing", change); + continue; + } + auto found = + std::find_if((*item)["components"].begin(), (*item)["components"].end(), + [&](const Json& value) { + return value.at("source_id") == address.at("component"); + }); + if (found == (*item)["components"].end()) { + conflict(nested_path, "override.component_missing", change); + continue; + } + const auto type = found->at("type").get(); + const auto field = address.at("field").get(); + if (!schemas.contains(type) || !schemas.schema(type)["fields"].contains(field)) { + conflict(nested_path, "override.field_unavailable", change); + continue; + } + try { + validate_field(change.at("value"), schemas.schema(type)["fields"][field]); + (*found)["fields"][field] = change.at("value"); + } catch (const std::exception& error) { + conflict(nested_path, "override.invalid", + {{"change", change}, {"message", error.what()}}); + } } std::set suppressed; - for(const auto& address:instance.value("suppressed",Json::array())) { - auto* item=target(expanded,nested_path,address);if(item)suppressed.insert(item->at("id").get());else conflict(nested_path,"suppression.object_missing",address); + for (const auto& address : instance.value("suppressed", Json::array())) { + auto* item = target(expanded, nested_path, address); + if (item) + suppressed.insert(item->at("id").get()); + else + conflict(nested_path, "suppression.object_missing", address); } - bool changed=true; - while(changed) {changed=false;for(const auto& item:expanded)if(item.contains("parent")&&item["parent"].is_string()&&suppressed.contains(item["parent"].get()))changed=suppressed.insert(item.at("id").get()).second||changed;} - expanded.erase(std::remove_if(expanded.begin(),expanded.end(),[&](const Json& item){return suppressed.contains(item.at("id").get());}),expanded.end()); - for(const auto& reparent:instance.value("reparents",Json::array())) { - auto* item=target(expanded,nested_path,reparent.at("object")); - auto* parent=reparent.at("parent").is_null()?nullptr:target(expanded,nested_path,reparent.at("parent")); - if(!item||(!reparent.at("parent").is_null()&&!parent)){conflict(nested_path,"reparent.target_missing",reparent);continue;} - if(reparent.value("keep_world",false)){conflict(nested_path,"reparent.world_transform_required",reparent);continue;} - (*item)["parent"]=parent?parent->at("id"):Json(nullptr); + bool changed = true; + while (changed) { + changed = false; + for (const auto& item : expanded) + if (item.contains("parent") && item["parent"].is_string() && + suppressed.contains(item["parent"].get())) + changed = + suppressed.insert(item.at("id").get()).second || changed; } - for(auto& item:expanded)output.push_back(std::move(item)); - require(output.size()<=100000,"template.size","Resolved scene exceeds object limit"); + expanded.erase(std::remove_if(expanded.begin(), expanded.end(), + [&](const Json& item) { + return suppressed.contains( + item.at("id").get()); + }), + expanded.end()); + for (const auto& reparent : instance.value("reparents", Json::array())) { + auto* item = target(expanded, nested_path, reparent.at("object")); + auto* parent = reparent.at("parent").is_null() + ? nullptr + : target(expanded, nested_path, reparent.at("parent")); + if (!item || (!reparent.at("parent").is_null() && !parent)) { + conflict(nested_path, "reparent.target_missing", reparent); + continue; + } + const auto id = item->at("id").get(); + const Json parent_id = parent ? parent->at("id") : Json(nullptr); + Json temporary = {{"entities", expanded}}; + try { + reparent_entity(temporary, id, parent_id, reparent.value("keep_world", false)); + expanded = temporary["entities"]; + } catch (const std::exception& error) { + conflict(nested_path, "reparent.transform_conflict", + {{"record", reparent}, {"message", error.what()}}); + } + } + for (auto& item : expanded) + output.push_back(std::move(item)); + require(output.size() <= 100000, "template.size", + "Resolved scene exceeds object limit"); } return output; } }; +} // namespace +ResolvedScene resolve_templates(const Json& scene, const SchemaRegistry& schemas, + const SceneLoader& loader) { + Resolver resolver{schemas, loader, scene.at("id").get()}; + resolver.sources.insert(scene.at("id").get()); + Json output = scene; + output["entities"] = resolver.expand(scene, Json::array()); + output["instances"] = Json::array(); + validate_scene(output, schemas); + return {output, resolver.conflicts}; } -ResolvedScene resolve_templates(const Json& scene,const SchemaRegistry& schemas,const SceneLoader& loader) { - Resolver resolver{schemas,loader,scene.at("id").get()};resolver.sources.insert(scene.at("id").get()); - Json output=scene;output["entities"]=resolver.expand(scene,Json::array());output["instances"]=Json::array(); - validate_scene(output,schemas);return {output,resolver.conflicts}; -} -} +} // namespace faset::authoring diff --git a/src/authoring/transforms.cpp b/src/authoring/transforms.cpp new file mode 100644 index 0000000..1b1b73f --- /dev/null +++ b/src/authoring/transforms.cpp @@ -0,0 +1,156 @@ +#include +#include +#include +#include +#include +#include + +namespace faset::authoring { +namespace { +using Matrix = std::array; +constexpr Matrix identity{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; +Json& find_entity(Json& scene, const std::string& id) { + for (auto& item : scene["entities"]) + if (item.at("id") == id) + return item; + throw Error("entity.missing", "Entity does not exist: " + id); +} +Json* transform(Json& entity) { + for (auto& component : entity["components"]) + if (component.at("type") == "faset.transform") + return &component; + return nullptr; +} +Matrix multiply(const Matrix& a, const Matrix& b) { + Matrix result{}; + for (int row = 0; row < 4; ++row) + for (int column = 0; column < 4; ++column) + for (int k = 0; k < 4; ++k) + result[column * 4 + row] += a[k * 4 + row] * b[column * 4 + k]; + return result; +} +Matrix local(Json& entity) { + const auto* component = transform(entity); + if (!component) + return identity; + const auto& fields = component->at("fields"); + const auto p = fields.value("position", std::array{0, 0, 0}), + r = fields.value("rotation", std::array{0, 0, 0}), + s = fields.value("scale", std::array{1, 1, 1}); + const auto cx = std::cos(r[0]), sx = std::sin(r[0]), cy = std::cos(r[1]), sy = std::sin(r[1]), + cz = std::cos(r[2]), sz = std::sin(r[2]); + return {cz * cy * s[0], + sz * cy * s[0], + -sy * s[0], + 0, + (cz * sy * sx - sz * cx) * s[1], + (sz * sy * sx + cz * cx) * s[1], + cy * sx * s[1], + 0, + (cz * sy * cx + sz * sx) * s[2], + (sz * sy * cx - cz * sx) * s[2], + cy * cx * s[2], + 0, + p[0], + p[1], + p[2], + 1}; +} +Matrix world(Json& scene, const std::string& id, std::set& visited) { + require(visited.insert(id).second, "entity.cycle", "Hierarchy contains a cycle"); + auto& item = find_entity(scene, id); + auto result = local(item); + if (item.contains("parent") && !item["parent"].is_null()) + result = multiply(world(scene, item["parent"].get(), visited), result); + return result; +} +Matrix world(Json& scene, const std::string& id) { + std::set visited; + return world(scene, id, visited); +} +Matrix inverse(const Matrix& matrix) { + std::array, 4> rows{}; + for (int r = 0; r < 4; ++r) { + for (int c = 0; c < 4; ++c) + rows[r][c] = matrix[c * 4 + r]; + rows[r][r + 4] = 1; + } + for (int column = 0; column < 4; ++column) { + int pivot = column; + for (int row = column + 1; row < 4; ++row) + if (std::abs(rows[row][column]) > std::abs(rows[pivot][column])) + pivot = row; + require(std::abs(rows[pivot][column]) > 1e-12, "transform.singular", + "Cannot preserve world transform under a non-invertible parent"); + std::swap(rows[pivot], rows[column]); + const auto scale = rows[column][column]; + for (auto& entry : rows[column]) + entry /= scale; + for (int row = 0; row < 4; ++row) + if (row != column) { + const auto factor = rows[row][column]; + for (int c = 0; c < 8; ++c) + rows[row][c] -= factor * rows[column][c]; + } + } + Matrix result{}; + for (int row = 0; row < 4; ++row) + for (int column = 0; column < 4; ++column) + result[column * 4 + row] = rows[row][column + 4]; + return result; +} +Json decompose(const Matrix& matrix) { + std::array scale{}; + Matrix rotation = matrix; + for (int c = 0; c < 3; ++c) { + scale[c] = std::sqrt(matrix[c * 4] * matrix[c * 4] + matrix[c * 4 + 1] * matrix[c * 4 + 1] + + matrix[c * 4 + 2] * matrix[c * 4 + 2]); + require(scale[c] > 1e-12, "transform.singular", "Cannot decompose zero scale"); + } + const double determinant = matrix[0] * (matrix[5] * matrix[10] - matrix[9] * matrix[6]) - + matrix[4] * (matrix[1] * matrix[10] - matrix[9] * matrix[2]) + + matrix[8] * (matrix[1] * matrix[6] - matrix[5] * matrix[2]); + if (determinant < 0) + scale[0] = -scale[0]; + for (int c = 0; c < 3; ++c) + for (int r = 0; r < 3; ++r) + rotation[c * 4 + r] /= scale[c]; + for (int a = 0; a < 3; ++a) + for (int b = a + 1; b < 3; ++b) { + double dot = 0; + for (int r = 0; r < 3; ++r) + dot += rotation[a * 4 + r] * rotation[b * 4 + r]; + require(std::abs(dot) < 1e-5, "transform.shear", + "Preserving this world transform would require shear; choose keep local or " + "change parent scale"); + } + std::array angles{}; + angles[1] = std::asin(std::clamp(-rotation[2], -1.0, 1.0)); + if (std::abs(std::cos(angles[1])) > 1e-7) { + angles[0] = std::atan2(rotation[6], rotation[10]); + angles[2] = std::atan2(rotation[1], rotation[0]); + } else { + angles[0] = std::atan2(-rotation[9], rotation[5]); + angles[2] = 0; + } + return { + {"position", {matrix[12], matrix[13], matrix[14]}}, {"rotation", angles}, {"scale", scale}}; +} +} // namespace +void reparent_entity(Json& scene, const std::string& id, const Json& parent, bool keep_world) { + auto& item = find_entity(scene, id); + if (!parent.is_null()) + find_entity(scene, parent.get()); + if (keep_world) { + auto* component = transform(item); + require(component != nullptr, "transform.required", + "World-preserving reparent requires a Transform component"); + const auto previous = world(scene, id); + const auto basis = parent.is_null() ? identity : world(scene, parent.get()); + const auto fields = decompose(multiply(inverse(basis), previous)); + for (const auto& [field, value] : fields.items()) + (*component)["fields"][field] = value; + } + item["parent"] = parent; +} +} // namespace faset::authoring diff --git a/src/core/hash.cpp b/src/core/hash.cpp index c48732a..19adefe 100644 --- a/src/core/hash.cpp +++ b/src/core/hash.cpp @@ -1,72 +1,116 @@ -#include -#include #include #include #include +#include +#include #include #include namespace faset { namespace { -constexpr std::array constants = { - 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, - 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, - 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, - 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, - 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, - 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, - 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, - 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 -}; +constexpr std::array constants = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2}; class Digest { - std::array state_{0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19}; - std::array pending_{}; - std::uint64_t count_=0; - std::size_t used_=0; + std::array state_{0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; + std::array pending_{}; + std::uint64_t count_ = 0; + std::size_t used_ = 0; void block() { - std::array w{}; - for (int i=0;i<16;++i) w[i]=(std::uint32_t(pending_[i*4])<<24)|(std::uint32_t(pending_[i*4+1])<<16)|(std::uint32_t(pending_[i*4+2])<<8)|pending_[i*4+3]; - for (int i=16;i<64;++i) { - const auto x=w[i-15],y=w[i-2]; - w[i]=w[i-16]+(std::rotr(x,7)^std::rotr(x,18)^(x>>3))+w[i-7]+(std::rotr(y,17)^std::rotr(y,19)^(y>>10)); + std::array w{}; + for (int i = 0; i < 16; ++i) + w[i] = (std::uint32_t(pending_[i * 4]) << 24) | + (std::uint32_t(pending_[i * 4 + 1]) << 16) | + (std::uint32_t(pending_[i * 4 + 2]) << 8) | pending_[i * 4 + 3]; + for (int i = 16; i < 64; ++i) { + const auto x = w[i - 15], y = w[i - 2]; + w[i] = w[i - 16] + (std::rotr(x, 7) ^ std::rotr(x, 18) ^ (x >> 3)) + w[i - 7] + + (std::rotr(y, 17) ^ std::rotr(y, 19) ^ (y >> 10)); } - auto a=state_[0],b=state_[1],c=state_[2],d=state_[3],e=state_[4],f=state_[5],g=state_[6],h=state_[7]; - for (int i=0;i<64;++i) { - const auto t1=h+(std::rotr(e,6)^std::rotr(e,11)^std::rotr(e,25))+((e&f)^(~e&g))+constants[i]+w[i]; - const auto t2=(std::rotr(a,2)^std::rotr(a,13)^std::rotr(a,22))+((a&b)^(a&c)^(b&c)); - h=g;g=f;f=e;e=d+t1;d=c;c=b;b=a;a=t1+t2; + auto a = state_[0], b = state_[1], c = state_[2], d = state_[3], e = state_[4], + f = state_[5], g = state_[6], h = state_[7]; + for (int i = 0; i < 64; ++i) { + const auto t1 = h + (std::rotr(e, 6) ^ std::rotr(e, 11) ^ std::rotr(e, 25)) + + ((e & f) ^ (~e & g)) + constants[i] + w[i]; + const auto t2 = (std::rotr(a, 2) ^ std::rotr(a, 13) ^ std::rotr(a, 22)) + + ((a & b) ^ (a & c) ^ (b & c)); + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; } - state_[0]+=a;state_[1]+=b;state_[2]+=c;state_[3]+=d;state_[4]+=e;state_[5]+=f;state_[6]+=g;state_[7]+=h; + state_[0] += a; + state_[1] += b; + state_[2] += c; + state_[3] += d; + state_[4] += e; + state_[5] += f; + state_[6] += g; + state_[7] += h; } -public: + + public: void update(std::span bytes) { - count_+=bytes.size(); - for (auto byte:bytes) { - pending_[used_++]=std::to_integer(byte); - if (used_==64) { block();used_=0; } + count_ += bytes.size(); + for (auto byte : bytes) { + pending_[used_++] = std::to_integer(byte); + if (used_ == 64) { + block(); + used_ = 0; + } } } std::string finish() { - const std::uint64_t bits=count_*8; - pending_[used_++]=0x80; - if (used_>56) { while(used_<64) pending_[used_++]=0;block();used_=0; } - while(used_<56) pending_[used_++]=0; - for (int i=7;i>=0;--i) pending_[used_++]=std::uint8_t(bits>>(i*8)); + const std::uint64_t bits = count_ * 8; + pending_[used_++] = 0x80; + if (used_ > 56) { + while (used_ < 64) + pending_[used_++] = 0; + block(); + used_ = 0; + } + while (used_ < 56) + pending_[used_++] = 0; + for (int i = 7; i >= 0; --i) + pending_[used_++] = std::uint8_t(bits >> (i * 8)); block(); - constexpr char hex[]="0123456789abcdef"; - std::string result;result.reserve(64); - for (auto word:state_) for (int i=7;i>=0;--i) result+=hex[(word>>(i*4))&15]; + constexpr char hex[] = "0123456789abcdef"; + std::string result; + result.reserve(64); + for (auto word : state_) + for (int i = 7; i >= 0; --i) + result += hex[(word >> (i * 4)) & 15]; return result; } }; -} -std::string sha256(std::span bytes) { Digest digest;digest.update(bytes);return digest.finish(); } -std::string sha256_file(const std::filesystem::path& path) { - std::ifstream stream(path,std::ios::binary); - require(bool(stream),"io.open","Cannot open file for hashing: "+path.string()); - Digest digest;std::array buffer{}; - while(stream) { stream.read(buffer.data(),buffer.size());digest.update(std::as_bytes(std::span(buffer.data(),static_cast(stream.gcount())))); } - require(stream.eof(),"io.read","Cannot read file for hashing: "+path.string()); +} // namespace +std::string sha256(std::span bytes) { + Digest digest; + digest.update(bytes); return digest.finish(); } +std::string sha256_file(const std::filesystem::path& path) { + std::ifstream stream(path, std::ios::binary); + require(bool(stream), "io.open", "Cannot open file for hashing: " + path.string()); + Digest digest; + std::array buffer{}; + while (stream) { + stream.read(buffer.data(), buffer.size()); + digest.update( + std::as_bytes(std::span(buffer.data(), static_cast(stream.gcount())))); + } + require(stream.eof(), "io.read", "Cannot read file for hashing: " + path.string()); + return digest.finish(); } +} // namespace faset diff --git a/src/core/io.cpp b/src/core/io.cpp index ca3e3b3..9fab6c2 100644 --- a/src/core/io.cpp +++ b/src/core/io.cpp @@ -1,9 +1,9 @@ -#include -#include #include +#include +#include #include -#include #include +#include #ifdef _WIN32 #define NOMINMAX #include @@ -16,57 +16,111 @@ namespace faset { std::string new_id() { static std::mutex mutex; static std::random_device random; - std::array bytes{}; - { std::lock_guard lock(mutex); for(auto& byte:bytes) byte=static_cast(random()); } - bytes[6]=(bytes[6]&0x0f)|0x40;bytes[8]=(bytes[8]&0x3f)|0x80; - constexpr char hex[]="0123456789abcdef"; - std::string result;result.reserve(36); - for(std::size_t i=0;i>4];result+=hex[bytes[i]&15]; } + std::array bytes{}; + { + std::lock_guard lock(mutex); + for (auto& byte : bytes) + byte = static_cast(random()); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + constexpr char hex[] = "0123456789abcdef"; + std::string result; + result.reserve(36); + for (std::size_t i = 0; i < bytes.size(); ++i) { + if (i == 4 || i == 6 || i == 8 || i == 10) + result += '-'; + result += hex[bytes[i] >> 4]; + result += hex[bytes[i] & 15]; + } return result; } std::string read_text(const std::filesystem::path& path) { - std::ifstream stream(path,std::ios::binary); - require(bool(stream),"io.open","Cannot open file: "+path.string()); - std::string value((std::istreambuf_iterator(stream)),{}); - require(!stream.bad(),"io.read","Cannot read file: "+path.string());return value; + std::ifstream stream(path, std::ios::binary); + require(bool(stream), "io.open", "Cannot open file: " + path.string()); + std::string value((std::istreambuf_iterator(stream)), {}); + require(!stream.bad(), "io.read", "Cannot read file: " + path.string()); + return value; } Json read_json(const std::filesystem::path& path) { - try { return Json::parse(read_text(path)); } - catch(const Json::exception& error) { throw Error("format.json","Invalid JSON in "+path.string(),{{"reason",error.what()}}); } + try { + return Json::parse(read_text(path)); + } catch (const Json::exception& error) { + throw Error("format.json", "Invalid JSON in " + path.string(), {{"reason", error.what()}}); + } } -void atomic_write(const std::filesystem::path& path,std::string_view bytes) { - const auto parent=path.has_parent_path()?path.parent_path():std::filesystem::path("."); +void atomic_write(const std::filesystem::path& path, std::string_view bytes) { + const auto parent = path.has_parent_path() ? path.parent_path() : std::filesystem::path("."); std::filesystem::create_directories(parent); - const auto temporary=parent/(path.filename().string()+".tmp-"+new_id()); + const auto temporary = parent / (path.filename().string() + ".tmp-" + new_id()); try { #ifdef _WIN32 - HANDLE file=CreateFileW(temporary.c_str(),GENERIC_WRITE,0,nullptr,CREATE_NEW,FILE_ATTRIBUTE_NORMAL,nullptr); - require(file!=INVALID_HANDLE_VALUE,"io.create","Cannot create temporary file"); - bool ok=true;std::size_t offset=0; - while(offset(std::min(bytes.size()-offset,1u<<30)); if(!WriteFile(file,bytes.data()+offset,count,&written,nullptr)||written==0){ok=false;break;} offset+=written; } - ok=FlushFileBuffers(file)&&ok;CloseHandle(file); - require(ok,"io.write","Cannot flush temporary file"); - require(MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)!=0,"io.replace","Cannot publish file: "+path.string()); + HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, nullptr); + require(file != INVALID_HANDLE_VALUE, "io.create", "Cannot create temporary file"); + bool ok = true; + std::size_t offset = 0; + while (offset < bytes.size()) { + DWORD written = 0; + const auto count = + static_cast(std::min(bytes.size() - offset, 1u << 30)); + if (!WriteFile(file, bytes.data() + offset, count, &written, nullptr) || written == 0) { + ok = false; + break; + } + offset += written; + } + ok = FlushFileBuffers(file) && ok; + CloseHandle(file); + require(ok, "io.write", "Cannot flush temporary file"); + require(MoveFileExW(temporary.c_str(), path.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0, + "io.replace", "Cannot publish file: " + path.string()); #else - const int fd=::open(temporary.c_str(),O_WRONLY|O_CREAT|O_EXCL,0644); - require(fd>=0,"io.create","Cannot create temporary file"); - bool ok=true;std::size_t offset=0; - while(offset(count); } - ok=(::fsync(fd)==0)&&ok;const auto closed=::close(fd);ok=ok&&(closed==0); - require(ok,"io.write","Cannot flush temporary file"); - std::filesystem::rename(temporary,path); - const int directory=::open(parent.c_str(),O_RDONLY|O_DIRECTORY); - if(directory>=0){::fsync(directory);::close(directory);} + const int fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644); + require(fd >= 0, "io.create", "Cannot create temporary file"); + bool ok = true; + std::size_t offset = 0; + while (offset < bytes.size()) { + const auto count = ::write(fd, bytes.data() + offset, bytes.size() - offset); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) { + ok = false; + break; + } + offset += static_cast(count); + } + ok = (::fsync(fd) == 0) && ok; + const auto closed = ::close(fd); + ok = ok && (closed == 0); + require(ok, "io.write", "Cannot flush temporary file"); + std::filesystem::rename(temporary, path); + const int directory = ::open(parent.c_str(), O_RDONLY | O_DIRECTORY); + if (directory >= 0) { + ::fsync(directory); + ::close(directory); + } #endif - } catch(...) { std::error_code ignored;std::filesystem::remove(temporary,ignored);throw; } + } catch (...) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + throw; + } } -void atomic_write_json(const std::filesystem::path& path,const Json& value) { atomic_write(path,value.dump(2)+"\n"); } -std::filesystem::path project_path(const std::filesystem::path& root,const std::filesystem::path& relative) { - require(!relative.is_absolute(),"path.outside_project","Expected a path relative to the project"); - const auto canonical=std::filesystem::weakly_canonical(root); - const auto target=std::filesystem::weakly_canonical(canonical/relative); - auto a=canonical.begin(),b=target.begin(); - for(;a!=canonical.end();++a,++b) require(b!=target.end()&&*a==*b,"path.outside_project","Path escapes the project root"); +void atomic_write_json(const std::filesystem::path& path, const Json& value) { + atomic_write(path, value.dump(2) + "\n"); +} +std::filesystem::path project_path(const std::filesystem::path& root, + const std::filesystem::path& relative) { + require(!relative.is_absolute(), "path.outside_project", + "Expected a path relative to the project"); + const auto canonical = std::filesystem::weakly_canonical(root); + const auto target = std::filesystem::weakly_canonical(canonical / relative); + auto a = canonical.begin(), b = target.begin(); + for (; a != canonical.end(); ++a, ++b) + require(b != target.end() && *a == *b, "path.outside_project", + "Path escapes the project root"); return target; } -} +} // namespace faset diff --git a/src/core/process.cpp b/src/core/process.cpp new file mode 100644 index 0000000..a02cd0c --- /dev/null +++ b/src/core/process.cpp @@ -0,0 +1,427 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#define NOMINMAX +#include +#else +#include +#include +#include +#include +#include +#include +extern char** environ; +#endif + +namespace faset { +namespace { +#ifdef _WIN32 +std::wstring widen(const std::string& value) { + if (value.empty()) + return {}; + int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0); + if (!length) + throw std::runtime_error("Invalid UTF-8 process argument"); + std::wstring result(length, 0); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast(value.size()), + result.data(), length); + return result; +} +std::wstring quote(const std::wstring& value) { + std::wstring result = L"\""; + std::size_t slashes{}; + for (wchar_t c : value) { + if (c == L'\\') { + ++slashes; + continue; + } + if (c == L'\"') { + result.append(slashes * 2 + 1, L'\\'); + result += c; + } else { + result.append(slashes, L'\\'); + result += c; + } + slashes = 0; + } + result.append(slashes * 2, L'\\'); + result += L'\"'; + return result; +} +struct CaseInsensitive { + bool operator()(const std::wstring& a, const std::wstring& b) const { + return _wcsicmp(a.c_str(), b.c_str()) < 0; + } +}; +#else +std::filesystem::path resolve_program(const std::string& name, const std::string& path, + const std::filesystem::path& cwd) { + if (name.find('/') != std::string::npos) { + auto file = std::filesystem::path(name); + if (file.is_relative()) + file = cwd / file; + if (::access(file.c_str(), X_OK) == 0 && !std::filesystem::is_directory(file)) + return std::filesystem::absolute(file); + throw std::runtime_error("Executable is missing or not executable: " + name); + } + std::size_t from{}; + do { + auto end = path.find(':', from); + auto directory = path.substr(from, end == std::string::npos ? end : end - from); + auto file = (directory.empty() ? cwd : std::filesystem::path(directory)) / name; + if (file.is_relative()) + file = cwd / file; + if (::access(file.c_str(), X_OK) == 0 && !std::filesystem::is_directory(file)) + return std::filesystem::absolute(file); + if (end == std::string::npos) + break; + from = end + 1; + } while (true); + throw std::runtime_error("Executable not found on PATH: " + name); +} +#endif +} // namespace +std::filesystem::path find_executable(const std::string& name) { +#ifdef _WIN32 + auto wide = widen(name); + std::vector buffer(32768); + DWORD length = SearchPathW(nullptr, wide.c_str(), L".exe", static_cast(buffer.size()), + buffer.data(), nullptr); + if (length == 0 || length >= buffer.size()) + throw std::runtime_error("Executable not found on PATH: " + name); + return std::filesystem::path(std::wstring(buffer.data(), length)); +#else + const char* path = std::getenv("PATH"); + return resolve_program(name, path ? path : "", std::filesystem::current_path()); +#endif +} +struct Process::Impl { +#ifdef _WIN32 + HANDLE process{}, thread{}, job{}, output{}; +#else + pid_t pid{-1}; + int output{-1}; +#endif + bool running{}; + std::optional exit_code; + ~Impl() { + try { + cancel(); + } catch (...) { + } +#ifdef _WIN32 + if (output) + CloseHandle(output); + if (thread) + CloseHandle(thread); + if (process) + CloseHandle(process); + if (job) + CloseHandle(job); +#else + if (output >= 0) + ::close(output); +#endif + } + ProcessPoll poll() { + std::string text; + char buffer[8192]; +#ifdef _WIN32 + if (output) { + DWORD available{}; + while (PeekNamedPipe(output, nullptr, 0, nullptr, &available, nullptr) && available) { + DWORD read{}; + if (!ReadFile(output, buffer, std::min(available, sizeof(buffer)), &read, + nullptr) || + !read) + break; + text.append(buffer, read); + } + } + if (running && WaitForSingleObject(process, 0) == WAIT_OBJECT_0) { + DWORD code{}; + if (!GetExitCodeProcess(process, &code)) + throw std::runtime_error("Cannot read process exit status"); + running = false; + exit_code = static_cast(code); + } +#else + if (output >= 0) { + while (true) { + auto count = ::read(output, buffer, sizeof(buffer)); + if (count > 0) { + text.append(buffer, static_cast(count)); + continue; + } + if (count < 0 && errno == EINTR) + continue; + if (count == 0) { + ::close(output); + output = -1; + } else if (errno != EAGAIN && errno != EWOULDBLOCK) + throw std::runtime_error("Cannot read child output"); + break; + } + } + if (running) { + int status{}; + pid_t result; + do { + result = ::waitpid(pid, &status, WNOHANG); + } while (result < 0 && errno == EINTR); + if (result == pid) { + running = false; + exit_code = WIFEXITED(status) ? WEXITSTATUS(status) + : WIFSIGNALED(status) ? 128 + WTERMSIG(status) + : 1; + } else if (result < 0) + throw std::runtime_error("Cannot collect child process"); + } +#endif + if (!running) { +#ifdef _WIN32 + DWORD available{}; + while (output && PeekNamedPipe(output, nullptr, 0, nullptr, &available, nullptr) && + available) { + DWORD read{}; + if (!ReadFile(output, buffer, std::min(available, sizeof(buffer)), &read, + nullptr) || + !read) + break; + text.append(buffer, read); + } +#else + if (output >= 0) + while (true) { + auto count = ::read(output, buffer, sizeof(buffer)); + if (count > 0) { + text.append(buffer, static_cast(count)); + continue; + } + if (count < 0 && errno == EINTR) + continue; + if (count == 0) { + ::close(output); + output = -1; + } + break; + } +#endif + } + return {running, exit_code, std::move(text)}; + } + void cancel() { + if (!running) + return; +#ifdef _WIN32 + if (job) + TerminateJobObject(job, 130); + TerminateProcess(process, 130); + WaitForSingleObject(process, INFINITE); + DWORD code{}; + GetExitCodeProcess(process, &code); + exit_code = static_cast(code); + running = false; +#else + ::kill(-pid, SIGTERM); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(200); + while (std::chrono::steady_clock::now() < deadline) { + int status{}; + auto result = ::waitpid(pid, &status, WNOHANG); + if (result == pid) { + running = false; + exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status); + ::kill(-pid, SIGKILL); + return; + } + if (result < 0 && errno != EINTR) { + running = false; + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + ::kill(-pid, SIGKILL); + int status{}; + while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } + running = false; + exit_code = 130; +#endif + } +}; +Process::Process(const ProcessOptions& options) : impl_(std::make_unique()) { + if (options.arguments.empty() || options.arguments[0].empty()) + throw std::invalid_argument("Process requires an executable"); + for (const auto& argument : options.arguments) + if (argument.find('\0') != std::string::npos) + throw std::invalid_argument("NUL in process argument"); + auto cwd = options.working_directory.empty() + ? std::filesystem::current_path() + : std::filesystem::absolute(options.working_directory); + if (!std::filesystem::is_directory(cwd)) + throw std::runtime_error("Process working directory does not exist"); +#ifdef _WIN32 + auto program = std::filesystem::path(widen(options.arguments[0])); + if (program.has_parent_path() && program.is_relative()) + program = cwd / program; + auto executable = program.has_parent_path() ? program : find_executable(options.arguments[0]); + std::wstring command; + for (const auto& argument : options.arguments) { + if (!command.empty()) + command += L' '; + command += quote(widen(argument)); + } + std::map environment; + auto block = GetEnvironmentStringsW(); + if (!block) + throw std::runtime_error("Cannot read process environment"); + for (auto entry = block; *entry; entry += wcslen(entry) + 1) { + std::wstring text(entry); + auto equals = text.find(L'=', text[0] == L'=' ? 1 : 0); + if (equals != std::wstring::npos) + environment[text.substr(0, equals)] = text.substr(equals + 1); + } + FreeEnvironmentStringsW(block); + for (auto& [key, value] : options.environment) { + if (key.empty() || key.find('=') != std::string::npos || + key.find('\0') != std::string::npos || value.find('\0') != std::string::npos) + throw std::invalid_argument("Invalid environment entry"); + environment[widen(key)] = widen(value); + } + std::vector env; + for (auto& [key, value] : environment) { + auto item = key + L"=" + value; + env.insert(env.end(), item.begin(), item.end()); + env.push_back(0); + } + env.push_back(0); + SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; + HANDLE output_write{}, input{}; + if (!CreatePipe(&impl_->output, &output_write, &security, 0)) + throw std::runtime_error("Cannot create process pipe"); + SetHandleInformation(impl_->output, HANDLE_FLAG_INHERIT, 0); + input = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &security, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + SIZE_T bytes{}; + InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes); + std::vector storage(bytes); + auto* attributes = reinterpret_cast(storage.data()); + bool initialized = InitializeProcThreadAttributeList(attributes, 1, 0, &bytes) != 0; + HANDLE handles[] = {output_write, input}; + bool updated = + initialized && UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + handles, sizeof(handles), nullptr, nullptr) != 0; + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdOutput = startup.StartupInfo.hStdError = output_write; + startup.StartupInfo.hStdInput = input; + startup.lpAttributeList = attributes; + PROCESS_INFORMATION process{}; + bool launched = + updated && CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, TRUE, + CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | + CREATE_NEW_PROCESS_GROUP | EXTENDED_STARTUPINFO_PRESENT, + env.data(), cwd.c_str(), &startup.StartupInfo, &process) != 0; + if (initialized) + DeleteProcThreadAttributeList(attributes); + CloseHandle(output_write); + if (input != INVALID_HANDLE_VALUE) + CloseHandle(input); + if (!launched) + throw std::runtime_error("CreateProcess failed for " + options.arguments[0]); + impl_->process = process.hProcess; + impl_->thread = process.hThread; + impl_->running = true; + impl_->job = CreateJobObjectW(nullptr, nullptr); + if (!impl_->job) + throw std::runtime_error("Cannot create compiler job object"); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!SetInformationJobObject(impl_->job, JobObjectExtendedLimitInformation, &limits, + sizeof(limits)) || + !AssignProcessToJobObject(impl_->job, impl_->process)) + throw std::runtime_error("Cannot isolate compiler process group"); + if (ResumeThread(impl_->thread) == DWORD(-1)) + throw std::runtime_error("Cannot start compiler process"); +#else + std::map environment; + for (char** item = environ; *item; ++item) { + std::string text(*item); + auto separator = text.find('='); + if (separator != std::string::npos) + environment[text.substr(0, separator)] = text.substr(separator + 1); + } + for (const auto& [key, value] : options.environment) { + if (key.empty() || key.find('=') != std::string::npos || + key.find('\0') != std::string::npos || value.find('\0') != std::string::npos) + throw std::invalid_argument("Invalid environment entry"); + environment[key] = value; + } + auto executable = resolve_program(options.arguments[0], environment["PATH"], cwd); + std::vector env_storage; + std::vector argv, envp; + for (const auto& argument : options.arguments) + argv.push_back(const_cast(argument.c_str())); + argv.push_back(nullptr); + for (const auto& [key, value] : environment) + env_storage.push_back(key + "=" + value); + for (auto& entry : env_storage) + envp.push_back(entry.data()); + envp.push_back(nullptr); + int pipes[2]; + if (::pipe2(pipes, O_CLOEXEC) < 0) + throw std::runtime_error("Cannot create process pipe"); + int input = ::open("/dev/null", O_RDONLY | O_CLOEXEC); + if (input < 0) { + ::close(pipes[0]); + ::close(pipes[1]); + throw std::runtime_error("Cannot open process input"); + } + auto pid = ::fork(); + if (pid == 0) { + ::setpgid(0, 0); + ::close(pipes[0]); + if (::dup2(input, STDIN_FILENO) < 0 || ::dup2(pipes[1], STDOUT_FILENO) < 0 || + ::dup2(pipes[1], STDERR_FILENO) < 0 || ::chdir(cwd.c_str()) < 0) + ::_exit(126); + ::close(input); + ::close(pipes[1]); + ::execve(executable.c_str(), argv.data(), envp.data()); + constexpr char message[] = "Cannot execute child process\n"; + ::write(STDERR_FILENO, message, sizeof(message) - 1); + ::_exit(127); + } + ::close(input); + ::close(pipes[1]); + if (pid < 0) { + ::close(pipes[0]); + throw std::runtime_error("Cannot fork process"); + } + ::setpgid(pid, pid); + impl_->pid = pid; + impl_->output = pipes[0]; + impl_->running = true; + int flags = ::fcntl(pipes[0], F_GETFL, 0); + if (flags < 0 || ::fcntl(pipes[0], F_SETFL, flags | O_NONBLOCK) < 0) + throw std::runtime_error("Cannot configure process output pipe"); +#endif +} +Process::~Process() = default; +Process::Process(Process&&) noexcept = default; +Process& Process::operator=(Process&&) noexcept = default; +ProcessPoll Process::poll() { + return impl_->poll(); +} +void Process::cancel() { + impl_->cancel(); +} +} // namespace faset diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp new file mode 100644 index 0000000..8ec51d5 --- /dev/null +++ b/src/editor/build_service.cpp @@ -0,0 +1,747 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace faset::editor { +namespace fs = std::filesystem; +namespace { +struct Cancelled {}; +constexpr std::size_t max_log_bytes = 8 * 1024 * 1024; +void validate_scene(const Json& scene) { + if (!scene.is_object() || scene.value("format", "") != "faset.scene" || + scene.value("version", 0) != 1 || !scene.contains("entities") || + !scene["entities"].is_array()) + throw std::runtime_error("Cook requires a version 1 Faset scene"); + if (!scene.value("instances", Json::array()).empty()) + throw std::runtime_error("Resolve template instances before cooking"); + int dimension = scene.value("dimension", 0); + if (dimension != 2 && dimension != 3) + throw std::runtime_error("Scene dimension must be 2 or 3"); +} +void validate_component_types(const Json& scene, const Json& schema) { + std::map versions; + for (const auto* type : {"faset.transform", "faset.sprite", "faset.mesh", "faset.camera", + "faset.light", "faset.rigid_body_2d", "faset.rigid_body_3d"}) + versions[type] = 1; + for (const auto& type : schema.value("types", Json::array())) { + auto id = type.at("id").get(); + if (versions.contains(id)) + throw std::runtime_error("Gameplay schema duplicates a component type: " + id); + versions[id] = type.value("version", 1); + } + for (const auto& entity : scene.at("entities")) + for (const auto& component : entity.value("components", Json::array())) { + const auto id = component.at("type").get(); + auto found = versions.find(id); + if (found == versions.end()) + throw std::runtime_error("Cannot cook unresolved component type: " + id); + if (component.value("version", 1) != found->second) + throw std::runtime_error("Migrate component '" + id + + "' to the current gameplay schema before cooking"); + } +} +std::set asset_references(const Json& scene) { + std::set result; + for (const auto& entity : scene.at("entities")) + for (const auto& component : entity.value("components", Json::array())) { + const auto type = component.value("type", ""); + if (type != "faset.mesh" && type != "faset.sprite") + continue; + const auto fields = component.value("fields", Json::object()); + auto asset = fields.value(type == "faset.sprite" ? "texture" : "asset", ""); + if (asset.empty() || asset == "builtin:cube" || asset == "builtin:plane") + continue; + if (asset.starts_with("builtin:")) + throw std::runtime_error("Unknown builtin asset: " + asset); + result.insert(asset.substr(0, asset.find('#'))); + } + return result; +} +void copy_required_file(const fs::path& source, const fs::path& target) { + if (!fs::is_regular_file(source) || fs::is_symlink(source)) + throw std::runtime_error("Required package file missing or not a regular file: " + + source.string()); + fs::create_directories(target.parent_path()); + fs::copy_file(source, target, fs::copy_options::overwrite_existing); +} +std::string executable_suffix() { +#ifdef _WIN32 + return ".exe"; +#else + return ""; +#endif +} +fs::path build_executable(const fs::path& build, const std::string& configuration, + const std::string& target) { + for (const auto& root : + {build, build / configuration, build / "bin", build / "bin" / configuration}) { + auto file = root / (target + executable_suffix()); + if (fs::is_regular_file(file)) + return file; + } + throw std::runtime_error("Build did not produce " + target); +} +} // namespace +Json JobStatus::json() const { + return {{"id", id}, {"kind", kind}, {"state", state}, + {"stage", stage}, {"progress", progress}, {"log", log}, + {"error", error}, {"result", result}}; +} +void write_cooked_scene(const fs::path& path, const Json& scene) { + validate_scene(scene); + const auto payload = Json::to_cbor(scene); + std::string bytes = "FASETSCN"; + bytes.reserve(20 + payload.size()); + for (unsigned i = 0; i < 4; ++i) + bytes.push_back(static_cast((std::uint32_t(1) >> (i * 8)) & 255)); + for (unsigned i = 0; i < 8; ++i) + bytes.push_back(static_cast((std::uint64_t(payload.size()) >> (i * 8)) & 255)); + bytes.append(reinterpret_cast(payload.data()), payload.size()); + atomic_write(path, bytes); +} +struct BuildService::Impl { + struct Job { + mutable std::mutex mutex; + JobStatus status; + std::atomic cancelled{}; + std::condition_variable finished; + Json scene; + Json asset_manifests = Json::object(); + fs::path output; + }; + BuildConfig config; + mutable std::mutex mutex; + std::condition_variable condition; + std::map> jobs; + std::deque> queue; + bool stopping{}; + std::thread worker; + explicit Impl(BuildConfig c) : config(std::move(c)) { + if (config.project_root.empty() || config.engine_root.empty()) + throw std::invalid_argument("Build service requires project and engine directories"); + config.project_root = fs::absolute(config.project_root); + config.engine_root = fs::absolute(config.engine_root); + if (config.cache_root.empty()) + config.cache_root = config.project_root / ".faset" / "cache"; + else + config.cache_root = fs::absolute(config.cache_root); + if (config.build_directory.empty()) + config.build_directory = config.project_root / ".faset" / "build"; + else + config.build_directory = fs::absolute(config.build_directory); + if (config.configuration != "Debug" && config.configuration != "Release" && + config.configuration != "RelWithDebInfo") + throw std::invalid_argument("Unsupported build configuration"); + if (config.export_configuration != "Release" && + config.export_configuration != "RelWithDebInfo") + throw std::invalid_argument("Export configuration must be Release or RelWithDebInfo"); + fs::create_directories(config.project_root); + fs::create_directories(config.cache_root); + worker = std::thread([this] { work(); }); + } + ~Impl() { + { + std::lock_guard lock(mutex); + stopping = true; + for (auto& [_, job] : jobs) + job->cancelled = true; + } + condition.notify_all(); + if (worker.joinable()) + worker.join(); + } + std::shared_ptr lookup(const std::string& id) const { + std::lock_guard lock(mutex); + auto it = jobs.find(id); + if (it == jobs.end()) + throw std::out_of_range("Unknown build job: " + id); + return it->second; + } + std::string enqueue(std::string kind, Json scene = {}, fs::path output = {}) { + auto job = std::make_shared(); + job->status.id = new_id(); + job->status.kind = std::move(kind); + job->scene = std::move(scene); + job->output = std::move(output); + { + std::lock_guard lock(mutex); + if (stopping) + throw std::runtime_error("Build service is stopping"); + jobs.emplace(job->status.id, job); + queue.push_back(job); + } + condition.notify_all(); + return job->status.id; + } + void checkpoint(Job& job, std::string stage, double progress) { + if (job.cancelled) + throw Cancelled{}; + std::lock_guard lock(job.mutex); + job.status.stage = std::move(stage); + job.status.progress = progress; + } + void log(Job& job, std::string_view text) { + std::lock_guard lock(job.mutex); + job.status.log.append(text); + if (job.status.log.size() > max_log_bytes) + job.status.log.erase(0, job.status.log.size() - max_log_bytes); + } + std::string run(Job& job, std::vector arguments, const fs::path& cwd) { + if (job.cancelled) + throw Cancelled{}; + std::string description = "$"; + for (const auto& argument : arguments) + description += " " + Json(argument).dump(); + description += '\n'; + log(job, description); + Process process({std::move(arguments), cwd, {}}); + std::string output; + while (true) { + if (job.cancelled) { + process.cancel(); + auto final = process.poll(); + log(job, final.output); + throw Cancelled{}; + } + auto poll = process.poll(); + log(job, poll.output); + output += poll.output; + if (output.size() > max_log_bytes) + output.erase(0, output.size() - max_log_bytes); + if (!poll.running) { + if (poll.exit_code.value_or(1) != 0) + throw std::runtime_error("Process exited with code " + + std::to_string(poll.exit_code.value_or(1)) + + "; see job log"); + return output; + } + std::this_thread::sleep_for(std::chrono::milliseconds(15)); + } + } + void validate_assets(const Json& scene) { + assets::AssetStore pipeline(config.cache_root); + for (const auto& id : asset_references(scene)) + pipeline.load_asset(id); + } + Json build(Job& job, bool exporting = false) { + const auto& configuration = exporting ? config.export_configuration : config.configuration; + const auto native_directory = config.build_directory / configuration; + checkpoint(job, "Configuring C++ gameplay", .05); + if (!fs::is_regular_file(config.project_root / "Scripts" / "Gameplay.cpp") || + !fs::is_regular_file(config.project_root / "Scripts" / "Gameplay.hpp")) + throw std::runtime_error("Project Scripts/Gameplay.cpp and Gameplay.hpp are required; " + "create a project scaffold first"); + fs::create_directories(native_directory); + std::vector arguments = {config.cmake, + "-S", + config.engine_root.string(), + "-B", + native_directory.string(), + "-G", + config.generator, + "-DCMAKE_BUILD_TYPE=" + configuration, + "-DBUILD_TESTING=OFF", + "-DFASET_BUILD_EDITOR=OFF", + "-DFASET_BUILD_RENDERER=ON", + "-DFASET_BUILD_RUNTIME=ON", + "-DFASET_BUILD_ASSETS=ON", + "-DFASET_GAMEPLAY_SOURCE_DIR=" + + (config.project_root / "Scripts").string()}; + bool compiler_overridden = false; + for (const auto& arg : config.configure_arguments) + if (arg.starts_with("-DCMAKE_CXX_COMPILER=")) + compiler_overridden = true; + if (!compiler_overridden) { +#ifdef _WIN32 + auto compiler = find_executable("clang-cl"); + arguments.push_back("-DCMAKE_C_COMPILER=" + compiler.string()); + arguments.push_back("-DCMAKE_CXX_COMPILER=" + compiler.string()); +#else + arguments.push_back("-DCMAKE_C_COMPILER=" + find_executable("clang").string()); + arguments.push_back("-DCMAKE_CXX_COMPILER=" + find_executable("clang++").string()); +#endif + } + arguments.insert(arguments.end(), config.configure_arguments.begin(), + config.configure_arguments.end()); + arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration); + run(job, std::move(arguments), config.project_root); + checkpoint(job, "Compiling and linking Player", .25); + run(job, + {config.cmake, "--build", native_directory.string(), "--config", configuration, + "--parallel", "4", "--target", "faset_player", "faset_schema_exporter"}, + config.project_root); + checkpoint(job, "Exporting gameplay schema", .58); + auto player = build_executable(native_directory, configuration, "faset_player"); + auto exporter = build_executable(native_directory, configuration, "faset_schema_exporter"); + const auto staging = config.cache_root / "builds" / (".staging-" + job.status.id); + const auto generation = config.cache_root / "builds" / job.status.id; + fs::create_directories(staging); + try { + const auto schema_file = staging / "schema.json"; + run(job, {exporter.string(), "--output", schema_file.string()}, config.project_root); + auto schema = read_json(schema_file); + if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 || + !schema.contains("types") || !schema.at("types").is_array()) + throw std::runtime_error("SchemaExporter returned an invalid manifest"); + std::string fingerprint = sha256_file(player) + sha256_file(exporter) + + read_text(native_directory / "CMakeCache.txt"); + for (const auto& file : {"Gameplay.cpp", "Gameplay.hpp"}) + fingerprint += read_text(config.project_root / "Scripts" / file); + fingerprint = sha256(fingerprint); + schema["build_fingerprint"] = fingerprint; + atomic_write_json(schema_file, schema); + copy_required_file(player, staging / ("faset_player" + executable_suffix())); + copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix())); + for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"}) + copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file); + copy_runtime_libraries(job, player, staging, native_directory, configuration); + Json manifest{{"format", "faset.build"}, + {"version", 1}, + {"id", job.status.id}, + {"fingerprint", fingerprint}, + {"configuration", configuration}, + {"player", "faset_player" + executable_suffix()}, + {"schema", "schema.json"}}; + atomic_write_json(staging / "manifest.json", manifest); + checkpoint(job, "Publishing build generation", .68); + fs::rename(staging, generation); + atomic_write_json(config.cache_root / "last_build.json", + {{"generation", job.status.id}, {"fingerprint", fingerprint}}); + return {{"generation", job.status.id}, + {"directory", generation.string()}, + {"build_directory", native_directory.string()}, + {"configuration", configuration}, + {"player", (generation / ("faset_player" + executable_suffix())).string()}, + {"schema", (generation / "schema.json").string()}, + {"fingerprint", fingerprint}}; + } catch (...) { + std::error_code error; + fs::remove_all(staging, error); + throw; + } + } + void copy_runtime_libraries(Job& job, const fs::path& executable, const fs::path& destination, + const fs::path& native_directory, + const std::string& configuration) { +#ifdef _WIN32 + // Libraries produced by the selected toolchain are copied beside the executable. + // System DLLs (including Vulkan) remain platform prerequisites. + for (const auto& root : + {executable.parent_path(), native_directory, native_directory / configuration}) + if (fs::is_directory(root)) + for (const auto& entry : fs::directory_iterator(root)) { + auto extension = entry.path().extension().string(); + for (auto& c : extension) + c = static_cast(std::tolower(static_cast(c))); + if (entry.is_regular_file() && extension == ".dll") + copy_required_file(entry.path(), destination / entry.path().filename()); + } + // The actual packaged executable is launched before publication to reject missing imports. + (void)job; +#else + const auto output = + run(job, {find_executable("ldd").string(), executable.string()}, config.project_root); + if (output.find("not found") != std::string::npos) + throw std::runtime_error("Player has unresolved shared library dependencies"); + // SDL/physics/gameplay are linked statically. glibc/libstdc++/Vulkan are the host baseline. + // Refuse an unexpected private DSO rather than silently publish a non-portable package. + std::size_t begin{}; + while (begin < output.size()) { + auto end = output.find('\n', begin); + auto line = output.substr(begin, end == std::string::npos ? end : end - begin); + auto arrow = line.find("=> "); + if (arrow != std::string::npos) { + auto name = line.substr(0, arrow); + name.erase(0, name.find_first_not_of(" \t")); + auto path_start = arrow + 3; + auto path_end = line.find(" (", path_start); + auto path = line.substr(path_start, path_end - path_start); + if (!path.empty() && path[0] == '/' && !path.starts_with("/lib/") && + !path.starts_with("/lib64/") && !path.starts_with("/usr/lib/") && + !path.starts_with("/usr/lib64/")) + throw std::runtime_error( + "Private shared library needs an explicit package rule: " + path); + } + if (end == std::string::npos) + break; + begin = end + 1; + } + (void)destination; + (void)native_directory; + (void)configuration; +#endif + } + Json cook(Job& job) { + checkpoint(job, "Validating scene and assets", .1); + validate_scene(job.scene); + validate_assets(job.scene); + Json schema = Json::object(); + if (fs::exists(config.cache_root / "last_build.json")) { + auto pointer = read_json(config.cache_root / "last_build.json"); + auto schema_path = project_path( + config.cache_root, + fs::path("builds") / pointer.at("generation").get() / "schema.json"); + schema = read_json(schema_path); + } + validate_component_types(job.scene, schema); + auto source_hash = sha256(job.scene.dump()); + auto schema_fingerprint = schema.value("build_fingerprint", std::string("builtin-v1")); + auto digest = sha256(source_hash + schema_fingerprint); + auto directory = config.cache_root / "cooked" / digest; + auto staging = config.cache_root / "cooked" / (".staging-" + job.status.id); + fs::create_directories(staging); + try { + checkpoint(job, "Writing cooked scene", .6); + write_cooked_scene(staging / "scene.fscene", job.scene); + Json manifest{{"format", "faset.cooked-scene"}, + {"version", 1}, + {"scene", job.scene.value("id", "")}, + {"source_hash", source_hash}, + {"schema_fingerprint", schema_fingerprint}, + {"sha256", sha256_file(staging / "scene.fscene")}}; + atomic_write_json(staging / "manifest.json", manifest); + checkpoint(job, "Publishing cooked scene", .9); + if (fs::exists(directory)) { + if (read_json(directory / "manifest.json") != manifest || + sha256_file(directory / "scene.fscene") != + manifest.at("sha256").get()) + throw std::runtime_error("Existing cooked generation is corrupt"); + fs::remove_all(staging); + } else + fs::rename(staging, directory); + atomic_write_json(config.cache_root / "last_cook.json", {{"generation", digest}}); + return {{"generation", digest}, + {"scene", (directory / "scene.fscene").string()}, + {"directory", directory.string()}}; + } catch (...) { + std::error_code error; + fs::remove_all(staging, error); + throw; + } + } + void package_notices(const fs::path& destination, const fs::path& native_directory) { + fs::create_directories(destination); + auto lock = read_json(config.engine_root / "dependencies.lock.json"); + const std::vector runtime_dependencies = {"sdl3", "entt", "box2d", + "box3d", "json", "stb"}; + Json used = Json::object(); + for (const auto& name : runtime_dependencies) { + std::vector roots = {native_directory / "_deps" / (name + "-src"), + config.engine_root / ".cache" / "deps-src" / name}; + bool copied{}; + for (const auto& source : roots) { + if (!fs::is_directory(source)) + continue; + for (const auto& entry : fs::directory_iterator(source)) { + if (!entry.is_regular_file()) + continue; + auto filename = entry.path().filename().string(); + std::string upper = filename; + for (auto& c : upper) + c = static_cast(std::toupper(static_cast(c))); + if (upper.starts_with("LICENSE") || upper.starts_with("COPYING") || + upper.starts_with("NOTICE")) { + copy_required_file(entry.path(), destination / name / filename); + copied = true; + } + } + if (copied) + break; + } + if (!copied) + throw std::runtime_error("Cannot package required license notices for " + name); + used[name] = lock.at("dependencies").at(name); + } + atomic_write_json(destination / "dependencies.json", used); + if (fs::is_regular_file(config.engine_root / "LICENSE")) + copy_required_file(config.engine_root / "LICENSE", destination / "Faset-LICENSE"); + atomic_write(destination / "Faset-NOTICE.txt", + "Faset Engine\nhttps://github.com/emil28092005/Faset_Engine\nSee the source " + "repository for the current license status of Faset's own code.\nThird-party " + "license texts are included in the adjacent directories.\n"); + } + void package_assets(Job& job, const fs::path& destination) { + assets::AssetStore packaged(destination); + for (const auto& id : asset_references(job.scene)) { + if (job.cancelled) + throw Cancelled{}; + auto manifest = job.asset_manifests.at(id); + auto generation = manifest.at("generation").get(); + auto source_directory = project_path(config.cache_root, fs::path("assets") / id / + "generations" / generation); + auto target = destination / "assets" / id / "generations" / generation; + for (const auto& file : manifest.at("files")) { + auto relative = fs::path(file.at("path").get()); + copy_required_file(project_path(source_directory, relative), + project_path(target, relative)); + } + manifest["source"] = ""; + if (manifest.contains("payload_source")) + manifest["payload_source"] = ""; + atomic_write_json(target / "manifest.json", manifest); + atomic_write_json( + destination / "assets" / id / "current.json", + {{"schema_version", 1}, {"generation", generation}, {"source", ""}}); + packaged.load_asset(id); + } + } + Json export_game(Job& job) { + validate_scene(job.scene); + validate_assets(job.scene); + assets::AssetStore source(config.cache_root); + for (const auto& id : asset_references(job.scene)) + job.asset_manifests[id] = source.current_manifest(id); + const auto built = build(job, true); + validate_component_types(job.scene, read_json(built.at("schema").get())); + auto output = fs::absolute(job.output); + if (output.empty()) + throw std::runtime_error("An export destination is required"); + fs::create_directories(output / "generations"); + auto staging = output / (".staging-" + job.status.id); + auto generation = output / "generations" / job.status.id; + fs::create_directories(staging); + try { + checkpoint(job, "Cooking export snapshot", .72); + write_cooked_scene(staging / "scene.fscene", job.scene); + auto build_directory = fs::path(built.at("directory").get()); + copy_required_file(build_directory / ("faset_player" + executable_suffix()), + staging / ("faset_player" + executable_suffix())); + for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"}) + copy_required_file(build_directory / "shaders" / shader, + staging / "shaders" / shader); + for (const auto& entry : fs::directory_iterator(build_directory)) { + auto extension = entry.path().extension().string(); + for (auto& c : extension) + c = static_cast(std::tolower(static_cast(c))); + if (entry.is_regular_file() && extension == ".dll") + copy_required_file(entry.path(), staging / entry.path().filename()); + } + checkpoint(job, "Packaging assets and notices", .80); + package_assets(job, staging); + package_notices(staging / "Notices", built.at("build_directory").get()); + atomic_write( + staging / "README.txt", + "Run faset_player" + executable_suffix() + + " to start this game.\nThe executable loads scene.fscene and assets beside " + "it.\nKeep shaders/, assets/, and Notices/ with the executable.\nA compatible " + "Vulkan 1.3 driver and the supported OS runtime are required.\n"); +#ifdef _WIN32 + atomic_write(staging / "Windows-Runtime.txt", + "Install the Microsoft Visual C++ x64 Redistributable if Windows reports " + "a missing MSVCP140 or VCRUNTIME140 DLL.\nOfficial installer: " + "https://aka.ms/vc14/vc_redist.x64.exe\nThe Vulkan loader and GPU driver " + "must also be installed.\n"); +#endif + checkpoint(job, "Validating packaged Player", .90); + auto validation_output = + run(job, + {(staging / ("faset_player" + executable_suffix())).string(), "--validate", + "--scene", (staging / "scene.fscene").string(), "--assets", staging.string()}, + staging); + Json files = Json::array(); + for (const auto& entry : fs::recursive_directory_iterator(staging)) { + if (entry.is_symlink()) + throw std::runtime_error("Export contains a symlink"); + if (entry.is_regular_file()) + files.push_back( + {{"path", entry.path().lexically_relative(staging).generic_string()}, + {"sha256", sha256_file(entry.path())}, + {"size", entry.file_size()}}); + } + Json manifest{{"format", "faset.export"}, + {"version", 1}, + {"generation", job.status.id}, + {"build_fingerprint", built.at("fingerprint")}, + {"scene_hash", sha256(job.scene.dump())}, + {"asset_generations", Json::object()}, + {"configuration", built.at("configuration")}, + {"executable", "faset_player" + executable_suffix()}, + {"files", files}}; + manifest["units"] = { + {"distance", "metre"}, {"angle", "radian"}, {"coordinates", "right-handed Y-up"}}; + manifest["simulation"] = {{"fixed_delta", 1.0 / 60.0}, + {"max_catch_up_ticks", 4}, + {"physics_substeps", 4}, + {"gravity", {0, -9.81, 0}}}; + if (job.scene.contains("simulation")) + manifest["simulation"].update(job.scene.at("simulation")); + manifest["renderer_profile"] = { + {"api", "Vulkan 1.3"}, + {"required_features", {"dynamicRendering", "synchronization2"}}, + {"materials", {"base-color factor and texture", "metallic and roughness factors"}}, + {"texture_sampling", "linear clamp, one mip level"}, + {"shadow_map", {{"resolution", 1024}, {"world_extent", 40}}}, + {"unsupported_material_features", + {"normal maps", "metallic-roughness maps", "emissive and occlusion maps", + "alpha mode selection", "unlit mode", "per-material face culling"}}}; + manifest["validation_log"] = validation_output; + for (const auto& [id, asset] : job.asset_manifests.items()) + manifest["asset_generations"][id] = asset.at("generation"); +#ifdef _WIN32 + manifest["platform"] = "windows"; + manifest["prerequisites"] = { + "Windows x64", "Vulkan 1.3 driver", + "Microsoft Visual C++ x64 Redistributable (Visual Studio 2022 or newer)"}; +#else + manifest["platform"] = "linux"; + manifest["prerequisites"] = {"Linux x86_64", "Vulkan 1.3 driver", + "Compatible glibc and libstdc++ runtime"}; +#endif + atomic_write_json(staging / "manifest.json", manifest); + checkpoint(job, "Publishing export generation", .98); + fs::rename(staging, generation); + atomic_write_json(output / "current.json", + {{"format", "faset.export-pointer"}, + {"version", 1}, + {"generation", job.status.id}, + {"directory", "generations/" + job.status.id}}); + return {{"directory", generation.string()}, + {"executable", (generation / ("faset_player" + executable_suffix())).string()}, + {"manifest", (generation / "manifest.json").string()}, + {"generation", job.status.id}, + {"build", built}, + {"schema", built.at("schema")}, + {"player", built.at("player")}, + {"build_directory", built.at("build_directory")}, + {"configuration", built.at("configuration")}}; + } catch (...) { + std::error_code error; + fs::remove_all(staging, error); + throw; + } + } + void work() { + while (true) { + std::shared_ptr job; + { + std::unique_lock lock(mutex); + condition.wait(lock, [&] { return stopping || !queue.empty(); }); + if (queue.empty()) { + if (stopping) + return; + continue; + } + job = queue.front(); + queue.pop_front(); + } + { + std::lock_guard lock(job->mutex); + job->status.state = "running"; + } + try { + if (job->cancelled) + throw Cancelled{}; + Json result; + if (job->status.kind == "build") + result = build(*job); + else if (job->status.kind == "cook") + result = cook(*job); + else + result = export_game(*job); + std::lock_guard lock(job->mutex); + job->status.state = "succeeded"; + job->status.stage = "Complete"; + job->status.progress = 1; + job->status.result = std::move(result); + } catch (const Cancelled&) { + std::lock_guard lock(job->mutex); + job->status.state = "cancelled"; + job->status.stage = "Cancelled"; + job->status.error = + "Job cancelled; previous published generations remain available"; + } catch (const std::exception& error) { + std::lock_guard lock(job->mutex); + job->status.state = "failed"; + job->status.stage = "Failed"; + job->status.error = error.what(); + } + job->finished.notify_all(); + condition.notify_all(); + } + } +}; +BuildService::BuildService(BuildConfig config) : impl_(std::make_unique(std::move(config))) {} +BuildService::~BuildService() = default; +const BuildConfig& BuildService::config() const { + return impl_->config; +} +void BuildService::scaffold(const std::string& name, int dimension) { + if (name.empty() || (dimension != 2 && dimension != 3)) + throw std::invalid_argument("Project name and dimension 2 or 3 required"); + const auto& c = impl_->config; + fs::create_directories(c.project_root / "Scripts"); + fs::create_directories(c.project_root / "Scenes"); + fs::create_directories(c.project_root / "Assets"); + for (const auto* file : {"Gameplay.cpp", "Gameplay.hpp"}) { + auto target = c.project_root / "Scripts" / file; + if (!fs::exists(target)) { + auto source = c.engine_root / "tools" / "project_templates" / file; + copy_required_file(source, target); + } + } + auto project = c.project_root / "project.faset.json"; + if (!fs::exists(project)) + atomic_write_json(project, {{"format", "faset.project"}, + {"version", 1}, + {"id", new_id()}, + {"name", name}, + {"dimension", dimension}, + {"start_scene", "Scenes/main.scene.json"}}); + if (!fs::exists(c.project_root / ".gitignore")) + atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n"); +} +std::string BuildService::start_build() { + return impl_->enqueue("build"); +} +std::string BuildService::start_cook(Json scene) { + return impl_->enqueue("cook", std::move(scene)); +} +std::string BuildService::start_export(Json scene, const fs::path& output) { + if (output.empty()) + throw std::invalid_argument("Export output directory is required"); + return impl_->enqueue("export", std::move(scene), output); +} +JobStatus BuildService::job(const std::string& id) const { + auto value = impl_->lookup(id); + std::lock_guard lock(value->mutex); + return value->status; +} +std::vector BuildService::jobs() const { + std::vector> values; + { + std::lock_guard lock(impl_->mutex); + for (auto& [_, value] : impl_->jobs) + values.push_back(value); + } + std::vector result; + for (auto& value : values) { + std::lock_guard lock(value->mutex); + result.push_back(value->status); + } + return result; +} +void BuildService::cancel(const std::string& id) { + impl_->lookup(id)->cancelled = true; + impl_->condition.notify_all(); +} +JobStatus BuildService::wait(const std::string& id) { + auto value = impl_->lookup(id); + std::unique_lock lock(value->mutex); + value->finished.wait(lock, [&] { return value->status.finished(); }); + return value->status; +} +} // namespace faset::editor diff --git a/src/editor/commands.cpp b/src/editor/commands.cpp new file mode 100644 index 0000000..8f6be50 --- /dev/null +++ b/src/editor/commands.cpp @@ -0,0 +1,157 @@ +#include +#include +#include +#include +#include + +namespace faset::editor { +Json Commands::object_schema(Json properties, Json required) { + return {{"type", "object"}, + {"properties", std::move(properties)}, + {"required", std::move(required)}, + {"additionalProperties", false}}; +} +void Commands::add(std::string name, std::string description, Json schema, Handler handler, + bool read_only) { + require(!commands_.contains(name), "command.duplicate", "Command already registered: " + name); + Json descriptor = {{"name", name}, + {"description", std::move(description)}, + {"inputSchema", std::move(schema)}, + {"annotations", {{"readOnlyHint", read_only}, {"openWorldHint", false}}}}; + commands_.emplace(std::move(name), Command{std::move(descriptor), std::move(handler)}); +} +void Commands::remove(const std::string& name) { + commands_.erase(name); +} +Json Commands::list() const { + Json result = Json::array(); + for (const auto& [name, command] : commands_) + result.push_back(command.descriptor); + return result; +} +Json Commands::call(const std::string& name, const Json& arguments) { + const auto found = commands_.find(name); + require(found != commands_.end(), "command.unknown", "Unknown editor command: " + name); + require(arguments.is_object(), "arguments.object", "Tool arguments must be an object"); + const auto& schema = found->second.descriptor["inputSchema"]; + for (const auto& key : schema.value("required", Json::array())) + require(arguments.contains(key.get()), "arguments.required", + "Missing argument: " + key.get()); + for (const auto& [key, value] : arguments.items()) { + require(schema["properties"].contains(key), "arguments.unknown", + "Unknown argument: " + key); + const auto& property = schema["properties"][key]; + const auto type = property.value("type", std::string()); + const bool valid = + type.empty() || (type == "string" && value.is_string()) || + (type == "boolean" && value.is_boolean()) || (type == "number" && value.is_number()) || + (type == "integer" && value.is_number_integer()) || + (type == "object" && value.is_object()) || (type == "array" && value.is_array()); + require(valid, "arguments.type", "Invalid type for argument: " + key); + if (property.contains("enum")) + require(std::find(property["enum"].begin(), property["enum"].end(), value) != + property["enum"].end(), + "arguments.enum", "Unsupported value for argument: " + key); + if (property.contains("maximum") && value.is_number()) + require(value.get() <= property["maximum"].get(), "arguments.maximum", + "Argument exceeds its maximum: " + key); + if (property.contains("minimum") && value.is_number()) + require(value.get() >= property["minimum"].get(), "arguments.minimum", + "Argument is below its minimum: " + key); + } + try { + return found->second.handler(arguments); + } catch (const Json::exception& error) { + throw Error("arguments.invalid", "Invalid command data", {{"reason", error.what()}}); + } +} +Json Commands::resolved_scene(const std::string& id) const { + const auto result = authoring::resolve_templates( + authoring_.query(id).at("scene"), authoring_.schemas(), + [&](const std::string& path) { return read_json(project_path(authoring_.root(), path)); }); + return {{"scene", result.scene}, {"conflicts", result.conflicts}}; +} +Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authoring) { + const Json text = {{"type", "string"}}, integer = {{"type", "integer"}, {"minimum", 0}}, + boolean = {{"type", "boolean"}}; + add( + "faset_documents", + "List open authoring documents and their revisions. This does not inspect a running game.", + object_schema(Json::object()), + [&](const Json&) { return Json{{"documents", authoring_.documents()}}; }, true); + add("faset_document_create", + "Create an unsaved 2D or 3D scene. Returns its persistent document ID and revision.", + object_schema({{"name", text}, {"dimension", integer}}, {"name", "dimension"}), + [&](const Json& args) { return authoring_.create(args.at("name"), args.at("dimension")); }); + add("faset_document_open", + "Open a scene relative to the project. Set recover=true to load its saved recovery " + "journal.", + object_schema({{"path", text}, {"recover", boolean}}, {"path"}), [&](const Json& args) { + return authoring_.open(args.at("path").get(), + args.value("recover", false)); + }); + add( + "faset_document_query", + "Read an authoring scene, persistent IDs, dirty state and revision. No Player or runtime " + "state is exposed.", + object_schema({{"document", text}}, {"document"}), + [&](const Json& args) { return authoring_.query(args.at("document")); }, true); + add("faset_document_save", + "Atomically save an authoring document. Refuses to overwrite an externally modified file.", + object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) { + return authoring_.save(args.at("document"), args.value("path", std::string())); + }); + add( + "faset_schema", + "Inspect registered component TypeIds, stable FieldIds, defaults and constraints.", + object_schema(Json::object()), [&](const Json&) { return authoring_.schemas().manifest(); }, + 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; " + "scene.rename; template.instance/override/revert/suppress/add/reparent. Use persistent IDs " + "from document_query and schema. An idempotency_key retries the same payload in this " + "session.", + object_schema({{"document", text}, + {"revision", integer}, + {"operations", {{"type", "array"}, {"items", {{"type", "object"}}}}}, + {"idempotency_key", text}}, + {"document", "revision", "operations"}), + [&](const Json& args) { + return authoring_.transact(args.at("document"), args.at("revision"), + args.at("operations"), + args.value("idempotency_key", std::string())); + }); + add("faset_undo", "Undo one authoring transaction. Requires the current document revision.", + object_schema({{"document", text}, {"revision", integer}}, {"document", "revision"}), + [&](const Json& args) { + return authoring_.undo(args.at("document"), args.at("revision")); + }); + add("faset_redo", "Redo one authoring transaction. Requires the current document revision.", + object_schema({{"document", text}, {"revision", integer}}, {"document", "revision"}), + [&](const Json& args) { + return authoring_.redo(args.at("document"), args.at("revision")); + }); + add( + "faset_template_preview", + "Resolve authoring templates and report conflicts without changing source documents. This " + "is not a live game query.", + object_schema({{"document", text}}, {"document"}), + [&](const Json& args) { return resolved_scene(args.at("document")); }, true); + add("faset_recovery_restore", + "Restore a recovery journal, including an unsaved new scene. If the document is already " + "open, pass its current revision. External file changes are never overwritten.", + object_schema({{"document", text}, {"revision", integer}}, {"document"}), + [&](const Json& args) { + return authoring_.recover( + args.at("document"), + args.contains("revision") + ? std::optional(args.at("revision").get()) + : std::nullopt); + }); + add( + "faset_recovery_list", "List document recovery records in this project.", + object_schema(Json::object()), + [&](const Json&) { return Json{{"recovery", authoring_.recovery_documents()}}; }, true); +} +} // namespace faset::editor diff --git a/src/editor/editor_ui.cpp b/src/editor/editor_ui.cpp new file mode 100644 index 0000000..d137272 --- /dev/null +++ b/src/editor/editor_ui.cpp @@ -0,0 +1,1553 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace faset::editor { +namespace { +using render::Mat4; +using render::Vec3; +using ui::Kind; +using ui::Widget; +float dot(Vec3 a, Vec3 b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} +Vec3 add(Vec3 a, Vec3 b) { + for (int i = 0; i < 3; ++i) + a[i] += b[i]; + return a; +} +Vec3 sub(Vec3 a, Vec3 b) { + for (int i = 0; i < 3; ++i) + a[i] -= b[i]; + return a; +} +Vec3 mul(Vec3 a, float s) { + for (auto& x : a) + x *= s; + return a; +} +Vec3 cross(Vec3 a, Vec3 b) { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; +} +Vec3 normalize(Vec3 a) { + return mul(a, 1 / std::max(.000001f, std::sqrt(dot(a, a)))); +} +Vec3 point(const Mat4& m, Vec3 p) { + return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], + m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], + m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]}; +} +Vec3 vector(const Mat4& m, Vec3 p) { + return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2], + m[2] * p[0] + m[6] * p[1] + m[10] * p[2]}; +} +bool inverse(const Mat4& m, Mat4& out) { + double a[4][8]{}; + for (int r = 0; r < 4; ++r) { + for (int c = 0; c < 4; ++c) + a[r][c] = m[c * 4 + r]; + a[r][r + 4] = 1; + } + for (int c = 0; c < 4; ++c) { + int pivot = c; + for (int r = c + 1; r < 4; ++r) + if (std::abs(a[r][c]) > std::abs(a[pivot][c])) + pivot = r; + if (std::abs(a[pivot][c]) < 1e-10) + return false; + for (int j = 0; j < 8; ++j) + std::swap(a[c][j], a[pivot][j]); + const auto d = a[c][c]; + for (auto& v : a[c]) + v /= d; + for (int r = 0; r < 4; ++r) + if (r != c) { + const auto f = a[r][c]; + for (int j = 0; j < 8; ++j) + a[r][j] -= f * a[c][j]; + } + } + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + out[c * 4 + r] = float(a[r][c + 4]); + return true; +} +Vec3 homogeneous(const Mat4& m, float x, float y, float z) { + const auto w = m[3] * x + m[7] * y + m[11] * z + m[15]; + if (std::abs(w) < 1e-8f) + return {}; + return {(m[0] * x + m[4] * y + m[8] * z + m[12]) / w, + (m[1] * x + m[5] * y + m[9] * z + m[13]) / w, + (m[2] * x + m[6] * y + m[10] * z + m[14]) / w}; +} +const Json* entity(const Json& scene, const std::string& id) { + for (const auto& e : scene.at("entities")) + if (e.at("id") == id) + return &e; + return nullptr; +} +const Json* component(const Json& e, const std::string& type) { + for (const auto& c : e.at("components")) + if (c.at("type") == type) + return &c; + return nullptr; +} +Vec3 vec(const Json& j, Vec3 fallback = {}) { + if (!j.is_array() || j.size() < 3) + return fallback; + for (int i = 0; i < 3; ++i) + fallback[i] = j[i].get(); + return fallback; +} +Mat4 world(const Json& scene, const Json& e, int depth = 0) { + if (depth > 256) + return render::identity; + Mat4 local = render::identity; + if (auto* t = component(e, "faset.transform")) { + const auto& f = t->at("fields"); + local = + render::transform(vec(f.value("position", Json{})), vec(f.value("rotation", Json{})), + vec(f.value("scale", Json{}), {1, 1, 1})); + } + if (e.contains("parent") && e["parent"].is_string()) + if (auto* p = entity(scene, e["parent"].get())) + return render::multiply(world(scene, *p, depth + 1), local); + return local; +} +bool ray_triangle(Vec3 origin, Vec3 direction, Vec3 a, Vec3 b, Vec3 c, float& distance) { + const auto e1 = sub(b, a), e2 = sub(c, a), h = cross(direction, e2); + const auto d = dot(e1, h); + if (std::abs(d) < 1e-7f) + return false; + const auto s = sub(origin, a); + const auto u = dot(s, h) / d; + if (u < 0 || u > 1) + return false; + const auto q = cross(s, e1); + const auto v = dot(direction, q) / d; + if (v < 0 || u + v > 1) + return false; + const auto t = dot(e2, q) / d; + if (t < 0 || t >= distance) + return false; + distance = t; + return true; +} +void trim_children(Widget& w, const std::set& keep) { + std::erase_if(w.children, [&](const auto& child) { return !keep.contains(child->id); }); +} +void label(Widget& p, const std::string& id, const std::string& value, float width = -1) { + auto& w = p.add(Kind::Label, id, value); + w.layout.width = width; +} +} // namespace +struct EditorUI::Impl { + Session& session; + render::Renderer& renderer; + ui::Context ui; + ui::DockLayout dock; + player::SceneView view; + render::Snapshot rendered; + std::string picks_key; + std::string document, selected, source_file, asset_filter, + active_bottom = "assets", menu, command_name = "faset_documents", status = "Ready", + gizmo_mode = "Move"; + Json current, resolved, files = Json::array(), assets = Json::array(), schemas = Json::object(), + recovery = Json::array(); + std::uint64_t shown_revision = std::numeric_limits::max(); + std::map edit_revisions; + std::map preview_fields; + std::filesystem::path layout_path; + bool palette = false, component_menu = false, assets_dirty = true, paused = false; + float yaw = .65f, pitch = .42f, distance = 12, ortho = 12, mouse_x = 0, mouse_y = 0, last_x = 0, + last_y = 0; + Vec3 target{}; + int camera_drag = 0, gizmo_axis = -1; + float gizmo_down_x = 0, gizmo_down_y = 0; + Json gizmo_original; + std::uint64_t gizmo_revision = 0; + std::string gizmo_component; + ui::Rect viewport; + Vec3 gizmo_origin{}; + render::Vec2 gizmo_drag_start{}, gizmo_drag_end{}; + float gizmo_world_length = 1; + std::array gizmo_screen{}; + bool gizmo_valid = false; + struct Pick { + std::string id; + render::DrawItem item; + }; + std::vector picks; + std::chrono::steady_clock::time_point last_assets{}; + Impl(Session& s, render::Renderer& r, const std::filesystem::path& font, + const std::filesystem::path& styles) + : session(s), renderer(r), ui(font), view(s.config().project_root / ".faset/cache") { + ui.set_theme(ui::Theme::load(styles)); + ui.apply_layout(read_json(styles.parent_path() / "editor-layout.json")); + layout_path = session.config().project_root / ".faset/editor-layout.json"; + dock.move("assets", "bottom", 0); + dock.move("console", "bottom", 1); + dock.move("jobs", "bottom", 2); + if (std::filesystem::exists(layout_path)) + try { + dock.load(layout_path); + } catch (const std::exception& e) { + session.log(std::string("Layout reset: ") + e.what()); + } + ui.find("scene_panel")->layout.width = dock.size("scene", 224); + ui.find("inspector_panel")->layout.width = dock.size("inspector", 300); + ui.find("bottom_panel")->layout.height = dock.size("bottom", 184); + ui.set_clipboard([this] { return renderer.clipboard(); }, + [this](const std::string& text) { renderer.set_clipboard(text); }); + ui.set_ime([this](bool enabled) { renderer.set_text_input(enabled); }, + [this](ui::Rect rect) { + renderer.set_text_input_area(rect.x, rect.y, rect.width, rect.height); + }); + ui.set_docking(&dock, [this] { persist_layout(); }); + for (const auto* id : {"left_divider", "right_divider", "bottom_divider"}) + ui.find(id)->on_commit = [this](Widget&) { persist_layout(); }; + const auto recovered = call("faset_recovery_list"); + if (!recovered.is_null()) + for (const auto& item : recovered.at("recovery")) + if (item.value("dirty", false)) + recovery.push_back(item); + build_static(); + const auto docs = session.authoring().documents(); + if (!docs.empty()) + document = docs.front().at("id"); + else + document = session.authoring() + .create("Untitled", session.project().value("dimension", 3)) + .at("id"); + refresh(); + } + void persist_layout() { + dock.set_size("scene", ui.find("scene_panel")->rect.width); + dock.set_size("inspector", ui.find("inspector_panel")->rect.width); + dock.set_size("bottom", ui.find("bottom_panel")->rect.height); + try { + dock.save(layout_path); + } catch (const std::exception& e) { + report(e.what()); + } + } + void report(const std::string& message) { + status = message; + session.log(message); + } + Json call(const std::string& name, Json args = Json::object()) { + try { + auto result = session.commands().call(name, args); + if (result.contains("job")) + status = "Started " + name + " (see Jobs)"; + return result; + } catch (const std::exception& e) { + report(e.what()); + return nullptr; + } + } + bool transaction(Json operations, + std::uint64_t revision = std::numeric_limits::max()) { + if (document.empty()) + return false; + const auto result = call("faset_scene_edit", + {{"document", document}, + {"revision", revision == std::numeric_limits::max() + ? current.at("revision").get() + : revision}, + {"operations", operations}}); + if (result.is_null()) + return false; + current = result; + shown_revision = std::numeric_limits::max(); + status = "Edited " + current.at("name").get(); + return true; + } + void history(bool redo) { + if (document.empty()) + return; + auto result = call(redo ? "faset_redo" : "faset_undo", + {{"document", document}, {"revision", current.at("revision")}}); + if (!result.is_null()) { + current = result; + preview_fields.clear(); + shown_revision = std::numeric_limits::max(); + } + } + void save() { + ui.clear_focus(); + const auto path = current.value("path", std::string()); + if (path.empty()) { + menu = "File"; + ui.update_text("save-path", + "Scenes/" + current.at("name").get() + ".scene.json", true); + return; + } + auto result = call("faset_document_save", {{"document", document}}); + if (!result.is_null()) { + current = result; + status = "Saved " + path; + } + } + Widget& button(Widget& row, const std::string& id, const std::string& text, + std::function action, float width = -1) { + auto& b = row.add(Kind::Button, id, text); + b.layout.width = width; + b.on_click = [action = std::move(action)](Widget&) { action(); }; + return b; + } + void create_object(const std::string& type) { + const auto id = new_id(); + Json object = authoring::make_entity(session.authoring().schemas(), type); + object["id"] = id; + if (type == "Cube" || type == "Plane") + object["components"].push_back({{"id", new_id()}, + {"type", "faset.mesh"}, + {"version", 1}, + {"fields", + {{"asset", ""}, + {"color", {.65, .65, .68, 1.0}}, + {"primitive", type == "Plane" ? "plane" : "cube"}}}}); + else if (type == "Sprite") + object["components"].push_back( + {{"id", new_id()}, + {"type", "faset.sprite"}, + {"version", 1}, + {"fields", session.authoring().schemas().default_fields("faset.sprite")}}); + if (transaction(Json::array({{{"op", "entity.create"}, {"entity", object}}}))) + selected = id; + } + void build_static() { + auto& menurow = ui.find("menubar")->add(Kind::Row, "menuitems"); + menurow.layout.gap = 2; + for (const std::string name : {"Faset", "File", "Edit", "Scene", "View", "Help"}) + button( + menurow, "menu-" + name, name, [this, name] { menu = menu == name ? "" : name; }, + name == "Faset" ? 72 : 54); + auto& project = menurow.add(Kind::Label, "project-title", + session.project().value("name", std::string("Project"))); + project.layout.flex = 1; + auto& toolbar = ui.find("toolbar")->add(Kind::Row, "tools"); + toolbar.layout.gap = 5; + button(toolbar, "save", "Save", [this] { save(); }, 60); + button(toolbar, "undo", "Undo", [this] { history(false); }, 56); + button(toolbar, "redo", "Redo", [this] { history(true); }, 56); + button( + toolbar, "play", "Play", [this] { call("faset_play", {{"document", document}}); }, 58); + button( + toolbar, "stop", "Stop", + [this] { + call("faset_stop"); + paused = false; + }, + 56); + button( + toolbar, "pause", "Pause", + [this] { + if (!call("faset_play_control", {{"command", paused ? "resume" : "pause"}}) + .is_null()) + paused = !paused; + }, + 65); + button( + toolbar, "step", "Step", [this] { call("faset_play_control", {{"command", "step"}}); }, + 55); + button(toolbar, "build", "Build C++", [this] { call("faset_build"); }, 94); + button( + toolbar, "export", "Export", + [this] { call("faset_export", {{"document", document}, {"output", "Exports"}}); }, 64); + auto& space = toolbar.add(Kind::Label, "toolbar-space", ""); + space.layout.flex = 1; + button(toolbar, "command-palette", "Commands", [this] { palette = !palette; }, 104); + auto& scene = *ui.find("scene_panel"); + scene.add(Kind::Tab, "scene-tab", "Scene").selected = true; + auto& tools = scene.add(Kind::Row, "scene-tools"); + tools.layout.height = 28; + tools.layout.padding = 3; + tools.layout.gap = 3; + button(tools, "add-object", "+ Object", [this] { create_object("Object"); }, 80); + button(tools, "add-cube", "Cube", [this] { create_object("Cube"); }, 55); + button(tools, "add-sprite", "Sprite", [this] { create_object("Sprite"); }, 58); + auto& tree = scene.add(Kind::Column, "scene-tree"); + tree.layout.flex = 1; + tree.layout.scroll = true; + tree.layout.gap = 0; + tree.on_drop = [this](Widget&, const Json& payload) { + if (payload.value("kind", std::string()) == "entity") + transaction(Json::array({{{"op", "entity.reparent"}, + {"entity", payload.at("id")}, + {"parent", nullptr}, + {"keep_world", true}}})); + }; + auto& inspector = *ui.find("inspector_panel"); + inspector.add(Kind::Tab, "inspector-tab", "Inspector").selected = true; + auto& body = inspector.add(Kind::Column, "properties"); + body.layout.flex = 1; + body.layout.padding = 10; + body.layout.gap = 7; + body.layout.scroll = true; + auto& vp = *ui.find("viewport"); + auto& vptools = vp.add(Kind::Row, "viewport-tools"); + vptools.layout.absolute = true; + vptools.layout.x = 8; + vptools.layout.y = 8; + vptools.layout.height = 28; + vptools.layout.width = 300; + for (const std::string mode : {"Move", "Rotate", "Scale"}) + button(vptools, "gizmo-" + mode, mode, [this, mode] { gizmo_mode = mode; }, 64); + button(vptools, "frame-selection", "Frame", [this] { frame_selection(); }, 64); + vp.on_drop = [this](Widget&, const Json& data) { + if (data.value("kind", std::string()) == "asset") + instantiate_asset(data.at("id").get()); + }; + auto& bottom = *ui.find("bottom_panel"); + bottom.dock_area = "bottom"; + auto& tabs = bottom.add(Kind::Row, "bottom-tabs"); + tabs.layout.height = 29; + tabs.layout.gap = 0; + for (const std::string id : {"assets", "console", "jobs"}) { + auto& tab = tabs.add(Kind::Tab, "tab-" + id, + id == "assets" ? "Assets" + : id == "console" ? "Console" + : "Jobs"); + tab.layout.width = 94; + tab.dock_area = "bottom"; + tab.dock_panel = id; + tab.on_click = [this, id](Widget&) { active_bottom = id; }; + } + auto& assetbar = bottom.add(Kind::Row, "asset-toolbar"); + assetbar.layout.height = 30; + assetbar.layout.padding = 2; + assetbar.layout.gap = 5; + label(assetbar, "asset-path", "Project assets", 135); + auto& search = assetbar.add(Kind::TextField, "asset-search", ""); + search.layout.width = 220; + search.on_preview = [this](Widget& w) { + asset_filter = w.text; + assets_dirty = true; + }; + button( + assetbar, "asset-import", "Import / Reimport", + [this] { + if (!source_file.empty()) + call("faset_import", {{"path", source_file}}); + }, + 146); + button(assetbar, "asset-open", "Open Scene", [this] { open_source(); }, 104); + button( + assetbar, "asset-refresh", "Refresh", + [this] { + assets_dirty = true; + view.clearCache(); + picks_key.clear(); + }, + 75); + auto& items = bottom.add(Kind::Column, "asset-items"); + items.layout.flex = 1; + items.layout.scroll = true; + items.layout.gap = 0; + auto& console = bottom.add(Kind::Column, "console-items"); + console.layout.flex = 1; + console.layout.scroll = true; + console.layout.gap = 0; + auto& jobs = bottom.add(Kind::Column, "job-items"); + jobs.layout.flex = 1; + jobs.layout.scroll = true; + jobs.layout.gap = 1; + auto& statusrow = ui.find("statusbar")->add(Kind::Row, "status-row"); + auto& statuslabel = statusrow.add(Kind::Label, "status", "Ready"); + statuslabel.layout.flex = 1; + label(statusrow, "renderer-status", "Vulkan", 225); + build_overlays(); + } + void build_overlays() { + auto& pop = ui.root().add(Kind::Panel, "menu-popup"); + pop.layout.absolute = true; + pop.layout.x = 72; + pop.layout.y = 35; + pop.layout.width = 350; + pop.layout.height = 355; + pop.layout.padding = 8; + pop.layout.gap = 5; + pop.visible = false; + label(pop, "menu-title", "File"); + button(pop, "new-3d", "New 3D scene", [this] { new_scene(3); }); + button(pop, "new-2d", "New 2D scene", [this] { new_scene(2); }); + pop.add(Kind::TextField, "open-path", "Scenes/Main.scene.json"); + button(pop, "open-path-button", "Open project-relative scene", [this] { + auto result = call("faset_document_open", {{"path", ui.find("open-path")->text}}); + if (!result.is_null()) { + choose_document(result.at("id")); + menu.clear(); + } + }); + pop.add(Kind::TextField, "save-path", "Scenes/Untitled.scene.json"); + button(pop, "save-as-button", "Save scene as", [this] { + auto result = call("faset_document_save", + {{"document", document}, {"path", ui.find("save-path")->text}}); + if (!result.is_null()) { + current = result; + menu.clear(); + assets_dirty = true; + } + }); + button(pop, "menu-duplicate", "Duplicate selection", [this] { + if (!selected.empty()) + transaction(Json::array({{{"op", "entity.duplicate"}, {"entity", selected}}})); + menu.clear(); + }); + button(pop, "menu-delete", "Delete selection", [this] { + delete_selected(); + menu.clear(); + }); + label(pop, "help-one", "Orbit: right drag. Pan: middle drag. Wheel: zoom."); + label(pop, "help-two", "W / E / R: gizmos. F: frame. Ctrl+P: commands."); + label(pop, "help-three", "Ctrl+S save. Ctrl+Z / Shift+Z undo / redo."); + auto& command = ui.root().add(Kind::Panel, "palette"); + command.layout.absolute = true; + command.layout.width = 570; + command.layout.height = 470; + command.layout.padding = 12; + command.layout.gap = 6; + command.visible = false; + label(command, "palette-title", "Editor commands · authoring only"); + auto& filter = command.add(Kind::TextField, "palette-filter", ""); + filter.on_preview = [](Widget&) {}; + auto& list = command.add(Kind::Column, "palette-list"); + list.layout.flex = 1; + list.layout.scroll = true; + list.layout.gap = 1; + command.add(Kind::TextField, "palette-arguments", "{}"); + button(command, "palette-run", "Run selected command", [this] { + try { + const auto result = + call(command_name, Json::parse(ui.find("palette-arguments")->text)); + if (!result.is_null()) { + session.log(result.dump(2)); + status = "Completed " + command_name; + assets_dirty = true; + } + } catch (const std::exception& e) { + report(e.what()); + } + }); + button(command, "palette-close", "Close", [this] { palette = false; }); + auto& recover = ui.root().add(Kind::Panel, "recovery-panel"); + recover.layout.absolute = true; + recover.layout.width = 470; + recover.layout.height = 260; + recover.layout.padding = 12; + recover.layout.gap = 5; + recover.visible = false; + } + void new_scene(int dimension) { + auto result = + call("faset_document_create", {{"name", "Untitled"}, {"dimension", dimension}}); + if (!result.is_null()) + choose_document(result.at("id")); + menu.clear(); + } + void choose_document(const std::string& id) { + ui.clear_focus(false); + document = id; + selected.clear(); + current = session.authoring().query(id); + shown_revision = std::numeric_limits::max(); + preview_fields.clear(); + edit_revisions.clear(); + gizmo_axis = -1; + } + void delete_selected() { + if (selected.empty()) + return; + if (transaction(Json::array({{{"op", "entity.delete"}, {"entity", selected}}}))) + selected.clear(); + } + void select(const std::string& id) { + if (selected == id) + return; + ui.clear_focus(); + selected = id; + preview_fields.clear(); + edit_revisions.clear(); + component_menu = false; + } + void open_source() { + if (source_file.empty()) + return; + auto result = call("faset_document_open", {{"path", source_file}}); + if (!result.is_null()) + choose_document(result.at("id")); + } + void instantiate_asset(const std::string& id) { + Json manifest; + for (const auto& entry : assets) + if (entry.at("id") == id && entry.contains("manifest")) + manifest = entry.at("manifest"); + const bool image = manifest.is_object() && manifest.value("kind", std::string()) == "image"; + const auto name = + manifest.is_object() + ? std::filesystem::path(manifest.value("source", std::string("Imported asset"))) + .stem() + .string() + : "Imported asset"; + auto object = authoring::make_entity(session.authoring().schemas(), name); + const auto entity_id = object.at("id").get(); + if (image) { + auto fields = session.authoring().schemas().default_fields("faset.sprite"); + fields["texture"] = id; + fields["color"] = {1, 1, 1, 1}; + if (manifest.contains("image")) { + const auto& info = manifest.at("image"); + const auto ppu = info.value("pixels_per_unit", 100.0); + fields["size"] = {info.at("width").get() / ppu, + info.at("height").get() / ppu}; + } + object["components"].push_back( + {{"id", new_id()}, {"type", "faset.sprite"}, {"version", 1}, {"fields", fields}}); + } else + object["components"].push_back( + {{"id", new_id()}, + {"type", "faset.mesh"}, + {"version", 1}, + {"fields", {{"asset", id}, {"primitive", "asset"}, {"color", {1, 1, 1, 1}}}}}); + if (transaction(Json::array({{{"op", "entity.create"}, {"entity", object}}}))) + selected = entity_id; + } + void refresh() { + if (document.empty()) + return; + current = session.authoring().query(document); + schemas = session.authoring().schemas().manifest(); + if (shown_revision != current.at("revision").get()) { + auto result = call("faset_template_preview", {{"document", document}}); + resolved = result.is_null() ? current.at("scene") : result.at("scene"); + if (!result.is_null() && !result.at("conflicts").empty()) + status = "Template conflicts: " + std::to_string(result.at("conflicts").size()); + shown_revision = current.at("revision"); + } + if (!selected.empty() && !entity(current.at("scene"), selected)) + selected.clear(); + refresh_tree(); + refresh_inspector(); + refresh_assets(); + refresh_bottom(); + refresh_overlays(); + ui.find("undo")->enabled = current.value("can_undo", false); + ui.find("redo")->enabled = current.value("can_redo", false); + ui.find("pause")->enabled = session.playing(); + ui.find("step")->enabled = session.playing(); + ui.find("pause")->text = paused ? "Resume" : "Pause"; + ui.find("project-title")->text = session.project().value("name", std::string("Project")) + + " / " + current.at("name").get() + + (current.value("dirty", false) ? " *" : ""); + ui.find("status")->text = status; + ui.find("renderer-status")->text = + "Vulkan 1.3 | " + std::to_string(current.at("scene").at("entities").size()) + + " objects"; + } + void refresh_tree() { + auto& tree = *ui.find("scene-tree"); + std::set keep; + const auto& scene = current.at("scene"); + auto& root = tree.add(Kind::TreeRow, "scene-root", scene.at("name")); + root.selected = selected.empty(); + root.on_click = [this](Widget&) { select(""); }; + keep.insert(root.id); + std::function append = [&](std::string parent, int depth) { + for (const auto& e : scene.at("entities")) { + const auto p = e.at("parent").is_string() ? e.at("parent").get() : ""; + if (p != parent) + continue; + const auto id = e.at("id").get(); + auto& row = tree.add(Kind::TreeRow, "entity-" + id, e.at("name")); + keep.insert(row.id); + row.indent = depth; + row.selected = selected == id; + row.drag_payload = {{"kind", "entity"}, {"id", id}, {"label", e.at("name")}}; + row.on_click = [this, id](Widget&) { select(id); }; + row.on_drop = [this, id](Widget&, const Json& payload) { + if (payload.value("kind", std::string()) == "entity") + transaction(Json::array({{{"op", "entity.reparent"}, + {"entity", payload.at("id")}, + {"parent", id}, + {"keep_world", true}}})); + }; + append(id, depth + 1); + } + }; + append("", 1); + trim_children(tree, keep); + // Match retained children to hierarchy order after a reparent without + // changing widget IDs. + std::map order; + std::size_t n = 0; + std::function visit = [&](std::string p) { + for (const auto& e : scene.at("entities")) + if ((e.at("parent").is_string() ? e.at("parent").get() : "") == p) { + const auto id = e.at("id").get(); + order["entity-" + id] = ++n; + visit(id); + } + }; + order["scene-root"] = 0; + visit(""); + std::stable_sort(tree.children.begin(), tree.children.end(), + [&](const auto& a, const auto& b) { return order[a->id] < order[b->id]; }); + } + void set_field(const std::string& component_id, const std::string& field, Json value, + const std::string& widget) { + const auto found = edit_revisions.find(widget); + const auto revision = found == edit_revisions.end() + ? current.at("revision").get() + : found->second; + transaction(Json::array({{{"op", "component.set"}, + {"entity", selected}, + {"component", component_id}, + {"field", field}, + {"value", std::move(value)}}}), + revision); + edit_revisions.erase(widget); + preview_fields.erase(component_id + "/" + field); + } + void bind_field(Widget& w, const std::string& cid, const std::string& field, Json original, + int index = -1) { + const auto id = w.id; + w.on_preview = [this, id, cid, field, original, index](Widget& widget) { + edit_revisions.try_emplace(id, current.at("revision").get()); + if (widget.kind == Kind::NumberField) { + try { + auto value = std::stod(widget.text); + if (!std::isfinite(value)) + return; + Json changed = original; + if (index >= 0) + changed[index] = value; + else if (original.is_number_integer()) + changed = std::int64_t(std::llround(value)); + else + changed = value; + preview_fields[cid + "/" + field] = changed; + } catch (...) { + } + } + }; + w.on_commit = [this, cid, field, original, index, id](Widget& widget) { + Json changed = original; + if (widget.kind == Kind::Checkbox) + changed = widget.checked; + else if (widget.kind == Kind::NumberField) { + if (index >= 0) + changed[index] = widget.value; + else if (original.is_number_integer()) + changed = std::int64_t(std::llround(widget.value)); + else + changed = widget.value; + } else if (original.is_string()) + changed = widget.text; + else + try { + changed = Json::parse(widget.text); + } catch (const std::exception& e) { + report(e.what()); + return; + } + set_field(cid, field, changed, id); + }; + w.on_cancel = [this, cid, field, id](Widget&) { + preview_fields.erase(cid + "/" + field); + edit_revisions.erase(id); + }; + w.on_drop = [this, cid, field, id](Widget&, const Json& payload) { + if (payload.value("kind", std::string()) == "asset") + set_field(cid, field, payload.at("id"), id); + }; + } + void refresh_inspector() { + auto& body = *ui.find("properties"); + std::set keep; + const auto* e = entity(current.at("scene"), selected); + if (!e) { + label(body, "inspector-empty", "Select an object to edit its components"); + keep.insert("inspector-empty"); + trim_children(body, keep); + return; + } + auto& name = body.add(Kind::TextField, "object-name"); + ui.update_text(name.id, e->at("name")); + keep.insert(name.id); + name.on_preview = [this](Widget&) { + edit_revisions.try_emplace("object-name", current.at("revision").get()); + }; + name.on_cancel = [this](Widget& w) { edit_revisions.erase(w.id); }; + name.on_commit = [this](Widget& w) { + transaction( + Json::array({{{"op", "entity.rename"}, {"entity", selected}, {"name", w.text}}}), + edit_revisions.contains(w.id) ? edit_revisions[w.id] + : current.at("revision").get()); + edit_revisions.erase(w.id); + }; + for (const auto& c : e->at("components")) { + const auto cid = c.at("id").get(), type = c.at("type").get(); + const bool known = session.authoring().schemas().contains(type); + const auto metadata = + known ? session.authoring().schemas().schema(type) + : Json{{"name", type + " (schema missing)"}, {"fields", Json::object()}}; + auto& header = body.add(Kind::Row, "component-header-" + cid); + keep.insert(header.id); + header.layout.height = 28; + auto& title = + header.add(Kind::Label, "component-title-" + cid, metadata.value("name", type)); + title.layout.flex = 1; + button( + header, "component-remove-" + cid, "x", + [this, cid] { + transaction(Json::array( + {{{"op", "component.remove"}, {"entity", selected}, {"component", cid}}})); + }, + 25); + for (const auto& [fid, value] : c.at("fields").items()) { + const auto descriptor = metadata.at("fields").value(fid, Json::object()); + const auto key = "field-" + cid + "-" + fid; + auto& row = body.add(Kind::Column, key); + keep.insert(key); + row.layout.gap = 2; + label(row, key + "-label", + descriptor.value("name", fid) + + (descriptor.value("unit", std::string()) == "radians" ? " (rad)" : "")); + const auto& options = descriptor.value("enum", Json::array()); + const bool vector_value = value.is_array() && value.size() >= 2 && + value.size() <= 4 && + std::all_of(value.begin(), value.end(), + [](const Json& v) { return v.is_number(); }); + const auto input_id = key + (vector_value ? "-vector" : "-value"); + const auto input_kind = !options.empty() ? Kind::Button + : vector_value ? Kind::Row + : value.is_boolean() ? Kind::Checkbox + : value.is_number() ? Kind::NumberField + : Kind::TextField; + if (auto* old = row.find(input_id); old && old->kind != input_kind) { + if (old->find(ui.focused_id())) + ui.clear_focus(false); + row.remove(input_id); + } + trim_children(row, {key + "-label", input_id}); + if (!options.empty()) { + auto& input = + button(row, key + "-value", + value.is_string() ? value.get() : value.dump(), + [this, cid, fid, value, options, key] { + auto found = std::find(options.begin(), options.end(), value); + auto index = found == options.end() + ? 0 + : (std::size_t(found - options.begin()) + 1) % + options.size(); + set_field(cid, fid, options[index], key + "-value"); + }); + input.layout.height = 27; + } else if (value.is_boolean()) { + auto& input = row.add(Kind::Checkbox, key + "-value", "Enabled"); + input.checked = value; + bind_field(input, cid, fid, value); + } else if (vector_value) { + auto& vectorrow = row.add(Kind::Row, key + "-vector"); + vectorrow.layout.gap = 3; + for (std::size_t axis = 0; axis < value.size(); ++axis) { + auto& input = + vectorrow.add(Kind::NumberField, key + "-" + std::to_string(axis)); + input.layout.flex = 1; + input.layout.min_width = 35; + ui.update_number(input.id, value[axis].get()); + input.step = .02; + bind_field(input, cid, fid, value, int(axis)); + } + } else if (value.is_number()) { + auto& input = row.add(Kind::NumberField, key + "-value"); + input.step = value.is_number_integer() ? 1 : .05; + input.precision = value.is_number_integer() ? 0 : 3; + ui.update_number(input.id, value.get()); + bind_field(input, cid, fid, value); + } else { + auto& input = row.add(Kind::TextField, key + "-value"); + ui.update_text(input.id, + value.is_string() ? value.get() : value.dump()); + bind_field(input, cid, fid, value); + } + } + } + auto& add = button(body, "add-component", "+ Add Component", + [this] { component_menu = !component_menu; }); + keep.insert(add.id); + if (component_menu) + for (const auto& schema : schemas.at("types")) { + const auto type = schema.at("id").get(); + auto& choice = button( + body, "component-choice-" + type, schema.value("name", type), [this, type] { + if (transaction(Json::array( + {{{"op", "component.add"}, {"entity", selected}, {"type", type}}}))) + component_menu = false; + }); + keep.insert(choice.id); + } + trim_children(body, keep); + } + void refresh_assets() { + const auto now = std::chrono::steady_clock::now(); + if (!assets_dirty && now - last_assets < std::chrono::seconds(1)) + return; + assets_dirty = false; + last_assets = now; + files = Json::array(); + try { + for (const std::string folder : {"Assets", "Scenes"}) { + const auto directory = session.config().project_root / folder; + if (!std::filesystem::exists(directory)) + continue; + std::size_t count = 0; + for (const auto& entry : std::filesystem::recursive_directory_iterator( + directory, std::filesystem::directory_options::skip_permission_denied)) { + if (++count > 4000) + break; + if (!entry.is_regular_file()) + continue; + auto relative = + std::filesystem::relative(entry.path(), session.config().project_root) + .generic_string(); + if (relative.find(".faset-") != std::string::npos) + continue; + if (!asset_filter.empty() && relative.find(asset_filter) == std::string::npos) + continue; + files.push_back(relative); + } + } + } catch (const std::exception& e) { + report(std::string("Asset scan: ") + e.what()); + } + auto result = call("faset_assets"); + if (!result.is_null()) { + if (assets != result.at("assets")) { + view.clearCache(); + picks_key.clear(); + } + assets = result.at("assets"); + } + auto& list = *ui.find("asset-items"); + std::set keep; + for (const auto& file : files) { + const auto path = file.get(); + auto& row = list.add(Kind::TreeRow, "file-" + path, path); + row.layout.height = 25; + row.indent = 1; + row.selected = source_file == path; + row.on_click = [this, path](Widget&) { + source_file = path; + assets_dirty = true; + }; + keep.insert(row.id); + } + for (const auto& asset : assets) { + const auto id = asset.at("id").get(); + const auto name = asset.contains("manifest") + ? std::filesystem::path(asset["manifest"].value("source", id)) + .filename() + .string() + : id; + auto& row = list.add(Kind::TreeRow, "asset-" + id, "Imported / " + name); + row.layout.height = 25; + row.indent = 1; + row.drag_payload = {{"kind", "asset"}, {"id", id}, {"label", name}}; + row.on_click = [this, id](Widget&) { + renderer.set_clipboard(id); + status = "Asset ID copied; drag to viewport or an asset field"; + }; + keep.insert(row.id); + } + if (keep.empty()) { + label(list, "assets-empty", "Place GLB, glTF, PNG or JPEG in Assets, then Import."); + keep.insert("assets-empty"); + } + trim_children(list, keep); + } + void refresh_bottom() { + for (const auto& panel : session.plugin_panels()) { + const auto panel_id = panel.at("id").get(); + const auto dock_id = "plugin-" + panel_id; + auto order = dock.panels("bottom"); + if (std::find(order.begin(), order.end(), dock_id) == order.end()) + dock.move(dock_id, "bottom", order.size()); + auto& tab = ui.find("bottom-tabs")->add(Kind::Tab, "tab-" + dock_id, panel.at("title")); + tab.layout.width = 150; + tab.dock_area = "bottom"; + tab.dock_panel = dock_id; + tab.selected = active_bottom == dock_id; + tab.on_click = [this, dock_id](Widget&) { active_bottom = dock_id; }; + auto& body = ui.find("bottom_panel")->add(Kind::Column, "plugin-panel-" + panel_id); + body.layout.flex = 1; + body.layout.padding = 10; + body.layout.gap = 6; + body.visible = active_bottom == dock_id; + label(body, "plugin-title-" + panel_id, panel.at("title")); + button(body, "plugin-action-" + panel_id, panel.value("action", std::string("Run")), + [this, panel] { + Json arguments = panel.value("arguments", Json::object()); + std::function resolve = [&](Json& value) { + if (value.is_string() && value == "$document") + value = document; + else if (value.is_structured()) + for (auto& item : value) + resolve(item); + }; + resolve(arguments); + auto result = call(panel.at("command"), arguments); + if (!result.is_null()) { + status = "Completed " + panel.at("title").get(); + shown_revision = std::numeric_limits::max(); + } + }); + } + const auto order = dock.panels("bottom"); + auto& tabs = *ui.find("bottom-tabs"); + std::stable_sort(tabs.children.begin(), tabs.children.end(), + [&](const auto& a, const auto& b) { + return std::find(order.begin(), order.end(), a->dock_panel) < + std::find(order.begin(), order.end(), b->dock_panel); + }); + for (const std::string id : {"assets", "console", "jobs"}) + ui.find("tab-" + id)->selected = active_bottom == id; + ui.find("asset-toolbar")->visible = active_bottom == "assets"; + ui.find("asset-items")->visible = active_bottom == "assets"; + ui.find("console-items")->visible = active_bottom == "console"; + ui.find("job-items")->visible = active_bottom == "jobs"; + auto& console = *ui.find("console-items"); + std::set keep; + const auto& logs = session.logs(); + for (std::size_t i = logs.size() > 150 ? logs.size() - 150 : 0; i < logs.size(); ++i) { + std::string line = logs[i]; + std::replace(line.begin(), line.end(), '\n', ' '); + const auto id = "log-" + std::to_string(i); + label(console, id, line); + console.children.back()->layout.height = 24; + keep.insert(id); + } + if (keep.empty()) { + label(console, "log-empty", "No editor messages."); + keep.insert("log-empty"); + } + trim_children(console, keep); + auto result = call("faset_jobs"); + auto& jobs = *ui.find("job-items"); + keep.clear(); + if (!result.is_null()) + for (const auto& job : result.at("jobs")) { + const auto id = job.at("id").get(); + auto& row = jobs.add(Kind::Row, "job-" + id); + row.layout.height = 28; + keep.insert(row.id); + const auto state = job.value("state", std::string()); + auto& text = + row.add(Kind::Label, "job-text-" + id, + job.value("kind", std::string("Job")) + " · " + state + " · " + + job.value("stage", std::string()) + " " + + std::to_string(int(job.value("progress", 0.0) * 100)) + "%"); + text.layout.flex = 1; + auto& cancel = button( + row, "job-cancel-" + id, "Cancel", + [this, id] { call("faset_job_cancel", {{"id", id}}); }, 72); + cancel.enabled = state == "queued" || state == "running"; + } + if (keep.empty()) { + label(jobs, "jobs-empty", "No active import, build or export jobs."); + keep.insert("jobs-empty"); + } + trim_children(jobs, keep); + } + void refresh_overlays() { + auto& popup = *ui.find("menu-popup"); + popup.visible = !menu.empty(); + ui.find("menu-title")->text = menu; + refresh_recovery(); + const bool file = menu == "File" || menu == "Faset", + edit = menu == "Edit" || menu == "Scene", + help = menu == "Help" || menu == "View"; + for (const auto* id : + {"new-3d", "new-2d", "open-path", "open-path-button", "save-path", "save-as-button"}) + ui.find(id)->visible = file; + for (const auto* id : {"menu-duplicate", "menu-delete"}) + ui.find(id)->visible = edit; + for (const auto* id : {"help-one", "help-two", "help-three"}) + ui.find(id)->visible = help; + popup.layout.height = file ? 260 : edit ? 112 : 150; + popup.layout.x = menu == "File" ? 70 + : menu == "Edit" ? 126 + : menu == "Scene" ? 182 + : menu == "View" ? 238 + : menu == "Help" ? 294 + : 5; + auto& command = *ui.find("palette"); + command.visible = palette; + command.layout.x = std::max(0.f, (float(renderer.width()) - 570) / 2); + command.layout.y = 90; + auto& list = *ui.find("palette-list"); + std::set keep; + const auto filter = ui.find("palette-filter")->text; + for (const auto& descriptor : session.commands().list()) { + const auto name = descriptor.at("name").get(); + if (!filter.empty() && name.find(filter) == std::string::npos) + continue; + auto& row = list.add(Kind::TreeRow, "command-" + name, name); + row.selected = command_name == name; + row.on_click = [this, name](Widget&) { command_name = name; }; + keep.insert(row.id); + } + trim_children(list, keep); + } + void refresh_recovery() { + auto& panel = *ui.find("recovery-panel"); + panel.visible = !recovery.empty(); + panel.layout.x = std::max(0.f, (float(renderer.width()) - 470) / 2); + panel.layout.y = 100; + std::set keep; + label(panel, "recovery-title", "Unsaved authoring recovery"); + keep.insert("recovery-title"); + for (const auto& item : recovery) { + const auto id = item.at("id").get(); + auto& restore = + button(panel, "recover-" + id, "Restore " + item.value("name", id), [this, id] { + Json args = {{"document", id}}; + for (const auto& doc : session.authoring().documents()) + if (doc.at("id") == id) + args["revision"] = doc.at("revision"); + auto result = call("faset_recovery_restore", args); + if (!result.is_null()) { + choose_document(result.at("id")); + recovery.erase( + std::remove_if(recovery.begin(), recovery.end(), + [&](const Json& entry) { return entry.at("id") == id; }), + recovery.end()); + } + }); + keep.insert(restore.id); + } + button(panel, "recovery-dismiss", "Continue without restoring", + [this] { recovery.clear(); }); + keep.insert("recovery-dismiss"); + trim_children(panel, keep); + panel.layout.height = 80 + float(recovery.size()) * 33; + } + player::CameraSettings camera() const { + player::CameraSettings c; + c.overrideSceneCamera = true; + c.target = target; + c.eye = add(target, {distance * std::cos(pitch) * std::sin(yaw), distance * std::sin(pitch), + distance * std::cos(pitch) * std::cos(yaw)}); + c.orthographicHeight = ortho; + if (current.at("scene").value("dimension", 3) == 2) + c.eye = add(target, {0, 0, distance}); + return c; + } + void frame_selection() { + if (auto* e = entity(resolved, selected)) + target = point(world(resolved, *e), {}); + else + target = {}; + distance = 8; + ortho = 8; + } + bool project(Vec3 p, render::Vec2& output) const { + const auto& m = rendered.view_projection; + const auto w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]; + if (w <= .0001f) + return false; + const auto x = (m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]) / w, + y = (m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]) / w; + output = {viewport.x + (x + 1) * viewport.width * .5f, + viewport.y + (y + 1) * viewport.height * .5f}; + return true; + } + void line(render::Vec2 a, render::Vec2 b, render::Color color, float thickness = 1) { + const auto length = std::hypot(b[0] - a[0], b[1] - a[1]); + const auto count = std::min(700, std::max(1, int(length / thickness))); + for (int i = 0; i <= count; ++i) { + const auto t = float(i) / count; + ui::Rect q{a[0] + (b[0] - a[0]) * t - thickness * .5f, + a[1] + (b[1] - a[1]) * t - thickness * .5f, thickness + 1, thickness + 1}; + q = q.intersection(viewport); + if (q.width > 0 && q.height > 0) + rendered.ui_quads.push_back({q.x, q.y, q.width, q.height, color, {}, {0, 0, 1, 1}}); + } + } + void build_snapshot() { + auto scene = resolved; + for (auto& e : scene["entities"]) + for (auto& c : e["components"]) + for (auto& [field, value] : c["fields"].items()) { + const auto key = c.at("id").get() + "/" + field; + if (preview_fields.contains(key)) + value = preview_fields[key]; + } + viewport = ui.find("viewport")->rect; + rendered = view.build(scene, viewport.width / std::max(1.f, viewport.height), camera()); + rendered.scene_rect = {viewport.x, viewport.y, viewport.width, viewport.height}; + rendered.clear_color = ui.theme().background; + const auto key = scene.dump() + assets.dump(); + if (key != picks_key) { + picks_key = key; + picks.clear(); + // Picking uses the same cooked meshes and transforms as the visible + // snapshot. + for (const auto& e : scene.at("entities")) { + if (!component(e, "faset.mesh") && !component(e, "faset.sprite")) + continue; + const auto id = e.at("id").get(); + auto isolated = scene; + for (auto& other : isolated["entities"]) + if (other.at("id") != id) { + auto& cs = other["components"]; + cs.erase(std::remove_if(cs.begin(), cs.end(), + [](const Json& c) { + return c.at("type") == "faset.mesh" || + c.at("type") == "faset.sprite"; + }), + cs.end()); + } + auto object = view.build(isolated, 1, camera()); + for (auto& item : object.draws) + picks.push_back({id, std::move(item)}); + for (const auto& sprite : object.sprites) { + auto mesh = std::make_shared(); + mesh->vertices = { + {{-.5f, -.5f, 0}}, {{.5f, -.5f, 0}}, {{.5f, .5f, 0}}, {{-.5f, .5f, 0}}}; + mesh->indices = {0, 1, 2, 0, 2, 3}; + render::DrawItem item; + item.mesh = mesh; + item.model = render::transform(sprite.position, {0, 0, sprite.rotation}, + {sprite.size[0], sprite.size[1], 1}); + picks.push_back({id, item}); + } + } + } + // Grid geometry participates in scene depth, so it never draws through + // objects. It is presentation only and is absent from authoring, picking + // and export. + const bool is2d = scene.value("dimension", 3) == 2; + for (int i = -10; i <= 10; ++i) { + if (is2d) { + rendered.sprites.push_back( + {{float(i), 0, -.02f}, {.012f, 20}, {.18f, .18f, .19f, 1}, 0, {}}); + rendered.sprites.push_back( + {{0, float(i), -.02f}, {20, .012f}, {.18f, .18f, .19f, 1}, 0, {}}); + } else { + render::DrawItem grid; + grid.mesh = render::cube_mesh(); + grid.cast_shadow = false; + grid.color = {.12f, .12f, .13f, 1}; + grid.model = render::transform({float(i), 0, 0}, {}, {.012f, .002f, 20}); + rendered.draws.push_back(grid); + grid.model = render::transform({0, 0, float(i)}, {}, {20, .002f, .012f}); + rendered.draws.push_back(grid); + } + } + gizmo_valid = false; + if (auto* e = entity(scene, selected)) { + gizmo_origin = point(world(scene, *e), {}); + gizmo_valid = project(gizmo_origin, gizmo_screen[0]); + const auto length = is2d ? ortho * .12f : distance * .12f; + for (int axis = 0; axis < 3; ++axis) { + Vec3 p = gizmo_origin; + p[axis] += length; + gizmo_valid = project(p, gizmo_screen[axis + 1]) && gizmo_valid; + } + if (gizmo_valid) { + const render::Color colors[3] = { + {.88f, .35f, .34f, 1}, {.38f, .75f, .49f, 1}, {.4f, .58f, .92f, 1}}; + for (int axis = 0; axis < (is2d ? 2 : 3); ++axis) { + line(gizmo_screen[0], gizmo_screen[axis + 1], colors[axis], 2); + const auto end = gizmo_screen[axis + 1]; + if (viewport.contains(end[0], end[1])) + rendered.ui_quads.push_back( + {end[0] - 4, end[1] - 4, 8, 8, colors[axis], {}, {0, 0, 1, 1}}); + } + } + } + ui.draw(rendered); + } + void pick(float x, float y) { + Mat4 inv; + if (!inverse(rendered.view_projection, inv)) + return; + const auto nx = 2 * (x - viewport.x) / viewport.width - 1, + ny = 2 * (y - viewport.y) / viewport.height - 1; + const auto origin = homogeneous(inv, nx, ny, 0), end = homogeneous(inv, nx, ny, 1), + direction = normalize(sub(end, origin)); + float distance_hit = std::numeric_limits::infinity(); + std::string hit; + for (const auto& candidate : picks) { + const auto& mesh = *candidate.item.mesh; + for (std::size_t i = 0; i + 2 < mesh.indices.size(); i += 3) { + const auto a = point(candidate.item.model, + mesh.vertices.at(mesh.indices[i]).position), + b = point(candidate.item.model, + mesh.vertices.at(mesh.indices[i + 1]).position), + c = point(candidate.item.model, + mesh.vertices.at(mesh.indices[i + 2]).position); + if (ray_triangle(origin, direction, a, b, c, distance_hit)) + hit = candidate.id; + } + } + select(hit); + } + bool start_gizmo(float x, float y) { + if (!gizmo_valid) + return false; + const auto* e = entity(current.at("scene"), selected); + if (!e) + return false; + const auto* c = component(*e, "faset.transform"); + if (!c) + return false; + float best = 8; + int axis = -1; + for (int i = 0; i < (current.at("scene").value("dimension", 3) == 2 ? 2 : 3); ++i) { + auto a = gizmo_screen[0], b = gizmo_screen[i + 1]; + const auto dx = b[0] - a[0], dy = b[1] - a[1], l = dx * dx + dy * dy; + if (l < 16) + continue; + const auto t = std::clamp(((x - a[0]) * dx + (y - a[1]) * dy) / l, 0.f, 1.f); + const auto d = std::hypot(x - a[0] - t * dx, y - a[1] - t * dy); + if (d < best) { + best = d; + axis = i; + } + } + if (axis < 0) + return false; + gizmo_axis = + current.at("scene").value("dimension", 3) == 2 && gizmo_mode == "Rotate" ? 2 : axis; + gizmo_drag_start = gizmo_screen[0]; + gizmo_drag_end = gizmo_screen[axis + 1]; + gizmo_world_length = + (current.at("scene").value("dimension", 3) == 2 ? ortho : distance) * .12f; + gizmo_down_x = x; + gizmo_down_y = y; + gizmo_original = c->at("fields"); + gizmo_component = c->at("id"); + gizmo_revision = current.at("revision"); + return true; + } + void update_gizmo(float x, float y, bool snap) { + if (gizmo_axis < 0) + return; + const auto a = gizmo_drag_start, b = gizmo_drag_end; + const auto dx = b[0] - a[0], dy = b[1] - a[1], l = dx * dx + dy * dy; + const auto amount = l > 1 ? ((x - gizmo_down_x) * dx + (y - gizmo_down_y) * dy) / l : 0; + if (gizmo_mode == "Move") { + Vec3 delta{}; + delta[gizmo_axis] = amount * gizmo_world_length; + if (snap) + delta[gizmo_axis] = std::round(delta[gizmo_axis] * 4) / 4; + const auto* e = entity(current.at("scene"), selected); + if (e && e->at("parent").is_string()) + if (auto* parent = + entity(current.at("scene"), e->at("parent").get())) { + Mat4 inv; + if (inverse(world(current.at("scene"), *parent), inv)) + delta = vector(inv, delta); + } + auto position = vec(gizmo_original.at("position")); + position = add(position, delta); + preview_fields[gizmo_component + "/position"] = position; + } else if (gizmo_mode == "Rotate") { + auto rotation = vec(gizmo_original.at("rotation")); + rotation[gizmo_axis] += snap ? std::round(amount * 12) * .261799f : amount * 3.141593f; + preview_fields[gizmo_component + "/rotation"] = rotation; + } else { + auto scale = vec(gizmo_original.at("scale")); + scale[gizmo_axis] *= std::max(.01f, 1 + amount); + if (snap) + scale[gizmo_axis] = std::max(.05f, std::round(scale[gizmo_axis] * 4) / 4); + preview_fields[gizmo_component + "/scale"] = scale; + } + } + bool viewport_event(const render::Event& event) { + using Type = render::Event::Type; + if (event.type == Type::FocusLost) { + camera_drag = 0; + gizmo_axis = -1; + preview_fields.clear(); + return false; + } + if (event.type == Type::MouseMove) { + const auto dx = event.x - last_x, dy = event.y - last_y; + last_x = event.x; + last_y = event.y; + mouse_x = event.x; + mouse_y = event.y; + if (gizmo_axis >= 0) { + update_gizmo(event.x, event.y, event.control); + return true; + } + if (camera_drag) { + const bool is2d = current.at("scene").value("dimension", 3) == 2; + if (camera_drag == 3 && !is2d) { + yaw -= dx * .008f; + pitch = std::clamp(pitch + dy * .008f, -1.5f, 1.5f); + } else { + const auto right = Vec3{std::cos(yaw), 0, -std::sin(yaw)}; + const auto up = + is2d ? Vec3{0, 1, 0} : normalize(cross(right, sub(camera().eye, target))); + const auto scale = (is2d ? ortho : distance) / std::max(1.f, viewport.height); + target = add(target, add(mul(is2d ? Vec3{1, 0, 0} : right, -dx * scale), + mul(up, dy * scale))); + } + return true; + } + } + if (event.type == Type::MouseDown && viewport.contains(event.x, event.y)) { + last_x = event.x; + last_y = event.y; + if (event.button == 2 || event.button == 3) { + camera_drag = event.button; + return true; + } + if (event.button == 1) { + if (!start_gizmo(event.x, event.y)) + pick(event.x, event.y); + return true; + } + } + if (event.type == Type::MouseUp) { + if (event.button == camera_drag) { + camera_drag = 0; + return true; + } + if (event.button == 1 && gizmo_axis >= 0) { + const auto field = gizmo_mode == "Move" ? "position" + : gizmo_mode == "Rotate" ? "rotation" + : "scale"; + const auto key = gizmo_component + "/" + field; + if (preview_fields.contains(key)) + transaction(Json::array({{{"op", "component.set"}, + {"entity", selected}, + {"component", gizmo_component}, + {"field", field}, + {"value", preview_fields[key]}}}), + gizmo_revision); + preview_fields.erase(key); + gizmo_axis = -1; + return true; + } + } + if (event.type == Type::Wheel && viewport.contains(mouse_x, mouse_y)) { + distance = std::clamp(distance * std::exp(-event.y * .12f), .25f, 500.f); + ortho = std::clamp(ortho * std::exp(-event.y * .12f), .1f, 1000.f); + return true; + } + return false; + } + void events(const std::vector& input) { + using Type = render::Event::Type; + for (const auto& event : input) { + if (event.type == Type::KeyDown && event.key == "Escape") { + if (gizmo_axis >= 0) { + gizmo_axis = -1; + preview_fields.clear(); + continue; + } + if (palette || !menu.empty()) { + palette = false; + menu.clear(); + ui.clear_focus(false); + continue; + } + } + if (event.type == Type::MouseDown && !menu.empty() && + !ui.find("menu-popup")->rect.contains(event.x, event.y) && event.y > 36) + menu.clear(); + if (event.type == Type::MouseMove || event.type == Type::MouseDown) { + mouse_x = event.x; + mouse_y = event.y; + } + if ((palette || !recovery.empty()) && event.type == Type::MouseDown) { + auto* modal = ui.find(!recovery.empty() ? "recovery-panel" : "palette"); + if (!modal->rect.contains(event.x, event.y)) + continue; + } + if (camera_drag || gizmo_axis >= 0) { + if (viewport_event(event)) + continue; + } + if (ui.handle(event)) + continue; + if (event.type == Type::KeyDown) { + if (event.control && (event.key == "S" || event.key == "s")) { + save(); + continue; + } + if (event.control && (event.key == "Z" || event.key == "z")) { + history(event.shift); + continue; + } + if (event.control && (event.key == "P" || event.key == "p")) { + palette = !palette; + continue; + } + if (!ui.editing()) { + if (event.key == "Delete") + delete_selected(); + if (event.key == "F" || event.key == "f") + frame_selection(); + if (event.key == "W" || event.key == "w") + gizmo_mode = "Move"; + if (event.key == "E" || event.key == "e") + gizmo_mode = "Rotate"; + if (event.key == "R" || event.key == "r") + gizmo_mode = "Scale"; + } + } + viewport_event(event); + } + } + void frame(const std::vector& events_) { + session.poll(); + refresh(); + ui.layout(float(renderer.width()), float(renderer.height())); + if (rendered.scene_rect[2] == 0) + build_snapshot(); + events(events_); + refresh(); + ui.layout(float(renderer.width()), float(renderer.height())); + build_snapshot(); + } +}; +EditorUI::EditorUI(Session& s, render::Renderer& r, const std::filesystem::path& font, + const std::filesystem::path& styles) + : impl_(std::make_unique(s, r, font, styles)) {} +EditorUI::~EditorUI() = default; +void EditorUI::frame(const std::vector& input) { + impl_->frame(input); +} +const render::Snapshot& EditorUI::snapshot() const { + return impl_->rendered; +} +ui::Context& EditorUI::widgets() { + return impl_->ui; +} +const std::string& EditorUI::current_document() const { + return impl_->document; +} +void EditorUI::select_document(const std::string& id) { + impl_->choose_document(id); +} +const std::string& EditorUI::selected_entity() const { + return impl_->selected; +} +void EditorUI::select_entity(const std::string& id) { + impl_->select(id); +} +} // namespace faset::editor diff --git a/src/editor/mcp.cpp b/src/editor/mcp.cpp new file mode 100644 index 0000000..7f0e475 --- /dev/null +++ b/src/editor/mcp.cpp @@ -0,0 +1,175 @@ +#include +#include +#ifdef _WIN32 +#define NOMINMAX +#include +#else +#include +#include +#include +#endif + +namespace faset::editor { +namespace { +Json rpc_error(Json id, int code, std::string message, Json data = Json::object()) { + return { + {"jsonrpc", "2.0"}, + {"id", id}, + {"error", {{"code", code}, {"message", std::move(message)}, {"data", std::move(data)}}}}; +} +} // namespace +Json McpServer::parse_error() const { + return rpc_error(nullptr, -32700, "Parse error"); +} +std::optional McpServer::handle(const Json& message) { + if (!message.is_object() || !message.contains("jsonrpc") || message["jsonrpc"] != "2.0" || + !message.contains("method") || !message["method"].is_string()) + return rpc_error(nullptr, -32600, "Invalid Request"); + const auto method = message.at("method").get(); + if (!message.contains("id")) { + if (method == "notifications/initialized" && initialized_) + ready_ = true; + return std::nullopt; + } + const Json id = message.at("id"); + if (!(id.is_string() || id.is_number_integer())) + return rpc_error(nullptr, -32600, "Request ID must be a string or integer"); + const auto params = message.value("params", Json::object()); + if (!params.is_object()) + return rpc_error(id, -32602, "Invalid params"); + auto result = [&](Json value) { + return Json{{"jsonrpc", "2.0"}, {"id", id}, {"result", std::move(value)}}; + }; + if (method == "ping") + return result(Json::object()); + if (method == "initialize") { + if (initialized_) + return rpc_error(id, -32600, "Session already initialized"); + if (!params.contains("protocolVersion") || !params["protocolVersion"].is_string()) + return rpc_error(id, -32602, "protocolVersion is required"); + initialized_ = true; + return result( + {{"protocolVersion", "2025-06-18"}, + {"serverInfo", {{"name", "faset-editor"}, {"version", FASET_VERSION}}}, + {"capabilities", {{"tools", Json::object()}, {"resources", Json::object()}}}, + {"instructions", + "Faset tools edit project documents and manage editor jobs. Query revisions before " + "writes. Player/runtime world access is intentionally unavailable."}}); + } + if (!ready_) + return rpc_error(id, -32002, "Initialize the session before using editor tools"); + if (method == "tools/list") + return result({{"tools", commands_.list()}}); + if (method == "tools/call") { + if (!params.contains("name") || !params["name"].is_string()) + return rpc_error(id, -32602, "Tool name is required"); + try { + auto value = + commands_.call(params.at("name"), params.value("arguments", Json::object())); + Json content = Json::array(); + if (value.is_object() && value.contains("image_base64")) { + content.push_back( + {{"type", "image"}, + {"data", value.at("image_base64")}, + {"mimeType", value.value("mimeType", std::string("image/png"))}}); + value.erase("image_base64"); + } + content.push_back({{"type", "text"}, {"text", value.dump()}}); + return result({{"content", content}, {"structuredContent", value}, {"isError", false}}); + } catch (const Error& error) { + const auto value = Json{{"error", error.json()}}; + return result({{"content", Json::array({{{"type", "text"}, {"text", value.dump()}}})}, + {"structuredContent", value}, + {"isError", true}}); + } catch (const std::exception& error) { + const auto value = + Json{{"error", {{"code", "editor.failure"}, {"message", error.what()}}}}; + return result({{"content", Json::array({{{"type", "text"}, {"text", value.dump()}}})}, + {"structuredContent", value}, + {"isError", true}}); + } + } + if (method == "resources/list") + return result({{"resources", Json::array({{{"uri", "faset://schema"}, + {"name", "Component schema"}, + {"mimeType", "application/json"}}, + {{"uri", "faset://documents"}, + {"name", "Open authoring documents"}, + {"mimeType", "application/json"}}})}}); + if (method == "resources/read") { + if (!params.contains("uri") || !params["uri"].is_string()) + return rpc_error(id, -32602, "Resource URI must be a string"); + const auto uri = params.at("uri").get(); + Json value; + if (uri == "faset://schema") + value = commands_.authoring().schemas().manifest(); + else if (uri == "faset://documents") + value = commands_.authoring().documents(); + else + return rpc_error(id, -32602, "Unknown resource URI"); + return result({{"contents", Json::array({{{"uri", uri}, + {"mimeType", "application/json"}, + {"text", value.dump()}}})}}); + } + return rpc_error(id, -32601, "Method not found"); +} +std::vector StdioTransport::poll() { + std::vector lines; + if (closed_) + return lines; + char bytes[65536]; + std::size_t count = 0; +#ifdef _WIN32 + const auto input = GetStdHandle(STD_INPUT_HANDLE); + const auto type = GetFileType(input); + DWORD available = 0; + if (type == FILE_TYPE_PIPE) { + if (!PeekNamedPipe(input, nullptr, 0, nullptr, &available, nullptr)) { + closed_ = true; + return lines; + } + } else if (type == FILE_TYPE_DISK) + available = sizeof(bytes); + else + return lines; + if (available) { + DWORD read = 0; + if (!ReadFile(input, bytes, std::min(available, sizeof(bytes)), &read, nullptr) || + read == 0) + closed_ = true; + count = read; + } +#else + pollfd input{STDIN_FILENO, POLLIN, 0}; + if (::poll(&input, 1, 0) > 0 && (input.revents & (POLLIN | POLLHUP))) { + const auto read = ::read(STDIN_FILENO, bytes, sizeof(bytes)); + if (read > 0) + count = static_cast(read); + else if (read == 0) + closed_ = true; + else if (errno != EINTR && errno != EAGAIN) + closed_ = true; + } +#endif + buffer_.append(bytes, count); + require(buffer_.size() <= 8 * 1024 * 1024, "mcp.message_size", "MCP input exceeds 8 MiB"); + std::size_t newline = 0; + while ((newline = buffer_.find('\n')) != std::string::npos) { + auto line = buffer_.substr(0, newline); + if (!line.empty() && line.back() == '\r') + line.pop_back(); + if (!line.empty()) + lines.push_back(std::move(line)); + buffer_.erase(0, newline + 1); + } + if (closed_ && !buffer_.empty()) { + lines.push_back(std::move(buffer_)); + buffer_.clear(); + } + return lines; +} +void StdioTransport::send(const Json& value) { + std::cout << value.dump() << '\n'; + std::cout.flush(); +} +} // namespace faset::editor diff --git a/src/editor/plugins.cpp b/src/editor/plugins.cpp new file mode 100644 index 0000000..63e97f9 --- /dev/null +++ b/src/editor/plugins.cpp @@ -0,0 +1,358 @@ +#include +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#define NOMINMAX +#include +#else +#include +#endif + +namespace faset::editor { +namespace { +void append(void* context, const char* bytes, std::uint64_t size) { + auto& output = *static_cast(context); + require(size <= 8 * 1024 * 1024 && output.size() + size <= 8 * 1024 * 1024, + "plugin.output_limit", "Plugin response exceeds 8 MiB"); + output.append(bytes, static_cast(size)); +} +Json response(const std::string& bytes) { + auto value = Json::parse(bytes); + require(value.is_object(), "plugin.response", "Plugin response must be a JSON object"); + return value; +} +bool identifier(const std::string& value) { + return !value.empty() && value.size() <= 128 && + std::all_of(value.begin(), value.end(), [](unsigned char c) { + return c < 128 && (std::isalnum(c) || c == '.' || c == '_' || c == '-'); + }); +} +std::string prefix(std::string id) { + for (auto& c : id) + if (c == '.' || c == '-') + c = '_'; + return "plugin_" + id + "_"; +} +} // namespace +struct PluginManager::Impl { + struct Registration { + Json descriptor; + FasetCommand callback = nullptr; + void* user = nullptr; + }; + struct Module { + Impl* owner = nullptr; + Json manifest; + std::filesystem::path directory; + std::vector commands; + Json panels = Json::array(); + std::vector installed; + FasetEditorHost host{}; + FasetEditorPlugin plugin{}; + void* library = nullptr; + bool ready = false; + std::string failure; + ~Module() { + ready = false; + for (const auto& name : installed) + owner->commands.remove(name); + if (plugin.shutdown) + try { + plugin.shutdown(plugin.user); + } catch (...) { /* Plugin contract forbids exceptions. */ + } +#ifdef _WIN32 + if (library) + FreeLibrary(static_cast(library)); +#else + if (library) + dlclose(library); +#endif + } + std::string id() const { + return manifest.at("id"); + } + void check_thread() const { + require(std::this_thread::get_id() == owner->thread, "plugin.thread", + "Editor SDK calls must run on the Editor thread"); + } + }; + Commands& commands; + Logger logger; + std::thread::id thread = std::this_thread::get_id(); + std::vector> modules; + Json records = Json::array(); + bool attempted = false; + unsigned call_depth = 0; + Impl(Commands& value, Logger output) : commands(value), logger(std::move(output)) {} + ~Impl() { + while (!modules.empty()) + modules.pop_back(); + } + static int command(void* context, const char* descriptor, FasetCommand callback, void* user) { + auto& module = *static_cast(context); + try { + module.check_thread(); + require(!module.ready, "plugin.registration_closed", "Registrations are startup-only"); + require(callback, "plugin.callback", "Command callback is missing"); + const auto value = Json::parse(descriptor); + const auto name = value.at("name").get(); + require(name.starts_with(prefix(module.id())) && identifier(name), + "plugin.command_owner", "Plugin command must use its module prefix"); + require(value.at("description").is_string() && + value.at("inputSchema").at("type") == "object" && + value.at("inputSchema").at("properties").is_object(), + "plugin.command_schema", "Command requires an object input schema"); + for (const auto& prior : module.commands) + require(prior.descriptor.at("name") != name, "plugin.command_duplicate", + "Duplicate plugin command"); + module.commands.push_back({value, callback, user}); + return 0; + } catch (const std::exception& error) { + module.failure = error.what(); + return 1; + } + } + static int panel(void* context, const char* descriptor) { + auto& module = *static_cast(context); + try { + module.check_thread(); + require(!module.ready, "plugin.registration_closed", "Registrations are startup-only"); + auto value = Json::parse(descriptor); + require(value.at("id").get().starts_with(module.id() + "."), + "plugin.panel_owner", "Panel ID must belong to its module"); + require(value.at("title").is_string() && value.at("command").is_string(), + "plugin.panel", "Panel needs a title and command"); + if (!value.contains("arguments")) + value["arguments"] = Json::object(); + require(value["arguments"].is_object(), "plugin.panel", + "Panel arguments must be an object"); + for (const auto& previous : module.panels) + require(previous["id"] != value["id"], "plugin.panel_duplicate", + "Duplicate panel ID"); + value["owner"] = module.id(); + module.panels.push_back(std::move(value)); + return 0; + } catch (const std::exception& error) { + module.failure = error.what(); + return 1; + } + } + static int invoke(void* context, const char* name, const char* arguments, FasetWrite write, + void* receiver) { + auto& module = *static_cast(context); + bool entered = false; + try { + module.check_thread(); + require(module.ready, "plugin.not_ready", + "Editor commands become available after plugin startup"); + require(module.owner->call_depth < 32, "plugin.recursion", + "Plugin command recursion limit exceeded"); + ++module.owner->call_depth; + entered = true; + const auto output = module.owner->commands.call(name, Json::parse(arguments)).dump(); + --module.owner->call_depth; + entered = false; + write(receiver, output.data(), output.size()); + return 0; + } catch (const std::exception& error) { + if (entered) + --module.owner->call_depth; + const auto* known = dynamic_cast(&error); + const auto output = + (known ? known->json() + : Json{{"code", "plugin.command"}, {"message", error.what()}}) + .dump(); + if (write) + try { + write(receiver, output.data(), output.size()); + } catch (...) { + } + return 1; + } + } + static void log(void* context, const char* text) { + auto& module = *static_cast(context); + try { + module.check_thread(); + module.owner->logger(module.id() + ": " + text); + } catch (...) { + } + } + void activate(const Json& manifest, const std::filesystem::path& directory) { + auto module = std::make_unique(); + module->owner = this; + module->manifest = manifest; + module->directory = directory; + const auto path = project_path(directory, manifest.at("library").get()); + require(std::filesystem::is_regular_file(path), "plugin.library", + "Plugin library is missing"); +#ifdef _WIN32 + module->library = + LoadLibraryExW(path.c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); + auto entry = module->library + ? reinterpret_cast(GetProcAddress( + static_cast(module->library), "faset_editor_plugin")) + : nullptr; +#else + module->library = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); + const auto error = module->library ? nullptr : dlerror(); + require(module->library, "plugin.load", error ? error : "Cannot load plugin library"); + auto entry = + reinterpret_cast(dlsym(module->library, "faset_editor_plugin")); +#endif + require(module->library && entry, "plugin.entry", + "Library does not export faset_editor_plugin"); + module->host = {FASET_EDITOR_API_VERSION, + sizeof(FasetEditorHost), + FASET_EDITOR_SDK_FINGERPRINT, + module.get(), + command, + panel, + invoke, + log}; + require(entry(&module->host, &module->plugin) == 0, "plugin.startup", + "Plugin startup failed"); + require(module->failure.empty(), "plugin.registration", module->failure); + require(module->plugin.api_version == FASET_EDITOR_API_VERSION && + module->plugin.struct_size == sizeof(FasetEditorPlugin), + "plugin.api", "Plugin returned an incompatible API"); + require(module->plugin.build_fingerprint && + std::string(module->plugin.build_fingerprint) == FASET_EDITOR_SDK_FINGERPRINT, + "plugin.build", "Plugin binary does not match this Editor SDK build"); + for (const auto& value : module->panels) { + const auto name = value.at("command").get(); + require(std::any_of(module->commands.begin(), module->commands.end(), + [&](const auto& reg) { return reg.descriptor.at("name") == name; }), + "plugin.panel_command", "Panel command must be registered by its owner"); + } + for (const auto& registration : module->commands) { + const auto& descriptor = registration.descriptor; + const auto name = descriptor.at("name").get(); + commands.add( + name, descriptor.at("description"), descriptor.at("inputSchema"), + [registration](const Json& arguments) { + std::string output; + const auto input = arguments.dump(); + const int result = + registration.callback(registration.user, input.c_str(), append, &output); + const auto value = response(output); + if (result != 0) + throw Error(value.value("code", std::string("plugin.failed")), + value.value("message", std::string("Plugin command failed")), + value); + return value; + }, + descriptor.value("read_only", false)); + module->installed.push_back(name); + } + module->ready = true; + logger("Loaded editor plugin: " + module->id()); + modules.push_back(std::move(module)); + } +}; +PluginManager::PluginManager(Commands& commands, Logger logger) + : impl_(std::make_unique(commands, std::move(logger))) {} +PluginManager::~PluginManager() = default; +std::string PluginManager::fingerprint() { + return FASET_EDITOR_SDK_FINGERPRINT; +} +Json PluginManager::status() const { + return impl_->records; +} +Json PluginManager::panels() const { + Json result = Json::array(); + for (const auto& module : impl_->modules) + for (const auto& panel : module->panels) + result.push_back(panel); + return result; +} +void PluginManager::load(const std::filesystem::path& directory) { + require(!impl_->attempted, "plugin.restart_required", + "Plugin discovery runs once; restart the Editor after changing packages"); + impl_->attempted = true; + if (!std::filesystem::exists(directory)) + return; + struct Source { + Json manifest; + std::filesystem::path directory; + }; + std::map sources; + std::set invalid; + auto failure = [&](const std::string& id, const std::string& message) { + invalid.insert(id); + impl_->records.push_back({{"id", id}, {"state", "failed"}, {"message", message}}); + impl_->logger("Plugin " + id + ": " + message); + }; + for (const auto& entry : std::filesystem::recursive_directory_iterator(directory)) + if (entry.is_regular_file() && + entry.path().filename().string().ends_with(".faset-plugin.json")) { + std::string id = entry.path().filename().string(); + try { + const auto manifest = read_json(entry.path()); + id = manifest.at("id"); + require(identifier(id), "plugin.id", "Invalid module ID"); + require(!sources.contains(id), "plugin.duplicate", "Duplicate module ID"); + require(manifest.at("format") == "faset.editor_plugin" && + manifest.at("version") == 1 && manifest.at("kind") == "editor", + "plugin.manifest", "Unsupported editor plugin manifest"); + require(manifest.at("module_version").is_string() && + manifest.at("dependencies").is_array(), + "plugin.manifest", "Plugin needs version and dependency list"); + require(manifest.at("api_version") == FASET_EDITOR_API_VERSION && + manifest.at("build_fingerprint") == fingerprint(), + "plugin.compatibility", + "Plugin manifest does not match this Editor SDK; rebuild it"); + project_path(entry.path().parent_path(), manifest.at("library").get()); + sources.emplace(id, Source{manifest, entry.path().parent_path()}); + } catch (const std::exception& error) { + failure(id, error.what()); + } + } + std::map colors; + std::vector order; + std::function visit = [&](const std::string& id) { + require(sources.contains(id) && !invalid.contains(id), "plugin.dependency", + "Missing or invalid dependency: " + id); + require(colors[id] != 1, "plugin.cycle", "Plugin dependency cycle at " + id); + if (colors[id] == 2) + return; + colors[id] = 1; + for (const auto& dependency : sources.at(id).manifest.at("dependencies")) { + const std::string required = dependency.at("id"); + visit(required); + require(sources.at(required).manifest.at("module_version") == dependency.at("version"), + "plugin.dependency_version", "Dependency version mismatch: " + required); + } + colors[id] = 2; + order.push_back(id); + }; + for (const auto& [id, source] : sources) + try { + visit(id); + } catch (const std::exception& error) { + failure(id, error.what()); + } + std::set loaded; + for (const auto& id : order) + if (!invalid.contains(id)) + try { + const auto& source = sources.at(id); + for (const auto& dependency : source.manifest.at("dependencies")) + require(loaded.contains(dependency.at("id").get()), + "plugin.dependency_failed", "A required plugin failed to load"); + impl_->activate(source.manifest, source.directory); + loaded.insert(id); + impl_->records.push_back({{"id", id}, + {"version", source.manifest.at("module_version")}, + {"state", "loaded"}}); + } catch (const std::exception& error) { + failure(id, error.what()); + } +} +} // namespace faset::editor diff --git a/src/editor/session.cpp b/src/editor/session.cpp new file mode 100644 index 0000000..5cf1710 --- /dev/null +++ b/src/editor/session.cpp @@ -0,0 +1,358 @@ +#include +#include +#include +#include +#include + +namespace faset::editor { +namespace { +std::string executable_name(const std::string& name) { +#ifdef _WIN32 + return name + ".exe"; +#else + return name; +#endif +} +BuildConfig build_config(const SessionConfig& config) { + BuildConfig result; + result.project_root = config.project_root; + result.engine_root = config.engine_root; + result.build_directory = config.project_root / ".faset/build"; + result.cache_root = config.project_root / ".faset/cache"; + return result; +} +Json resolved_or_throw(Commands& commands, const std::string& id) { + const auto resolved = commands.resolved_scene(id); + if (!resolved.at("conflicts").empty()) + throw Error("template.conflicts", "Resolve template conflicts before Play or export", + {{"conflicts", resolved.at("conflicts")}}); + return resolved.at("scene"); +} +} // namespace +struct Session::ImportTask { + std::string id; + std::shared_ptr job = std::make_shared(); + mutable std::mutex mutex; + std::string state = "queued", error; + Json result = Json::object(); + Json json() const { + std::lock_guard lock(mutex); + const auto progress = job->progress(); + return {{"id", id}, + {"kind", "import"}, + {"state", state}, + {"stage", progress.stage}, + {"progress", progress.fraction}, + {"error", error}, + {"result", result}}; + } +}; +Session::Session(SessionConfig config) + : config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_), + assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)) { + register_commands(); + plugins_ = std::make_unique( + commands_, [this](std::string message) { log(std::move(message)); }); + plugins_->load(config_.project_root / "Plugins"); + const auto schema = config_.project_root / ".faset/schema.json"; + if (std::filesystem::exists(schema)) + try { + load_schema(schema); + } catch (const std::exception& error) { + log(std::string("Schema load failed: ") + error.what()); + } +} +Session::~Session() { + for (const auto& [id, task] : imports_) + task->job->cancel(); + workers_.clear(); + stop_player(); +} +void Session::log(std::string value) { + if (value.empty()) + return; + logs_.push_back(std::move(value)); + if (logs_.size() > 1000) + logs_.erase(logs_.begin(), logs_.begin() + 100); +} +Json Session::project() const { + const auto path = config_.project_root / "project.faset.json"; + if (std::filesystem::exists(path)) + return read_json(path); + return {{"format", "faset.project"}, + {"version", 1}, + {"name", config_.project_root.filename().string()}, + {"dimension", 3}}; +} +void Session::scaffold(const std::string& name, int dimension) { + builds_.scaffold(name, dimension); + log("Created project: " + name); +} +Json Session::assets_list() const { + Json list = Json::array(); + const auto directory = assets_.cache_root() / "assets"; + if (std::filesystem::exists(directory)) + for (const auto& entry : std::filesystem::directory_iterator(directory)) + if (entry.is_directory()) { + try { + const auto id = entry.path().filename().string(); + const auto manifest = assets_.current_manifest(id); + list.push_back({{"id", id}, {"manifest", manifest}}); + } catch (const std::exception& error) { + list.push_back( + {{"id", entry.path().filename().string()}, {"error", error.what()}}); + } + } + return {{"assets", list}}; +} +Json Session::jobs() const { + Json list = Json::array(); + for (const auto& item : builds_.jobs()) + list.push_back(item.json()); + for (const auto& [id, task] : imports_) + list.push_back(task->json()); + return {{"jobs", list}}; +} +Json Session::job(const std::string& id) const { + if (imports_.contains(id)) + return imports_.at(id)->json(); + return builds_.job(id).json(); +} +void Session::load_schema(const std::filesystem::path& path) { + const auto value = read_json(path); + authoring_.replace_external_schemas(value); + const auto output = config_.project_root / ".faset/schema.json"; + if (std::filesystem::weakly_canonical(path) != std::filesystem::weakly_canonical(output)) + atomic_write_json(output, value); + log("Gameplay schema loaded"); +} +void Session::launch_player(Json scene, const std::filesystem::path& executable) { + require(std::filesystem::is_regular_file(executable), "play.missing_player", + "Build the Player before starting Play"); + const auto directory = config_.project_root / ".faset/play" / new_id(); + std::filesystem::create_directories(directory); + const auto snapshot = directory / "scene.fscene"; + write_cooked_scene(snapshot, scene); + control_path_ = directory / "control.json"; + control_sequence_ = 0; + ProcessOptions options; + options.arguments = { + executable.string(), "--scene", snapshot.string(), "--assets", + assets_.cache_root().string(), "--control", control_path_.string()}; + options.working_directory = config_.project_root; + player_ = std::make_unique(options); + log("Play started in a separate Player process"); +} +void Session::stop_player() { + if (!pending_play_job_.empty()) { + builds_.cancel(pending_play_job_); + pending_play_job_.clear(); + pending_play_scene_ = nullptr; + } + if (player_) { + player_->cancel(); + const auto result = player_->poll(); + log(result.output); + player_.reset(); + log("Play stopped; authoring scene unchanged"); + } +} +void Session::poll() { + if (player_) { + const auto result = player_->poll(); + log(result.output); + if (!result.running) { + log("Player exited with code " + std::to_string(result.exit_code.value_or(-1))); + player_.reset(); + } + } + for (const auto& value : builds_.jobs()) { + if (observed_jobs_[value.id] == value.state) + continue; + observed_jobs_[value.id] = value.state; + if (value.state == "failed") + log(value.kind + " failed: " + value.error); + if (value.state == "succeeded") { + log(value.kind + " completed"); + if (value.result.contains("schema")) + try { + load_schema(value.result.at("schema").get()); + } catch (const std::exception& error) { + log(std::string("Schema update failed: ") + error.what()); + } + } + if (value.id == pending_play_job_ && value.finished()) { + pending_play_job_.clear(); + if (value.state == "succeeded") + try { + const auto executable = + value.result.value("player", (builds_.config().build_directory / + executable_name("faset_player")) + .string()); + launch_player(pending_play_scene_, executable); + } catch (const std::exception& error) { + log(std::string("Play failed: ") + error.what()); + } + pending_play_scene_ = nullptr; + } + } + for (const auto& [id, task] : imports_) { + const auto value = task->json(); + const auto status = value.at("state").get(); + if (observed_jobs_[id] == status) + continue; + observed_jobs_[id] = status; + if (status == "succeeded") + log("Asset import completed: " + value["result"].value("asset_id", std::string())); + if (status == "failed" || status == "conflict") + log("Asset import " + status + ": " + value.value("error", std::string())); + } +} +void Session::register_commands() { + const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}}; + auto schema = [](Json properties, Json required = Json::array()) { + return Commands::object_schema(std::move(properties), std::move(required)); + }; + commands_.add( + "faset_capabilities", "Inspect available Editor services and rendering capabilities.", + schema(Json::object()), + [&](const Json&) { + bool screenshot = false; + for (const auto& command : commands_.list()) + if (command.at("name") == "faset_editor_capture") + screenshot = true; + return Json{{"authoring", true}, + {"build", true}, + {"import", true}, + {"plugins", true}, + {"viewport_capture", screenshot}, + {"runtime_entity_access", false}, + {"protocol", "2025-06-18"}}; + }, + true); + commands_.add( + "faset_plugins", "Inspect startup-loaded Editor plugins and exact SDK compatibility.", + schema(Json::object()), + [&](const Json&) { + return Json{{"sdk_fingerprint", PluginManager::fingerprint()}, + {"plugins", plugins_->status()}, + {"panels", plugins_->panels()}}; + }, + true); + commands_.add( + "faset_project", "Read the authoring project's settings.", schema(Json::object()), + [&](const Json&) { return project(); }, true); + commands_.add( + "faset_assets", "List imported asset manifests and resource identities.", + schema(Json::object()), [&](const Json&) { return assets_list(); }, true); + commands_.add("faset_import", + "Import GLB/glTF or a Blender export manifest relative to this project. Returns " + "a cancellable job ID; failure retains the last successful generation.", + schema({{"path", text}, + {"settings", {{"type", "object"}}}, + {"allow_removed_outputs", boolean}}, + {"path"}), + [&](const Json& args) { + assets::ImportRequest request; + request.source = + project_path(config_.project_root, args.at("path").get()); + request.settings = args.value("settings", Json(nullptr)); + request.allow_removed_outputs = args.value("allow_removed_outputs", false); + auto task = std::make_shared(); + task->id = "import-" + new_id(); + imports_[task->id] = task; + workers_.emplace_back([this, task, request] { + { + std::lock_guard lock(task->mutex); + task->state = "running"; + } + try { + const auto result = assets_.import_asset(request, *task->job); + std::lock_guard lock(task->mutex); + task->state = + result.status == assets::ImportStatus::succeeded ? "succeeded" + : result.status == assets::ImportStatus::cancelled ? "cancelled" + : result.status == assets::ImportStatus::conflict ? "conflict" + : "failed"; + task->result = {{"asset_id", result.asset_id}, + {"generation", result.generation}, + {"diagnostics", result.diagnostics}, + {"cache_hit", result.cache_hit}, + {"manifest", result.manifest}}; + for (const auto& message : result.diagnostics) + task->error += message + "\n"; + } catch (const std::exception& error) { + std::lock_guard lock(task->mutex); + task->state = "failed"; + task->error = error.what(); + } + }); + return Json{{"job", task->id}}; + }); + commands_.add("faset_build", + "Incrementally compile C++ gameplay and export its metadata in separate native " + "processes. Returns a job ID.", + schema(Json::object()), + [&](const Json&) { return Json{{"job", builds_.start_build()}}; }); + commands_.add("faset_export", + "Build, validate and export a resolved authoring snapshot to a project-relative " + "output directory. Returns a job ID.", + schema({{"document", text}, {"output", text}}, {"document", "output"}), + [&](const Json& args) { + return Json{{"job", builds_.start_export( + resolved_or_throw(commands_, args.at("document")), + project_path(config_.project_root, + args.at("output").get()))}}; + }); + commands_.add( + "faset_jobs", "List editor import/build/export jobs and their progress.", + schema(Json::object()), [&](const Json&) { return jobs(); }, true); + commands_.add( + "faset_job", "Read an editor job's progress, result and diagnostics.", + schema({{"id", text}}, {"id"}), [&](const Json& args) { return job(args.at("id")); }, true); + commands_.add("faset_job_cancel", + "Cancel an editor job. Cancellation is separate from authoring Undo.", + schema({{"id", text}}, {"id"}), [&](const Json& args) { + const auto id = args.at("id").get(); + if (imports_.contains(id)) + imports_.at(id)->job->cancel(); + else + builds_.cancel(id); + return Json{{"cancel_requested", true}}; + }); + commands_.add("faset_play", + "Build gameplay, then start a separate Player from an immutable snapshot of the " + "current authoring document. Returns the build job ID.", + schema({{"document", text}}, {"document"}), [&](const Json& args) { + stop_player(); + pending_play_scene_ = resolved_or_throw(commands_, args.at("document")); + pending_play_job_ = builds_.start_build(); + return Json{{"job", pending_play_job_}, {"play_pending", true}}; + }); + commands_.add( + "faset_stop", + "Stop editor Play or cancel its pending build. Does not modify the authoring scene.", + schema(Json::object()), [&](const Json&) { + stop_player(); + return Json{{"stopped", true}}; + }); + commands_.add("faset_play_control", + "Pause, resume, or single-step the Editor's Player session. No game entities or " + "state are exposed.", + schema({{"command", {{"type", "string"}, {"enum", {"pause", "resume", "step"}}}}}, + {"command"}), + [&](const Json& args) { + require(bool(player_), "play.not_running", "Player is not running"); + const auto command = args.at("command").get(); + require(command == "pause" || command == "resume" || command == "step", + "play.command", "Unknown Play control"); + atomic_write_json(control_path_, + {{"sequence", ++control_sequence_}, {"command", command}}); + return Json{{"queued", true}}; + }); + commands_.add( + "faset_editor_logs", + "Read compiler, importer and process diagnostics collected by this Editor session.", + schema(Json::object()), [&](const Json&) { return Json{{"logs", logs_}}; }, true); +} +} // namespace faset::editor diff --git a/src/player/SceneView.cpp b/src/player/SceneView.cpp new file mode 100644 index 0000000..5f7d6a1 --- /dev/null +++ b/src/player/SceneView.cpp @@ -0,0 +1,378 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(FASET_HAS_STB) +#define STB_IMAGE_IMPLEMENTATION +#define STBI_NO_STDIO +#include +#endif + +namespace faset::player { +namespace { +using Json = nlohmann::json; +Json properties(const Json& entity, const std::string& name) { + if (entity.contains(name)) + return entity.at(name); + if (entity.contains("components")) + for (const auto& c : entity["components"]) + if (c.at("type") == "faset." + name) + return c.at("fields"); + return Json{}; +} +template +std::array vec(const Json& value, const char* name, std::array fallback) { + if (value.is_null() || !value.contains(name)) + return fallback; + auto result = value.at(name).get>(); + for (float v : result) + if (!std::isfinite(v)) + throw std::runtime_error("nonfinite scene vector"); + return result; +} +render::Vec3 point(const render::Mat4& m, render::Vec3 p) { + return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], + m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], + m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]}; +} +render::Vec3 direction(const render::Mat4& m, render::Vec3 p) { + return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2], + m[2] * p[0] + m[6] * p[1] + m[10] * p[2]}; +} +std::pair reference(const std::string& ref) { + const auto hash = ref.find('#'); + return {ref.substr(0, hash), hash == std::string::npos ? std::string{} : ref.substr(hash + 1)}; +} +std::shared_ptr plane() { + static const auto mesh = []() { + auto out = std::make_shared(); + out->vertices = {{{-.5f, 0, -.5f}, {0, 1, 0}, {1, 1, 1, 1}, {0, 0}}, + {{.5f, 0, -.5f}, {0, 1, 0}, {1, 1, 1, 1}, {1, 0}}, + {{.5f, 0, .5f}, {0, 1, 0}, {1, 1, 1, 1}, {1, 1}}, + {{-.5f, 0, .5f}, {0, 1, 0}, {1, 1, 1, 1}, {0, 1}}}; + out->indices = {0, 2, 1, 0, 3, 2}; + return out; + }(); + return mesh; +} +} // namespace +struct SceneView::Impl { + struct Bundle { + assets::CookedAsset data; + std::vector>> meshes; + std::vector> textures; + }; + assets::AssetStore pipeline; + std::unordered_map bundles; + std::vector messages; + explicit Impl(std::filesystem::path path) : pipeline(std::move(path)) {} + Bundle& bundle(const std::string& id) { + if (auto it = bundles.find(id); it != bundles.end()) + return it->second; + Bundle result; + result.data = pipeline.load_asset(id); + for (const auto& mesh : result.data.meshes) { + auto& primitives = result.meshes.emplace_back(); + for (const auto& primitive : mesh.primitives) { + auto converted = std::make_shared(); + converted->indices = primitive.indices; + converted->vertices.reserve(primitive.vertices.size()); + for (const auto& vertex : primitive.vertices) + converted->vertices.push_back( + {vertex.position, vertex.normal, {1, 1, 1, 1}, vertex.uv}); + primitives.push_back(std::move(converted)); + } + } + for (const auto& texture : result.data.textures) { + std::shared_ptr converted; +#if defined(FASET_HAS_STB) + if (texture.bytes.size() > std::size_t(std::numeric_limits::max())) + throw std::runtime_error("Encoded texture exceeds decoder limit"); + int width = 0, height = 0, channels = 0; + if (!stbi_info_from_memory(reinterpret_cast(texture.bytes.data()), + static_cast(texture.bytes.size()), &width, &height, + &channels)) + throw std::runtime_error("Cannot read texture dimensions: " + texture.id); + if (width <= 0 || height <= 0 || width > 16384 || height > 16384 || + std::uint64_t(width) * std::uint64_t(height) > 64 * 1024 * 1024) + throw std::runtime_error("Texture exceeds decoder image limits"); + auto pixels = stbi_load_from_memory( + reinterpret_cast(texture.bytes.data()), + static_cast(texture.bytes.size()), &width, &height, &channels, 4); + if (!pixels) + throw std::runtime_error("Cannot decode texture " + texture.id + ": " + + std::string(stbi_failure_reason() ? stbi_failure_reason() + : "unsupported image")); + std::unique_ptr guard(pixels, + &stbi_image_free); + if (width <= 0 || height <= 0 || width > 16384 || height > 16384) + throw std::runtime_error("Texture exceeds 16384 dimension limit"); + converted = std::make_shared(); + converted->width = width; + converted->height = height; + converted->srgb = true; + converted->rgba.assign(pixels, pixels + std::size_t(width) * std::size_t(height) * 4); +#else + throw std::runtime_error("Image decoding was not enabled for this Player build"); +#endif + result.textures.push_back(std::move(converted)); + } + return bundles.emplace(id, std::move(result)).first->second; + } + std::shared_ptr texture(const std::string& ref) { + auto [id, selector] = reference(ref); + auto& asset = bundle(id); + if (asset.textures.empty()) + throw std::runtime_error("Asset has no texture: " + ref); + if (selector.empty()) + return asset.textures.front(); + for (std::size_t i = 0; i < asset.data.textures.size(); ++i) + if (asset.data.textures[i].id == selector) + return asset.textures[i]; + throw std::runtime_error("Texture subasset does not exist: " + ref); + } + void imported(render::Snapshot& out, const std::string& ref, const render::Mat4& model, + render::Color tint) { + const auto [id, selector] = reference(ref); + auto& asset = bundle(id); + auto emit = [&](std::size_t meshIndex, const render::Mat4& local) { + if (meshIndex >= asset.meshes.size()) + throw std::runtime_error("Invalid cooked mesh index"); + for (std::size_t p = 0; p < asset.meshes[meshIndex].size(); ++p) { + render::DrawItem draw; + draw.mesh = asset.meshes[meshIndex][p]; + draw.model = render::multiply(model, local); + draw.color = tint; + const auto material = asset.data.meshes[meshIndex].primitives[p].material; + if (material >= 0) { + if (std::size_t(material) >= asset.data.materials.size()) + throw std::runtime_error("Invalid cooked material index"); + const auto& m = asset.data.materials[material]; + for (int i = 0; i < 4; ++i) + draw.color[i] *= m.base_color[i]; + draw.roughness = m.roughness; + draw.metallic = m.metallic; + if (m.base_color_texture >= 0) { + if (std::size_t(m.base_color_texture) >= asset.textures.size()) + throw std::runtime_error("Invalid base-color texture index"); + draw.texture = asset.textures[m.base_color_texture]; + } + if (m.normal_texture >= 0 || m.metallic_roughness_texture >= 0 || + m.alpha_mode != "OPAQUE" || m.unlit || m.double_sided) + messages.push_back( + "warning: material " + m.id + + " has features beyond the initial base-color/PBR renderer"); + } + out.draws.push_back(std::move(draw)); + } + }; + if (!selector.empty()) + for (std::size_t i = 0; i < asset.data.meshes.size(); ++i) + if (asset.data.meshes[i].id == selector) { + emit(i, render::identity); + return; + } + std::unordered_map nodes; + for (const auto& node : asset.data.nodes) + nodes.emplace(node.id, &node); + if (!selector.empty() && !nodes.contains(selector)) + throw std::runtime_error("Node/mesh subasset does not exist: " + ref); + std::unordered_map matrices; + std::set active; + auto world = [&](auto&& self, const assets::Node& node) -> render::Mat4 { + if (auto it = matrices.find(node.id); it != matrices.end()) + return it->second; + if (!active.insert(node.id).second) + throw std::runtime_error("Cyclic cooked node hierarchy"); + auto matrix = node.local_transform; + if (!node.parent_id.empty()) { + auto parent = nodes.find(node.parent_id); + if (parent == nodes.end()) + throw std::runtime_error("Missing cooked parent node"); + matrix = render::multiply(self(self, *parent->second), matrix); + } + active.erase(node.id); + return matrices.emplace(node.id, matrix).first->second; + }; + if (asset.data.nodes.empty()) + for (std::size_t i = 0; i < asset.meshes.size(); ++i) + emit(i, render::identity); + for (const auto& node : asset.data.nodes) + if (node.mesh >= 0) { + bool selected = selector.empty(); + auto current = &node; + std::set seen; + while (!selected && current && seen.insert(current->id).second) { + selected = current->id == selector; + auto parent = nodes.find(current->parent_id); + current = parent == nodes.end() ? nullptr : parent->second; + } + if (selected) + emit(static_cast(node.mesh), world(world, node)); + } + } +}; +SceneView::SceneView(std::filesystem::path cacheRoot) + : impl_(std::make_unique(std::move(cacheRoot))) {} +SceneView::~SceneView() = default; +void SceneView::clearCache() { + impl_->bundles.clear(); +} +const std::vector& SceneView::diagnostics() const { + return impl_->messages; +} +render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSettings camera) { + if (!std::isfinite(aspect) || aspect <= 0) + throw std::invalid_argument("Viewport aspect must be positive"); + impl_->messages.clear(); + render::Snapshot out; + const auto& entities = scene.at("entities"); + if (!entities.is_array()) + throw std::invalid_argument("Scene entities must be an array"); + std::unordered_map byId; + for (const auto& entity : entities) + if (!byId.emplace(entity.at("id").get(), &entity).second) + throw std::invalid_argument("Duplicate scene ID"); + std::unordered_map matrices; + std::set active; + auto world = [&](auto&& self, const Json& entity) -> render::Mat4 { + auto id = entity.at("id").get(); + if (auto it = matrices.find(id); it != matrices.end()) + return it->second; + if (!active.insert(id).second) + throw std::invalid_argument("Cyclic scene hierarchy"); + const auto fields = properties(entity, "transform"); + auto matrix = render::transform(vec<3>(fields, "position", {0, 0, 0}), + vec<3>(fields, "rotation", {0, 0, 0}), + vec<3>(fields, "scale", {1, 1, 1})); + if (entity.contains("parent") && !entity["parent"].is_null()) { + auto parent = byId.find(entity["parent"].get()); + if (parent == byId.end()) + throw std::invalid_argument("Missing scene parent"); + matrix = render::multiply(self(self, *parent->second), matrix); + } + active.erase(id); + return matrices.emplace(id, matrix).first->second; + }; + const int dimension = scene.value("dimension", 3); + if (dimension != 2 && dimension != 3) + throw std::invalid_argument("Scene dimension must be 2 or 3"); + render::Vec3 cameraUp{0, 1, 0}; + bool foundCamera = false; + std::vector> sprites; + for (const auto& entity : entities) { + const auto model = world(world, entity); + if (auto fields = properties(entity, "camera"); + !fields.is_null() && !camera.overrideSceneCamera && !foundCamera) { + camera.eye = point(model, {0, 0, 0}); + camera.target = point(model, {0, 0, -1}); + cameraUp = direction(model, {0, 1, 0}); + camera.verticalFovDegrees = fields.value("fov", 60.0f); + camera.nearPlane = fields.value("near", 0.1f); + camera.farPlane = fields.value("far", 1000.0f); + foundCamera = true; + } + if (auto fields = properties(entity, "light"); !fields.is_null()) + out.light_direction = direction(model, {-0.5f, -1, -0.3f}); + if (auto fields = properties(entity, "sprite"); !fields.is_null()) { + render::Sprite sprite; + sprite.position = point(model, {0, 0, 0}); + auto size = vec<2>(fields, "size", {1, 1}); + float sx = std::hypot(model[0], model[1]), sy = std::hypot(model[4], model[5]); + sprite.size = {size[0] * sx, size[1] * sy}; + sprite.rotation = std::atan2(model[1], model[0]); + sprite.color = vec<4>(fields, "color", {1, 1, 1, 1}); + if (model[0] * model[5] - model[1] * model[4] < 0) + sprite.size[1] = -sprite.size[1]; + if (sx > 0 && sy > 0 && + std::abs((model[0] * model[4] + model[1] * model[5]) / (sx * sy)) > 0.0001f) + impl_->messages.push_back("warning: sprite hierarchy shear is approximated"); + auto texture = fields.value("texture", std::string{}); + if (!texture.empty()) + try { + sprite.texture = impl_->texture(texture); + } catch (const std::exception& e) { + impl_->messages.push_back("error: " + std::string(e.what())); + sprite.color = {1, 0, 1, 1}; + } + sprites.emplace_back(fields.value("layer", 0), std::move(sprite)); + } + if (auto fields = properties(entity, "mesh"); !fields.is_null()) { + const auto tint = vec<4>(fields, "color", {1, 1, 1, 1}); + const auto asset = fields.value("asset", std::string{}); + if (asset.empty() || asset.starts_with("builtin:")) { + const auto primitive = asset.empty() + ? fields.value("primitive", std::string("cube")) + : asset.substr(8); + if (primitive != "plane" && primitive != "cube") + throw std::invalid_argument("Unsupported builtin mesh: " + primitive); + out.draws.push_back({primitive == "plane" ? plane() : render::cube_mesh(), + model, + tint, + 0.65f, + 0.0f, + true, + {}}); + } else + try { + impl_->imported(out, asset, model, tint); + } catch (const std::exception& e) { + impl_->messages.push_back("error: " + std::string(e.what())); + out.draws.push_back( + {render::cube_mesh(), model, {1, 0, 1, 1}, 0.65f, 0.0f, true, {}}); + } + } + } + std::stable_sort(sprites.begin(), sprites.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + for (auto& pair : sprites) + out.sprites.push_back(std::move(pair.second)); + if (dimension == 2) { + const float height = camera.orthographicHeight; + if (!std::isfinite(height) || height <= 0) + throw std::invalid_argument("Orthographic height must be positive"); + const auto center = foundCamera ? camera.eye : camera.target; + out.view_projection = + render::multiply(render::orthographic(-height * aspect / 2, height * aspect / 2, + -height / 2, height / 2, -100, 100), + render::transform({-center[0], -center[1], 0})); + out.eye = {center[0], center[1], 10}; + } else { + if (!std::isfinite(camera.verticalFovDegrees) || camera.verticalFovDegrees <= 0 || + camera.verticalFovDegrees >= 179) + throw std::invalid_argument("Camera FOV out of range"); + if (!std::isfinite(camera.nearPlane) || !std::isfinite(camera.farPlane) || + camera.nearPlane <= 0 || camera.farPlane <= camera.nearPlane) + throw std::invalid_argument("Camera depth range is invalid"); + float distance = 0; + render::Vec3 delta{}; + for (int i = 0; i < 3; ++i) { + if (!std::isfinite(camera.eye[i]) || !std::isfinite(camera.target[i]) || + !std::isfinite(cameraUp[i])) + throw std::invalid_argument("Camera basis must be finite"); + delta[i] = camera.target[i] - camera.eye[i]; + distance += delta[i] * delta[i]; + } + const render::Vec3 cross{delta[1] * cameraUp[2] - delta[2] * cameraUp[1], + delta[2] * cameraUp[0] - delta[0] * cameraUp[2], + delta[0] * cameraUp[1] - delta[1] * cameraUp[0]}; + if (distance < 1e-10f || + cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2] < 1e-10f) + throw std::invalid_argument("Camera basis is degenerate"); + out.eye = camera.eye; + out.view_projection = render::multiply( + render::perspective(camera.verticalFovDegrees * std::numbers::pi_v / 180, aspect, + camera.nearPlane, camera.farPlane), + render::look_at(camera.eye, camera.target, cameraUp)); + } + std::sort(impl_->messages.begin(), impl_->messages.end()); + impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()), + impl_->messages.end()); + return out; +} +} // namespace faset::player diff --git a/src/player/scene_io.cpp b/src/player/scene_io.cpp new file mode 100644 index 0000000..0e1caaa --- /dev/null +++ b/src/player/scene_io.cpp @@ -0,0 +1,32 @@ +#include +#include +#include +#include + +namespace faset::player { +nlohmann::json readScene(const std::filesystem::path& path) { + if (!std::filesystem::is_regular_file(path)) + throw std::runtime_error("Scene file does not exist: " + path.string()); + if (std::filesystem::file_size(path) > 256 * 1024 * 1024) + throw std::runtime_error("Scene exceeds the 256 MiB reader limit"); + const auto bytes = faset::read_text(path); + if (bytes.size() >= 8 && std::memcmp(bytes.data(), "FASETSCN", 8) == 0) { + if (bytes.size() < 20) + throw std::runtime_error("Truncated cooked scene header"); + std::uint32_t version = 0; + std::uint64_t size = 0; + for (int i = 0; i < 4; ++i) + version |= std::uint32_t(static_cast(bytes[8 + i])) << (8 * i); + for (int i = 0; i < 8; ++i) + size |= std::uint64_t(static_cast(bytes[12 + i])) << (8 * i); + if (version != 1) + throw std::runtime_error("Unsupported cooked scene version"); + if (size != bytes.size() - 20) + throw std::runtime_error("Cooked scene payload size mismatch"); + return nlohmann::json::from_cbor(bytes.begin() + 20, bytes.end(), true, true); + } + if (path.extension() == ".fscene") + throw std::runtime_error("Cooked scene magic is invalid"); + return nlohmann::json::parse(bytes); +} +} // namespace faset::player diff --git a/src/render/math.cpp b/src/render/math.cpp index f444301..8eef458 100644 --- a/src/render/math.cpp +++ b/src/render/math.cpp @@ -1,26 +1,87 @@ -#include #include +#include #include namespace faset::render { namespace { -Vec3 sub(Vec3 a, Vec3 b) { return {a[0]-b[0],a[1]-b[1],a[2]-b[2]}; } -float dot(Vec3 a,Vec3 b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];} -Vec3 cross(Vec3 a,Vec3 b){return {a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]};} -Vec3 unit(Vec3 a){float l=std::sqrt(dot(a,a)); if(l<1e-6f) throw std::invalid_argument("Degenerate camera axis"); return {a[0]/l,a[1]/l,a[2]/l};} +Vec3 sub(Vec3 a, Vec3 b) { + return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; } -Mat4 multiply(const Mat4& a,const Mat4& b){Mat4 r{};for(int c=0;c<4;++c)for(int y=0;y<4;++y)for(int k=0;k<4;++k)r[c*4+y]+=a[k*4+y]*b[c*4+k];return r;} -Mat4 transform(Vec3 p,Vec3 r,Vec3 s){ - const float cx=std::cos(r[0]),sx=std::sin(r[0]),cy=std::cos(r[1]),sy=std::sin(r[1]),cz=std::cos(r[2]),sz=std::sin(r[2]); - Mat4 x{1,0,0,0,0,cx,sx,0,0,-sx,cx,0,0,0,0,1}; - Mat4 y{cy,0,-sy,0,0,1,0,0,sy,0,cy,0,0,0,0,1}; - Mat4 z{cz,sz,0,0,-sz,cz,0,0,0,0,1,0,0,0,0,1}; - auto m=multiply(z,multiply(y,x));for(int c=0;c<3;++c)for(int i=0;i<3;++i)m[c*4+i]*=s[c];m[12]=p[0];m[13]=p[1];m[14]=p[2];return m; +float dot(Vec3 a, Vec3 b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } -Mat4 perspective(float fov,float aspect,float n,float f){if(aspect<=0||n<=0||f<=n)throw std::invalid_argument("Invalid perspective volume");float q=1/std::tan(fov*.5f);return {q/aspect,0,0,0,0,-q,0,0,0,0,f/(n-f),-1,0,0,n*f/(n-f),0};} -Mat4 orthographic(float l,float r,float b,float t,float n,float f){if(r==l||t==b||f==n)throw std::invalid_argument("Invalid orthographic volume");return {2/(r-l),0,0,0,0,-2/(t-b),0,0,0,0,1/(n-f),0,-(r+l)/(r-l),(t+b)/(t-b),n/(n-f),1};} -Mat4 look_at(Vec3 e,Vec3 t,Vec3 up){auto f=unit(sub(t,e));auto s=unit(cross(f,up));auto u=cross(s,f);return {s[0],u[0],-f[0],0,s[1],u[1],-f[1],0,s[2],u[2],-f[2],0,-dot(s,e),-dot(u,e),dot(f,e),1};} -std::shared_ptr cube_mesh(){static auto mesh=[](){auto m=std::make_shared(); - const Vec3 normals[]={{0,0,1},{0,0,-1},{1,0,0},{-1,0,0},{0,1,0},{0,-1,0}}; - const Vec3 points[][4]={{{-.5f,-.5f,.5f},{.5f,-.5f,.5f},{.5f,.5f,.5f},{-.5f,.5f,.5f}},{{.5f,-.5f,-.5f},{-.5f,-.5f,-.5f},{-.5f,.5f,-.5f},{.5f,.5f,-.5f}},{{.5f,-.5f,.5f},{.5f,-.5f,-.5f},{.5f,.5f,-.5f},{.5f,.5f,.5f}},{{-.5f,-.5f,-.5f},{-.5f,-.5f,.5f},{-.5f,.5f,.5f},{-.5f,.5f,-.5f}},{{-.5f,.5f,.5f},{.5f,.5f,.5f},{.5f,.5f,-.5f},{-.5f,.5f,-.5f}},{{-.5f,-.5f,-.5f},{.5f,-.5f,-.5f},{.5f,-.5f,.5f},{-.5f,-.5f,.5f}}}; - for(int face=0;face<6;++face){for(auto p:points[face])m->vertices.push_back({p,normals[face],{1,1,1,1}});for(auto i:{0u,1u,2u,0u,2u,3u})m->indices.push_back(face*4+i);}return m;}();return mesh;} +Vec3 cross(Vec3 a, Vec3 b) { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; } +Vec3 unit(Vec3 a) { + float l = std::sqrt(dot(a, a)); + if (l < 1e-6f) + throw std::invalid_argument("Degenerate camera axis"); + return {a[0] / l, a[1] / l, a[2] / l}; +} +} // namespace +Mat4 multiply(const Mat4& a, const Mat4& b) { + Mat4 r{}; + for (int c = 0; c < 4; ++c) + for (int y = 0; y < 4; ++y) + for (int k = 0; k < 4; ++k) + r[c * 4 + y] += a[k * 4 + y] * b[c * 4 + k]; + return r; +} +Mat4 transform(Vec3 p, Vec3 r, Vec3 s) { + const float cx = std::cos(r[0]), sx = std::sin(r[0]), cy = std::cos(r[1]), sy = std::sin(r[1]), + cz = std::cos(r[2]), sz = std::sin(r[2]); + Mat4 x{1, 0, 0, 0, 0, cx, sx, 0, 0, -sx, cx, 0, 0, 0, 0, 1}; + Mat4 y{cy, 0, -sy, 0, 0, 1, 0, 0, sy, 0, cy, 0, 0, 0, 0, 1}; + Mat4 z{cz, sz, 0, 0, -sz, cz, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; + auto m = multiply(z, multiply(y, x)); + for (int c = 0; c < 3; ++c) + for (int i = 0; i < 3; ++i) + m[c * 4 + i] *= s[c]; + m[12] = p[0]; + m[13] = p[1]; + m[14] = p[2]; + return m; +} +Mat4 perspective(float fov, float aspect, float n, float f) { + if (aspect <= 0 || n <= 0 || f <= n) + throw std::invalid_argument("Invalid perspective volume"); + float q = 1 / std::tan(fov * .5f); + return {q / aspect, 0, 0, 0, 0, -q, 0, 0, 0, 0, f / (n - f), -1, 0, 0, n * f / (n - f), 0}; +} +Mat4 orthographic(float l, float r, float b, float t, float n, float f) { + if (r == l || t == b || f == n) + throw std::invalid_argument("Invalid orthographic volume"); + return {2 / (r - l), 0, 0, 0, 0, -2 / (t - b), 0, + 0, 0, 0, 1 / (n - f), 0, -(r + l) / (r - l), (t + b) / (t - b), + n / (n - f), 1}; +} +Mat4 look_at(Vec3 e, Vec3 t, Vec3 up) { + auto f = unit(sub(t, e)); + auto s = unit(cross(f, up)); + auto u = cross(s, f); + return {s[0], u[0], -f[0], 0, s[1], u[1], -f[1], 0, + s[2], u[2], -f[2], 0, -dot(s, e), -dot(u, e), dot(f, e), 1}; +} +std::shared_ptr cube_mesh() { + static auto mesh = []() { + auto m = std::make_shared(); + const Vec3 normals[] = {{0, 0, 1}, {0, 0, -1}, {1, 0, 0}, + {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}}; + const Vec3 points[][4] = { + {{-.5f, -.5f, .5f}, {.5f, -.5f, .5f}, {.5f, .5f, .5f}, {-.5f, .5f, .5f}}, + {{.5f, -.5f, -.5f}, {-.5f, -.5f, -.5f}, {-.5f, .5f, -.5f}, {.5f, .5f, -.5f}}, + {{.5f, -.5f, .5f}, {.5f, -.5f, -.5f}, {.5f, .5f, -.5f}, {.5f, .5f, .5f}}, + {{-.5f, -.5f, -.5f}, {-.5f, -.5f, .5f}, {-.5f, .5f, .5f}, {-.5f, .5f, -.5f}}, + {{-.5f, .5f, .5f}, {.5f, .5f, .5f}, {.5f, .5f, -.5f}, {-.5f, .5f, -.5f}}, + {{-.5f, -.5f, -.5f}, {.5f, -.5f, -.5f}, {.5f, -.5f, .5f}, {-.5f, -.5f, .5f}}}; + for (int face = 0; face < 6; ++face) { + for (auto p : points[face]) + m->vertices.push_back({p, normals[face], {1, 1, 1, 1}}); + for (auto i : {0u, 1u, 2u, 0u, 2u, 3u}) + m->indices.push_back(face * 4 + i); + } + return m; + }(); + return mesh; +} +} // namespace faset::render diff --git a/src/render/render_graph.cpp b/src/render/render_graph.cpp index 0ff8538..2c55c8e 100644 --- a/src/render/render_graph.cpp +++ b/src/render/render_graph.cpp @@ -3,25 +3,36 @@ #include #include namespace faset::render { -void RenderGraph::import(std::string resource) { imports_.push_back(std::move(resource)); } -void RenderGraph::add(std::string name, std::vector reads, std::vector writes, Callback execute) { - if (name.empty() || !execute) throw std::invalid_argument("RenderGraph pass requires a name and callback"); - for (const auto& pass : passes_) if (pass.name == name) throw std::invalid_argument("Duplicate RenderGraph pass: " + name); - passes_.push_back({std::move(name),std::move(reads),std::move(writes),std::move(execute)}); +void RenderGraph::import(std::string resource) { + imports_.push_back(std::move(resource)); +} +void RenderGraph::add(std::string name, std::vector reads, + std::vector writes, Callback execute) { + if (name.empty() || !execute) + throw std::invalid_argument("RenderGraph pass requires a name and callback"); + for (const auto& pass : passes_) + if (pass.name == name) + throw std::invalid_argument("Duplicate RenderGraph pass: " + name); + passes_.push_back({std::move(name), std::move(reads), std::move(writes), std::move(execute)}); } void RenderGraph::execute() const { std::unordered_set available(imports_.begin(), imports_.end()); // Validate the whole graph before recording any GPU work. for (const auto& pass : passes_) { for (const auto& resource : pass.reads) - if (!available.contains(resource)) throw std::runtime_error("RenderGraph pass '" + pass.name + "' reads uninitialized resource '" + resource + "'"); - for (const auto& resource : pass.writes) available.insert(resource); + if (!available.contains(resource)) + throw std::runtime_error("RenderGraph pass '" + pass.name + + "' reads uninitialized resource '" + resource + "'"); + for (const auto& resource : pass.writes) + available.insert(resource); } - for (const auto& pass : passes_) pass.callback(); + for (const auto& pass : passes_) + pass.callback(); } std::vector RenderGraph::pass_names() const { std::vector result; - for (const auto& pass : passes_) result.push_back(pass.name); + for (const auto& pass : passes_) + result.push_back(pass.name); return result; } -} +} // namespace faset::render diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 5868238..afb6e76 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -1,13 +1,12 @@ -#include -#include #include #include -#include #include #include #include #include #include +#include +#include #include #include #include @@ -15,24 +14,52 @@ #include #include #include +#include namespace faset::render { namespace { -void check(VkResult result,const char* action){if(result!=VK_SUCCESS)throw std::runtime_error(std::string(action)+" failed (Vulkan "+std::to_string(result)+")");} -struct GpuVertex {float clip[4], world[3], normal[3], color[4], material[2], uv[2];}; -struct Push {Mat4 light_view_projection; std::array light_direction,eye;}; -static_assert(sizeof(Push)==96, "Slang FrameParameters layout"); -std::array point(const Mat4& m,std::array p){std::array o{};for(int r=0;r<4;++r)for(int c=0;c<4;++c)o[r]+=m[c*4+r]*p[c];return o;} -struct Buffer { VkBuffer handle{}; VkDeviceMemory memory{}; VkDeviceSize size{}; }; -struct Image {VkImage handle{}; VkDeviceMemory memory{}; VkImageView view{}; VkImageLayout layout{VK_IMAGE_LAYOUT_UNDEFINED};}; -struct Batch {std::uint32_t first{},count{}; const Texture* texture{};}; -constexpr std::uint32_t shadow_size=1024; +void check(VkResult result, const char* action) { + if (result != VK_SUCCESS) + throw std::runtime_error(std::string(action) + " failed (Vulkan " + std::to_string(result) + + ")"); } +struct GpuVertex { + float clip[4], world[3], normal[3], color[4], material[2], uv[2]; +}; +struct Push { + Mat4 light_view_projection; + std::array light_direction, eye; +}; +static_assert(sizeof(Push) == 96, "Slang FrameParameters layout"); +std::array point(const Mat4& m, std::array p) { + std::array o{}; + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + o[r] += m[c * 4 + r] * p[c]; + return o; +} +struct Buffer { + VkBuffer handle{}; + VkDeviceMemory memory{}; + VkDeviceSize size{}; +}; +struct Image { + VkImage handle{}; + VkDeviceMemory memory{}; + VkImageView view{}; + VkImageLayout layout{VK_IMAGE_LAYOUT_UNDEFINED}; +}; +struct Batch { + std::uint32_t first{}, count{}; + const Texture* texture{}; +}; +constexpr std::uint32_t shadow_size = 1024; +} // namespace struct Renderer::Impl { RendererConfig config; SDL_Window* window{}; - bool sdl{},close{},dirty_swapchain{}; - std::uint32_t width{},height{}; + bool sdl{}, close{}, dirty_swapchain{}; + std::uint32_t width{}, height{}; VkInstance instance{}; VkDebugUtilsMessengerEXT messenger{}; VkSurfaceKHR surface{}; @@ -46,256 +73,1403 @@ struct Renderer::Impl { VkQueryPool timestamp_pool{}; float timestamp_period{}; std::uint32_t timestamp_bits{}; - VkSemaphore acquired{},present_ready{}; + VkSemaphore acquired{}, present_ready{}; VkSwapchainKHR swapchain{}; VkFormat swap_format{}; VkExtent2D swap_extent{}; std::vector swap_images; std::vector swap_layouts; - Image color,depth,shadow; - Buffer vertices,readback; + Image color, depth, shadow; + Buffer vertices, readback; VkDescriptorSetLayout descriptor_layout{}; VkDescriptorPool descriptor_pool{}; - VkSampler shadow_sampler{},color_sampler{}; + VkSampler shadow_sampler{}, color_sampler{}; VkPipelineLayout pipeline_layout{}; - VkPipeline pipeline{},ui_pipeline{},shadow_pipeline{}; - struct GpuTexture {Image image; VkDescriptorSet descriptor{}; std::shared_ptr source; std::uint64_t revision{};}; - std::unordered_map textures; + VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}; + struct GpuTexture { + Image image; + VkDescriptorSet descriptor{}; + std::shared_ptr source; + std::uint64_t revision{}; + }; + std::unordered_map textures; std::shared_ptr white; std::vector last_pixels; FrameStats statistics; std::atomic validation_errors{}; - ~Impl(){cleanup();} - static VKAPI_ATTR VkBool32 VKAPI_CALL debug(VkDebugUtilsMessageSeverityFlagBitsEXT severity,VkDebugUtilsMessageTypeFlagsEXT,const VkDebugUtilsMessengerCallbackDataEXT* data,void* user){ - if(severity>=VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT)static_cast(user)->validation_errors.fetch_add(1); - if(severity>=VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT)std::cerr<<"[Vulkan] "<pMessage<<'\n';return VK_FALSE; + ~Impl() { + cleanup(); } - void destroy(Buffer& b){if(device){if(b.handle)vkDestroyBuffer(device,b.handle,nullptr);if(b.memory)vkFreeMemory(device,b.memory,nullptr);}b={};} - void destroy(Image& i){if(device){if(i.view)vkDestroyImageView(device,i.view,nullptr);if(i.handle)vkDestroyImage(device,i.handle,nullptr);if(i.memory)vkFreeMemory(device,i.memory,nullptr);}i={};} - void cleanup(){ - if(device)vkDeviceWaitIdle(device); - for(auto& [_,texture]:textures)destroy(texture.image); - destroy(vertices);destroy(readback);destroy(color);destroy(depth);destroy(shadow); - if(device){ - if(pipeline)vkDestroyPipeline(device,pipeline,nullptr);if(ui_pipeline)vkDestroyPipeline(device,ui_pipeline,nullptr);if(shadow_pipeline)vkDestroyPipeline(device,shadow_pipeline,nullptr); - if(pipeline_layout)vkDestroyPipelineLayout(device,pipeline_layout,nullptr);if(descriptor_pool)vkDestroyDescriptorPool(device,descriptor_pool,nullptr);if(descriptor_layout)vkDestroyDescriptorSetLayout(device,descriptor_layout,nullptr); - if(shadow_sampler)vkDestroySampler(device,shadow_sampler,nullptr);if(color_sampler)vkDestroySampler(device,color_sampler,nullptr); - if(swapchain)vkDestroySwapchainKHR(device,swapchain,nullptr); - if(timestamp_pool)vkDestroyQueryPool(device,timestamp_pool,nullptr); - if(acquired)vkDestroySemaphore(device,acquired,nullptr);if(present_ready)vkDestroySemaphore(device,present_ready,nullptr);if(fence)vkDestroyFence(device,fence,nullptr);if(pool)vkDestroyCommandPool(device,pool,nullptr); - vkDestroyDevice(device,nullptr); + static VKAPI_ATTR VkBool32 VKAPI_CALL debug(VkDebugUtilsMessageSeverityFlagBitsEXT severity, + VkDebugUtilsMessageTypeFlagsEXT, + const VkDebugUtilsMessengerCallbackDataEXT* data, + void* user) { + if (severity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) + static_cast(user)->validation_errors.fetch_add(1); + if (severity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) + std::cerr << "[Vulkan] " << data->pMessage << '\n'; + return VK_FALSE; + } + void destroy(Buffer& b) { + if (device) { + if (b.handle) + vkDestroyBuffer(device, b.handle, nullptr); + if (b.memory) + vkFreeMemory(device, b.memory, nullptr); } - if(surface)vkDestroySurfaceKHR(instance,surface,nullptr); - if(messenger){auto fn=reinterpret_cast(vkGetInstanceProcAddr(instance,"vkDestroyDebugUtilsMessengerEXT"));if(fn)fn(instance,messenger,nullptr);} - if(instance)vkDestroyInstance(instance,nullptr); - if(window)SDL_DestroyWindow(window);if(sdl)SDL_QuitSubSystem(SDL_INIT_VIDEO); + b = {}; } - std::uint32_t memory_type(std::uint32_t bits,VkMemoryPropertyFlags properties){VkPhysicalDeviceMemoryProperties p{};vkGetPhysicalDeviceMemoryProperties(physical,&p);for(std::uint32_t i=0;i( + vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT")); + if (fn) + fn(instance, messenger, nullptr); + } + if (instance) + vkDestroyInstance(instance, nullptr); + if (window) + SDL_DestroyWindow(window); + if (sdl) + SDL_QuitSubSystem(SDL_INIT_VIDEO); } - void transition(VkCommandBuffer cmd,VkImage image,VkImageLayout& before,VkImageLayout after,VkImageAspectFlags aspect){ + std::uint32_t memory_type(std::uint32_t bits, VkMemoryPropertyFlags properties) { + VkPhysicalDeviceMemoryProperties p{}; + vkGetPhysicalDeviceMemoryProperties(physical, &p); + for (std::uint32_t i = 0; i < p.memoryTypeCount; ++i) + if ((bits & (1u << i)) && (p.memoryTypes[i].propertyFlags & properties) == properties) + return i; + throw std::runtime_error("Required Vulkan memory type is unavailable"); + } + Buffer make_buffer(VkDeviceSize bytes, VkBufferUsageFlags usage, + VkMemoryPropertyFlags properties) { + Buffer b{}; + b.size = bytes; + VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + info.size = bytes; + info.usage = usage; + info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + check(vkCreateBuffer(device, &info, nullptr, &b.handle), "Create buffer"); + try { + VkMemoryRequirements req{}; + vkGetBufferMemoryRequirements(device, b.handle, &req); + VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; + alloc.allocationSize = req.size; + alloc.memoryTypeIndex = memory_type(req.memoryTypeBits, properties); + check(vkAllocateMemory(device, &alloc, nullptr, &b.memory), "Allocate buffer memory"); + check(vkBindBufferMemory(device, b.handle, b.memory, 0), "Bind buffer memory"); + } catch (...) { + destroy(b); + throw; + } + return b; + } + Image make_image(std::uint32_t w, std::uint32_t h, VkFormat format, VkImageUsageFlags usage, + VkImageAspectFlags aspect) { + Image image{}; + VkImageCreateInfo info{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; + info.imageType = VK_IMAGE_TYPE_2D; + info.format = format; + info.extent = {w, h, 1}; + info.mipLevels = 1; + info.arrayLayers = 1; + info.samples = VK_SAMPLE_COUNT_1_BIT; + info.tiling = VK_IMAGE_TILING_OPTIMAL; + info.usage = usage; + info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + check(vkCreateImage(device, &info, nullptr, &image.handle), "Create image"); + try { + VkMemoryRequirements req{}; + vkGetImageMemoryRequirements(device, image.handle, &req); + VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; + alloc.allocationSize = req.size; + alloc.memoryTypeIndex = + memory_type(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + check(vkAllocateMemory(device, &alloc, nullptr, &image.memory), + "Allocate image memory"); + check(vkBindImageMemory(device, image.handle, image.memory, 0), "Bind image memory"); + VkImageViewCreateInfo view{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; + view.image = image.handle; + view.viewType = VK_IMAGE_VIEW_TYPE_2D; + view.format = format; + view.subresourceRange = {aspect, 0, 1, 0, 1}; + check(vkCreateImageView(device, &view, nullptr, &image.view), "Create image view"); + } catch (...) { + destroy(image); + throw; + } + return image; + } + void transition(VkCommandBuffer cmd, VkImage image, VkImageLayout& before, VkImageLayout after, + VkImageAspectFlags aspect) { // Conservative dependencies make the first single-queue backend auditable. - VkImageMemoryBarrier2 barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2};barrier.srcStageMask=before==VK_IMAGE_LAYOUT_UNDEFINED?VK_PIPELINE_STAGE_2_NONE:VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;barrier.srcAccessMask=before==VK_IMAGE_LAYOUT_UNDEFINED?0:VK_ACCESS_2_MEMORY_WRITE_BIT;barrier.dstStageMask=VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;barrier.dstAccessMask=VK_ACCESS_2_MEMORY_READ_BIT|VK_ACCESS_2_MEMORY_WRITE_BIT;barrier.oldLayout=before;barrier.newLayout=after;barrier.srcQueueFamilyIndex=barrier.dstQueueFamilyIndex=VK_QUEUE_FAMILY_IGNORED;barrier.image=image;barrier.subresourceRange={aspect,0,1,0,1};VkDependencyInfo dependency{VK_STRUCTURE_TYPE_DEPENDENCY_INFO};dependency.imageMemoryBarrierCount=1;dependency.pImageMemoryBarriers=&barrier;vkCmdPipelineBarrier2(cmd,&dependency);before=after; + VkImageMemoryBarrier2 barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2}; + barrier.srcStageMask = before == VK_IMAGE_LAYOUT_UNDEFINED + ? VK_PIPELINE_STAGE_2_NONE + : VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT; + barrier.srcAccessMask = + before == VK_IMAGE_LAYOUT_UNDEFINED ? 0 : VK_ACCESS_2_MEMORY_WRITE_BIT; + barrier.dstStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT; + barrier.dstAccessMask = VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT; + barrier.oldLayout = before; + barrier.newLayout = after; + barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = image; + barrier.subresourceRange = {aspect, 0, 1, 0, 1}; + VkDependencyInfo dependency{VK_STRUCTURE_TYPE_DEPENDENCY_INFO}; + dependency.imageMemoryBarrierCount = 1; + dependency.pImageMemoryBarriers = &barrier; + vkCmdPipelineBarrier2(cmd, &dependency); + before = after; } - void transition(VkCommandBuffer cmd,Image& image,VkImageLayout after,VkImageAspectFlags aspect){transition(cmd,image.handle,image.layout,after,aspect);} - void begin(){check(vkResetCommandBuffer(command,0),"Reset command buffer");VkCommandBufferBeginInfo info{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};info.flags=VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;check(vkBeginCommandBuffer(command,&info),"Begin command buffer");} - void submit(bool present=false){ - check(vkEndCommandBuffer(command),"End command buffer");check(vkResetFences(device,1,&fence),"Reset fence");VkCommandBufferSubmitInfo cmd{VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO};cmd.commandBuffer=command;VkSubmitInfo2 info{VK_STRUCTURE_TYPE_SUBMIT_INFO_2};info.commandBufferInfoCount=1;info.pCommandBufferInfos=&cmd;VkSemaphoreSubmitInfo wait{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO},signal{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO};if(present){wait.semaphore=acquired;wait.stageMask=VK_PIPELINE_STAGE_2_TRANSFER_BIT;signal.semaphore=present_ready;signal.stageMask=VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;info.waitSemaphoreInfoCount=1;info.pWaitSemaphoreInfos=&wait;info.signalSemaphoreInfoCount=1;info.pSignalSemaphoreInfos=&signal;}check(vkQueueSubmit2(queue,1,&info,fence),"Submit frame");check(vkWaitForFences(device,1,&fence,VK_TRUE,UINT64_MAX),"Wait frame fence"); + void transition(VkCommandBuffer cmd, Image& image, VkImageLayout after, + VkImageAspectFlags aspect) { + transition(cmd, image.handle, image.layout, after, aspect); } - void initialize(const RendererConfig& c){ - config=c;width=c.width;height=c.height;if(!width||!height)throw std::invalid_argument("Renderer dimensions must be nonzero"); - std::vector extensions; - if(!c.headless){if(!SDL_InitSubSystem(SDL_INIT_VIDEO))throw std::runtime_error(SDL_GetError());sdl=true;window=SDL_CreateWindow(c.title.c_str(),static_cast(width),static_cast(height),SDL_WINDOW_VULKAN|SDL_WINDOW_RESIZABLE|SDL_WINDOW_HIGH_PIXEL_DENSITY);if(!window)throw std::runtime_error(SDL_GetError());Uint32 count{};auto names=SDL_Vulkan_GetInstanceExtensions(&count);if(!names)throw std::runtime_error(SDL_GetError());extensions.assign(names,names+count);SDL_StartTextInput(window);} - std::uint32_t count{};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;}); - if(c.validation&&!validation)std::cerr<<"[Faset] Vulkan validation layer not installed; diagnostics disabled.\n"; - if(validation)extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME); - VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};app.pApplicationName="Faset Engine";app.apiVersion=VK_API_VERSION_1_3; - VkDebugUtilsMessengerCreateInfoEXT debug_info{VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT};debug_info.messageSeverity=VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT|VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;debug_info.messageType=VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT|VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT|VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT;debug_info.pfnUserCallback=debug;debug_info.pUserData=this; - const char* validation_name="VK_LAYER_KHRONOS_validation";VkInstanceCreateInfo info{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};info.pApplicationInfo=&app;info.enabledExtensionCount=static_cast(extensions.size());info.ppEnabledExtensionNames=extensions.data();if(validation){info.enabledLayerCount=1;info.ppEnabledLayerNames=&validation_name;info.pNext=&debug_info;}check(vkCreateInstance(&info,nullptr,&instance),"Create Vulkan instance"); - if(validation){auto fn=reinterpret_cast(vkGetInstanceProcAddr(instance,"vkCreateDebugUtilsMessengerEXT"));if(fn)check(fn(instance,&debug_info,nullptr,&messenger),"Create validation messenger");} - if(window&&!SDL_Vulkan_CreateSurface(window,instance,nullptr,&surface))throw std::runtime_error(SDL_GetError()); - check(vkEnumeratePhysicalDevices(instance,&count,nullptr),"Enumerate GPUs");std::vector devices(count);check(vkEnumeratePhysicalDevices(instance,&count,devices.data()),"Enumerate GPUs"); - int best=-1; - for(auto gpu:devices){VkPhysicalDeviceProperties properties{};vkGetPhysicalDeviceProperties(gpu,&properties);if(properties.apiVersion queues(n);vkGetPhysicalDeviceQueueFamilyProperties(gpu,&n,queues.data());for(std::uint32_t i=0;ibest){best=score;physical=gpu;queue_family=i;statistics.device=properties.deviceName;timestamp_period=properties.limits.timestampPeriod;timestamp_bits=queues[i].timestampValidBits;}} + void begin() { + check(vkResetCommandBuffer(command, 0), "Reset command buffer"); + VkCommandBufferBeginInfo info{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + info.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + check(vkBeginCommandBuffer(command, &info), "Begin command buffer"); + } + void submit(bool present = false) { + check(vkEndCommandBuffer(command), "End command buffer"); + check(vkResetFences(device, 1, &fence), "Reset fence"); + VkCommandBufferSubmitInfo cmd{VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO}; + cmd.commandBuffer = command; + VkSubmitInfo2 info{VK_STRUCTURE_TYPE_SUBMIT_INFO_2}; + info.commandBufferInfoCount = 1; + info.pCommandBufferInfos = &cmd; + VkSemaphoreSubmitInfo wait{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO}, + signal{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO}; + if (present) { + wait.semaphore = acquired; + wait.stageMask = VK_PIPELINE_STAGE_2_TRANSFER_BIT; + signal.semaphore = present_ready; + signal.stageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT; + info.waitSemaphoreInfoCount = 1; + info.pWaitSemaphoreInfos = &wait; + info.signalSemaphoreInfoCount = 1; + info.pSignalSemaphoreInfos = &signal; } - if(!physical)throw std::runtime_error("No Vulkan 1.3 device supports dynamic rendering, synchronization2 and required color/depth formats"); - float priority=1;VkDeviceQueueCreateInfo qi{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO};qi.queueFamilyIndex=queue_family;qi.queueCount=1;qi.pQueuePriorities=&priority;VkPhysicalDeviceVulkan13Features f13{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES};f13.synchronization2=VK_TRUE;f13.dynamicRendering=VK_TRUE;VkDeviceCreateInfo di{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};di.pNext=&f13;di.queueCreateInfoCount=1;di.pQueueCreateInfos=&qi;const char* swap_extension=VK_KHR_SWAPCHAIN_EXTENSION_NAME;if(surface){di.enabledExtensionCount=1;di.ppEnabledExtensionNames=&swap_extension;}check(vkCreateDevice(physical,&di,nullptr,&device),"Create Vulkan device");vkGetDeviceQueue(device,queue_family,0,&queue); - VkCommandPoolCreateInfo pi{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};pi.queueFamilyIndex=queue_family;pi.flags=VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;check(vkCreateCommandPool(device,&pi,nullptr,&pool),"Create command pool");VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};ai.commandPool=pool;ai.level=VK_COMMAND_BUFFER_LEVEL_PRIMARY;ai.commandBufferCount=1;check(vkAllocateCommandBuffers(device,&ai,&command),"Allocate command buffer");VkFenceCreateInfo fi{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};fi.flags=VK_FENCE_CREATE_SIGNALED_BIT;check(vkCreateFence(device,&fi,nullptr,&fence),"Create frame fence");VkSemaphoreCreateInfo si{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};check(vkCreateSemaphore(device,&si,nullptr,&acquired),"Create acquire semaphore");check(vkCreateSemaphore(device,&si,nullptr,&present_ready),"Create present semaphore"); - if(timestamp_bits){VkQueryPoolCreateInfo query{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO};query.queryType=VK_QUERY_TYPE_TIMESTAMP;query.queryCount=2;check(vkCreateQueryPool(device,&query,nullptr,×tamp_pool),"Create GPU timestamp queries");} - shadow=make_image(shadow_size,shadow_size,VK_FORMAT_D32_SFLOAT,VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT|VK_IMAGE_USAGE_SAMPLED_BIT,VK_IMAGE_ASPECT_DEPTH_BIT); - make_targets();make_descriptors();make_pipelines();white=std::make_shared();white->width=white->height=1;white->rgba={255,255,255,255};upload_texture(white);if(surface)make_swapchain(); + check(vkQueueSubmit2(queue, 1, &info, fence), "Submit frame"); + check(vkWaitForFences(device, 1, &fence, VK_TRUE, UINT64_MAX), "Wait frame fence"); } - void make_targets(){ - check(vkDeviceWaitIdle(device),"Wait resize");destroy(color);destroy(depth);destroy(readback); - color=make_image(width,height,VK_FORMAT_R8G8B8A8_UNORM,VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT|VK_IMAGE_USAGE_TRANSFER_SRC_BIT,VK_IMAGE_ASPECT_COLOR_BIT); - depth=make_image(width,height,VK_FORMAT_D32_SFLOAT,VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT,VK_IMAGE_ASPECT_DEPTH_BIT); - readback=make_buffer(VkDeviceSize(width)*height*4,VK_BUFFER_USAGE_TRANSFER_DST_BIT,VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT|VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + void initialize(const RendererConfig& c) { + config = c; + width = c.width; + height = c.height; + if (!width || !height) + throw std::invalid_argument("Renderer dimensions must be nonzero"); + std::vector extensions; + if (!c.headless) { + if (!SDL_InitSubSystem(SDL_INIT_VIDEO)) + throw std::runtime_error(SDL_GetError()); + sdl = true; + window = SDL_CreateWindow( + c.title.c_str(), static_cast(width), static_cast(height), + SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY); + if (!window) + throw std::runtime_error(SDL_GetError()); + Uint32 count{}; + auto names = SDL_Vulkan_GetInstanceExtensions(&count); + if (!names) + throw std::runtime_error(SDL_GetError()); + extensions.assign(names, names + count); + SDL_StartTextInput(window); + } + std::uint32_t count{}; + 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; + }); + if (c.validation && !validation) + std::cerr << "[Faset] Vulkan validation layer not installed; diagnostics disabled.\n"; + if (validation) + extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME); + VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO}; + app.pApplicationName = "Faset Engine"; + app.apiVersion = VK_API_VERSION_1_3; + VkDebugUtilsMessengerCreateInfoEXT debug_info{ + VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT}; + debug_info.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT; + debug_info.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT; + debug_info.pfnUserCallback = debug; + debug_info.pUserData = this; + const char* validation_name = "VK_LAYER_KHRONOS_validation"; + VkInstanceCreateInfo info{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO}; + info.pApplicationInfo = &app; + info.enabledExtensionCount = static_cast(extensions.size()); + info.ppEnabledExtensionNames = extensions.data(); + if (validation) { + info.enabledLayerCount = 1; + info.ppEnabledLayerNames = &validation_name; + info.pNext = &debug_info; + } + check(vkCreateInstance(&info, nullptr, &instance), "Create Vulkan instance"); + if (validation) { + auto fn = reinterpret_cast( + vkGetInstanceProcAddr(instance, "vkCreateDebugUtilsMessengerEXT")); + if (fn) + check(fn(instance, &debug_info, nullptr, &messenger), + "Create validation messenger"); + } + if (window && !SDL_Vulkan_CreateSurface(window, instance, nullptr, &surface)) + throw std::runtime_error(SDL_GetError()); + check(vkEnumeratePhysicalDevices(instance, &count, nullptr), "Enumerate GPUs"); + std::vector devices(count); + check(vkEnumeratePhysicalDevices(instance, &count, devices.data()), "Enumerate GPUs"); + int best = -1; + for (auto gpu : devices) { + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(gpu, &properties); + if (properties.apiVersion < VK_API_VERSION_1_3) + continue; + VkPhysicalDeviceVulkan13Features f13{ + VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES}; + VkPhysicalDeviceFeatures2 features{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2}; + features.pNext = &f13; + vkGetPhysicalDeviceFeatures2(gpu, &features); + if (!f13.synchronization2 || !f13.dynamicRendering) + continue; + VkFormatProperties color_props{}, depth_props{}; + vkGetPhysicalDeviceFormatProperties(gpu, VK_FORMAT_R8G8B8A8_UNORM, &color_props); + vkGetPhysicalDeviceFormatProperties(gpu, VK_FORMAT_D32_SFLOAT, &depth_props); + if (!(color_props.optimalTilingFeatures & VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT) || + !(depth_props.optimalTilingFeatures & + VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT) || + !(depth_props.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT)) + continue; + std::uint32_t n{}; + vkGetPhysicalDeviceQueueFamilyProperties(gpu, &n, nullptr); + std::vector queues(n); + vkGetPhysicalDeviceQueueFamilyProperties(gpu, &n, queues.data()); + for (std::uint32_t i = 0; i < n; ++i) { + VkBool32 supports = VK_TRUE; + if (surface) + check(vkGetPhysicalDeviceSurfaceSupportKHR(gpu, i, surface, &supports), + "Query present support"); + int score = properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU ? 3 + : properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU ? 2 + : 1; + if (supports && (queues[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && score > best) { + best = score; + physical = gpu; + queue_family = i; + statistics.device = properties.deviceName; + timestamp_period = properties.limits.timestampPeriod; + timestamp_bits = queues[i].timestampValidBits; + } + } + } + if (!physical) + throw std::runtime_error("No Vulkan 1.3 device supports dynamic rendering, " + "synchronization2 and required color/depth formats"); + float priority = 1; + VkDeviceQueueCreateInfo qi{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO}; + qi.queueFamilyIndex = queue_family; + qi.queueCount = 1; + qi.pQueuePriorities = &priority; + VkPhysicalDeviceVulkan13Features f13{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES}; + f13.synchronization2 = VK_TRUE; + f13.dynamicRendering = VK_TRUE; + VkDeviceCreateInfo di{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO}; + di.pNext = &f13; + di.queueCreateInfoCount = 1; + di.pQueueCreateInfos = &qi; + const char* swap_extension = VK_KHR_SWAPCHAIN_EXTENSION_NAME; + if (surface) { + di.enabledExtensionCount = 1; + di.ppEnabledExtensionNames = &swap_extension; + } + check(vkCreateDevice(physical, &di, nullptr, &device), "Create Vulkan device"); + vkGetDeviceQueue(device, queue_family, 0, &queue); + VkCommandPoolCreateInfo pi{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO}; + pi.queueFamilyIndex = queue_family; + pi.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + check(vkCreateCommandPool(device, &pi, nullptr, &pool), "Create command pool"); + VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO}; + ai.commandPool = pool; + ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + ai.commandBufferCount = 1; + check(vkAllocateCommandBuffers(device, &ai, &command), "Allocate command buffer"); + VkFenceCreateInfo fi{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; + fi.flags = VK_FENCE_CREATE_SIGNALED_BIT; + check(vkCreateFence(device, &fi, nullptr, &fence), "Create frame fence"); + VkSemaphoreCreateInfo si{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; + check(vkCreateSemaphore(device, &si, nullptr, &acquired), "Create acquire semaphore"); + check(vkCreateSemaphore(device, &si, nullptr, &present_ready), "Create present semaphore"); + if (timestamp_bits) { + VkQueryPoolCreateInfo query{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO}; + query.queryType = VK_QUERY_TYPE_TIMESTAMP; + query.queryCount = 2; + check(vkCreateQueryPool(device, &query, nullptr, ×tamp_pool), + "Create GPU timestamp queries"); + } + shadow = + make_image(shadow_size, shadow_size, VK_FORMAT_D32_SFLOAT, + VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + VK_IMAGE_ASPECT_DEPTH_BIT); + make_targets(); + make_descriptors(); + make_pipelines(); + white = std::make_shared(); + white->width = white->height = 1; + white->rgba = {255, 255, 255, 255}; + upload_texture(white); + if (surface) + make_swapchain(); + } + void make_targets() { + check(vkDeviceWaitIdle(device), "Wait resize"); + destroy(color); + destroy(depth); + destroy(readback); + color = make_image(width, height, VK_FORMAT_R8G8B8A8_UNORM, + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT, + VK_IMAGE_ASPECT_COLOR_BIT); + depth = make_image(width, height, VK_FORMAT_D32_SFLOAT, + VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, VK_IMAGE_ASPECT_DEPTH_BIT); + readback = + make_buffer(VkDeviceSize(width) * height * 4, VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); last_pixels.clear(); } - void make_swapchain(){ - if(!surface)return;int w{},h{};SDL_GetWindowSizeInPixels(window,&w,&h);if(w<=0||h<=0)return; - check(vkDeviceWaitIdle(device),"Wait swapchain");VkSurfaceCapabilitiesKHR caps{};check(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physical,surface,&caps),"Read surface capabilities"); - std::uint32_t count{};check(vkGetPhysicalDeviceSurfaceFormatsKHR(physical,surface,&count,nullptr),"Read surface formats");std::vector formats(count);check(vkGetPhysicalDeviceSurfaceFormatsKHR(physical,surface,&count,formats.data()),"Read surface formats");if(formats.empty())throw std::runtime_error("Window surface has no formats");auto chosen=formats.front();for(auto f:formats)if(f.format==VK_FORMAT_B8G8R8A8_UNORM&&f.colorSpace==VK_COLOR_SPACE_SRGB_NONLINEAR_KHR)chosen=f; - VkFormatProperties properties{};vkGetPhysicalDeviceFormatProperties(physical,chosen.format,&properties);if(!(caps.supportedUsageFlags&VK_IMAGE_USAGE_TRANSFER_DST_BIT)||!(properties.optimalTilingFeatures&VK_FORMAT_FEATURE_BLIT_DST_BIT))throw std::runtime_error("Window surface does not support transfer presentation"); - swap_extent=caps.currentExtent;if(swap_extent.width==UINT32_MAX)swap_extent={std::clamp(static_cast(w),caps.minImageExtent.width,caps.maxImageExtent.width),std::clamp(static_cast(h),caps.minImageExtent.height,caps.maxImageExtent.height)}; - count=caps.minImageCount+1;if(caps.maxImageCount)count=std::min(count,caps.maxImageCount);VkSwapchainCreateInfoKHR info{VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR};info.surface=surface;info.minImageCount=count;info.imageFormat=chosen.format;info.imageColorSpace=chosen.colorSpace;info.imageExtent=swap_extent;info.imageArrayLayers=1;info.imageUsage=VK_IMAGE_USAGE_TRANSFER_DST_BIT;info.imageSharingMode=VK_SHARING_MODE_EXCLUSIVE;info.preTransform=caps.currentTransform;info.compositeAlpha=VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; - if(!(caps.supportedCompositeAlpha&info.compositeAlpha)){for(auto a:{VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR,VK_COMPOSITE_ALPHA_POST_MULTIPLIED_BIT_KHR,VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR})if(caps.supportedCompositeAlpha&a){info.compositeAlpha=a;break;}} - info.presentMode=VK_PRESENT_MODE_FIFO_KHR;info.clipped=VK_TRUE;info.oldSwapchain=swapchain;VkSwapchainKHR next{};check(vkCreateSwapchainKHR(device,&info,nullptr,&next),"Create swapchain");if(swapchain)vkDestroySwapchainKHR(device,swapchain,nullptr);swapchain=next;swap_format=chosen.format; - check(vkGetSwapchainImagesKHR(device,swapchain,&count,nullptr),"Get swapchain images");swap_images.resize(count);check(vkGetSwapchainImagesKHR(device,swapchain,&count,swap_images.data()),"Get swapchain images");swap_layouts.assign(count,VK_IMAGE_LAYOUT_UNDEFINED);dirty_swapchain=false; - if(width!=swap_extent.width||height!=swap_extent.height){width=swap_extent.width;height=swap_extent.height;make_targets();} + void make_swapchain() { + if (!surface) + return; + int w{}, h{}; + SDL_GetWindowSizeInPixels(window, &w, &h); + if (w <= 0 || h <= 0) + return; + check(vkDeviceWaitIdle(device), "Wait swapchain"); + VkSurfaceCapabilitiesKHR caps{}; + check(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physical, surface, &caps), + "Read surface capabilities"); + std::uint32_t count{}; + check(vkGetPhysicalDeviceSurfaceFormatsKHR(physical, surface, &count, nullptr), + "Read surface formats"); + std::vector formats(count); + check(vkGetPhysicalDeviceSurfaceFormatsKHR(physical, surface, &count, formats.data()), + "Read surface formats"); + if (formats.empty()) + throw std::runtime_error("Window surface has no formats"); + auto chosen = formats.front(); + for (auto f : formats) + if (f.format == VK_FORMAT_B8G8R8A8_UNORM && + f.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) + chosen = f; + VkFormatProperties properties{}; + vkGetPhysicalDeviceFormatProperties(physical, chosen.format, &properties); + if (!(caps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_DST_BIT) || + !(properties.optimalTilingFeatures & VK_FORMAT_FEATURE_BLIT_DST_BIT)) + throw std::runtime_error("Window surface does not support transfer presentation"); + swap_extent = caps.currentExtent; + if (swap_extent.width == UINT32_MAX) + swap_extent = {std::clamp(static_cast(w), caps.minImageExtent.width, + caps.maxImageExtent.width), + std::clamp(static_cast(h), caps.minImageExtent.height, + caps.maxImageExtent.height)}; + count = caps.minImageCount + 1; + if (caps.maxImageCount) + count = std::min(count, caps.maxImageCount); + VkSwapchainCreateInfoKHR info{VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR}; + info.surface = surface; + info.minImageCount = count; + info.imageFormat = chosen.format; + info.imageColorSpace = chosen.colorSpace; + info.imageExtent = swap_extent; + info.imageArrayLayers = 1; + info.imageUsage = VK_IMAGE_USAGE_TRANSFER_DST_BIT; + info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; + info.preTransform = caps.currentTransform; + info.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; + if (!(caps.supportedCompositeAlpha & info.compositeAlpha)) { + for (auto a : + {VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR, + VK_COMPOSITE_ALPHA_POST_MULTIPLIED_BIT_KHR, VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR}) + if (caps.supportedCompositeAlpha & a) { + info.compositeAlpha = a; + break; + } + } + info.presentMode = VK_PRESENT_MODE_FIFO_KHR; + info.clipped = VK_TRUE; + info.oldSwapchain = swapchain; + VkSwapchainKHR next{}; + check(vkCreateSwapchainKHR(device, &info, nullptr, &next), "Create swapchain"); + if (swapchain) + vkDestroySwapchainKHR(device, swapchain, nullptr); + swapchain = next; + swap_format = chosen.format; + check(vkGetSwapchainImagesKHR(device, swapchain, &count, nullptr), "Get swapchain images"); + swap_images.resize(count); + check(vkGetSwapchainImagesKHR(device, swapchain, &count, swap_images.data()), + "Get swapchain images"); + swap_layouts.assign(count, VK_IMAGE_LAYOUT_UNDEFINED); + dirty_swapchain = false; + if (width != swap_extent.width || height != swap_extent.height) { + width = swap_extent.width; + height = swap_extent.height; + make_targets(); + } } - void make_descriptors(){ - std::array bindings{};for(std::uint32_t i=0;i<4;++i){bindings[i].binding=i;bindings[i].descriptorType=i%2?VK_DESCRIPTOR_TYPE_SAMPLER:VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE;bindings[i].descriptorCount=1;bindings[i].stageFlags=VK_SHADER_STAGE_FRAGMENT_BIT;} - VkDescriptorSetLayoutCreateInfo li{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};li.bindingCount=4;li.pBindings=bindings.data();check(vkCreateDescriptorSetLayout(device,&li,nullptr,&descriptor_layout),"Create descriptor layout"); - VkDescriptorPoolSize sizes[]={{VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,2048},{VK_DESCRIPTOR_TYPE_SAMPLER,2048}};VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};pi.flags=VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;pi.maxSets=1024;pi.poolSizeCount=2;pi.pPoolSizes=sizes;check(vkCreateDescriptorPool(device,&pi,nullptr,&descriptor_pool),"Create descriptor pool"); - VkSamplerCreateInfo si{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO};si.magFilter=si.minFilter=VK_FILTER_NEAREST;si.mipmapMode=VK_SAMPLER_MIPMAP_MODE_NEAREST;si.addressModeU=si.addressModeV=si.addressModeW=VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;si.maxLod=0;check(vkCreateSampler(device,&si,nullptr,&shadow_sampler),"Create shadow sampler");si.magFilter=si.minFilter=VK_FILTER_LINEAR;check(vkCreateSampler(device,&si,nullptr,&color_sampler),"Create color sampler"); + void make_descriptors() { + std::array bindings{}; + for (std::uint32_t i = 0; i < 4; ++i) { + bindings[i].binding = i; + bindings[i].descriptorType = + i % 2 ? VK_DESCRIPTOR_TYPE_SAMPLER : VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE; + bindings[i].descriptorCount = 1; + bindings[i].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + } + VkDescriptorSetLayoutCreateInfo li{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + li.bindingCount = 4; + li.pBindings = bindings.data(); + check(vkCreateDescriptorSetLayout(device, &li, nullptr, &descriptor_layout), + "Create descriptor layout"); + VkDescriptorPoolSize sizes[] = {{VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 2048}, + {VK_DESCRIPTOR_TYPE_SAMPLER, 2048}}; + VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + pi.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; + pi.maxSets = 1024; + pi.poolSizeCount = 2; + pi.pPoolSizes = sizes; + check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool), + "Create descriptor pool"); + VkSamplerCreateInfo si{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO}; + si.magFilter = si.minFilter = VK_FILTER_NEAREST; + si.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + si.addressModeU = si.addressModeV = si.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + si.maxLod = 0; + check(vkCreateSampler(device, &si, nullptr, &shadow_sampler), "Create shadow sampler"); + si.magFilter = si.minFilter = VK_FILTER_LINEAR; + check(vkCreateSampler(device, &si, nullptr, &color_sampler), "Create color sampler"); } - VkDescriptorSet upload_texture(std::shared_ptr source){ - if(!source)source=white;if(!source||!source->width||!source->height||source->rgba.size()!=std::size_t(source->width)*source->height*4)throw std::invalid_argument("Texture requires width * height * 4 RGBA bytes"); - auto found=textures.find(source.get());if(found!=textures.end()&&found->second.revision==source->revision)return found->second.descriptor; - check(vkDeviceWaitIdle(device),"Wait texture upload");GpuTexture texture{};texture.source=source;texture.revision=source->revision; - texture.image=make_image(source->width,source->height,source->srgb?VK_FORMAT_R8G8B8A8_SRGB:VK_FORMAT_R8G8B8A8_UNORM,VK_IMAGE_USAGE_TRANSFER_DST_BIT|VK_IMAGE_USAGE_SAMPLED_BIT,VK_IMAGE_ASPECT_COLOR_BIT); + VkDescriptorSet upload_texture(std::shared_ptr source) { + if (!source) + source = white; + if (!source || !source->width || !source->height || + source->rgba.size() != std::size_t(source->width) * source->height * 4) + throw std::invalid_argument("Texture requires width * height * 4 RGBA bytes"); + auto found = textures.find(source.get()); + if (found != textures.end() && found->second.revision == source->revision) + return found->second.descriptor; + check(vkDeviceWaitIdle(device), "Wait texture upload"); + GpuTexture texture{}; + texture.source = source; + texture.revision = source->revision; + texture.image = + make_image(source->width, source->height, + source->srgb ? VK_FORMAT_R8G8B8A8_SRGB : VK_FORMAT_R8G8B8A8_UNORM, + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + VK_IMAGE_ASPECT_COLOR_BIT); Buffer staging{}; - try{staging=make_buffer(source->rgba.size(),VK_BUFFER_USAGE_TRANSFER_SRC_BIT,VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT|VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);void* mapped{};check(vkMapMemory(device,staging.memory,0,staging.size,0,&mapped),"Map texture staging");std::memcpy(mapped,source->rgba.data(),source->rgba.size());vkUnmapMemory(device,staging.memory);begin();transition(command,texture.image,VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,VK_IMAGE_ASPECT_COLOR_BIT);VkBufferImageCopy copy{};copy.imageSubresource={VK_IMAGE_ASPECT_COLOR_BIT,0,0,1};copy.imageExtent={source->width,source->height,1};vkCmdCopyBufferToImage(command,staging.handle,texture.image.handle,VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,1,©);transition(command,texture.image,VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,VK_IMAGE_ASPECT_COLOR_BIT);submit();destroy(staging); - VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};ai.descriptorPool=descriptor_pool;ai.descriptorSetCount=1;ai.pSetLayouts=&descriptor_layout;check(vkAllocateDescriptorSets(device,&ai,&texture.descriptor),"Allocate texture descriptor"); - VkDescriptorImageInfo images[]={{VK_NULL_HANDLE,shadow.view,VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL},{shadow_sampler,VK_NULL_HANDLE,VK_IMAGE_LAYOUT_UNDEFINED},{VK_NULL_HANDLE,texture.image.view,VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL},{color_sampler,VK_NULL_HANDLE,VK_IMAGE_LAYOUT_UNDEFINED}}; - std::array writes{};for(std::uint32_t i=0;i<4;++i){writes[i]={VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};writes[i].dstSet=texture.descriptor;writes[i].dstBinding=i;writes[i].descriptorCount=1;writes[i].descriptorType=i%2?VK_DESCRIPTOR_TYPE_SAMPLER:VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE;writes[i].pImageInfo=&images[i];}vkUpdateDescriptorSets(device,4,writes.data(),0,nullptr); - }catch(...){destroy(staging);destroy(texture.image);throw;} - if(found!=textures.end()){destroy(found->second.image);vkFreeDescriptorSets(device,descriptor_pool,1,&found->second.descriptor);found->second=std::move(texture);return found->second.descriptor;} - auto [inserted,_]=textures.emplace(source.get(),std::move(texture));return inserted->second.descriptor; + try { + staging = make_buffer(source->rgba.size(), VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + void* mapped{}; + check(vkMapMemory(device, staging.memory, 0, staging.size, 0, &mapped), + "Map texture staging"); + std::memcpy(mapped, source->rgba.data(), source->rgba.size()); + vkUnmapMemory(device, staging.memory); + begin(); + transition(command, texture.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_IMAGE_ASPECT_COLOR_BIT); + VkBufferImageCopy copy{}; + copy.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + copy.imageExtent = {source->width, source->height, 1}; + vkCmdCopyBufferToImage(command, staging.handle, texture.image.handle, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©); + transition(command, texture.image, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_IMAGE_ASPECT_COLOR_BIT); + submit(); + destroy(staging); + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = descriptor_pool; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &descriptor_layout; + check(vkAllocateDescriptorSets(device, &ai, &texture.descriptor), + "Allocate texture descriptor"); + VkDescriptorImageInfo images[] = { + {VK_NULL_HANDLE, shadow.view, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL}, + {shadow_sampler, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_UNDEFINED}, + {VK_NULL_HANDLE, texture.image.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL}, + {color_sampler, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_UNDEFINED}}; + std::array writes{}; + for (std::uint32_t i = 0; i < 4; ++i) { + writes[i] = {VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + writes[i].dstSet = texture.descriptor; + writes[i].dstBinding = i; + writes[i].descriptorCount = 1; + writes[i].descriptorType = + i % 2 ? VK_DESCRIPTOR_TYPE_SAMPLER : VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE; + writes[i].pImageInfo = &images[i]; + } + vkUpdateDescriptorSets(device, 4, writes.data(), 0, nullptr); + } catch (...) { + destroy(staging); + destroy(texture.image); + throw; + } + if (found != textures.end()) { + destroy(found->second.image); + vkFreeDescriptorSets(device, descriptor_pool, 1, &found->second.descriptor); + found->second = std::move(texture); + return found->second.descriptor; + } + auto [inserted, _] = textures.emplace(source.get(), std::move(texture)); + return inserted->second.descriptor; } - VkShaderModule shader(const char* name){ - std::vector roots;const char* base=SDL_GetBasePath();if(base)roots.emplace_back(std::filesystem::path(base)/"shaders");roots.emplace_back(std::filesystem::current_path()/"shaders");roots.emplace_back(FASET_SHADER_DIRECTORY); - std::ifstream file;for(const auto& root:roots){file.open(root/(std::string(name)+".spv"),std::ios::binary|std::ios::ate);if(file)break;file.clear();}if(!file)throw std::runtime_error(std::string("Compiled Slang shader missing: ")+name+".spv");auto size=file.tellg();if(size<=0||size%4!=0)throw std::runtime_error("Invalid SPIR-V byte length");std::vector bytes(static_cast(size)/4);file.seekg(0);file.read(reinterpret_cast(bytes.data()),size);VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};ci.codeSize=static_cast(size);ci.pCode=bytes.data();VkShaderModule result{};check(vkCreateShaderModule(device,&ci,nullptr,&result),"Create shader module");return result; + VkShaderModule shader(const char* name) { + std::vector roots; + const char* base = SDL_GetBasePath(); + if (base) + roots.emplace_back(std::filesystem::path(base) / "shaders"); + roots.emplace_back(std::filesystem::current_path() / "shaders"); + roots.emplace_back(FASET_SHADER_DIRECTORY); + std::ifstream file; + for (const auto& root : roots) { + file.open(root / (std::string(name) + ".spv"), std::ios::binary | std::ios::ate); + if (file) + break; + file.clear(); + } + if (!file) + throw std::runtime_error(std::string("Compiled Slang shader missing: ") + name + + ".spv"); + auto size = file.tellg(); + if (size <= 0 || size % 4 != 0) + throw std::runtime_error("Invalid SPIR-V byte length"); + std::vector bytes(static_cast(size) / 4); + file.seekg(0); + file.read(reinterpret_cast(bytes.data()), size); + VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; + ci.codeSize = static_cast(size); + ci.pCode = bytes.data(); + VkShaderModule result{}; + check(vkCreateShaderModule(device, &ci, nullptr, &result), "Create shader module"); + return result; } - void make_pipelines(){ - VkPushConstantRange push{VK_SHADER_STAGE_VERTEX_BIT|VK_SHADER_STAGE_FRAGMENT_BIT,0,sizeof(Push)};VkPipelineLayoutCreateInfo li{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};li.setLayoutCount=1;li.pSetLayouts=&descriptor_layout;li.pushConstantRangeCount=1;li.pPushConstantRanges=&push;check(vkCreatePipelineLayout(device,&li,nullptr,&pipeline_layout),"Create pipeline layout"); - VkShaderModule vertex{},fragment{},shadow_vertex{}; - try{vertex=shader("vertexMain");fragment=shader("fragmentMain");shadow_vertex=shader("shadowMain");for(int mode=0;mode<3;++mode){bool shadow_pass=mode==2,ui=mode==1; - VkPipelineShaderStageCreateInfo stages[2]{};stages[0]={VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};stages[0].stage=VK_SHADER_STAGE_VERTEX_BIT;stages[0].module=shadow_pass?shadow_vertex:vertex;stages[0].pName="main";stages[1]={VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};stages[1].stage=VK_SHADER_STAGE_FRAGMENT_BIT;stages[1].module=fragment;stages[1].pName="main"; - VkVertexInputBindingDescription binding{0,sizeof(GpuVertex),VK_VERTEX_INPUT_RATE_VERTEX};VkVertexInputAttributeDescription attrs[]={{0,0,VK_FORMAT_R32G32B32A32_SFLOAT,offsetof(GpuVertex,clip)},{1,0,VK_FORMAT_R32G32B32_SFLOAT,offsetof(GpuVertex,world)},{2,0,VK_FORMAT_R32G32B32_SFLOAT,offsetof(GpuVertex,normal)},{3,0,VK_FORMAT_R32G32B32A32_SFLOAT,offsetof(GpuVertex,color)},{4,0,VK_FORMAT_R32G32_SFLOAT,offsetof(GpuVertex,material)},{5,0,VK_FORMAT_R32G32_SFLOAT,offsetof(GpuVertex,uv)}}; - VkPipelineVertexInputStateCreateInfo vi{VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};vi.vertexBindingDescriptionCount=1;vi.pVertexBindingDescriptions=&binding;vi.vertexAttributeDescriptionCount=shadow_pass?1:6;vi.pVertexAttributeDescriptions=shadow_pass?attrs+1:attrs;VkPipelineInputAssemblyStateCreateInfo ia{VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO};ia.topology=VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; - VkPipelineViewportStateCreateInfo vp{VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO};vp.viewportCount=vp.scissorCount=1;VkPipelineRasterizationStateCreateInfo rs{VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO};rs.polygonMode=VK_POLYGON_MODE_FILL;rs.cullMode=VK_CULL_MODE_NONE;rs.frontFace=VK_FRONT_FACE_COUNTER_CLOCKWISE;rs.lineWidth=1; - VkPipelineMultisampleStateCreateInfo ms{VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO};ms.rasterizationSamples=VK_SAMPLE_COUNT_1_BIT;VkPipelineDepthStencilStateCreateInfo ds{VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO};ds.depthTestEnable=!ui;ds.depthWriteEnable=!ui;ds.depthCompareOp=VK_COMPARE_OP_LESS_OR_EQUAL; - VkPipelineColorBlendAttachmentState blend{};blend.colorWriteMask=15;blend.blendEnable=VK_TRUE;blend.srcColorBlendFactor=VK_BLEND_FACTOR_SRC_ALPHA;blend.dstColorBlendFactor=VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;blend.colorBlendOp=VK_BLEND_OP_ADD;blend.srcAlphaBlendFactor=VK_BLEND_FACTOR_ONE;blend.dstAlphaBlendFactor=VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;blend.alphaBlendOp=VK_BLEND_OP_ADD;VkPipelineColorBlendStateCreateInfo cb{VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO};cb.attachmentCount=shadow_pass?0:1;cb.pAttachments=&blend; - VkDynamicState states[]={VK_DYNAMIC_STATE_VIEWPORT,VK_DYNAMIC_STATE_SCISSOR};VkPipelineDynamicStateCreateInfo dynamic{VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO};dynamic.dynamicStateCount=2;dynamic.pDynamicStates=states;VkFormat format=VK_FORMAT_R8G8B8A8_UNORM;VkPipelineRenderingCreateInfo rendering{VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO};rendering.colorAttachmentCount=shadow_pass?0:1;rendering.pColorAttachmentFormats=&format;rendering.depthAttachmentFormat=VK_FORMAT_D32_SFLOAT; - VkGraphicsPipelineCreateInfo pi{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO};pi.pNext=&rendering;pi.stageCount=shadow_pass?1:2;pi.pStages=stages;pi.pVertexInputState=&vi;pi.pInputAssemblyState=&ia;pi.pViewportState=&vp;pi.pRasterizationState=&rs;pi.pMultisampleState=&ms;pi.pDepthStencilState=&ds;pi.pColorBlendState=&cb;pi.pDynamicState=&dynamic;pi.layout=pipeline_layout;auto* output=shadow_pass?&shadow_pipeline:ui?&ui_pipeline:&pipeline;check(vkCreateGraphicsPipelines(device,VK_NULL_HANDLE,1,&pi,nullptr,output),"Create graphics pipeline"); - }}catch(...){vkDestroyShaderModule(device,vertex,nullptr);vkDestroyShaderModule(device,fragment,nullptr);vkDestroyShaderModule(device,shadow_vertex,nullptr);throw;} - vkDestroyShaderModule(device,vertex,nullptr);vkDestroyShaderModule(device,fragment,nullptr);vkDestroyShaderModule(device,shadow_vertex,nullptr); + void make_pipelines() { + VkPushConstantRange push{VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(Push)}; + VkPipelineLayoutCreateInfo li{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + li.setLayoutCount = 1; + li.pSetLayouts = &descriptor_layout; + li.pushConstantRangeCount = 1; + li.pPushConstantRanges = &push; + check(vkCreatePipelineLayout(device, &li, nullptr, &pipeline_layout), + "Create pipeline layout"); + VkShaderModule vertex{}, fragment{}, shadow_vertex{}; + try { + vertex = shader("vertexMain"); + fragment = shader("fragmentMain"); + shadow_vertex = shader("shadowMain"); + for (int mode = 0; mode < 3; ++mode) { + bool shadow_pass = mode == 2, ui = mode == 1; + VkPipelineShaderStageCreateInfo stages[2]{}; + stages[0] = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO}; + stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT; + stages[0].module = shadow_pass ? shadow_vertex : vertex; + stages[0].pName = "main"; + stages[1] = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO}; + stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT; + stages[1].module = fragment; + stages[1].pName = "main"; + VkVertexInputBindingDescription binding{0, sizeof(GpuVertex), + VK_VERTEX_INPUT_RATE_VERTEX}; + VkVertexInputAttributeDescription attrs[] = { + {0, 0, VK_FORMAT_R32G32B32A32_SFLOAT, offsetof(GpuVertex, clip)}, + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, offsetof(GpuVertex, world)}, + {2, 0, VK_FORMAT_R32G32B32_SFLOAT, offsetof(GpuVertex, normal)}, + {3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, offsetof(GpuVertex, color)}, + {4, 0, VK_FORMAT_R32G32_SFLOAT, offsetof(GpuVertex, material)}, + {5, 0, VK_FORMAT_R32G32_SFLOAT, offsetof(GpuVertex, uv)}}; + VkPipelineVertexInputStateCreateInfo vi{ + VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO}; + vi.vertexBindingDescriptionCount = 1; + vi.pVertexBindingDescriptions = &binding; + vi.vertexAttributeDescriptionCount = shadow_pass ? 1 : 6; + vi.pVertexAttributeDescriptions = shadow_pass ? attrs + 1 : attrs; + VkPipelineInputAssemblyStateCreateInfo ia{ + VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO}; + ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + VkPipelineViewportStateCreateInfo vp{ + VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO}; + vp.viewportCount = vp.scissorCount = 1; + VkPipelineRasterizationStateCreateInfo rs{ + VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO}; + rs.polygonMode = VK_POLYGON_MODE_FILL; + rs.cullMode = VK_CULL_MODE_NONE; + rs.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + rs.lineWidth = 1; + rs.depthBiasEnable = shadow_pass; + rs.depthBiasConstantFactor = 1.25f; + rs.depthBiasSlopeFactor = 1.75f; + VkPipelineMultisampleStateCreateInfo ms{ + VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO}; + ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + VkPipelineDepthStencilStateCreateInfo ds{ + VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO}; + ds.depthTestEnable = !ui; + ds.depthWriteEnable = !ui; + ds.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + VkPipelineColorBlendAttachmentState blend{}; + blend.colorWriteMask = 15; + blend.blendEnable = VK_TRUE; + blend.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + blend.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + blend.colorBlendOp = VK_BLEND_OP_ADD; + blend.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + blend.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + blend.alphaBlendOp = VK_BLEND_OP_ADD; + VkPipelineColorBlendStateCreateInfo cb{ + VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO}; + cb.attachmentCount = shadow_pass ? 0 : 1; + cb.pAttachments = &blend; + VkDynamicState states[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo dynamic{ + VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO}; + dynamic.dynamicStateCount = 2; + dynamic.pDynamicStates = states; + VkFormat format = VK_FORMAT_R8G8B8A8_UNORM; + VkPipelineRenderingCreateInfo rendering{ + VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO}; + rendering.colorAttachmentCount = shadow_pass ? 0 : 1; + rendering.pColorAttachmentFormats = &format; + rendering.depthAttachmentFormat = VK_FORMAT_D32_SFLOAT; + VkGraphicsPipelineCreateInfo pi{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO}; + pi.pNext = &rendering; + pi.stageCount = shadow_pass ? 1 : 2; + pi.pStages = stages; + pi.pVertexInputState = &vi; + pi.pInputAssemblyState = &ia; + pi.pViewportState = &vp; + pi.pRasterizationState = &rs; + pi.pMultisampleState = &ms; + pi.pDepthStencilState = &ds; + pi.pColorBlendState = &cb; + pi.pDynamicState = &dynamic; + pi.layout = pipeline_layout; + auto* output = shadow_pass ? &shadow_pipeline : ui ? &ui_pipeline : &pipeline; + check(vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pi, nullptr, output), + "Create graphics pipeline"); + } + } catch (...) { + vkDestroyShaderModule(device, vertex, nullptr); + vkDestroyShaderModule(device, fragment, nullptr); + vkDestroyShaderModule(device, shadow_vertex, nullptr); + throw; + } + vkDestroyShaderModule(device, vertex, nullptr); + vkDestroyShaderModule(device, fragment, nullptr); + vkDestroyShaderModule(device, shadow_vertex, nullptr); } - GpuVertex gpu_vertex(const Vertex& v,const DrawItem& item,const Mat4& vp){ - GpuVertex out{};auto world=point(item.model,{v.position[0],v.position[1],v.position[2],1});auto clip=point(vp,world);std::copy(clip.begin(),clip.end(),out.clip);std::copy_n(world.begin(),3,out.world); + GpuVertex gpu_vertex(const Vertex& v, const DrawItem& item, const Mat4& vp) { + GpuVertex out{}; + auto world = point(item.model, {v.position[0], v.position[1], v.position[2], 1}); + auto clip = point(vp, world); + std::copy(clip.begin(), clip.end(), out.clip); + std::copy_n(world.begin(), 3, out.world); // Inverse-transpose 3x3, including nonuniform scale. Singular models have no valid normal. - const auto& m=item.model;Vec3 a{m[0],m[1],m[2]},b{m[4],m[5],m[6]},c{m[8],m[9],m[10]}; - auto cross=[](Vec3 x,Vec3 y){return Vec3{x[1]*y[2]-x[2]*y[1],x[2]*y[0]-x[0]*y[2],x[0]*y[1]-x[1]*y[0]};};auto ca=cross(b,c),cb=cross(c,a),cc=cross(a,b);float determinant=a[0]*ca[0]+a[1]*ca[1]+a[2]*ca[2]; - for(int i=0;i<3;++i)out.normal[i]=std::abs(determinant)>1e-8f?(ca[i]*v.normal[0]+cb[i]*v.normal[1]+cc[i]*v.normal[2])/determinant:0; - for(int i=0;i<4;++i)out.color[i]=item.color[i]*v.color[i];out.material[0]=item.roughness;out.material[1]=item.metallic;out.uv[0]=v.uv[0];out.uv[1]=v.uv[1];return out; - } - bool outside(const std::vector& data,std::size_t start) const { - for(int plane=0;plane<6;++plane){bool all=true;for(std::size_t i=start;i=0){all=false;break;}}if(all)return true;}return false; - } - void quad(std::vector& data,const Quad& q){ - const float xy[4][2]={{q.x,q.y},{q.x+q.width,q.y},{q.x+q.width,q.y+q.height},{q.x,q.y+q.height}}; - const float uv[4][2]={{q.uv_rect[0],q.uv_rect[1]},{q.uv_rect[2],q.uv_rect[1]},{q.uv_rect[2],q.uv_rect[3]},{q.uv_rect[0],q.uv_rect[3]}}; - for(auto i:{0,1,2,0,2,3}){GpuVertex v{};v.clip[0]=xy[i][0]/float(width)*2-1;v.clip[1]=xy[i][1]/float(height)*2-1;v.clip[3]=1;std::copy(q.color.begin(),q.color.end(),v.color);v.uv[0]=uv[i][0];v.uv[1]=uv[i][1];data.push_back(v);} - } - void draw_debug_text(std::vector& data,const Text& text){ - // Small diagnostic alphabet only. The editor supplies shaped Unicode text as texture quads. - static const std::unordered_map> glyphs={ - {'A',{14,17,17,31,17,17,17}},{'B',{30,17,17,30,17,17,30}},{'C',{14,17,16,16,16,17,14}},{'D',{30,17,17,17,17,17,30}},{'E',{31,16,16,30,16,16,31}},{'F',{31,16,16,30,16,16,16}},{'G',{14,17,16,23,17,17,15}},{'H',{17,17,17,31,17,17,17}},{'I',{14,4,4,4,4,4,14}},{'J',{7,2,2,2,18,18,12}},{'K',{17,18,20,24,20,18,17}},{'L',{16,16,16,16,16,16,31}},{'M',{17,27,21,21,17,17,17}},{'N',{17,25,21,19,17,17,17}},{'O',{14,17,17,17,17,17,14}},{'P',{30,17,17,30,16,16,16}},{'Q',{14,17,17,17,21,18,13}},{'R',{30,17,17,30,20,18,17}},{'S',{15,16,16,14,1,1,30}},{'T',{31,4,4,4,4,4,4}},{'U',{17,17,17,17,17,17,14}},{'V',{17,17,17,17,17,10,4}},{'W',{17,17,17,21,21,27,17}},{'X',{17,17,10,4,10,17,17}},{'Y',{17,17,10,4,4,4,4}},{'Z',{31,1,2,4,8,16,31}}, - {'0',{14,17,19,21,25,17,14}},{'1',{4,12,4,4,4,4,14}},{'2',{14,17,1,2,4,8,31}},{'3',{30,1,1,14,1,1,30}},{'4',{2,6,10,18,31,2,2}},{'5',{31,16,16,30,1,1,30}},{'6',{14,16,16,30,17,17,14}},{'7',{31,1,2,4,8,8,8}},{'8',{14,17,17,14,17,17,14}},{'9',{14,17,17,15,1,1,14}}, - {'.',{0,0,0,0,0,12,12}},{':',{0,12,12,0,12,12,0}},{'-',{0,0,0,31,0,0,0}},{'/',{1,1,2,4,8,16,16}},{'_', {0,0,0,0,0,0,31}},{'(',{2,4,8,8,8,4,2}},{')',{8,4,2,2,2,4,8}},{'+',{0,4,4,31,4,4,0}},{'=',{0,0,31,0,31,0,0}},{'[',{14,8,8,8,8,8,14}},{']',{14,2,2,2,2,2,14}},{'?',{14,17,1,2,4,0,4}},{'!',{4,4,4,4,4,0,4}} + const auto& m = item.model; + Vec3 a{m[0], m[1], m[2]}, b{m[4], m[5], m[6]}, c{m[8], m[9], m[10]}; + auto cross = [](Vec3 x, Vec3 y) { + return Vec3{x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], + x[0] * y[1] - x[1] * y[0]}; }; - float x=text.x,y=text.y,unit=text.size/7;for(unsigned char c:text.value){if(c=='\n'){x=text.x;y+=text.size*1.4f;continue;}if(c>='a'&&c<='z')c-=32;if(c!=' '){auto it=glyphs.find(static_cast(c));auto pattern=it==glyphs.end()?std::array{31,17,17,17,17,17,31}:it->second;for(int row=0;row<7;++row)for(int col=0;col<5;++col)if(pattern[row]&(1<<(4-col)))quad(data,{x+col*unit,y+row*unit,unit,unit,text.color});}x+=6*unit;} + auto ca = cross(b, c), cb = cross(c, a), cc = cross(a, b); + float determinant = a[0] * ca[0] + a[1] * ca[1] + a[2] * ca[2]; + for (int i = 0; i < 3; ++i) + out.normal[i] = + determinant != 0 + ? (ca[i] * v.normal[0] + cb[i] * v.normal[1] + cc[i] * v.normal[2]) * + (determinant < 0 ? -1.f : 1.f) + : 0; + float normal_length = std::hypot(out.normal[0], out.normal[1], out.normal[2]); + if (normal_length > 0) + for (float& component : out.normal) + component /= normal_length; + for (int i = 0; i < 4; ++i) + out.color[i] = item.color[i] * v.color[i]; + out.material[0] = item.roughness; + out.material[1] = item.metallic; + out.uv[0] = v.uv[0]; + out.uv[1] = v.uv[1]; + return out; } - void render(const Snapshot& snapshot){ - auto start=std::chrono::steady_clock::now();statistics.draw_calls=statistics.culled_meshes=0; - if(surface){int w{},h{};SDL_GetWindowSizeInPixels(window,&w,&h);if(w<=0||h<=0)return;if(dirty_swapchain||!swapchain)make_swapchain();} + bool outside(const std::vector& data, std::size_t start) const { + for (int plane = 0; plane < 6; ++plane) { + bool all = true; + for (std::size_t i = start; i < data.size(); ++i) { + auto& p = data[i].clip; + float d = plane == 0 ? p[0] + p[3] + : plane == 1 ? p[3] - p[0] + : plane == 2 ? p[1] + p[3] + : plane == 3 ? p[3] - p[1] + : plane == 4 ? p[2] + : p[3] - p[2]; + if (d >= 0) { + all = false; + break; + } + } + if (all) + return true; + } + return false; + } + void quad(std::vector& data, const Quad& q) { + const float xy[4][2] = {{q.x, q.y}, + {q.x + q.width, q.y}, + {q.x + q.width, q.y + q.height}, + {q.x, q.y + q.height}}; + const float uv[4][2] = {{q.uv_rect[0], q.uv_rect[1]}, + {q.uv_rect[2], q.uv_rect[1]}, + {q.uv_rect[2], q.uv_rect[3]}, + {q.uv_rect[0], q.uv_rect[3]}}; + for (auto i : {0, 1, 2, 0, 2, 3}) { + GpuVertex v{}; + v.clip[0] = xy[i][0] / float(width) * 2 - 1; + v.clip[1] = xy[i][1] / float(height) * 2 - 1; + v.clip[3] = 1; + std::copy(q.color.begin(), q.color.end(), v.color); + v.uv[0] = uv[i][0]; + v.uv[1] = uv[i][1]; + v.material[0] = q.texture && q.texture->srgb ? 1.f : 0.f; + data.push_back(v); + } + } + void draw_debug_text(std::vector& data, const Text& text) { + // Small diagnostic alphabet only. The editor supplies shaped Unicode text as texture quads. + static const std::unordered_map> glyphs = { + {'A', {14, 17, 17, 31, 17, 17, 17}}, {'B', {30, 17, 17, 30, 17, 17, 30}}, + {'C', {14, 17, 16, 16, 16, 17, 14}}, {'D', {30, 17, 17, 17, 17, 17, 30}}, + {'E', {31, 16, 16, 30, 16, 16, 31}}, {'F', {31, 16, 16, 30, 16, 16, 16}}, + {'G', {14, 17, 16, 23, 17, 17, 15}}, {'H', {17, 17, 17, 31, 17, 17, 17}}, + {'I', {14, 4, 4, 4, 4, 4, 14}}, {'J', {7, 2, 2, 2, 18, 18, 12}}, + {'K', {17, 18, 20, 24, 20, 18, 17}}, {'L', {16, 16, 16, 16, 16, 16, 31}}, + {'M', {17, 27, 21, 21, 17, 17, 17}}, {'N', {17, 25, 21, 19, 17, 17, 17}}, + {'O', {14, 17, 17, 17, 17, 17, 14}}, {'P', {30, 17, 17, 30, 16, 16, 16}}, + {'Q', {14, 17, 17, 17, 21, 18, 13}}, {'R', {30, 17, 17, 30, 20, 18, 17}}, + {'S', {15, 16, 16, 14, 1, 1, 30}}, {'T', {31, 4, 4, 4, 4, 4, 4}}, + {'U', {17, 17, 17, 17, 17, 17, 14}}, {'V', {17, 17, 17, 17, 17, 10, 4}}, + {'W', {17, 17, 17, 21, 21, 27, 17}}, {'X', {17, 17, 10, 4, 10, 17, 17}}, + {'Y', {17, 17, 10, 4, 4, 4, 4}}, {'Z', {31, 1, 2, 4, 8, 16, 31}}, + {'0', {14, 17, 19, 21, 25, 17, 14}}, {'1', {4, 12, 4, 4, 4, 4, 14}}, + {'2', {14, 17, 1, 2, 4, 8, 31}}, {'3', {30, 1, 1, 14, 1, 1, 30}}, + {'4', {2, 6, 10, 18, 31, 2, 2}}, {'5', {31, 16, 16, 30, 1, 1, 30}}, + {'6', {14, 16, 16, 30, 17, 17, 14}}, {'7', {31, 1, 2, 4, 8, 8, 8}}, + {'8', {14, 17, 17, 14, 17, 17, 14}}, {'9', {14, 17, 17, 15, 1, 1, 14}}, + {'.', {0, 0, 0, 0, 0, 12, 12}}, {':', {0, 12, 12, 0, 12, 12, 0}}, + {'-', {0, 0, 0, 31, 0, 0, 0}}, {'/', {1, 1, 2, 4, 8, 16, 16}}, + {'_', {0, 0, 0, 0, 0, 0, 31}}, {'(', {2, 4, 8, 8, 8, 4, 2}}, + {')', {8, 4, 2, 2, 2, 4, 8}}, {'+', {0, 4, 4, 31, 4, 4, 0}}, + {'=', {0, 0, 31, 0, 31, 0, 0}}, {'[', {14, 8, 8, 8, 8, 8, 14}}, + {']', {14, 2, 2, 2, 2, 2, 14}}, {'?', {14, 17, 1, 2, 4, 0, 4}}, + {'!', {4, 4, 4, 4, 4, 0, 4}}}; + float x = text.x, y = text.y, unit = text.size / 7; + for (unsigned char c : text.value) { + if (c == '\n') { + x = text.x; + y += text.size * 1.4f; + continue; + } + if (c >= 'a' && c <= 'z') + c -= 32; + if (c != ' ') { + auto it = glyphs.find(static_cast(c)); + auto pattern = it == glyphs.end() + ? std::array{31, 17, 17, 17, 17, 17, 31} + : it->second; + for (int row = 0; row < 7; ++row) + for (int col = 0; col < 5; ++col) + if (pattern[row] & (1 << (4 - col))) + quad(data, {x + col * unit, y + row * unit, unit, unit, text.color}); + } + x += 6 * unit; + } + } + void render(const Snapshot& snapshot) { + auto start = std::chrono::steady_clock::now(); + statistics.draw_calls = statistics.culled_meshes = 0; + if (surface) { + int w{}, h{}; + SDL_GetWindowSizeInPixels(window, &w, &h); + if (w <= 0 || h <= 0) + return; + if (dirty_swapchain || !swapchain) + make_swapchain(); + } // Retire atlas/image resources no longer retained by a caller. - for(auto it=textures.begin();it!=textures.end();){if(it->first!=white.get()&&it->second.source.use_count()==1){destroy(it->second.image);vkFreeDescriptorSets(device,descriptor_pool,1,&it->second.descriptor);it=textures.erase(it);}else ++it;} - const VkDescriptorSet white_descriptor=upload_texture(white);for(const auto& q:snapshot.ui_quads)if(q.texture)upload_texture(q.texture);for(const auto& draw:snapshot.draws)if(draw.texture)upload_texture(draw.texture);for(const auto& sprite:snapshot.sprites)if(sprite.texture)upload_texture(sprite.texture); - std::vector data;std::vector scene_batches,shadow_batches,ui_batches; - for(const auto& item:snapshot.draws){if(!item.mesh)continue;auto first=data.size();const auto& mesh=*item.mesh;auto emit=[&](std::uint32_t index){if(index>=mesh.vertices.size())throw std::out_of_range("Mesh index outside vertex range");data.push_back(gpu_vertex(mesh.vertices[index],item,snapshot.view_projection));};if(mesh.indices.empty())for(std::uint32_t i=0;i(data.size()-first);if(count%3)throw std::invalid_argument("Mesh triangle vertex count must be divisible by three");if(!count)continue;Batch batch{static_cast(first),count,item.texture?item.texture.get():white.get()};if(item.cast_shadow)shadow_batches.push_back(batch);if(outside(data,first))++statistics.culled_meshes;else scene_batches.push_back(batch);} - for(const auto& sprite:snapshot.sprites){auto first=static_cast(data.size());float c=std::cos(sprite.rotation),s=std::sin(sprite.rotation);for(auto i:{0,1,2,0,2,3}){const float corners[4][2]={{-.5f,-.5f},{.5f,-.5f},{.5f,.5f},{-.5f,.5f}};float x=corners[i][0]*sprite.size[0],y=corners[i][1]*sprite.size[1];auto clip=point(snapshot.view_projection,{sprite.position[0]+c*x-s*y,sprite.position[1]+s*x+c*y,sprite.position[2],1});GpuVertex vertex{};std::copy(clip.begin(),clip.end(),vertex.clip);std::copy(sprite.color.begin(),sprite.color.end(),vertex.color);vertex.uv[0]=corners[i][0]+.5f;vertex.uv[1]=.5f-corners[i][1];data.push_back(vertex);}scene_batches.push_back({first,6,sprite.texture?sprite.texture.get():white.get()});} - for(const auto& q:snapshot.ui_quads){auto first=static_cast(data.size());quad(data,q);const Texture* texture=q.texture?q.texture.get():white.get();if(!ui_batches.empty()&&ui_batches.back().texture==texture)ui_batches.back().count+=6;else ui_batches.push_back({first,6,texture});} - auto text_first=static_cast(data.size());for(const auto& text:snapshot.ui_text)draw_debug_text(data,text);if(data.size()>text_first)ui_batches.push_back({text_first,static_cast(data.size()-text_first),white.get()}); - statistics.vertices=static_cast(data.size());auto byte_count=std::max(sizeof(GpuVertex),data.size()*sizeof(GpuVertex));if(vertices.size.98f?Vec3{0,0,1}:Vec3{0,1,0};Push push{multiply(orthographic(-20,20,-20,20,.1f,80),look_at(light_eye,{0,0,0},light_up)),{direction[0],direction[1],direction[2],0},{snapshot.eye[0],snapshot.eye[1],snapshot.eye[2],1}}; + for (auto it = textures.begin(); it != textures.end();) { + if (it->first != white.get() && it->second.source.use_count() == 1) { + destroy(it->second.image); + vkFreeDescriptorSets(device, descriptor_pool, 1, &it->second.descriptor); + it = textures.erase(it); + } else + ++it; + } + const VkDescriptorSet white_descriptor = upload_texture(white); + for (const auto& q : snapshot.ui_quads) + if (q.texture) + upload_texture(q.texture); + for (const auto& draw : snapshot.draws) + if (draw.texture) + upload_texture(draw.texture); + for (const auto& sprite : snapshot.sprites) + if (sprite.texture) + upload_texture(sprite.texture); + std::vector data; + std::vector scene_batches, shadow_batches, ui_batches; + for (const auto& item : snapshot.draws) { + if (!item.mesh) + continue; + auto first = data.size(); + const auto& mesh = *item.mesh; + auto emit = [&](std::uint32_t index) { + if (index >= mesh.vertices.size()) + throw std::out_of_range("Mesh index outside vertex range"); + data.push_back(gpu_vertex(mesh.vertices[index], item, snapshot.view_projection)); + }; + if (mesh.indices.empty()) + for (std::uint32_t i = 0; i < mesh.vertices.size(); ++i) + emit(i); + else + for (auto i : mesh.indices) + emit(i); + auto count = static_cast(data.size() - first); + if (count % 3) + throw std::invalid_argument( + "Mesh triangle vertex count must be divisible by three"); + if (!count) + continue; + Batch batch{static_cast(first), count, + item.texture ? item.texture.get() : white.get()}; + if (item.cast_shadow) + shadow_batches.push_back(batch); + if (outside(data, first)) + ++statistics.culled_meshes; + else + scene_batches.push_back(batch); + } + for (const auto& sprite : snapshot.sprites) { + auto first = static_cast(data.size()); + float c = std::cos(sprite.rotation), s = std::sin(sprite.rotation); + for (auto i : {0, 1, 2, 0, 2, 3}) { + const float corners[4][2] = {{-.5f, -.5f}, {.5f, -.5f}, {.5f, .5f}, {-.5f, .5f}}; + float x = corners[i][0] * sprite.size[0], y = corners[i][1] * sprite.size[1]; + auto clip = point(snapshot.view_projection, + {sprite.position[0] + c * x - s * y, + sprite.position[1] + s * x + c * y, sprite.position[2], 1}); + GpuVertex vertex{}; + std::copy(clip.begin(), clip.end(), vertex.clip); + std::copy(sprite.color.begin(), sprite.color.end(), vertex.color); + vertex.uv[0] = corners[i][0] + .5f; + vertex.uv[1] = .5f - corners[i][1]; + vertex.material[0] = sprite.texture && sprite.texture->srgb ? 1.f : 0.f; + data.push_back(vertex); + } + scene_batches.push_back( + {first, 6, sprite.texture ? sprite.texture.get() : white.get()}); + } + for (const auto& q : snapshot.ui_quads) { + auto first = static_cast(data.size()); + quad(data, q); + const Texture* texture = q.texture ? q.texture.get() : white.get(); + if (!ui_batches.empty() && ui_batches.back().texture == texture) + ui_batches.back().count += 6; + else + ui_batches.push_back({first, 6, texture}); + } + auto text_first = static_cast(data.size()); + for (const auto& text : snapshot.ui_text) + draw_debug_text(data, text); + if (data.size() > text_first) + ui_batches.push_back( + {text_first, static_cast(data.size() - text_first), white.get()}); + statistics.vertices = static_cast(data.size()); + auto byte_count = std::max(sizeof(GpuVertex), data.size() * sizeof(GpuVertex)); + if (vertices.size < byte_count) { + destroy(vertices); + vertices = make_buffer(byte_count, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + } + void* mapped{}; + check(vkMapMemory(device, vertices.memory, 0, vertices.size, 0, &mapped), "Map vertices"); + if (!data.empty()) + std::memcpy(mapped, data.data(), data.size() * sizeof(GpuVertex)); + vkUnmapMemory(device, vertices.memory); + Vec3 direction = snapshot.light_direction; + float length = std::sqrt(direction[0] * direction[0] + direction[1] * direction[1] + + direction[2] * direction[2]); + if (length < 1e-5f) { + direction = {-.5f, -1, -.3f}; + length = std::sqrt(1.34f); + } + for (auto& v : direction) + v /= length; + Vec3 light_eye{-direction[0] * 30, -direction[1] * 30, -direction[2] * 30}; + Vec3 light_up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0}; + Push push{multiply(orthographic(-20, 20, -20, 20, .1f, 80), + look_at(light_eye, {0, 0, 0}, light_up)), + {direction[0], direction[1], direction[2], 0}, + {snapshot.eye[0], snapshot.eye[1], snapshot.eye[2], 1}}; std::optional swap_index; - if(surface){std::uint32_t index{};auto result=vkAcquireNextImageKHR(device,swapchain,UINT64_MAX,acquired,VK_NULL_HANDLE,&index);if(result==VK_ERROR_OUT_OF_DATE_KHR){dirty_swapchain=true;return;}if(result==VK_SUBOPTIMAL_KHR)dirty_swapchain=true;else check(result,"Acquire swapchain image");swap_index=index;} - begin();if(timestamp_pool){vkCmdResetQueryPool(command,timestamp_pool,0,2);vkCmdWriteTimestamp2(command,VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT,timestamp_pool,0);}VkDeviceSize offset{};vkCmdBindVertexBuffers(command,0,1,&vertices.handle,&offset);vkCmdPushConstants(command,pipeline_layout,VK_SHADER_STAGE_VERTEX_BIT|VK_SHADER_STAGE_FRAGMENT_BIT,0,sizeof(push),&push); - auto set_viewport=[&](std::uint32_t w,std::uint32_t h){VkViewport viewport{0,0,float(w),float(h),0,1};VkRect2D scissor{{0,0},{w,h}};vkCmdSetViewport(command,0,1,&viewport);vkCmdSetScissor(command,0,1,&scissor);}; + if (surface) { + std::uint32_t index{}; + auto result = vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, acquired, + VK_NULL_HANDLE, &index); + if (result == VK_ERROR_OUT_OF_DATE_KHR) { + dirty_swapchain = true; + return; + } + if (result == VK_SUBOPTIMAL_KHR) + dirty_swapchain = true; + else + check(result, "Acquire swapchain image"); + swap_index = index; + } + begin(); + if (timestamp_pool) { + vkCmdResetQueryPool(command, timestamp_pool, 0, 2); + vkCmdWriteTimestamp2(command, VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT, timestamp_pool, 0); + } + VkDeviceSize offset{}; + vkCmdBindVertexBuffers(command, 0, 1, &vertices.handle, &offset); + vkCmdPushConstants(command, pipeline_layout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(push), &push); + auto set_viewport = [&](std::uint32_t w, std::uint32_t h) { + VkViewport viewport{0, 0, float(w), float(h), 0, 1}; + VkRect2D scissor{{0, 0}, {w, h}}; + vkCmdSetViewport(command, 0, 1, &viewport); + vkCmdSetScissor(command, 0, 1, &scissor); + }; RenderGraph graph; - graph.add("ShadowMap",{}, {"shadow"},[&]{ - transition(command,shadow,VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,VK_IMAGE_ASPECT_DEPTH_BIT);VkRenderingAttachmentInfo attachment{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};attachment.imageView=shadow.view;attachment.imageLayout=VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;attachment.loadOp=VK_ATTACHMENT_LOAD_OP_CLEAR;attachment.storeOp=VK_ATTACHMENT_STORE_OP_STORE;attachment.clearValue.depthStencil={1,0};VkRenderingInfo rendering{VK_STRUCTURE_TYPE_RENDERING_INFO};rendering.renderArea={{0,0},{shadow_size,shadow_size}};rendering.layerCount=1;rendering.pDepthAttachment=&attachment;vkCmdBeginRendering(command,&rendering);set_viewport(shadow_size,shadow_size);vkCmdBindPipeline(command,VK_PIPELINE_BIND_POINT_GRAPHICS,shadow_pipeline);vkCmdPushConstants(command,pipeline_layout,VK_SHADER_STAGE_VERTEX_BIT|VK_SHADER_STAGE_FRAGMENT_BIT,0,sizeof(push),&push);for(auto batch:shadow_batches){vkCmdDraw(command,batch.count,1,batch.first,0);++statistics.draw_calls;}vkCmdEndRendering(command);transition(command,shadow,VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,VK_IMAGE_ASPECT_DEPTH_BIT); + graph.add("ShadowMap", {}, {"shadow"}, [&] { + transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, + VK_IMAGE_ASPECT_DEPTH_BIT); + VkRenderingAttachmentInfo attachment{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}; + attachment.imageView = shadow.view; + attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL; + attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachment.clearValue.depthStencil = {1, 0}; + VkRenderingInfo rendering{VK_STRUCTURE_TYPE_RENDERING_INFO}; + rendering.renderArea = {{0, 0}, {shadow_size, shadow_size}}; + rendering.layerCount = 1; + rendering.pDepthAttachment = &attachment; + vkCmdBeginRendering(command, &rendering); + set_viewport(shadow_size, shadow_size); + vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, shadow_pipeline); + vkCmdPushConstants(command, pipeline_layout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(push), &push); + for (auto batch : shadow_batches) { + vkCmdDraw(command, batch.count, 1, batch.first, 0); + ++statistics.draw_calls; + } + vkCmdEndRendering(command); + transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL, + VK_IMAGE_ASPECT_DEPTH_BIT); }); - graph.add("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,VK_IMAGE_ASPECT_DEPTH_BIT);VkRenderingAttachmentInfo ca{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};ca.imageView=color.view;ca.imageLayout=VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;ca.loadOp=VK_ATTACHMENT_LOAD_OP_CLEAR;ca.storeOp=VK_ATTACHMENT_STORE_OP_STORE;std::copy(snapshot.clear_color.begin(),snapshot.clear_color.end(),ca.clearValue.color.float32);VkRenderingAttachmentInfo da{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};da.imageView=depth.view;da.imageLayout=VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;da.loadOp=VK_ATTACHMENT_LOAD_OP_CLEAR;da.storeOp=VK_ATTACHMENT_STORE_OP_DONT_CARE;da.clearValue.depthStencil={1,0};VkRenderingInfo rendering{VK_STRUCTURE_TYPE_RENDERING_INFO};rendering.renderArea={{0,0},{width,height}};rendering.layerCount=1;rendering.colorAttachmentCount=1;rendering.pColorAttachments=&ca;rendering.pDepthAttachment=&da;vkCmdBeginRendering(command,&rendering);set_viewport(width,height);if(snapshot.scene_rect[2]>0&&snapshot.scene_rect[3]>0){auto r=snapshot.scene_rect;float x=std::clamp(r[0],0.f,float(width)),y=std::clamp(r[1],0.f,float(height));float w=std::min(r[2],float(width)-x),h=std::min(r[3],float(height)-y);VkViewport viewport{x,y,w,h,0,1};VkRect2D scissor{{static_cast(x),static_cast(y)},{static_cast(w),static_cast(h)}};vkCmdSetViewport(command,0,1,&viewport);vkCmdSetScissor(command,0,1,&scissor);}vkCmdBindPipeline(command,VK_PIPELINE_BIND_POINT_GRAPHICS,pipeline);vkCmdPushConstants(command,pipeline_layout,VK_SHADER_STAGE_VERTEX_BIT|VK_SHADER_STAGE_FRAGMENT_BIT,0,sizeof(push),&push);vkCmdBindDescriptorSets(command,VK_PIPELINE_BIND_POINT_GRAPHICS,pipeline_layout,0,1,&white_descriptor,0,nullptr);for(auto batch:scene_batches){auto descriptor=textures.at(batch.texture).descriptor;vkCmdBindDescriptorSets(command,VK_PIPELINE_BIND_POINT_GRAPHICS,pipeline_layout,0,1,&descriptor,0,nullptr);vkCmdDraw(command,batch.count,1,batch.first,0);++statistics.draw_calls;}set_viewport(width,height);vkCmdBindPipeline(command,VK_PIPELINE_BIND_POINT_GRAPHICS,ui_pipeline);vkCmdPushConstants(command,pipeline_layout,VK_SHADER_STAGE_VERTEX_BIT|VK_SHADER_STAGE_FRAGMENT_BIT,0,sizeof(push),&push);for(auto batch:ui_batches){auto descriptor=textures.at(batch.texture).descriptor;vkCmdBindDescriptorSets(command,VK_PIPELINE_BIND_POINT_GRAPHICS,pipeline_layout,0,1,&descriptor,0,nullptr);vkCmdDraw(command,batch.count,1,batch.first,0);++statistics.draw_calls;}vkCmdEndRendering(command); + graph.add("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, + VK_IMAGE_ASPECT_DEPTH_BIT); + VkRenderingAttachmentInfo ca{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}; + ca.imageView = color.view; + ca.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + ca.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + ca.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + std::copy(snapshot.clear_color.begin(), snapshot.clear_color.end(), + ca.clearValue.color.float32); + VkRenderingAttachmentInfo da{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}; + da.imageView = depth.view; + da.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL; + da.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + da.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + da.clearValue.depthStencil = {1, 0}; + VkRenderingInfo rendering{VK_STRUCTURE_TYPE_RENDERING_INFO}; + rendering.renderArea = {{0, 0}, {width, height}}; + rendering.layerCount = 1; + rendering.colorAttachmentCount = 1; + rendering.pColorAttachments = &ca; + rendering.pDepthAttachment = &da; + vkCmdBeginRendering(command, &rendering); + set_viewport(width, height); + if (snapshot.scene_rect[2] > 0 && snapshot.scene_rect[3] > 0) { + auto r = snapshot.scene_rect; + float x = std::clamp(r[0], 0.f, float(width)), + y = std::clamp(r[1], 0.f, float(height)); + float w = std::min(r[2], float(width) - x), h = std::min(r[3], float(height) - y); + VkViewport viewport{x, y, w, h, 0, 1}; + VkRect2D scissor{{static_cast(x), static_cast(y)}, + {static_cast(w), static_cast(h)}}; + vkCmdSetViewport(command, 0, 1, &viewport); + vkCmdSetScissor(command, 0, 1, &scissor); + } + vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + vkCmdPushConstants(command, pipeline_layout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(push), &push); + vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, 0, 1, + &white_descriptor, 0, nullptr); + for (auto batch : scene_batches) { + auto descriptor = textures.at(batch.texture).descriptor; + vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, + 0, 1, &descriptor, 0, nullptr); + vkCmdDraw(command, batch.count, 1, batch.first, 0); + ++statistics.draw_calls; + } + set_viewport(width, height); + vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_pipeline); + vkCmdPushConstants(command, pipeline_layout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(push), &push); + for (auto batch : ui_batches) { + auto descriptor = textures.at(batch.texture).descriptor; + vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, + 0, 1, &descriptor, 0, nullptr); + vkCmdDraw(command, batch.count, 1, batch.first, 0); + ++statistics.draw_calls; + } + vkCmdEndRendering(command); }); - graph.add("Readback",{"color"},{"capture"},[&]{transition(command,color,VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,VK_IMAGE_ASPECT_COLOR_BIT);VkBufferImageCopy copy{};copy.imageSubresource={VK_IMAGE_ASPECT_COLOR_BIT,0,0,1};copy.imageExtent={width,height,1};vkCmdCopyImageToBuffer(command,color.handle,VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,readback.handle,1,©);}); - if(swap_index)graph.add("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);VkImageBlit blit{};blit.srcSubresource={VK_IMAGE_ASPECT_COLOR_BIT,0,0,1};blit.srcOffsets[1]={static_cast(width),static_cast(height),1};blit.dstSubresource={VK_IMAGE_ASPECT_COLOR_BIT,0,0,1};blit.dstOffsets[1]={static_cast(swap_extent.width),static_cast(swap_extent.height),1};vkCmdBlitImage(command,color.handle,VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,swap_images[index],VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,1,&blit,VK_FILTER_NEAREST);transition(command,swap_images[index],swap_layouts[index],VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,VK_IMAGE_ASPECT_COLOR_BIT);}); - graph.execute();if(timestamp_pool)vkCmdWriteTimestamp2(command,VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT,timestamp_pool,1);submit(swap_index.has_value());if(timestamp_pool){std::uint64_t stamps[2]{};check(vkGetQueryPoolResults(device,timestamp_pool,0,2,sizeof(stamps),stamps,sizeof(std::uint64_t),VK_QUERY_RESULT_64_BIT|VK_QUERY_RESULT_WAIT_BIT),"Read GPU timestamps");auto delta=stamps[1]-stamps[0];if(timestamp_bits<64)delta&=(std::uint64_t(1)<(std::chrono::steady_clock::now()-start).count(); + graph.add("Readback", {"color"}, {"capture"}, [&] { + transition(command, color, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_IMAGE_ASPECT_COLOR_BIT); + VkBufferImageCopy copy{}; + copy.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + copy.imageExtent = {width, height, 1}; + vkCmdCopyImageToBuffer(command, color.handle, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + readback.handle, 1, ©); + }); + if (swap_index) + graph.add("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); + VkImageBlit blit{}; + blit.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + blit.srcOffsets[1] = {static_cast(width), static_cast(height), 1}; + blit.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + blit.dstOffsets[1] = {static_cast(swap_extent.width), + static_cast(swap_extent.height), 1}; + vkCmdBlitImage(command, color.handle, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + swap_images[index], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, + VK_FILTER_NEAREST); + transition(command, swap_images[index], swap_layouts[index], + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_ASPECT_COLOR_BIT); + }); + graph.execute(); + if (timestamp_pool) + vkCmdWriteTimestamp2(command, VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT, timestamp_pool, + 1); + submit(swap_index.has_value()); + if (timestamp_pool) { + std::uint64_t stamps[2]{}; + check(vkGetQueryPoolResults(device, timestamp_pool, 0, 2, sizeof(stamps), stamps, + sizeof(std::uint64_t), + VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT), + "Read GPU timestamps"); + auto delta = stamps[1] - stamps[0]; + if (timestamp_bits < 64) + delta &= (std::uint64_t(1) << timestamp_bits) - 1; + statistics.gpu_ms = double(delta) * timestamp_period / 1000000.0; + } + if (swap_index) { + VkPresentInfoKHR present{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR}; + present.waitSemaphoreCount = 1; + present.pWaitSemaphores = &present_ready; + present.swapchainCount = 1; + present.pSwapchains = &swapchain; + present.pImageIndices = &*swap_index; + auto result = vkQueuePresentKHR(queue, &present); + if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR) + dirty_swapchain = true; + else + check(result, "Present frame"); + check(vkQueueWaitIdle(queue), "Wait presentation"); + } + last_pixels.resize(std::size_t(width) * height * 4); + check(vkMapMemory(device, readback.memory, 0, readback.size, 0, &mapped), + "Map captured frame"); + std::memcpy(last_pixels.data(), mapped, last_pixels.size()); + vkUnmapMemory(device, readback.memory); + ++statistics.frame; + statistics.validation_errors = validation_errors.load(); + statistics.cpu_ms = + std::chrono::duration(std::chrono::steady_clock::now() - start) + .count(); } }; -Renderer::Renderer(const RendererConfig& config):impl_(std::make_unique()){impl_->initialize(config);} -Renderer::~Renderer()=default; -Renderer::Renderer(Renderer&&) noexcept=default; -Renderer& Renderer::operator=(Renderer&&) noexcept=default; -void Renderer::render(const Snapshot& snapshot){impl_->render(snapshot);} -bool Renderer::reload_shaders(std::string& error){ - auto& r=*impl_;check(vkDeviceWaitIdle(r.device),"Wait shader reload"); - auto previous_layout=r.pipeline_layout;auto previous=r.pipeline;auto previous_ui=r.ui_pipeline;auto previous_shadow=r.shadow_pipeline; - r.pipeline_layout={};r.pipeline={};r.ui_pipeline={};r.shadow_pipeline={}; - try{r.make_pipelines();}catch(const std::exception& exception){ - if(r.pipeline)vkDestroyPipeline(r.device,r.pipeline,nullptr);if(r.ui_pipeline)vkDestroyPipeline(r.device,r.ui_pipeline,nullptr);if(r.shadow_pipeline)vkDestroyPipeline(r.device,r.shadow_pipeline,nullptr);if(r.pipeline_layout)vkDestroyPipelineLayout(r.device,r.pipeline_layout,nullptr); - r.pipeline_layout=previous_layout;r.pipeline=previous;r.ui_pipeline=previous_ui;r.shadow_pipeline=previous_shadow;error=exception.what();return false; +Renderer::Renderer(const RendererConfig& config) : impl_(std::make_unique()) { + impl_->initialize(config); +} +Renderer::~Renderer() = default; +Renderer::Renderer(Renderer&&) noexcept = default; +Renderer& Renderer::operator=(Renderer&&) noexcept = default; +void Renderer::render(const Snapshot& snapshot) { + impl_->render(snapshot); +} +bool Renderer::reload_shaders(std::string& error) { + auto& r = *impl_; + check(vkDeviceWaitIdle(r.device), "Wait shader reload"); + auto previous_layout = r.pipeline_layout; + auto previous = r.pipeline; + auto previous_ui = r.ui_pipeline; + auto previous_shadow = r.shadow_pipeline; + r.pipeline_layout = {}; + r.pipeline = {}; + r.ui_pipeline = {}; + r.shadow_pipeline = {}; + try { + r.make_pipelines(); + } catch (const std::exception& exception) { + if (r.pipeline) + vkDestroyPipeline(r.device, r.pipeline, nullptr); + if (r.ui_pipeline) + vkDestroyPipeline(r.device, r.ui_pipeline, nullptr); + if (r.shadow_pipeline) + vkDestroyPipeline(r.device, r.shadow_pipeline, nullptr); + if (r.pipeline_layout) + vkDestroyPipelineLayout(r.device, r.pipeline_layout, nullptr); + r.pipeline_layout = previous_layout; + r.pipeline = previous; + r.ui_pipeline = previous_ui; + r.shadow_pipeline = previous_shadow; + error = exception.what(); + return false; } - vkDestroyPipeline(r.device,previous,nullptr);vkDestroyPipeline(r.device,previous_ui,nullptr);vkDestroyPipeline(r.device,previous_shadow,nullptr);vkDestroyPipelineLayout(r.device,previous_layout,nullptr);error.clear();return true; + vkDestroyPipeline(r.device, previous, nullptr); + vkDestroyPipeline(r.device, previous_ui, nullptr); + vkDestroyPipeline(r.device, previous_shadow, nullptr); + vkDestroyPipelineLayout(r.device, previous_layout, nullptr); + error.clear(); + return true; } -void Renderer::resize(std::uint32_t w,std::uint32_t h){if(!w||!h)return;if(impl_->window){SDL_SetWindowSize(impl_->window,static_cast(w),static_cast(h));impl_->dirty_swapchain=true;}else if(w!=impl_->width||h!=impl_->height){impl_->width=w;impl_->height=h;impl_->make_targets();}} -std::uint32_t Renderer::width()const{return impl_->width;} -std::uint32_t Renderer::height()const{return impl_->height;} -bool Renderer::should_close()const{return impl_->close;} -const FrameStats& Renderer::stats()const{return impl_->statistics;} -std::vector Renderer::pixels()const{return impl_->last_pixels;} -void Renderer::capture(const std::filesystem::path& path){if(impl_->last_pixels.empty())throw std::runtime_error("Cannot capture before a completed frame");std::ofstream out(path,std::ios::binary);if(!out)throw std::runtime_error("Cannot write screenshot: "+path.string());out<<"P6\n"<last_pixels.size();i+=4)out.write(reinterpret_cast(impl_->last_pixels.data()+i),3);if(!out)throw std::runtime_error("Screenshot write failed");} -void Renderer::set_title(const std::string& title){if(impl_->window)SDL_SetWindowTitle(impl_->window,title.c_str());} -void Renderer::set_text_input(bool enabled){if(!impl_->window)return;if(enabled)SDL_StartTextInput(impl_->window);else SDL_StopTextInput(impl_->window);} -void Renderer::set_text_input_area(float x,float y,float width,float height){ - if(!impl_->window)return;int w{},h{},pw{},ph{};SDL_GetWindowSize(impl_->window,&w,&h);SDL_GetWindowSizeInPixels(impl_->window,&pw,&ph);float sx=pw>0?float(w)/float(pw):1,sy=ph>0?float(h)/float(ph):1;SDL_Rect rectangle{int(x*sx),int(y*sy),std::max(1,int(width*sx)),std::max(1,int(height*sy))};if(!SDL_SetTextInputArea(impl_->window,&rectangle,0))throw std::runtime_error(SDL_GetError()); +void Renderer::resize(std::uint32_t w, std::uint32_t h) { + if (!w || !h) + return; + if (impl_->window) { + SDL_SetWindowSize(impl_->window, static_cast(w), static_cast(h)); + impl_->dirty_swapchain = true; + } else if (w != impl_->width || h != impl_->height) { + impl_->width = w; + impl_->height = h; + impl_->make_targets(); + } } -void Renderer::set_clipboard(const std::string& text){if(!SDL_SetClipboardText(text.c_str()))throw std::runtime_error(SDL_GetError());} -std::string Renderer::clipboard()const{char* text=SDL_GetClipboardText();if(!text)return {};std::string result=text;SDL_free(text);return result;} -std::vector Renderer::poll_events(){ - std::vector result;SDL_Event event{};while(SDL_PollEvent(&event)){Event item;bool emit=true;auto modifiers=SDL_GetModState();item.control=(modifiers&SDL_KMOD_CTRL)!=0;item.shift=(modifiers&SDL_KMOD_SHIFT)!=0;item.alt=(modifiers&SDL_KMOD_ALT)!=0; - switch(event.type){ - case SDL_EVENT_QUIT:case SDL_EVENT_WINDOW_CLOSE_REQUESTED:item.type=Event::Type::Quit;impl_->close=true;break; - case SDL_EVENT_WINDOW_RESIZED:case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:item.type=Event::Type::Resize;item.x=float(event.window.data1);item.y=float(event.window.data2);impl_->dirty_swapchain=true;break; - case SDL_EVENT_WINDOW_FOCUS_GAINED:item.type=Event::Type::FocusGained;break; - case SDL_EVENT_WINDOW_FOCUS_LOST:item.type=Event::Type::FocusLost;break; - case SDL_EVENT_MOUSE_MOTION:item.type=Event::Type::MouseMove;item.x=event.motion.x;item.y=event.motion.y;break; - case SDL_EVENT_MOUSE_BUTTON_DOWN:case SDL_EVENT_MOUSE_BUTTON_UP:item.type=event.type==SDL_EVENT_MOUSE_BUTTON_DOWN?Event::Type::MouseDown:Event::Type::MouseUp;item.x=event.button.x;item.y=event.button.y;item.button=event.button.button;break; - case SDL_EVENT_MOUSE_WHEEL:item.type=Event::Type::Wheel;item.x=event.wheel.x;item.y=event.wheel.y;break; - case SDL_EVENT_KEY_DOWN:case SDL_EVENT_KEY_UP:item.type=event.type==SDL_EVENT_KEY_DOWN?Event::Type::KeyDown:Event::Type::KeyUp;item.key=SDL_GetKeyName(event.key.key);item.repeat=event.key.repeat;break; - case SDL_EVENT_TEXT_INPUT:item.type=Event::Type::TextInput;item.text=event.text.text;break; - case SDL_EVENT_TEXT_EDITING:item.type=Event::Type::TextEditing;item.text=event.edit.text;item.edit_start=event.edit.start;item.edit_length=event.edit.length;break; - default:emit=false; +std::uint32_t Renderer::width() const { + return impl_->width; +} +std::uint32_t Renderer::height() const { + return impl_->height; +} +bool Renderer::should_close() const { + return impl_->close; +} +const FrameStats& Renderer::stats() const { + return impl_->statistics; +} +std::vector Renderer::pixels() const { + return impl_->last_pixels; +} +void Renderer::capture(const std::filesystem::path& path) { + if (impl_->last_pixels.empty()) + throw std::runtime_error("Cannot capture before a completed frame"); + std::ofstream out(path, std::ios::binary); + if (!out) + throw std::runtime_error("Cannot write screenshot: " + path.string()); + out << "P6\n" << width() << ' ' << height() << "\n255\n"; + for (std::size_t i = 0; i < impl_->last_pixels.size(); i += 4) + out.write(reinterpret_cast(impl_->last_pixels.data() + i), 3); + if (!out) + throw std::runtime_error("Screenshot write failed"); +} +void Renderer::set_title(const std::string& title) { + if (impl_->window) + SDL_SetWindowTitle(impl_->window, title.c_str()); +} +void Renderer::set_text_input(bool enabled) { + if (!impl_->window) + return; + if (enabled) + SDL_StartTextInput(impl_->window); + else + SDL_StopTextInput(impl_->window); +} +void Renderer::set_text_input_area(float x, float y, float width, float height) { + if (!impl_->window) + return; + int w{}, h{}, pw{}, ph{}; + SDL_GetWindowSize(impl_->window, &w, &h); + SDL_GetWindowSizeInPixels(impl_->window, &pw, &ph); + float sx = pw > 0 ? float(w) / float(pw) : 1, sy = ph > 0 ? float(h) / float(ph) : 1; + SDL_Rect rectangle{int(x * sx), int(y * sy), std::max(1, int(width * sx)), + std::max(1, int(height * sy))}; + if (!SDL_SetTextInputArea(impl_->window, &rectangle, 0)) + throw std::runtime_error(SDL_GetError()); +} +void Renderer::set_clipboard(const std::string& text) { + if (!SDL_SetClipboardText(text.c_str())) + throw std::runtime_error(SDL_GetError()); +} +std::string Renderer::clipboard() const { + char* text = SDL_GetClipboardText(); + if (!text) + return {}; + std::string result = text; + SDL_free(text); + return result; +} +std::vector Renderer::poll_events() { + std::vector result; + SDL_Event event{}; + while (SDL_PollEvent(&event)) { + Event item; + bool emit = true; + auto modifiers = SDL_GetModState(); + item.control = (modifiers & SDL_KMOD_CTRL) != 0; + item.shift = (modifiers & SDL_KMOD_SHIFT) != 0; + item.alt = (modifiers & SDL_KMOD_ALT) != 0; + switch (event.type) { + case SDL_EVENT_QUIT: + case SDL_EVENT_WINDOW_CLOSE_REQUESTED: + item.type = Event::Type::Quit; + impl_->close = true; + break; + case SDL_EVENT_WINDOW_RESIZED: + case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: + item.type = Event::Type::Resize; + item.x = float(event.window.data1); + item.y = float(event.window.data2); + impl_->dirty_swapchain = true; + break; + case SDL_EVENT_WINDOW_FOCUS_GAINED: + item.type = Event::Type::FocusGained; + break; + case SDL_EVENT_WINDOW_FOCUS_LOST: + item.type = Event::Type::FocusLost; + break; + case SDL_EVENT_MOUSE_MOTION: + item.type = Event::Type::MouseMove; + item.x = event.motion.x; + item.y = event.motion.y; + break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + case SDL_EVENT_MOUSE_BUTTON_UP: + item.type = event.type == SDL_EVENT_MOUSE_BUTTON_DOWN ? Event::Type::MouseDown + : Event::Type::MouseUp; + item.x = event.button.x; + item.y = event.button.y; + item.button = event.button.button; + break; + case SDL_EVENT_MOUSE_WHEEL: + item.type = Event::Type::Wheel; + item.x = event.wheel.x; + item.y = event.wheel.y; + break; + case SDL_EVENT_KEY_DOWN: + case SDL_EVENT_KEY_UP: + item.type = + event.type == SDL_EVENT_KEY_DOWN ? Event::Type::KeyDown : Event::Type::KeyUp; + item.key = SDL_GetKeyName(event.key.key); + item.repeat = event.key.repeat; + break; + case SDL_EVENT_TEXT_INPUT: + item.type = Event::Type::TextInput; + item.text = event.text.text; + break; + case SDL_EVENT_TEXT_EDITING: + item.type = Event::Type::TextEditing; + item.text = event.edit.text; + item.edit_start = event.edit.start; + item.edit_length = event.edit.length; + break; + default: + emit = false; } - // Rendering/UI coordinates use drawable pixels; SDL pointer events use logical window units. - if(impl_->window&&(item.type==Event::Type::MouseMove||item.type==Event::Type::MouseDown||item.type==Event::Type::MouseUp)){int w{},h{},pw{},ph{};SDL_GetWindowSize(impl_->window,&w,&h);SDL_GetWindowSizeInPixels(impl_->window,&pw,&ph);if(w>0&&h>0){item.x*=float(pw)/float(w);item.y*=float(ph)/float(h);}} - if(emit)result.push_back(std::move(item)); - }return result; -} + // Rendering/UI coordinates use drawable pixels; SDL pointer events use logical window + // units. + if (impl_->window && + (item.type == Event::Type::MouseMove || item.type == Event::Type::MouseDown || + item.type == Event::Type::MouseUp)) { + int w{}, h{}, pw{}, ph{}; + SDL_GetWindowSize(impl_->window, &w, &h); + SDL_GetWindowSizeInPixels(impl_->window, &pw, &ph); + if (w > 0 && h > 0) { + item.x *= float(pw) / float(w); + item.y *= float(ph) / float(h); + } + } + if (emit) + result.push_back(std::move(item)); + } + return result; } +} // namespace faset::render diff --git a/src/runtime/Physics.cpp b/src/runtime/Physics.cpp index ddd50a4..d48609a 100644 --- a/src/runtime/Physics.cpp +++ b/src/runtime/Physics.cpp @@ -1,7 +1,7 @@ #include "Physics.hpp" +#include #include #include -#include #include #include #include @@ -15,103 +15,245 @@ b3Quat quaternion(Vec3 e) { return b3MulQuat(z, b3MulQuat(y, x)); } Vec3 euler(b3Quat q) { - const float x=q.v.x, y=q.v.y, z=q.v.z, w=q.s; - return {std::atan2(2*(w*x+y*z), 1-2*(x*x+y*y)), - std::asin(std::clamp(2*(w*y-z*x), -1.0f, 1.0f)), - std::atan2(2*(w*z+x*y), 1-2*(y*y+z*z))}; -} + const float x = q.v.x, y = q.v.y, z = q.v.z, w = q.s; + return {std::atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)), + std::asin(std::clamp(2 * (w * y - z * x), -1.0f, 1.0f)), + std::atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))}; } +} // namespace struct Physics::Impl { - struct Body { b2BodyId two{}; b3BodyId three{}; std::uint64_t shape{}; bool dynamic{}; }; + struct Body { + b2BodyId two{}; + b3BodyId three{}; + std::uint64_t shape{}; + bool dynamic{}; + bool contactQueryValid{}; + }; int dimension; int substeps; + Vec3 up{0, 1, 0}; b2WorldId world2{}; b3WorldId world3{}; std::unordered_map bodies; std::unordered_map shapes; - Impl(int dim, Vec3 gravity, int count):dimension(dim),substeps(count) { - if(dim==2) { auto def=b2DefaultWorldDef(); def.gravity={gravity[0],gravity[1]}; world2=b2CreateWorld(&def); } - else { auto def=b3DefaultWorldDef(); def.gravity={gravity[0],gravity[1],gravity[2]}; world3=b3CreateWorld(&def); } + Impl(int dim, Vec3 gravity, int count) : dimension(dim), substeps(count) { + const float length = std::sqrt(gravity[0] * gravity[0] + gravity[1] * gravity[1] + + (dim == 3 ? gravity[2] * gravity[2] : 0)); + if (length > 0.00001f) + up = {-gravity[0] / length, -gravity[1] / length, dim == 3 ? -gravity[2] / length : 0}; + if (dim == 2) { + auto def = b2DefaultWorldDef(); + def.gravity = {gravity[0], gravity[1]}; + world2 = b2CreateWorld(&def); + } else { + auto def = b3DefaultWorldDef(); + def.gravity = {gravity[0], gravity[1], gravity[2]}; + world3 = b3CreateWorld(&def); + } + } + ~Impl() { + if (dimension == 2) + b2DestroyWorld(world2); + else + b3DestroyWorld(world3); } - ~Impl() { if(dimension==2) b2DestroyWorld(world2); else b3DestroyWorld(world3); } }; -Physics::Physics(int dimension, Vec3 gravity, int substeps):impl_(std::make_unique(dimension,gravity,substeps)){} -Physics::~Physics()=default; +Physics::Physics(int dimension, Vec3 gravity, int substeps) + : impl_(std::make_unique(dimension, gravity, substeps)) {} +Physics::~Physics() = default; void Physics::add(std::uint32_t id, const Transform& t, const BodySettings& settings) { - if(contains(id)) throw std::logic_error("physics body already exists"); - Impl::Body body{}; body.dynamic=settings.type=="dynamic"; - if(impl_->dimension==2) { - auto def=b2DefaultBodyDef(); - def.type=settings.type=="static"?b2_staticBody:settings.type=="kinematic"?b2_kinematicBody:b2_dynamicBody; - def.position={t.position[0],t.position[1]}; def.rotation=b2MakeRot(t.rotation[2]); - def.linearVelocity={settings.linearVelocity[0],settings.linearVelocity[1]}; def.gravityScale=settings.gravityScale; - body.two=b2CreateBody(impl_->world2,&def); - auto shape=b2DefaultShapeDef(); shape.density=settings.density; shape.material.friction=settings.friction; - shape.material.restitution=settings.restitution; shape.enableContactEvents=true; - shape.filter.categoryBits=settings.categoryBits; shape.filter.maskBits=settings.maskBits; - const auto box=b2MakeBox(settings.halfExtents[0]*std::abs(t.scale[0]),settings.halfExtents[1]*std::abs(t.scale[1])); - body.shape=b2StoreShapeId(b2CreatePolygonShape(body.two,&shape,&box)); + if (contains(id)) + throw std::logic_error("physics body already exists"); + Impl::Body body{}; + body.dynamic = settings.type == "dynamic"; + if (impl_->dimension == 2) { + auto def = b2DefaultBodyDef(); + def.type = settings.type == "static" ? b2_staticBody + : settings.type == "kinematic" ? b2_kinematicBody + : b2_dynamicBody; + def.position = {t.position[0], t.position[1]}; + def.rotation = b2MakeRot(t.rotation[2]); + def.linearVelocity = {settings.linearVelocity[0], settings.linearVelocity[1]}; + def.gravityScale = settings.gravityScale; + body.two = b2CreateBody(impl_->world2, &def); + auto shape = b2DefaultShapeDef(); + shape.density = settings.density; + shape.material.friction = settings.friction; + shape.material.restitution = settings.restitution; + shape.enableContactEvents = true; + shape.filter.categoryBits = settings.categoryBits; + shape.filter.maskBits = settings.maskBits; + const auto box = b2MakeBox(settings.halfExtents[0] * std::abs(t.scale[0]), + settings.halfExtents[1] * std::abs(t.scale[1])); + body.shape = b2StoreShapeId(b2CreatePolygonShape(body.two, &shape, &box)); } else { - auto def=b3DefaultBodyDef(); - def.type=settings.type=="static"?b3_staticBody:settings.type=="kinematic"?b3_kinematicBody:b3_dynamicBody; - def.position={t.position[0],t.position[1],t.position[2]}; def.rotation=quaternion(t.rotation); - def.linearVelocity={settings.linearVelocity[0],settings.linearVelocity[1],settings.linearVelocity[2]}; def.gravityScale=settings.gravityScale; - body.three=b3CreateBody(impl_->world3,&def); - auto shape=b3DefaultShapeDef(); shape.density=settings.density; shape.baseMaterial.friction=settings.friction; - shape.baseMaterial.restitution=settings.restitution; shape.enableContactEvents=true; - shape.filter.categoryBits=settings.categoryBits; shape.filter.maskBits=settings.maskBits; - auto box=b3MakeBoxHull(settings.halfExtents[0]*std::abs(t.scale[0]),settings.halfExtents[1]*std::abs(t.scale[1]),settings.halfExtents[2]*std::abs(t.scale[2])); - body.shape=b3StoreShapeId(b3CreateHullShape(body.three,&shape,&box.base)); + auto def = b3DefaultBodyDef(); + def.type = settings.type == "static" ? b3_staticBody + : settings.type == "kinematic" ? b3_kinematicBody + : b3_dynamicBody; + def.position = {t.position[0], t.position[1], t.position[2]}; + def.rotation = quaternion(t.rotation); + def.linearVelocity = {settings.linearVelocity[0], settings.linearVelocity[1], + settings.linearVelocity[2]}; + def.gravityScale = settings.gravityScale; + body.three = b3CreateBody(impl_->world3, &def); + auto shape = b3DefaultShapeDef(); + shape.density = settings.density; + shape.baseMaterial.friction = settings.friction; + shape.baseMaterial.restitution = settings.restitution; + shape.enableContactEvents = true; + shape.filter.categoryBits = settings.categoryBits; + shape.filter.maskBits = settings.maskBits; + auto box = b3MakeBoxHull(settings.halfExtents[0] * std::abs(t.scale[0]), + settings.halfExtents[1] * std::abs(t.scale[1]), + settings.halfExtents[2] * std::abs(t.scale[2])); + body.shape = b3StoreShapeId(b3CreateHullShape(body.three, &shape, &box.base)); } - impl_->shapes.emplace(body.shape,id); impl_->bodies.emplace(id,body); + impl_->shapes.emplace(body.shape, id); + impl_->bodies.emplace(id, body); } void Physics::remove(std::uint32_t id) { - const auto it=impl_->bodies.find(id); if(it==impl_->bodies.end()) return; + const auto it = impl_->bodies.find(id); + if (it == impl_->bodies.end()) + return; impl_->shapes.erase(it->second.shape); - if(impl_->dimension==2) b2DestroyBody(it->second.two); else b3DestroyBody(it->second.three); + if (impl_->dimension == 2) + b2DestroyBody(it->second.two); + else + b3DestroyBody(it->second.three); impl_->bodies.erase(it); } -bool Physics::contains(std::uint32_t id) const { return impl_->bodies.contains(id); } -bool Physics::dynamic(std::uint32_t id) const { return impl_->bodies.at(id).dynamic; } +bool Physics::contains(std::uint32_t id) const { + return impl_->bodies.contains(id); +} +bool Physics::dynamic(std::uint32_t id) const { + return impl_->bodies.at(id).dynamic; +} Transform Physics::transform(std::uint32_t id, Transform t) const { - const auto& body=impl_->bodies.at(id); - if(impl_->dimension==2) { auto p=b2Body_GetPosition(body.two); t.position[0]=p.x;t.position[1]=p.y;t.rotation[2]=b2Rot_GetAngle(b2Body_GetRotation(body.two)); } - else { auto p=b3Body_GetPosition(body.three);t.position={float(p.x),float(p.y),float(p.z)};t.rotation=euler(b3Body_GetRotation(body.three)); } + const auto& body = impl_->bodies.at(id); + if (impl_->dimension == 2) { + auto p = b2Body_GetPosition(body.two); + t.position[0] = p.x; + t.position[1] = p.y; + t.rotation[2] = b2Rot_GetAngle(b2Body_GetRotation(body.two)); + } else { + auto p = b3Body_GetPosition(body.three); + t.position = {float(p.x), float(p.y), float(p.z)}; + t.rotation = euler(b3Body_GetRotation(body.three)); + } return t; } Vec3 Physics::velocity(std::uint32_t id) const { - const auto& body=impl_->bodies.at(id); - if(impl_->dimension==2) { auto v=b2Body_GetLinearVelocity(body.two);return {v.x,v.y,0}; } - auto v=b3Body_GetLinearVelocity(body.three);return {v.x,v.y,v.z}; + const auto& body = impl_->bodies.at(id); + if (impl_->dimension == 2) { + auto v = b2Body_GetLinearVelocity(body.two); + return {v.x, v.y, 0}; + } + auto v = b3Body_GetLinearVelocity(body.three); + return {v.x, v.y, v.z}; +} +bool Physics::grounded(std::uint32_t id) const { + const auto& body = impl_->bodies.at(id); + if (!body.contactQueryValid) + return false; + auto supports = [&](Vec3 normal, float sign) { + return sign * (normal[0] * impl_->up[0] + normal[1] * impl_->up[1] + + normal[2] * impl_->up[2]) > + 0.6f; + }; + if (impl_->dimension == 2) { + const int capacity = b2Body_GetContactCapacity(body.two); + if (capacity <= 0) + return false; + std::vector contacts(static_cast(capacity)); + const int count = b2Body_GetContactData(body.two, contacts.data(), capacity); + for (int i = 0; i < count; ++i) { + const auto& c = contacts[i]; + const float sign = b2StoreShapeId(c.shapeIdA) == body.shape ? -1.0f : 1.0f; + if (!supports({c.manifold.normal.x, c.manifold.normal.y, 0}, sign)) + continue; + for (int p = 0; p < c.manifold.pointCount; ++p) + if (c.manifold.points[p].separation <= 0.02f) + return true; + } + } else { + const int capacity = b3Body_GetContactCapacity(body.three); + if (capacity <= 0) + return false; + std::vector contacts(static_cast(capacity)); + const int count = b3Body_GetContactData(body.three, contacts.data(), capacity); + for (int i = 0; i < count; ++i) { + const auto& c = contacts[i]; + const float sign = b3StoreShapeId(c.shapeIdA) == body.shape ? -1.0f : 1.0f; + // Box3D's manifold pointer is consumed now and never retained. + for (int m = 0; m < c.manifoldCount; ++m) { + const auto& manifold = c.manifolds[m]; + if (!supports({manifold.normal.x, manifold.normal.y, manifold.normal.z}, sign)) + continue; + for (int p = 0; p < manifold.pointCount; ++p) + if (manifold.points[p].separation <= 0.02f) + return true; + } + } + } + return false; } void Physics::teleport(std::uint32_t id, const Transform& t) { - const auto& body=impl_->bodies.at(id); - if(impl_->dimension==2) b2Body_SetTransform(body.two,{t.position[0],t.position[1]},b2MakeRot(t.rotation[2])); - else b3Body_SetTransform(body.three,{t.position[0],t.position[1],t.position[2]},quaternion(t.rotation)); + auto& body = impl_->bodies.at(id); + body.contactQueryValid = false; + if (impl_->dimension == 2) { + b2Body_SetTransform(body.two, {t.position[0], t.position[1]}, b2MakeRot(t.rotation[2])); + b2Body_SetAwake(body.two, true); + } else { + b3Body_SetTransform(body.three, {t.position[0], t.position[1], t.position[2]}, + quaternion(t.rotation)); + b3Body_SetAwake(body.three, true); + } } void Physics::setVelocity(std::uint32_t id, Vec3 v) { - const auto& body=impl_->bodies.at(id); - if(impl_->dimension==2) b2Body_SetLinearVelocity(body.two,{v[0],v[1]}); else b3Body_SetLinearVelocity(body.three,{v[0],v[1],v[2]}); + const auto& body = impl_->bodies.at(id); + if (impl_->dimension == 2) + b2Body_SetLinearVelocity(body.two, {v[0], v[1]}); + else + b3Body_SetLinearVelocity(body.three, {v[0], v[1], v[2]}); } void Physics::impulse(std::uint32_t id, Vec3 v) { - const auto& body=impl_->bodies.at(id); - if(impl_->dimension==2) b2Body_ApplyLinearImpulseToCenter(body.two,{v[0],v[1]},true); else b3Body_ApplyLinearImpulseToCenter(body.three,{v[0],v[1],v[2]},true); + const auto& body = impl_->bodies.at(id); + if (impl_->dimension == 2) + b2Body_ApplyLinearImpulseToCenter(body.two, {v[0], v[1]}, true); + else + b3Body_ApplyLinearImpulseToCenter(body.three, {v[0], v[1], v[2]}, true); } std::vector Physics::step(float delta) { std::vector contacts; - auto append=[&](std::uint64_t a,std::uint64_t b,bool began) { - auto first=impl_->shapes.find(a),second=impl_->shapes.find(b); - if(first!=impl_->shapes.end() && second!=impl_->shapes.end()) contacts.push_back({first->second,second->second,began}); + auto append = [&](std::uint64_t a, std::uint64_t b, bool began) { + auto first = impl_->shapes.find(a), second = impl_->shapes.find(b); + if (first != impl_->shapes.end() && second != impl_->shapes.end()) + contacts.push_back({first->second, second->second, began}); }; - if(impl_->dimension==2) { - b2World_Step(impl_->world2,delta,impl_->substeps);auto events=b2World_GetContactEvents(impl_->world2); - for(int i=0;idimension == 2) { + b2World_Step(impl_->world2, delta, impl_->substeps); + auto events = b2World_GetContactEvents(impl_->world2); + for (int i = 0; i < events.beginCount; ++i) + append(b2StoreShapeId(events.beginEvents[i].shapeIdA), + b2StoreShapeId(events.beginEvents[i].shapeIdB), true); + for (int i = 0; i < events.endCount; ++i) + append(b2StoreShapeId(events.endEvents[i].shapeIdA), + b2StoreShapeId(events.endEvents[i].shapeIdB), false); } else { - b3World_Step(impl_->world3,delta,impl_->substeps);auto events=b3World_GetContactEvents(impl_->world3); - for(int i=0;iworld3, delta, impl_->substeps); + auto events = b3World_GetContactEvents(impl_->world3); + for (int i = 0; i < events.beginCount; ++i) + append(b3StoreShapeId(events.beginEvents[i].shapeIdA), + b3StoreShapeId(events.beginEvents[i].shapeIdB), true); + for (int i = 0; i < events.endCount; ++i) + append(b3StoreShapeId(events.endEvents[i].shapeIdA), + b3StoreShapeId(events.endEvents[i].shapeIdB), false); + } + for (auto& [id, body] : impl_->bodies) { + (void)id; + body.contactQueryValid = true; } return contacts; } -} +} // namespace faset::runtime::detail diff --git a/src/runtime/Physics.hpp b/src/runtime/Physics.hpp index 2c87feb..c5a3229 100644 --- a/src/runtime/Physics.hpp +++ b/src/runtime/Physics.hpp @@ -14,9 +14,13 @@ struct BodySettings { std::uint64_t categoryBits{1}; std::uint64_t maskBits{~std::uint64_t{0}}; }; -struct Contact { std::uint32_t first; std::uint32_t second; bool began; }; +struct Contact { + std::uint32_t first; + std::uint32_t second; + bool began; +}; class Physics { -public: + public: Physics(int dimension, Vec3 gravity, int substeps); ~Physics(); void add(std::uint32_t id, const Transform&, const BodySettings&); @@ -25,12 +29,14 @@ public: bool dynamic(std::uint32_t id) const; Transform transform(std::uint32_t id, Transform previous) const; Vec3 velocity(std::uint32_t id) const; + bool grounded(std::uint32_t id) const; void teleport(std::uint32_t id, const Transform&); void setVelocity(std::uint32_t id, Vec3); void impulse(std::uint32_t id, Vec3); std::vector step(float delta); -private: + + private: struct Impl; std::unique_ptr impl_; }; -} +} // namespace faset::runtime::detail diff --git a/src/runtime/Runtime.cpp b/src/runtime/Runtime.cpp index 975311c..9b4e7b6 100644 --- a/src/runtime/Runtime.cpp +++ b/src/runtime/Runtime.cpp @@ -1,10 +1,10 @@ -#include #include "Physics.hpp" -#include #include #include #include #include +#include +#include #include #include #include @@ -12,282 +12,761 @@ namespace faset::runtime { namespace { -using Json=nlohmann::json; +using Json = nlohmann::json; std::atomic nextSession{1}; -constexpr const char* body2="faset.rigid_body_2d"; -constexpr const char* body3="faset.rigid_body_3d"; -void require(bool condition,const std::string& message) { if(!condition) throw std::invalid_argument(message); } -template std::array vectorValue(const Json& object,const char* key,std::array fallback) { - if(!object.contains(key)) return fallback; - const auto& value=object.at(key); require(value.is_array()&&value.size()==N,std::string(key)+": wrong vector size"); - for(std::size_t i=0;i(); require(std::isfinite(fallback[i]),std::string(key)+": nonfinite value"); } +constexpr const char* body2 = "faset.rigid_body_2d"; +constexpr const char* body3 = "faset.rigid_body_3d"; +void require(bool condition, const std::string& message) { + if (!condition) + throw std::invalid_argument(message); +} +template +std::array vectorValue(const Json& object, const char* key, + std::array fallback) { + if (!object.contains(key)) + return fallback; + const auto& value = object.at(key); + require(value.is_array() && value.size() == N, std::string(key) + ": wrong vector size"); + for (std::size_t i = 0; i < N; ++i) { + require(value[i].is_number(), std::string(key) + ": expected number"); + fallback[i] = value[i].get(); + require(std::isfinite(fallback[i]), std::string(key) + ": nonfinite value"); + } return fallback; } -float number(const Json& fields,const char* key,float fallback) { - if(!fields.contains(key)) return fallback; - require(fields.at(key).is_number(),std::string(key)+": expected number"); - float v=fields.at(key).get();require(std::isfinite(v),std::string(key)+": nonfinite value");return v; +float number(const Json& fields, const char* key, float fallback) { + if (!fields.contains(key)) + return fallback; + require(fields.at(key).is_number(), std::string(key) + ": expected number"); + float v = fields.at(key).get(); + require(std::isfinite(v), std::string(key) + ": nonfinite value"); + return v; } Transform readTransform(const Json& fields) { - return {vectorValue<3>(fields,"position",{0,0,0}),vectorValue<3>(fields,"rotation",{0,0,0}),vectorValue<3>(fields,"scale",{1,1,1})}; + return {vectorValue<3>(fields, "position", {0, 0, 0}), + vectorValue<3>(fields, "rotation", {0, 0, 0}), + vectorValue<3>(fields, "scale", {1, 1, 1})}; } void validateTransform(const Transform& t) { - for(const auto& values:{t.position,t.rotation,t.scale}) for(float value:values) require(std::isfinite(value),"nonfinite transform"); + for (const auto& values : {t.position, t.rotation, t.scale}) + for (float value : values) + require(std::isfinite(value), "nonfinite transform"); } -Json transformJson(const Transform& t) { return {{"position",t.position},{"rotation",t.rotation},{"scale",t.scale}}; } -detail::BodySettings settings(const Json& fields,int dimension) { +Json transformJson(const Transform& t) { + return {{"position", t.position}, {"rotation", t.rotation}, {"scale", t.scale}}; +} +detail::BodySettings settings(const Json& fields, int dimension) { detail::BodySettings b; - b.type=fields.value("body_type",std::string("dynamic"));require(b.type=="dynamic"||b.type=="static"||b.type=="kinematic","invalid body_type"); - if(dimension==2) { - auto half=vectorValue<2>(fields,"half_extents",{0.5f,0.5f});b.halfExtents={half[0],half[1],0.5f}; - auto vel=vectorValue<2>(fields,"linear_velocity",{0,0});b.linearVelocity={vel[0],vel[1],0}; - } else { b.halfExtents=vectorValue<3>(fields,"half_extents",{0.5f,0.5f,0.5f});b.linearVelocity=vectorValue<3>(fields,"linear_velocity",{0,0,0}); } - for(float extent:b.halfExtents) require(extent>0&&extent<100000,"half_extents must be positive and finite"); - b.density=number(fields,"density",1); b.friction=number(fields,"friction",0.3f); - b.restitution=number(fields,"restitution",0);b.gravityScale=number(fields,"gravity_scale",1); - require(b.density>0&&b.friction>=0&&b.restitution>=0&&b.restitution<=1,"invalid physics material"); - auto bits=[&](const char* name,std::uint64_t fallback) { if(!fields.contains(name))return fallback; const auto& value=fields.at(name);require(value.is_number_unsigned()||(value.is_number_integer()&&value.get()>=0),std::string(name)+": expected nonnegative bits");return value.get(); }; - b.categoryBits=bits("category_bits",1);b.maskBits=bits("mask_bits",~std::uint64_t{0});return b; -} -void validateEntity(const Json& entity,int dimension) { - require(entity.is_object(),"entity must be an object"); - require(entity.contains("id")&&entity["id"].is_string()&&!entity["id"].get().empty(),"entity requires id"); - require(!entity.contains("name")||entity["name"].is_string(),"entity name must be a string"); - require(entity.contains("components")&&entity["components"].is_array(),"entity requires components array"); - if(entity.contains("parent"))require(entity["parent"].is_null()||entity["parent"].is_string(),"parent must be an id or null"); - std::set types, ids;Transform transform{};bool physical=false; - for(const auto& component:entity["components"]) { - require(component.is_object()&&component.contains("id")&&component["id"].is_string()&&!component["id"].get().empty(),"component requires id"); - require(component.contains("type")&&component["type"].is_string()&&!component["type"].get().empty(),"component requires type"); - require(component.value("version",1)==1,"unsupported component version"); - require(component.contains("fields")&&component["fields"].is_object(),"component requires fields"); - require(ids.insert(component["id"].get()).second,"duplicate component id"); - const auto type=component["type"].get();require(types.insert(type).second,"duplicate component type"); - const auto& f=component["fields"]; - if(type=="faset.transform")transform=readTransform(f); - if(type==body2||type==body3) { require(type==(dimension==2?body2:body3),"physics dimension does not match scene");settings(f,dimension);physical=true; } - if(type=="faset.sprite") {vectorValue<4>(f,"color",{1,1,1,1});auto size=vectorValue<2>(f,"size",{1,1});require(size[0]>0&&size[1]>0,"sprite size must be positive");require(!f.contains("texture")||f["texture"].is_string(),"sprite texture must be a string");require(!f.contains("layer")||f["layer"].is_number_integer(),"sprite layer must be an integer");} - if(type=="faset.mesh") {vectorValue<4>(f,"color",{1,1,1,1});require(!f.contains("asset")||f["asset"].is_string(),"mesh asset must be a string");require(!f.contains("primitive")||f["primitive"].is_string(),"mesh primitive must be a string");} + b.type = fields.value("body_type", std::string("dynamic")); + require(b.type == "dynamic" || b.type == "static" || b.type == "kinematic", + "invalid body_type"); + if (dimension == 2) { + auto half = vectorValue<2>(fields, "half_extents", {0.5f, 0.5f}); + b.halfExtents = {half[0], half[1], 0.5f}; + auto vel = vectorValue<2>(fields, "linear_velocity", {0, 0}); + b.linearVelocity = {vel[0], vel[1], 0}; + } else { + b.halfExtents = vectorValue<3>(fields, "half_extents", {0.5f, 0.5f, 0.5f}); + b.linearVelocity = vectorValue<3>(fields, "linear_velocity", {0, 0, 0}); } - if(physical) { - require(!entity.contains("parent")||entity["parent"].is_null(),"physics bodies must be root entities in the initial runtime"); - for(int i=0;i0.00001f,"physics scale must be nonzero"); - if(dimension==2) require(transform.rotation[0]==0&&transform.rotation[1]==0,"2D physics rotates only around Z"); - } -} -Transform interpolate(const Transform& a,const Transform& b,float alpha) { - Transform out; - for(int i=0;i<3;++i) { - out.position[i]=std::lerp(a.position[i],b.position[i],alpha);out.scale[i]=std::lerp(a.scale[i],b.scale[i],alpha); - } - auto quaternion=[](Vec3 r) { - const float cx=std::cos(r[0]*0.5f),sx=std::sin(r[0]*0.5f),cy=std::cos(r[1]*0.5f),sy=std::sin(r[1]*0.5f),cz=std::cos(r[2]*0.5f),sz=std::sin(r[2]*0.5f); - return Vec4{sx*cy*cz-cx*sy*sz,cx*sy*cz+sx*cy*sz,cx*cy*sz-sx*sy*cz,cx*cy*cz+sx*sy*sz}; + for (float extent : b.halfExtents) + require(extent > 0 && extent < 100000, "half_extents must be positive and finite"); + b.density = number(fields, "density", 1); + b.friction = number(fields, "friction", 0.3f); + b.restitution = number(fields, "restitution", 0); + b.gravityScale = number(fields, "gravity_scale", 1); + require(b.density > 0 && b.friction >= 0 && b.restitution >= 0 && b.restitution <= 1, + "invalid physics material"); + auto bits = [&](const char* name, std::uint64_t fallback) { + if (!fields.contains(name)) + return fallback; + const auto& value = fields.at(name); + require(value.is_number_unsigned() || + (value.is_number_integer() && value.get() >= 0), + std::string(name) + ": expected nonnegative bits"); + return value.get(); }; - auto qa=quaternion(a.rotation),qb=quaternion(b.rotation);float dot=0; - for(int i=0;i<4;++i)dot+=qa[i]*qb[i]; - if(dot<0){for(auto& q:qb)q=-q;dot=-dot;} - float wa=1-alpha,wb=alpha; - if(dot<0.9995f){const float angle=std::acos(std::clamp(dot,-1.0f,1.0f)),denom=std::sin(angle);wa=std::sin((1-alpha)*angle)/denom;wb=std::sin(alpha*angle)/denom;} - Vec4 q{};float length=0;for(int i=0;i<4;++i){q[i]=wa*qa[i]+wb*qb[i];length+=q[i]*q[i];}for(auto& v:q)v/=std::sqrt(length); - const auto [x,y,z,w]=q; - out.rotation={std::atan2(2*(w*x+y*z),1-2*(x*x+y*y)),std::asin(std::clamp(2*(w*y-z*x),-1.0f,1.0f)),std::atan2(2*(w*z+x*y),1-2*(y*y+z*z))}; + b.categoryBits = bits("category_bits", 1); + b.maskBits = bits("mask_bits", ~std::uint64_t{0}); + return b; +} +void validateEntity(const Json& entity, int dimension) { + require(entity.is_object(), "entity must be an object"); + require(entity.contains("id") && entity["id"].is_string() && + !entity["id"].get().empty(), + "entity requires id"); + require(!entity.contains("name") || entity["name"].is_string(), "entity name must be a string"); + require(entity.contains("components") && entity["components"].is_array(), + "entity requires components array"); + if (entity.contains("parent")) + require(entity["parent"].is_null() || entity["parent"].is_string(), + "parent must be an id or null"); + std::set types, ids; + Transform transform{}; + bool physical = false; + for (const auto& component : entity["components"]) { + require(component.is_object() && component.contains("id") && component["id"].is_string() && + !component["id"].get().empty(), + "component requires id"); + require(component.contains("type") && component["type"].is_string() && + !component["type"].get().empty(), + "component requires type"); + require(component.value("version", 1) == 1, "unsupported component version"); + require(component.contains("fields") && component["fields"].is_object(), + "component requires fields"); + require(ids.insert(component["id"].get()).second, "duplicate component id"); + const auto type = component["type"].get(); + require(types.insert(type).second, "duplicate component type"); + const auto& f = component["fields"]; + if (type == "faset.transform") + transform = readTransform(f); + if (type == body2 || type == body3) { + require(type == (dimension == 2 ? body2 : body3), + "physics dimension does not match scene"); + settings(f, dimension); + physical = true; + } + if (type == "faset.sprite") { + vectorValue<4>(f, "color", {1, 1, 1, 1}); + auto size = vectorValue<2>(f, "size", {1, 1}); + require(size[0] > 0 && size[1] > 0, "sprite size must be positive"); + require(!f.contains("texture") || f["texture"].is_string(), + "sprite texture must be a string"); + require(!f.contains("layer") || f["layer"].is_number_integer(), + "sprite layer must be an integer"); + } + if (type == "faset.mesh") { + vectorValue<4>(f, "color", {1, 1, 1, 1}); + require(!f.contains("asset") || f["asset"].is_string(), "mesh asset must be a string"); + require(!f.contains("primitive") || f["primitive"].is_string(), + "mesh primitive must be a string"); + } + } + if (physical) { + require(!entity.contains("parent") || entity["parent"].is_null(), + "physics bodies must be root entities in the initial runtime"); + for (int i = 0; i < dimension; ++i) + require(std::abs(transform.scale[i]) > 0.00001f, "physics scale must be nonzero"); + if (dimension == 2) + require(transform.rotation[0] == 0 && transform.rotation[1] == 0, + "2D physics rotates only around Z"); + } +} +Transform interpolate(const Transform& a, const Transform& b, float alpha) { + Transform out; + for (int i = 0; i < 3; ++i) { + out.position[i] = std::lerp(a.position[i], b.position[i], alpha); + out.scale[i] = std::lerp(a.scale[i], b.scale[i], alpha); + } + auto quaternion = [](Vec3 r) { + const float cx = std::cos(r[0] * 0.5f), sx = std::sin(r[0] * 0.5f), + cy = std::cos(r[1] * 0.5f), sy = std::sin(r[1] * 0.5f), + cz = std::cos(r[2] * 0.5f), sz = std::sin(r[2] * 0.5f); + return Vec4{sx * cy * cz - cx * sy * sz, cx * sy * cz + sx * cy * sz, + cx * cy * sz - sx * sy * cz, cx * cy * cz + sx * sy * sz}; + }; + auto qa = quaternion(a.rotation), qb = quaternion(b.rotation); + float dot = 0; + for (int i = 0; i < 4; ++i) + dot += qa[i] * qb[i]; + if (dot < 0) { + for (auto& q : qb) + q = -q; + dot = -dot; + } + float wa = 1 - alpha, wb = alpha; + if (dot < 0.9995f) { + const float angle = std::acos(std::clamp(dot, -1.0f, 1.0f)), denom = std::sin(angle); + wa = std::sin((1 - alpha) * angle) / denom; + wb = std::sin(alpha * angle) / denom; + } + Vec4 q{}; + float length = 0; + for (int i = 0; i < 4; ++i) { + q[i] = wa * qa[i] + wb * qb[i]; + length += q[i] * q[i]; + } + for (auto& v : q) + v /= std::sqrt(length); + const auto [x, y, z, w] = q; + out.rotation = {std::atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)), + std::asin(std::clamp(2 * (w * y - z * x), -1.0f, 1.0f)), + std::atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))}; return out; } -} +} // namespace struct Runtime::Impl { - struct Data { Json document; std::uint64_t generation; }; - struct Pose { Transform previous,current,presented; bool changedInUpdate{}; }; + struct Data { + Json document; + std::uint64_t generation; + }; + struct Pose { + Transform previous, current, presented; + bool changedInUpdate{}; + }; enum class Phase { Idle, Initialize, Fixed, Update, Late, Destroy }; enum class Kind { Spawn, Destroy, Add, Remove }; - struct Command { Kind kind; EntityHandle handle; Json payload; std::string type; }; + struct Command { + Kind kind; + EntityHandle handle; + Json payload; + std::string type; + }; Runtime* owner; RuntimeConfig config; entt::registry registry; - std::unordered_map ids; - std::unordered_map behaviors; + std::unordered_map ids; + std::unordered_map behaviors; std::vector order; std::deque pending; std::unique_ptr physics; std::vector contacts; std::vector diagnostics; std::uint64_t session{nextSession.fetch_add(1)}; - std::uint64_t generation{},tick{}; + std::uint64_t generation{}, tick{}; int dimension{3}; - double accumulator{},alpha{}; - bool paused{},busy{}; - InputState currentInput{},queuedInput{}; + double accumulator{}, alpha{}; + bool paused{}, busy{}; + InputState currentInput{}, queuedInput{}; Phase phase{Phase::Idle}; - Impl(Runtime* runtime,RuntimeConfig cfg):owner(runtime),config(cfg){} - EntityHandle handle(entt::entity e) const {return {session,entt::to_integral(e),registry.get(e).generation};} - bool valid(EntityHandle h)const noexcept { auto e=static_cast(h.slot);return h.session==session&®istry.valid(e)&®istry.all_of(e)&®istry.get(e).generation==h.generation; } - entt::entity entity(EntityHandle h)const {if(!valid(h))throw std::invalid_argument("stale or foreign runtime handle");return static_cast(h.slot);} - const Json* component(entt::entity e,const std::string& type)const {for(const auto& c:registry.get(e).document["components"])if(c["type"]==type)return &c;return nullptr;} - void callback(const Behavior::Callback& fn,entt::entity e,double dt) { - if(!fn)return; - try {fn(*owner,handle(e),dt);}catch(const std::exception& ex){diagnostics.push_back("gameplay "+registry.get(e).document["id"].get()+": "+ex.what());}catch(...){diagnostics.push_back("unknown gameplay exception");} + Impl(Runtime* runtime, RuntimeConfig cfg) : owner(runtime), config(cfg) {} + EntityHandle handle(entt::entity e) const { + return {session, entt::to_integral(e), registry.get(e).generation}; } - void lifecycle(entt::entity e,Behavior::Callback Behavior::* member,double dt) { - const auto components=registry.get(e).document["components"]; - for(const auto& c:components) {auto it=behaviors.find(c["type"].get());if(it!=behaviors.end())callback(it->second.*member,e,dt);} + bool valid(EntityHandle h) const noexcept { + auto e = static_cast(h.slot); + return h.session == session && registry.valid(e) && registry.all_of(e) && + registry.get(e).generation == h.generation; + } + entt::entity entity(EntityHandle h) const { + if (!valid(h)) + throw std::invalid_argument("stale or foreign runtime handle"); + return static_cast(h.slot); + } + const Json* component(entt::entity e, const std::string& type) const { + for (const auto& c : registry.get(e).document["components"]) + if (c["type"] == type) + return &c; + return nullptr; + } + void callback(const Behavior::Callback& fn, entt::entity e, double dt) { + if (!fn) + return; + try { + fn(*owner, handle(e), dt); + } catch (const std::exception& ex) { + diagnostics.push_back("gameplay " + + registry.get(e).document["id"].get() + ": " + + ex.what()); + } catch (...) { + diagnostics.push_back("unknown gameplay exception"); + } + } + void lifecycle(entt::entity e, Behavior::Callback Behavior::* member, double dt) { + const auto components = registry.get(e).document["components"]; + for (const auto& c : components) { + auto it = behaviors.find(c["type"].get()); + if (it != behaviors.end()) + callback(it->second.*member, e, dt); + } + } + void all(Behavior::Callback Behavior::* member, double dt) { + for (auto e : order) + if (registry.valid(e)) + lifecycle(e, member, dt); } - void all(Behavior::Callback Behavior::* member,double dt) {for(auto e:order)if(registry.valid(e))lifecycle(e,member,dt);} void syncVisual(entt::entity e) { - if(auto c=component(e,"faset.sprite")){const auto& f=(*c)["fields"];registry.emplace_or_replace(e,vectorValue<4>(f,"color",{1,1,1,1}),vectorValue<2>(f,"size",{1,1}),f.value("texture",std::string{}),f.value("layer",0));}else registry.remove(e); - if(auto c=component(e,"faset.mesh")){const auto& f=(*c)["fields"];registry.emplace_or_replace(e,f.value("asset",std::string{}),vectorValue<4>(f,"color",{1,1,1,1}),f.value("primitive",std::string("cube")));}else registry.remove(e); + if (auto c = component(e, "faset.sprite")) { + const auto& f = (*c)["fields"]; + registry.emplace_or_replace( + e, vectorValue<4>(f, "color", {1, 1, 1, 1}), vectorValue<2>(f, "size", {1, 1}), + f.value("texture", std::string{}), f.value("layer", 0)); + } else + registry.remove(e); + if (auto c = component(e, "faset.mesh")) { + const auto& f = (*c)["fields"]; + registry.emplace_or_replace(e, f.value("asset", std::string{}), + vectorValue<4>(f, "color", {1, 1, 1, 1}), + f.value("primitive", std::string("cube"))); + } else + registry.remove(e); } void addPhysics(entt::entity e) { - require(bool(physics),"load a scene before creating physics"); - auto c=component(e,dimension==2?body2:body3);if(c)physics->add(entt::to_integral(e),registry.get(e).current,settings((*c)["fields"],dimension)); + require(bool(physics), "load a scene before creating physics"); + auto c = component(e, dimension == 2 ? body2 : body3); + if (c) + physics->add(entt::to_integral(e), registry.get(e).current, + settings((*c)["fields"], dimension)); } entt::entity create(Json document) { - const auto id=document["id"].get();require(!ids.contains(id),"duplicate entity id: "+id); - auto e=registry.create();Transform t{}; - for(const auto& c:document["components"])if(c["type"]=="faset.transform")t=readTransform(c["fields"]); - registry.emplace(e,std::move(document),++generation);registry.emplace(e,t,t,t,false);ids.emplace(id,e);order.push_back(e);addPhysics(e);syncVisual(e);return e; + const auto id = document["id"].get(); + require(!ids.contains(id), "duplicate entity id: " + id); + auto e = registry.create(); + Transform t{}; + for (const auto& c : document["components"]) + if (c["type"] == "faset.transform") + t = readTransform(c["fields"]); + registry.emplace(e, std::move(document), ++generation); + registry.emplace(e, t, t, t, false); + ids.emplace(id, e); + order.push_back(e); + addPhysics(e); + syncVisual(e); + return e; } void erase(entt::entity e) { // Authoring hierarchy destruction has the same subtree semantics in runtime. - auto id=registry.get(e).document["id"].get(); + auto id = registry.get(e).document["id"].get(); std::vector children; - for(auto child:order)if(registry.valid(child)&®istry.get(child).document.value("parent",Json{})==id)children.push_back(child); - for(auto child:children)erase(child); - phase=Phase::Destroy;lifecycle(e,&Behavior::onDestroy,0); - physics->remove(entt::to_integral(e));ids.erase(id);registry.destroy(e); - std::erase(order,e); + for (auto child : order) + if (registry.valid(child) && + registry.get(child).document.value("parent", Json{}) == id) + children.push_back(child); + for (auto child : children) + erase(child); + phase = Phase::Destroy; + lifecycle(e, &Behavior::onDestroy, 0); + physics->remove(entt::to_integral(e)); + ids.erase(id); + registry.destroy(e); + std::erase(order, e); } void commands() { - auto commands=std::move(pending);pending.clear(); - for(auto& command:commands)try { - if(command.kind==Kind::Spawn) { - validateEntity(command.payload,dimension); - auto parent=command.payload.value("parent",Json{});require(parent.is_null()||ids.contains(parent.get()),"spawn parent is absent"); - auto e=create(std::move(command.payload));phase=Phase::Initialize;lifecycle(e,&Behavior::onStart,0);continue; + auto commands = std::move(pending); + pending.clear(); + for (auto& command : commands) + try { + if (command.kind == Kind::Spawn) { + validateEntity(command.payload, dimension); + auto parent = command.payload.value("parent", Json{}); + require(parent.is_null() || ids.contains(parent.get()), + "spawn parent is absent"); + auto e = create(std::move(command.payload)); + phase = Phase::Initialize; + lifecycle(e, &Behavior::onStart, 0); + continue; + } + if (!valid(command.handle)) { + diagnostics.push_back("ignored structural command for stale handle"); + continue; + } + auto e = entity(command.handle); + if (command.kind == Kind::Destroy) { + erase(e); + continue; + } + auto candidate = registry.get(e).document; + auto& components = candidate["components"]; + if (command.kind == Kind::Add) { + components.push_back(command.payload); + validateEntity(candidate, dimension); + } else { + auto it = + std::find_if(components.begin(), components.end(), + [&](const Json& c) { return c["type"] == command.type; }); + if (it == components.end()) + continue; + if (command.type == "faset.transform" && physics->contains(command.handle.slot)) + throw std::invalid_argument("remove physics before removing transform"); + auto behavior = behaviors.find(command.type); + if (behavior != behaviors.end()) { + phase = Phase::Destroy; + callback(behavior->second.onDestroy, e, 0); + } + components.erase(it); + } + const std::string changed = command.kind == Kind::Add + ? command.payload["type"].get() + : command.type; + if ((changed == body2 || changed == body3) && command.kind == Kind::Add) { + const auto& pose = registry.get(e).current; + for (int i = 0; i < dimension; ++i) + require(std::abs(pose.scale[i]) > 0.00001f, + "runtime physics scale must be nonzero"); + if (dimension == 2) + require(pose.rotation[0] == 0 && pose.rotation[1] == 0, + "2D physics rotates only around Z"); + } + registry.get(e).document = std::move(candidate); + syncVisual(e); + if (changed == body2 || changed == body3) { + if (command.kind == Kind::Add) + addPhysics(e); + else + physics->remove(command.handle.slot); + } + if (changed == "faset.transform") { + auto& d = registry.get(e); + d.current = command.kind == Kind::Add ? readTransform(command.payload["fields"]) + : Transform{}; + d.previous = d.presented = d.current; + } + if (command.kind == Kind::Add) { + auto it = behaviors.find(changed); + if (it != behaviors.end()) { + phase = Phase::Initialize; + callback(it->second.onStart, e, 0); + } + } + } catch (const std::exception& ex) { + diagnostics.push_back(std::string("structural command rejected: ") + ex.what()); } - if(!valid(command.handle)){diagnostics.push_back("ignored structural command for stale handle");continue;} - auto e=entity(command.handle); - if(command.kind==Kind::Destroy){erase(e);continue;} - auto candidate=registry.get(e).document; - auto& components=candidate["components"]; - if(command.kind==Kind::Add) {components.push_back(command.payload);validateEntity(candidate,dimension);} - else { auto it=std::find_if(components.begin(),components.end(),[&](const Json& c){return c["type"]==command.type;});if(it==components.end())continue; - if(command.type=="faset.transform"&&physics->contains(command.handle.slot))throw std::invalid_argument("remove physics before removing transform"); - auto behavior=behaviors.find(command.type);if(behavior!=behaviors.end()){phase=Phase::Destroy;callback(behavior->second.onDestroy,e,0);}components.erase(it); - } - const std::string changed=command.kind==Kind::Add?command.payload["type"].get():command.type; - if((changed==body2||changed==body3)&&command.kind==Kind::Add) { - const auto& pose=registry.get(e).current; - for(int i=0;i0.00001f,"runtime physics scale must be nonzero"); - if(dimension==2)require(pose.rotation[0]==0&&pose.rotation[1]==0,"2D physics rotates only around Z"); - } - registry.get(e).document=std::move(candidate);syncVisual(e); - if(changed==body2||changed==body3) {if(command.kind==Kind::Add)addPhysics(e);else physics->remove(command.handle.slot);} - if(changed=="faset.transform") {auto& d=registry.get(e);d.current=command.kind==Kind::Add?readTransform(command.payload["fields"]):Transform{};d.previous=d.presented=d.current;} - if(command.kind==Kind::Add){auto it=behaviors.find(changed);if(it!=behaviors.end()){phase=Phase::Initialize;callback(it->second.onStart,e,0);}} - }catch(const std::exception& ex){diagnostics.push_back(std::string("structural command rejected: ")+ex.what());} } void fixed() { - commands();phase=Phase::Fixed; - for(auto [e,d]:registry.view().each()){(void)e;d.previous=d.current;} - currentInput=queuedInput;queuedInput.jumpPressed=false;queuedInput.interactPressed=false; - all(&Behavior::fixedUpdate,config.fixedDelta); - contacts.clear(); - if(physics) { - auto events=physics->step(static_cast(config.fixedDelta)); - for(auto e:order)if(physics->contains(entt::to_integral(e))) {auto& d=registry.get(e);d.current=physics->transform(entt::to_integral(e),d.current);} - for(const auto& event:events) { - auto a=static_cast(event.first),b=static_cast(event.second); - if(!registry.valid(a)||!registry.valid(b))continue; - contacts.push_back({handle(a),handle(b),event.began}); - } - for(const auto& event:contacts)for(auto h:{event.first,event.second}) { - auto e=entity(h);for(const auto& c:registry.get(e).document["components"]) { - auto it=behaviors.find(c["type"].get());if(it!=behaviors.end()&&it->second.onCollision) - try{it->second.onCollision(*owner,h,event);}catch(const std::exception& ex){diagnostics.push_back(std::string("collision callback: ")+ex.what());}catch(...){diagnostics.push_back("unknown collision callback exception");} - } - } + commands(); + phase = Phase::Fixed; + for (auto [e, d] : registry.view().each()) { + (void)e; + d.previous = d.current; } - ++tick;phase=Phase::Idle; + currentInput = queuedInput; + queuedInput.jumpPressed = false; + queuedInput.interactPressed = false; + all(&Behavior::fixedUpdate, config.fixedDelta); + contacts.clear(); + if (physics) { + auto events = physics->step(static_cast(config.fixedDelta)); + for (auto e : order) + if (physics->contains(entt::to_integral(e))) { + auto& d = registry.get(e); + d.current = physics->transform(entt::to_integral(e), d.current); + } + for (const auto& event : events) { + auto a = static_cast(event.first), + b = static_cast(event.second); + if (!registry.valid(a) || !registry.valid(b)) + continue; + contacts.push_back({handle(a), handle(b), event.began}); + } + for (const auto& event : contacts) + for (auto h : {event.first, event.second}) { + auto e = entity(h); + for (const auto& c : registry.get(e).document["components"]) { + auto it = behaviors.find(c["type"].get()); + if (it != behaviors.end() && it->second.onCollision) + try { + it->second.onCollision(*owner, h, event); + } catch (const std::exception& ex) { + diagnostics.push_back(std::string("collision callback: ") + + ex.what()); + } catch (...) { + diagnostics.push_back("unknown collision callback exception"); + } + } + } + } + ++tick; + phase = Phase::Idle; } - FrameStats frame(double elapsed,InputState input,bool step) { - require(std::isfinite(elapsed)&&elapsed>=0,"elapsed time must be finite and nonnegative");require(!busy,"recursive runtime advance"); - require(std::isfinite(input.horizontal)&&std::isfinite(input.vertical),"input axes must be finite"); - struct Guard {bool& busy;~Guard(){busy=false;}}guard{busy};busy=true; + FrameStats frame(double elapsed, InputState input, bool step) { + require(std::isfinite(elapsed) && elapsed >= 0, + "elapsed time must be finite and nonnegative"); + require(!busy, "recursive runtime advance"); + require(std::isfinite(input.horizontal) && std::isfinite(input.vertical), + "input axes must be finite"); + struct Guard { + bool& busy; + ~Guard() { + busy = false; + } + } guard{busy}; + busy = true; FrameStats stats{}; - if(paused&&!step){accumulator=0;currentInput={};queuedInput={};return {0,0,alpha,tick};} - queuedInput.horizontal=input.horizontal;queuedInput.vertical=input.vertical; - queuedInput.jumpPressed=queuedInput.jumpPressed||input.jumpPressed;queuedInput.interactPressed=queuedInput.interactPressed||input.interactPressed; - for(auto [e,d]:registry.view().each()){(void)e;d.changedInUpdate=false;} - accumulator+=step?config.fixedDelta:elapsed; - while(accumulator+1e-12>=config.fixedDelta&&stats.fixedTicks<(step?1u:config.maxCatchUpTicks)) {fixed();accumulator=std::max(0.0,accumulator-config.fixedDelta);++stats.fixedTicks;} - if(accumulator>=config.fixedDelta){auto remaining=std::fmod(accumulator,config.fixedDelta);stats.droppedTime=accumulator-remaining;accumulator=remaining;diagnostics.push_back("dropped_time="+std::to_string(stats.droppedTime));} - currentInput=input;phase=Phase::Update;all(&Behavior::update,step?config.fixedDelta:elapsed); - alpha=step?1.0:std::clamp(accumulator/config.fixedDelta,0.0,1.0); - for(auto [e,d]:registry.view().each()){(void)e;d.presented=d.changedInUpdate?d.current:interpolate(d.previous,d.current,static_cast(alpha));} - phase=Phase::Late;all(&Behavior::lateUpdate,step?config.fixedDelta:elapsed);phase=Phase::Idle; - stats.interpolationAlpha=alpha;stats.tick=tick;return stats; + if (paused && !step) { + accumulator = 0; + currentInput = {}; + queuedInput = {}; + return {0, 0, alpha, tick}; + } + queuedInput.horizontal = input.horizontal; + queuedInput.vertical = input.vertical; + queuedInput.jumpPressed = queuedInput.jumpPressed || input.jumpPressed; + queuedInput.interactPressed = queuedInput.interactPressed || input.interactPressed; + for (auto [e, d] : registry.view().each()) { + (void)e; + d.changedInUpdate = false; + } + accumulator += step ? config.fixedDelta : elapsed; + while (accumulator + 1e-12 >= config.fixedDelta && + stats.fixedTicks < (step ? 1u : config.maxCatchUpTicks)) { + fixed(); + accumulator = std::max(0.0, accumulator - config.fixedDelta); + ++stats.fixedTicks; + } + if (accumulator >= config.fixedDelta) { + auto remaining = std::fmod(accumulator, config.fixedDelta); + stats.droppedTime = accumulator - remaining; + accumulator = remaining; + diagnostics.push_back("dropped_time=" + std::to_string(stats.droppedTime)); + } + currentInput = input; + phase = Phase::Update; + all(&Behavior::update, step ? config.fixedDelta : elapsed); + alpha = step ? 1.0 : std::clamp(accumulator / config.fixedDelta, 0.0, 1.0); + for (auto [e, d] : registry.view().each()) { + (void)e; + d.presented = d.changedInUpdate + ? d.current + : interpolate(d.previous, d.current, static_cast(alpha)); + } + phase = Phase::Late; + all(&Behavior::lateUpdate, step ? config.fixedDelta : elapsed); + phase = Phase::Idle; + stats.interpolationAlpha = alpha; + stats.tick = tick; + return stats; } }; -Runtime::Runtime(RuntimeConfig cfg):impl_(std::make_unique(this,cfg)) { - require(std::isfinite(cfg.fixedDelta)&&cfg.fixedDelta>0&&cfg.fixedDelta<=1,"invalid fixed delta"); - require(cfg.maxCatchUpTicks>0&&cfg.maxCatchUpTicks<=1024,"invalid catchup limit");require(cfg.physicsSubsteps>0&&cfg.physicsSubsteps<=128,"invalid physics substeps"); - for(float value:cfg.gravity)require(std::isfinite(value),"invalid gravity"); +Runtime::Runtime(RuntimeConfig cfg) : impl_(std::make_unique(this, cfg)) { + require(std::isfinite(cfg.fixedDelta) && cfg.fixedDelta > 0 && cfg.fixedDelta <= 1, + "invalid fixed delta"); + require(cfg.maxCatchUpTicks > 0 && cfg.maxCatchUpTicks <= 1024, "invalid catchup limit"); + require(cfg.physicsSubsteps > 0 && cfg.physicsSubsteps <= 128, "invalid physics substeps"); + for (float value : cfg.gravity) + require(std::isfinite(value), "invalid gravity"); } -Runtime::~Runtime(){try{clear();}catch(...){}} -void Runtime::registerBehavior(std::string type,Behavior behavior) { - require(!impl_->busy&&impl_->ids.empty(),"register gameplay before loading scene");require(!type.empty()&&!impl_->behaviors.contains(type),"duplicate or empty behavior type");impl_->behaviors.emplace(std::move(type),std::move(behavior)); +Runtime::~Runtime() { + try { + clear(); + } catch (...) { + } +} +void Runtime::registerBehavior(std::string type, Behavior behavior) { + require(!impl_->busy && impl_->ids.empty(), "register gameplay before loading scene"); + require(!type.empty() && !impl_->behaviors.contains(type), "duplicate or empty behavior type"); + impl_->behaviors.emplace(std::move(type), std::move(behavior)); } void Runtime::load(const Json& scene) { - require(!impl_->busy,"cannot load scene from gameplay callback"); - require(scene.is_object()&&scene.value("format",std::string{})=="faset.scene"&&scene.value("version",0)==1,"unsupported scene format/version"); - const int dimension=scene.value("dimension",3);require(dimension==2||dimension==3,"scene dimension must be 2 or 3"); - require(scene.contains("entities")&&scene["entities"].is_array(),"scene entities must be an array"); - require(!scene.contains("instances")||(scene["instances"].is_array()&&scene["instances"].empty()),"resolve template instances before runtime loading"); - std::unordered_map entities; - for(const auto& entity:scene["entities"]) {validateEntity(entity,dimension);require(entities.emplace(entity["id"].get(),entity).second,"duplicate scene entity id");} - for(const auto& [id,entity]:entities) { - std::set visited{id};auto parent=entity.value("parent",Json{}); - while(!parent.is_null()){auto key=parent.get();require(entities.contains(key),"unknown parent entity");require(visited.insert(key).second,"cyclic parent hierarchy");parent=entities.at(key).value("parent",Json{});} + require(!impl_->busy, "cannot load scene from gameplay callback"); + require(scene.is_object() && scene.value("format", std::string{}) == "faset.scene" && + scene.value("version", 0) == 1, + "unsupported scene format/version"); + const int dimension = scene.value("dimension", 3); + require(dimension == 2 || dimension == 3, "scene dimension must be 2 or 3"); + require(scene.contains("entities") && scene["entities"].is_array(), + "scene entities must be an array"); + require(!scene.contains("instances") || + (scene["instances"].is_array() && scene["instances"].empty()), + "resolve template instances before runtime loading"); + std::unordered_map entities; + for (const auto& entity : scene["entities"]) { + validateEntity(entity, dimension); + require(entities.emplace(entity["id"].get(), entity).second, + "duplicate scene entity id"); } - auto next=std::make_unique(this,impl_->config);next->dimension=dimension;next->behaviors=impl_->behaviors; - next->physics=std::make_unique(dimension,next->config.gravity,next->config.physicsSubsteps); - for(const auto& entity:scene["entities"])next->create(entity); - clear();impl_=std::move(next);impl_->busy=true;impl_->phase=Impl::Phase::Initialize;impl_->all(&Behavior::onStart,0);impl_->phase=Impl::Phase::Idle;impl_->busy=false; + for (const auto& [id, entity] : entities) { + std::set visited{id}; + auto parent = entity.value("parent", Json{}); + while (!parent.is_null()) { + auto key = parent.get(); + require(entities.contains(key), "unknown parent entity"); + require(visited.insert(key).second, "cyclic parent hierarchy"); + parent = entities.at(key).value("parent", Json{}); + } + } + auto next = std::make_unique(this, impl_->config); + next->dimension = dimension; + next->behaviors = impl_->behaviors; + next->physics = std::make_unique(dimension, next->config.gravity, + next->config.physicsSubsteps); + for (const auto& entity : scene["entities"]) + next->create(entity); + clear(); + impl_ = std::move(next); + impl_->busy = true; + impl_->phase = Impl::Phase::Initialize; + impl_->all(&Behavior::onStart, 0); + impl_->phase = Impl::Phase::Idle; + impl_->busy = false; } -void Runtime::clear(){require(!impl_->busy,"cannot clear runtime from gameplay callback");impl_->busy=true;while(!impl_->order.empty())impl_->erase(impl_->order.back());impl_->pending.clear();impl_->contacts.clear();impl_->physics.reset();impl_->accumulator=0;impl_->tick=0;impl_->session=nextSession.fetch_add(1);impl_->busy=false;} -FrameStats Runtime::advance(double dt,InputState input){return impl_->frame(dt,input,false);} -FrameStats Runtime::singleStep(InputState input){return impl_->frame(0,input,true);} -void Runtime::setPaused(bool value){require(!impl_->busy,"pause control belongs outside gameplay callbacks");impl_->paused=value;impl_->accumulator=0;impl_->queuedInput={};impl_->alpha=0;for(auto [e,pose]:impl_->registry.view().each()){(void)e;pose.previous=pose.presented=pose.current;}} -bool Runtime::paused()const noexcept{return impl_->paused;} -EntityHandle Runtime::find(const std::string& id)const{auto it=impl_->ids.find(id);return it==impl_->ids.end()?EntityHandle{}:impl_->handle(it->second);} -bool Runtime::valid(EntityHandle handle)const noexcept{return impl_->valid(handle);} -Transform Runtime::transform(EntityHandle h)const{return impl_->registry.get(impl_->entity(h)).current;} -Transform Runtime::presentation(EntityHandle h)const{return impl_->registry.get(impl_->entity(h)).presented;} -Json Runtime::fields(EntityHandle h,const std::string& type)const{auto c=impl_->component(impl_->entity(h),type);if(!c)throw std::invalid_argument("entity has no component: "+type);return (*c)["fields"];} -Vec3 Runtime::velocity(EntityHandle h)const{impl_->entity(h);if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");return impl_->physics->velocity(h.slot);} -InputState Runtime::input()const noexcept{return impl_->currentInput;} -const std::vector& Runtime::collisions()const noexcept{return impl_->contacts;} -void Runtime::setTransform(EntityHandle h,const Transform& value){validateTransform(value);auto e=impl_->entity(h);if(impl_->physics&&impl_->physics->contains(h.slot))throw std::invalid_argument("physics transform requires teleport");auto& d=impl_->registry.get(e);d.current=value;if(impl_->phase!=Impl::Phase::Fixed){d.previous=d.presented=value;d.changedInUpdate=true;}} -void Runtime::setPresentation(EntityHandle h,const Transform& value){validateTransform(value);require(impl_->phase==Impl::Phase::Late,"presentation may only be changed during LateUpdate");impl_->registry.get(impl_->entity(h)).presented=value;} -void Runtime::teleport(EntityHandle h,const Transform& value){validateTransform(value);auto e=impl_->entity(h);auto& d=impl_->registry.get(e);if(impl_->physics&&impl_->physics->contains(h.slot)){require(d.current.scale==value.scale,"changing collider scale requires remove/add body");if(impl_->dimension==2)require(value.rotation[0]==0&&value.rotation[1]==0,"2D physics rotates only around Z");impl_->physics->teleport(h.slot,value);}d.previous=d.current=d.presented=value;d.changedInUpdate=true;} -void Runtime::setVelocity(EntityHandle h,Vec3 value){impl_->entity(h);for(float v:value)require(std::isfinite(v),"nonfinite velocity");if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");impl_->physics->setVelocity(h.slot,value);} -void Runtime::applyImpulse(EntityHandle h,Vec3 value){impl_->entity(h);for(float v:value)require(std::isfinite(v),"nonfinite impulse");if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");impl_->physics->impulse(h.slot,value);} -void Runtime::spawn(Json entity){require(bool(impl_->physics),"load a scene before spawning");validateEntity(entity,impl_->dimension);impl_->pending.push_back({Impl::Kind::Spawn,{},std::move(entity),{}});} -void Runtime::destroy(EntityHandle h){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Destroy,h,{},{}});} -void Runtime::addComponent(EntityHandle h,Json component){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Add,h,std::move(component),{}});} -void Runtime::removeComponent(EntityHandle h,const std::string& type){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Remove,h,{},type});} -RuntimeSnapshot Runtime::snapshot()const { - RuntimeSnapshot out{impl_->dimension,impl_->tick,impl_->alpha,{}};out.entities.reserve(impl_->order.size()); - for(auto e:impl_->order) {const auto& d=impl_->registry.get(e);RenderEntity item;item.id=d.document["id"].get();item.name=d.document.value("name",item.id);item.transform=impl_->registry.get(e).presented; - if(d.document.contains("parent")&&!d.document["parent"].is_null())item.parent=d.document["parent"].get(); - if(auto sprite=impl_->registry.try_get(e))item.sprite=*sprite; - if(auto mesh=impl_->registry.try_get(e))item.mesh=*mesh; +void Runtime::clear() { + require(!impl_->busy, "cannot clear runtime from gameplay callback"); + impl_->busy = true; + while (!impl_->order.empty()) + impl_->erase(impl_->order.back()); + impl_->pending.clear(); + impl_->contacts.clear(); + impl_->physics.reset(); + impl_->accumulator = 0; + impl_->tick = 0; + impl_->session = nextSession.fetch_add(1); + impl_->busy = false; +} +FrameStats Runtime::advance(double dt, InputState input) { + return impl_->frame(dt, input, false); +} +FrameStats Runtime::singleStep(InputState input) { + return impl_->frame(0, input, true); +} +void Runtime::setPaused(bool value) { + require(!impl_->busy, "pause control belongs outside gameplay callbacks"); + impl_->paused = value; + impl_->accumulator = 0; + impl_->queuedInput = {}; + impl_->alpha = 0; + for (auto [e, pose] : impl_->registry.view().each()) { + (void)e; + pose.previous = pose.presented = pose.current; + } +} +bool Runtime::paused() const noexcept { + return impl_->paused; +} +EntityHandle Runtime::find(const std::string& id) const { + auto it = impl_->ids.find(id); + return it == impl_->ids.end() ? EntityHandle{} : impl_->handle(it->second); +} +bool Runtime::valid(EntityHandle handle) const noexcept { + return impl_->valid(handle); +} +Transform Runtime::transform(EntityHandle h) const { + return impl_->registry.get(impl_->entity(h)).current; +} +Transform Runtime::presentation(EntityHandle h) const { + return impl_->registry.get(impl_->entity(h)).presented; +} +Json Runtime::fields(EntityHandle h, const std::string& type) const { + auto c = impl_->component(impl_->entity(h), type); + if (!c) + throw std::invalid_argument("entity has no component: " + type); + return (*c)["fields"]; +} +Vec3 Runtime::velocity(EntityHandle h) const { + impl_->entity(h); + if (!impl_->physics || !impl_->physics->contains(h.slot)) + throw std::invalid_argument("entity has no physics body"); + return impl_->physics->velocity(h.slot); +} +bool Runtime::grounded(EntityHandle h) const { + impl_->entity(h); + if (!impl_->physics || !impl_->physics->contains(h.slot)) + throw std::invalid_argument("entity has no physics body"); + return impl_->physics->grounded(h.slot); +} +InputState Runtime::input() const noexcept { + return impl_->currentInput; +} +const std::vector& Runtime::collisions() const noexcept { + return impl_->contacts; +} +void Runtime::setTransform(EntityHandle h, const Transform& value) { + validateTransform(value); + auto e = impl_->entity(h); + if (impl_->physics && impl_->physics->contains(h.slot)) + throw std::invalid_argument("physics transform requires teleport"); + auto& d = impl_->registry.get(e); + d.current = value; + if (impl_->phase != Impl::Phase::Fixed) { + d.previous = d.presented = value; + d.changedInUpdate = true; + } +} +void Runtime::setPresentation(EntityHandle h, const Transform& value) { + validateTransform(value); + require(impl_->phase == Impl::Phase::Late, + "presentation may only be changed during LateUpdate"); + impl_->registry.get(impl_->entity(h)).presented = value; +} +void Runtime::teleport(EntityHandle h, const Transform& value) { + validateTransform(value); + auto e = impl_->entity(h); + auto& d = impl_->registry.get(e); + if (impl_->physics && impl_->physics->contains(h.slot)) { + require(d.current.scale == value.scale, "changing collider scale requires remove/add body"); + if (impl_->dimension == 2) + require(value.rotation[0] == 0 && value.rotation[1] == 0, + "2D physics rotates only around Z"); + impl_->physics->teleport(h.slot, value); + } + d.previous = d.current = d.presented = value; + d.changedInUpdate = true; +} +void Runtime::setVelocity(EntityHandle h, Vec3 value) { + impl_->entity(h); + for (float v : value) + require(std::isfinite(v), "nonfinite velocity"); + if (!impl_->physics || !impl_->physics->contains(h.slot)) + throw std::invalid_argument("entity has no physics body"); + impl_->physics->setVelocity(h.slot, value); +} +void Runtime::applyImpulse(EntityHandle h, Vec3 value) { + impl_->entity(h); + for (float v : value) + require(std::isfinite(v), "nonfinite impulse"); + if (!impl_->physics || !impl_->physics->contains(h.slot)) + throw std::invalid_argument("entity has no physics body"); + impl_->physics->impulse(h.slot, value); +} +void Runtime::spawn(Json entity) { + require(bool(impl_->physics), "load a scene before spawning"); + validateEntity(entity, impl_->dimension); + impl_->pending.push_back({Impl::Kind::Spawn, {}, std::move(entity), {}}); +} +void Runtime::destroy(EntityHandle h) { + impl_->entity(h); + impl_->pending.push_back({Impl::Kind::Destroy, h, {}, {}}); +} +void Runtime::addComponent(EntityHandle h, Json component) { + impl_->entity(h); + impl_->pending.push_back({Impl::Kind::Add, h, std::move(component), {}}); +} +void Runtime::removeComponent(EntityHandle h, const std::string& type) { + impl_->entity(h); + impl_->pending.push_back({Impl::Kind::Remove, h, {}, type}); +} +RuntimeSnapshot Runtime::snapshot() const { + RuntimeSnapshot out{impl_->dimension, impl_->tick, impl_->alpha, {}}; + out.entities.reserve(impl_->order.size()); + for (auto e : impl_->order) { + const auto& d = impl_->registry.get(e); + RenderEntity item; + item.id = d.document["id"].get(); + item.name = d.document.value("name", item.id); + item.transform = impl_->registry.get(e).presented; + if (d.document.contains("parent") && !d.document["parent"].is_null()) + item.parent = d.document["parent"].get(); + if (auto sprite = impl_->registry.try_get(e)) + item.sprite = *sprite; + if (auto mesh = impl_->registry.try_get(e)) + item.mesh = *mesh; out.entities.push_back(std::move(item)); - }return out; + } + return out; } -Json Runtime::snapshotJson()const{auto value=snapshot();Json entities=Json::array();for(const auto& e:value.entities){Json item{{"id",e.id},{"name",e.name},{"parent",e.parent?Json(*e.parent):Json{}},{"transform",transformJson(e.transform)}};if(e.sprite)item["sprite"]={{"color",e.sprite->color},{"size",e.sprite->size},{"texture",e.sprite->texture},{"layer",e.sprite->layer}};if(e.mesh)item["mesh"]={{"asset",e.mesh->asset},{"color",e.mesh->color},{"primitive",e.mesh->primitive}};const auto entity=impl_->ids.at(e.id);for(const auto& type:{"faset.camera","faset.light"})if(auto c=impl_->component(entity,type))item[type==std::string("faset.camera")?"camera":"light"]=(*c)["fields"];entities.push_back(std::move(item));}return {{"dimension",value.dimension},{"tick",value.tick},{"alpha",value.alpha},{"entities",entities}};} -std::uint64_t Runtime::session()const noexcept{return impl_->session;} -const std::vector& Runtime::diagnostics()const noexcept{return impl_->diagnostics;} +Json Runtime::snapshotJson() const { + auto value = snapshot(); + Json entities = Json::array(); + for (const auto& e : value.entities) { + Json item{{"id", e.id}, + {"name", e.name}, + {"parent", e.parent ? Json(*e.parent) : Json{}}, + {"transform", transformJson(e.transform)}}; + if (e.sprite) + item["sprite"] = {{"color", e.sprite->color}, + {"size", e.sprite->size}, + {"texture", e.sprite->texture}, + {"layer", e.sprite->layer}}; + if (e.mesh) + item["mesh"] = {{"asset", e.mesh->asset}, + {"color", e.mesh->color}, + {"primitive", e.mesh->primitive}}; + const auto entity = impl_->ids.at(e.id); + for (const auto& type : {"faset.camera", "faset.light"}) + if (auto c = impl_->component(entity, type)) + item[type == std::string("faset.camera") ? "camera" : "light"] = (*c)["fields"]; + entities.push_back(std::move(item)); + } + return {{"dimension", value.dimension}, + {"tick", value.tick}, + {"alpha", value.alpha}, + {"entities", entities}}; } +std::uint64_t Runtime::session() const noexcept { + return impl_->session; +} +const std::vector& Runtime::diagnostics() const noexcept { + return impl_->diagnostics; +} +} // namespace faset::runtime diff --git a/src/ui/font.cpp b/src/ui/font.cpp new file mode 100644 index 0000000..e29f3f4 --- /dev/null +++ b/src/ui/font.cpp @@ -0,0 +1,171 @@ +#include +#include +#include FT_FREETYPE_H +#include +#include +#include +#include +#include +#include +#include + +namespace faset::ui { +namespace { +void quad(render::Snapshot& snapshot, render::Quad q, const Rect& clip) { + const Rect area{q.x, q.y, q.width, q.height}; + const auto visible = area.intersection(clip); + if (visible.width <= 0 || visible.height <= 0 || q.width <= 0 || q.height <= 0) + return; + const float u0 = q.uv_rect[0], v0 = q.uv_rect[1], du = q.uv_rect[2] - u0, + dv = q.uv_rect[3] - v0; + q.uv_rect = {u0 + (visible.x - q.x) / q.width * du, v0 + (visible.y - q.y) / q.height * dv, + u0 + (visible.x + visible.width - q.x) / q.width * du, + v0 + (visible.y + visible.height - q.y) / q.height * dv}; + q.x = visible.x; + q.y = visible.y; + q.width = visible.width; + q.height = visible.height; + snapshot.ui_quads.push_back(std::move(q)); +} +} // namespace +struct FontAtlas::Impl { + FT_Library library{}; + FT_Face face{}; + hb_font_t* font{}; + std::vector font_bytes; + std::shared_ptr atlas = std::make_shared(); + struct Glyph { + unsigned x, y, width, height; + int left, top; + }; + std::map, Glyph> glyphs; + unsigned x = 2, y = 2, row_height = 0, size = 0; + explicit Impl(const std::filesystem::path& path) { + std::ifstream in(path, std::ios::binary | std::ios::ate); + if (!in) + throw std::runtime_error("Cannot open UI font: " + path.string()); + auto length = in.tellg(); + if (length <= 0) + throw std::runtime_error("Empty UI font"); + font_bytes.resize(static_cast(length)); + in.seekg(0); + in.read(reinterpret_cast(font_bytes.data()), length); + if (!in) + throw std::runtime_error("Cannot read UI font"); + if (FT_Init_FreeType(&library)) + throw std::runtime_error("FreeType initialization failed"); + if (FT_New_Memory_Face(library, font_bytes.data(), static_cast(font_bytes.size()), + 0, &face)) { + FT_Done_FreeType(library); + library = nullptr; + throw std::runtime_error("Cannot load UI font face"); + } + font = hb_ft_font_create_referenced(face); + hb_ft_font_set_load_flags(font, FT_LOAD_DEFAULT); + atlas->width = atlas->height = 2048; + atlas->rgba.resize(2048 * 2048 * 4, 0); + atlas->revision = 1; + atlas->srgb = false; + } + ~Impl() { + if (font) + hb_font_destroy(font); + if (face) + FT_Done_Face(face); + if (library) + FT_Done_FreeType(library); + } + void set_size(float pixels) { + const auto wanted = static_cast(std::clamp(std::round(pixels), 6.f, 128.f)); + if (wanted != size) { + size = wanted; + if (FT_Set_Pixel_Sizes(face, 0, size)) + throw std::runtime_error("Invalid font size"); + hb_ft_font_changed(font); + } + } + Glyph glyph(unsigned index) { + const auto key = std::make_pair(size, index); + if (auto found = glyphs.find(key); found != glyphs.end()) + return found->second; + if (FT_Load_Glyph(face, index, FT_LOAD_DEFAULT) || + FT_Render_Glyph(face->glyph, FT_RENDER_MODE_NORMAL)) + throw std::runtime_error("Cannot rasterize UI glyph"); + const auto& bitmap = face->glyph->bitmap; + if (x + bitmap.width + 2 > atlas->width) { + x = 2; + y += row_height + 2; + row_height = 0; + } + if (y + bitmap.rows + 2 > atlas->height) + throw std::runtime_error("UI font atlas exhausted"); + Glyph result{ + x, y, bitmap.width, bitmap.rows, face->glyph->bitmap_left, face->glyph->bitmap_top}; + for (unsigned py = 0; py < bitmap.rows; ++py) { + const auto* row = bitmap.buffer + (bitmap.pitch >= 0 ? py : bitmap.rows - 1 - py) * + std::abs(bitmap.pitch); + for (unsigned px = 0; px < bitmap.width; ++px) { + const auto offset = ((y + py) * atlas->width + x + px) * 4; + atlas->rgba[offset] = atlas->rgba[offset + 1] = atlas->rgba[offset + 2] = 255; + atlas->rgba[offset + 3] = row[px]; + } + } + x += bitmap.width + 2; + row_height = std::max(row_height, bitmap.rows); + ++atlas->revision; + glyphs.emplace(key, result); + return result; + } + hb_buffer_t* shape(std::string_view text, float pixels) { + set_size(pixels); + auto* buffer = hb_buffer_create(); + hb_buffer_add_utf8(buffer, text.data(), static_cast(text.size()), 0, + static_cast(text.size())); + hb_buffer_guess_segment_properties(buffer); + hb_shape(font, buffer, nullptr, 0); + return buffer; + } +}; +FontAtlas::FontAtlas(const std::filesystem::path& path) : impl_(std::make_unique(path)) {} +FontAtlas::~FontAtlas() = default; +float FontAtlas::measure(std::string_view text, float pixels) { + auto* buffer = impl_->shape(text, pixels); + std::unique_ptr guard(buffer, &hb_buffer_destroy); + unsigned count = 0; + const auto* positions = hb_buffer_get_glyph_positions(buffer, &count); + float advance = 0; + for (unsigned i = 0; i < count; ++i) + advance += positions[i].x_advance / 64.f; + return advance; +} +void FontAtlas::draw(render::Snapshot& snapshot, std::string_view text, float x, float y, + float pixels, Color color, const Rect& clip) { + auto* buffer = impl_->shape(text, pixels); + std::unique_ptr guard(buffer, &hb_buffer_destroy); + unsigned count = 0; + const auto* infos = hb_buffer_get_glyph_infos(buffer, &count); + const auto* positions = hb_buffer_get_glyph_positions(buffer, &count); + const float baseline = y + impl_->face->size->metrics.ascender / 64.f; + for (unsigned i = 0; i < count; ++i) { + const auto glyph = impl_->glyph(infos[i].codepoint); + if (glyph.width && glyph.height) { + render::Quad q; + q.x = x + positions[i].x_offset / 64.f + glyph.left; + q.y = baseline - positions[i].y_offset / 64.f - glyph.top; + q.width = static_cast(glyph.width); + q.height = static_cast(glyph.height); + q.color = color; + q.texture = impl_->atlas; + q.uv_rect = {float(glyph.x) / impl_->atlas->width, + float(glyph.y) / impl_->atlas->height, + float(glyph.x + glyph.width) / impl_->atlas->width, + float(glyph.y + glyph.height) / impl_->atlas->height}; + quad(snapshot, std::move(q), clip); + } + x += positions[i].x_advance / 64.f; + } +} +std::shared_ptr FontAtlas::texture() const { + return impl_->atlas; +} +} // namespace faset::ui diff --git a/src/ui/text.cpp b/src/ui/text.cpp new file mode 100644 index 0000000..278949f --- /dev/null +++ b/src/ui/text.cpp @@ -0,0 +1,196 @@ +#include +#include +#include +#include + +namespace faset::ui { +namespace { +bool continuation(unsigned char c) { + return (c & 0xc0) == 0x80; +} +std::size_t previous(std::string_view text, std::size_t at) { + if (!at) + return 0; + --at; + while (at && continuation(static_cast(text[at]))) + --at; + return at; +} +std::size_t next(std::string_view text, std::size_t at) { + if (at >= text.size()) + return text.size(); + ++at; + while (at < text.size() && continuation(static_cast(text[at]))) + ++at; + return at; +} +bool word(unsigned char c) { + return c >= 128 || std::isalnum(c) || c == '_'; +} +} // namespace +bool TextBuffer::valid_utf8(std::string_view text) { + for (std::size_t i = 0; i < text.size();) { + const auto lead = static_cast(text[i++]); + if (lead < 128) + continue; + unsigned cp = 0; + int n = 0; + unsigned minimum = 0; + if ((lead & 0xe0) == 0xc0) { + cp = lead & 31; + n = 1; + minimum = 128; + } else if ((lead & 0xf0) == 0xe0) { + cp = lead & 15; + n = 2; + minimum = 2048; + } else if ((lead & 0xf8) == 0xf0) { + cp = lead & 7; + n = 3; + minimum = 65536; + } else + return false; + if (i + static_cast(n) > text.size()) + return false; + while (n--) { + auto c = static_cast(text[i++]); + if (!continuation(c)) + return false; + cp = (cp << 6) | (c & 63); + } + if (cp < minimum || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) + return false; + } + return true; +} +TextBuffer::TextBuffer(std::string text) { + reset(std::move(text)); +} +void TextBuffer::reset(std::string text) { + if (!valid_utf8(text)) + throw std::invalid_argument("Invalid UTF-8 text"); + text_ = std::move(text); + cursor_ = anchor_ = text_.size(); + undo_.clear(); + redo_.clear(); +} +void TextBuffer::set_cursor(std::size_t byte, bool select) { + cursor_ = std::min(byte, text_.size()); + while (cursor_ && cursor_ < text_.size() && + continuation(static_cast(text_[cursor_]))) + --cursor_; + if (!select) + anchor_ = cursor_; +} +void TextBuffer::select_all() { + anchor_ = 0; + cursor_ = text_.size(); +} +std::string TextBuffer::selected_text() const { + return text_.substr(std::min(cursor_, anchor_), + std::max(cursor_, anchor_) - std::min(cursor_, anchor_)); +} +void TextBuffer::left(bool select, bool by_word) { + if (!select && has_selection()) { + set_cursor(std::min(cursor_, anchor_)); + return; + } + auto position = previous(text_, cursor_); + if (by_word) { + while (position && !word(static_cast(text_[position]))) + position = previous(text_, position); + while (position && word(static_cast(text_[previous(text_, position)]))) + position = previous(text_, position); + } + set_cursor(position, select); +} +void TextBuffer::right(bool select, bool by_word) { + if (!select && has_selection()) { + set_cursor(std::max(cursor_, anchor_)); + return; + } + auto position = next(text_, cursor_); + if (by_word) { + while (position < text_.size() && word(static_cast(text_[position]))) + position = next(text_, position); + while (position < text_.size() && !word(static_cast(text_[position]))) + position = next(text_, position); + } + set_cursor(position, select); +} +void TextBuffer::home(bool select) { + set_cursor(0, select); +} +void TextBuffer::end(bool select) { + set_cursor(text_.size(), select); +} +void TextBuffer::remember() { + undo_.push_back({text_, cursor_, anchor_}); + if (undo_.size() > 256) + undo_.erase(undo_.begin()); + redo_.clear(); +} +void TextBuffer::erase_selection() { + const auto begin = std::min(cursor_, anchor_), end = std::max(cursor_, anchor_); + text_.erase(begin, end - begin); + cursor_ = anchor_ = begin; +} +bool TextBuffer::insert(std::string_view utf8) { + if (!valid_utf8(utf8) || text_.size() + utf8.size() > 1024 * 1024) + return false; + if (utf8.empty() && !has_selection()) + return false; + remember(); + erase_selection(); + text_.insert(cursor_, utf8); + cursor_ += utf8.size(); + anchor_ = cursor_; + return true; +} +bool TextBuffer::backspace() { + if (!has_selection() && !cursor_) + return false; + remember(); + if (has_selection()) + erase_selection(); + else { + auto begin = previous(text_, cursor_); + text_.erase(begin, cursor_ - begin); + cursor_ = anchor_ = begin; + } + return true; +} +bool TextBuffer::delete_forward() { + if (!has_selection() && cursor_ == text_.size()) + return false; + remember(); + if (has_selection()) + erase_selection(); + else + text_.erase(cursor_, next(text_, cursor_) - cursor_); + anchor_ = cursor_; + return true; +} +bool TextBuffer::undo() { + if (undo_.empty()) + return false; + redo_.push_back({text_, cursor_, anchor_}); + auto old = std::move(undo_.back()); + undo_.pop_back(); + text_ = std::move(old.text); + cursor_ = old.cursor; + anchor_ = old.anchor; + return true; +} +bool TextBuffer::redo() { + if (redo_.empty()) + return false; + undo_.push_back({text_, cursor_, anchor_}); + auto old = std::move(redo_.back()); + redo_.pop_back(); + text_ = std::move(old.text); + cursor_ = old.cursor; + anchor_ = old.anchor; + return true; +} +} // namespace faset::ui diff --git a/src/ui/ui.cpp b/src/ui/ui.cpp new file mode 100644 index 0000000..ff57c77 --- /dev/null +++ b/src/ui/ui.cpp @@ -0,0 +1,1149 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace faset::ui { +bool Rect::contains(float px, float py) const noexcept { + return px >= x && py >= y && px < x + width && py < y + height; +} +Rect Rect::intersection(const Rect& b) const noexcept { + const auto left = std::max(x, b.x), top = std::max(y, b.y); + return {left, top, std::max(0.f, std::min(x + width, b.x + b.width) - left), + std::max(0.f, std::min(y + height, b.y + b.height) - top)}; +} +namespace { +Color color(const Json& j, const char* key, Color fallback) { + if (!j.contains(key)) + return fallback; + auto value = j.at(key).get(); + for (float c : value) + if (!std::isfinite(c) || c < 0 || c > 1) + throw std::runtime_error("Theme colors require normalized RGBA"); + return value; +} +float positive(const Json& j, const char* key, float fallback) { + float value = j.value(key, fallback); + if (!std::isfinite(value) || value <= 0 || value > 256) + throw std::runtime_error("Invalid theme metric"); + return value; +} +std::string number(double value, int precision) { + std::ostringstream out; + out << std::fixed << std::setprecision(std::clamp(precision, 0, 9)) << value; + return out.str(); +} +bool field(const Widget& w) { + return w.kind == Kind::TextField || w.kind == Kind::NumberField; +} +bool focusable(const Widget& w) { + for (auto* parent = w.parent; parent; parent = parent->parent) + if (!parent->visible || !parent->enabled) + return false; + return w.visible && w.enabled && + (field(w) || w.kind == Kind::Button || w.kind == Kind::Checkbox || w.kind == Kind::Tab || + w.kind == Kind::TreeRow); +} +void collect_focus(Widget& w, std::vector& ids) { + if (!w.visible) + return; + if (focusable(w)) + ids.push_back(w.id); + for (auto& child : w.children) + collect_focus(*child, ids); +} +void fill(render::Snapshot& frame, Rect rect, Color color, const Rect& clip) { + rect = rect.intersection(clip); + if (rect.width > 0 && rect.height > 0) + frame.ui_quads.push_back( + {rect.x, rect.y, rect.width, rect.height, color, {}, {0, 0, 1, 1}}); +} +void outline(render::Snapshot& f, const Rect& r, Color c, const Rect& clip, float thickness = 1) { + fill(f, {r.x, r.y, r.width, thickness}, c, clip); + fill(f, {r.x, r.y + r.height - thickness, r.width, thickness}, c, clip); + fill(f, {r.x, r.y, thickness, r.height}, c, clip); + fill(f, {r.x + r.width - thickness, r.y, thickness, r.height}, c, clip); +} +Kind kind_from_string(const std::string& name) { + static const std::unordered_map kinds = {{"panel", Kind::Panel}, + {"row", Kind::Row}, + {"column", Kind::Column}, + {"label", Kind::Label}, + {"button", Kind::Button}, + {"tab", Kind::Tab}, + {"tree_row", Kind::TreeRow}, + {"text_field", Kind::TextField}, + {"number_field", Kind::NumberField}, + {"checkbox", Kind::Checkbox}, + {"divider", Kind::Divider}, + {"viewport", Kind::Viewport}}; + auto found = kinds.find(name); + if (found == kinds.end()) + throw std::runtime_error("Unknown widget kind: " + name); + return found->second; +} +Layout parse_layout(const Json& j, Layout l = {}) { + if (!j.is_object()) + throw std::runtime_error("Layout must be an object"); +#define UI_FLOAT(name) \ + l.name = j.value(#name, l.name); \ + if (!std::isfinite(l.name)) \ + throw std::runtime_error("Non-finite layout metric: " #name) + UI_FLOAT(width); + UI_FLOAT(height); + UI_FLOAT(flex); + UI_FLOAT(min_width); + UI_FLOAT(min_height); + UI_FLOAT(max_width); + UI_FLOAT(max_height); + UI_FLOAT(padding); + UI_FLOAT(gap); + UI_FLOAT(x); + UI_FLOAT(y); +#undef UI_FLOAT + l.absolute = j.value("absolute", l.absolute); + l.scroll = j.value("scroll", l.scroll); + l.clip = j.value("clip", l.clip); + if (l.flex < 0 || l.padding < 0 || l.gap < 0 || l.min_width < 0 || l.min_height < 0 || + l.max_width < l.min_width || l.max_height < l.min_height) + throw std::runtime_error("Invalid layout constraints"); + return l; +} +} // namespace +Theme Theme::from_json(const Json& j) { + Theme t; +#define UI_COLOR(name) t.name = color(j, #name, t.name) + UI_COLOR(background); + UI_COLOR(surface); + UI_COLOR(raised); + UI_COLOR(hover); + UI_COLOR(border); + UI_COLOR(text); + UI_COLOR(muted); + UI_COLOR(accent); + UI_COLOR(selection); + UI_COLOR(danger); +#undef UI_COLOR + t.font_size = positive(j, "font_size", t.font_size); + t.row_height = positive(j, "row_height", t.row_height); + t.padding = positive(j, "padding", t.padding); + t.gap = positive(j, "gap", t.gap); + return t; +} +Theme Theme::load(const std::filesystem::path& path) { + return from_json(faset::read_json(path)); +} +Json Theme::to_json() const { + return {{"background", background}, {"surface", surface}, + {"raised", raised}, {"hover", hover}, + {"border", border}, {"text", text}, + {"muted", muted}, {"accent", accent}, + {"selection", selection}, {"danger", danger}, + {"font_size", font_size}, {"row_height", row_height}, + {"padding", padding}, {"gap", gap}}; +} +Widget& Widget::add(Kind type, const std::string& stable_id, const std::string& label) { + for (auto& child : children) + if (child->id == stable_id) { + if (child->kind != type) + throw std::runtime_error("Widget ID reused with different kind: " + stable_id); + if (!label.empty()) + child->text = label; + return *child; + } + auto child = std::make_unique(); + child->kind = type; + child->id = stable_id; + child->text = label; + child->parent = this; + children.push_back(std::move(child)); + return *children.back(); +} +Widget* Widget::find(std::string_view wanted) { + if (id == wanted) + return this; + for (auto& child : children) + if (auto* result = child->find(wanted)) + return result; + return nullptr; +} +const Widget* Widget::find(std::string_view wanted) const { + if (id == wanted) + return this; + for (const auto& child : children) + if (auto* result = child->find(wanted)) + return result; + return nullptr; +} +void Widget::remove(std::string_view wanted) { + std::erase_if(children, [&](const auto& child) { return child->id == wanted; }); +} +void DockLayout::move(const std::string& panel, const std::string& area, std::size_t index) { + if (panel.empty() || area.empty()) + throw std::runtime_error("Dock IDs cannot be empty"); + for (auto& [name, panels] : areas_) { + (void)name; + std::erase(panels, panel); + } + auto& panels = areas_[area]; + panels.insert(panels.begin() + static_cast(std::min(index, panels.size())), + panel); +} +std::vector DockLayout::panels(const std::string& area) const { + auto found = areas_.find(area); + return found == areas_.end() ? std::vector{} : found->second; +} +void DockLayout::set_size(const std::string& panel, float size) { + if (!std::isfinite(size) || size < 24 || size > 10000) + throw std::runtime_error("Invalid dock panel size"); + sizes_[panel] = size; +} +float DockLayout::size(const std::string& panel, float fallback) const { + auto found = sizes_.find(panel); + return found == sizes_.end() ? fallback : found->second; +} +Json DockLayout::to_json() const { + return {{"version", 1}, {"areas", areas_}, {"sizes", sizes_}}; +} +void DockLayout::from_json(const Json& j) { + if (j.at("version") != 1) + throw std::runtime_error("Unsupported dock layout version"); + auto areas = j.at("areas").get(); + auto sizes = j.at("sizes").get(); + std::set seen; + for (const auto& [area, panels] : areas) { + if (area.empty()) + throw std::runtime_error("Empty dock area"); + for (const auto& panel : panels) + if (panel.empty() || !seen.insert(panel).second) + throw std::runtime_error("Duplicate dock panel"); + } + for (const auto& [id, size] : sizes) + if (id.empty() || !std::isfinite(size) || size < 24 || size > 10000) + throw std::runtime_error("Invalid dock size"); + areas_ = std::move(areas); + sizes_ = std::move(sizes); +} +void DockLayout::save(const std::filesystem::path& path) const { + faset::atomic_write_json(path, to_json()); +} +void DockLayout::load(const std::filesystem::path& path) { + from_json(faset::read_json(path)); +} + +struct Context::Impl { + Widget root; + Theme theme; + FontAtlas font; + float width = 0, height = 0, scale = 1, mouse_x = 0, mouse_y = 0; + std::string focused, hovered, captured; + struct Edit { + TextBuffer buffer; + std::string original, composition; + double original_value = 0; + float scroll = 0; + int composition_start = 0, composition_length = 0; + }; + std::unordered_map edits; + float down_x = 0, down_y = 0; + double down_value = 0; + bool dragging = false; + Json payload; + float before_size = 0, after_size = 0; + Layout before_layout, after_layout; + std::function clipboard_read; + std::function clipboard_write; + std::function ime_enabled; + std::function ime_rectangle; + DockLayout* docking = nullptr; + std::function docking_changed; + explicit Impl(const std::filesystem::path& file) : font(file) { + root.kind = Kind::Column; + root.id = "root"; + root.layout.gap = 0; + } + Widget* find(std::string_view id) { + return root.find(id); + } + float intrinsic(Widget& w, bool horizontal) { + const auto fixed = horizontal ? w.layout.width : w.layout.height; + if (fixed >= 0) + return fixed * scale; + if (w.kind == Kind::Divider) + return 5 * scale; + if (horizontal) { + if (w.kind == Kind::Label || w.kind == Kind::Button || w.kind == Kind::Tab) + return font.measure(w.text, theme.font_size * scale) + theme.padding * 2 * scale; + return 80 * scale; + } + if (w.kind == Kind::Panel || w.kind == Kind::Column || w.kind == Kind::Row) { + float total = 0; + std::size_t count = 0; + for (auto& child : w.children) + if (child->visible && !child->layout.absolute) { + const auto value = intrinsic(*child, false); + if (w.kind == Kind::Row) + total = std::max(total, value); + else + total += value; + ++count; + } + if (w.kind != Kind::Row && count) + total += float(count - 1) * w.layout.gap * scale; + return total + w.layout.padding * 2 * scale; + } + return theme.row_height * scale; + } + void arrange(Widget& w, Rect rect, Rect clip) { + w.rect = rect; + w.clip = w.layout.clip ? clip.intersection(rect) : clip; + if (!w.visible) + return; + const auto pad = w.layout.padding * scale; + Rect inner{rect.x + pad, rect.y + pad, std::max(0.f, rect.width - 2 * pad), + std::max(0.f, rect.height - 2 * pad)}; + const bool horizontal = w.kind == Kind::Row; + const float available = horizontal ? inner.width : inner.height; + std::vector flow; + float fixed = 0, flex = 0; + for (auto& child : w.children) + if (child->visible && !child->layout.absolute) { + flow.push_back(child.get()); + if (child->layout.flex > 0) { + flex += child->layout.flex; + fixed += + (horizontal ? child->layout.min_width : child->layout.min_height) * scale; + } else + fixed += intrinsic(*child, horizontal); + } + if (!flow.empty()) + fixed += float(flow.size() - 1) * w.layout.gap * scale; + const auto extra = std::max(0.f, available - fixed); + std::vector extents; + float content = 0; + for (auto* child : flow) { + auto extent = + child->layout.flex > 0 + ? (horizontal ? child->layout.min_width : child->layout.min_height) * scale + + extra * child->layout.flex / std::max(.001f, flex) + : intrinsic(*child, horizontal); + extent = std::clamp( + extent, (horizontal ? child->layout.min_width : child->layout.min_height) * scale, + (horizontal ? child->layout.max_width : child->layout.max_height) * scale); + extents.push_back(extent); + content += extent; + } + if (!flow.empty()) + content += float(flow.size() - 1) * w.layout.gap * scale; + w.content_height = horizontal ? inner.height : content; + w.scroll_y = std::clamp(w.scroll_y, 0.f, std::max(0.f, w.content_height - inner.height)); + float position = horizontal ? inner.x : inner.y - (w.layout.scroll ? w.scroll_y : 0); + for (std::size_t i = 0; i < flow.size(); ++i) { + auto& child = *flow[i]; + float cross = + horizontal ? (child.layout.height >= 0 ? child.layout.height * scale : inner.height) + : (child.layout.width >= 0 ? child.layout.width * scale : inner.width); + cross = std::clamp( + cross, (horizontal ? child.layout.min_height : child.layout.min_width) * scale, + (horizontal ? child.layout.max_height : child.layout.max_width) * scale); + Rect target = horizontal ? Rect{position, inner.y, extents[i], cross} + : Rect{inner.x, position, cross, extents[i]}; + arrange(child, target, w.clip.intersection(inner)); + position += extents[i] + w.layout.gap * scale; + } + for (auto& child : w.children) + if (child->visible && child->layout.absolute) + arrange(*child, + {inner.x + child->layout.x * scale, inner.y + child->layout.y * scale, + child->layout.width >= 0 ? child->layout.width * scale : inner.width, + child->layout.height >= 0 ? child->layout.height * scale + : intrinsic(*child, false)}, + w.clip.intersection(inner)); + } + Widget* hit(Widget& w, float x, float y) { + if (!w.visible || !w.clip.contains(x, y)) + return nullptr; + for (auto child = w.children.rbegin(); child != w.children.rend(); ++child) + if (auto* target = hit(**child, x, y)) + return target; + return w.rect.contains(x, y) ? &w : nullptr; + } + bool commit() { + if (focused.empty()) + return true; + auto* widget = find(focused); + auto found = edits.find(focused); + if (!widget || !field(*widget) || found == edits.end()) + return true; + auto& edit = found->second; + if (!edit.composition.empty()) + return false; + const bool changed = edit.buffer.text() != edit.original; + if (widget->kind == Kind::NumberField) { + try { + std::size_t end = 0; + const auto value = std::stod(edit.buffer.text(), &end); + if (end != edit.buffer.text().size() || !std::isfinite(value)) + throw std::runtime_error("number"); + widget->value = value; + } catch (...) { + widget->error = "Enter a finite number"; + return false; + } + } + widget->error.clear(); + widget->text = edit.buffer.text(); + edit.original = edit.buffer.text(); + edit.original_value = widget->value; + const auto cursor = edit.buffer.cursor(); + edit.buffer.reset(edit.original); + edit.buffer.set_cursor(cursor); + auto callback = widget->on_commit; + if (changed && callback) + callback(*widget); + return true; + } + void unfocus(bool save) { + if (save && !commit()) + return; + focused.clear(); + if (ime_enabled) + ime_enabled(false); + } + bool focus(const std::string& id) { + auto* widget = find(id); + if (!widget || !focusable(*widget)) + return false; + if (focused == id) + return true; + if (!commit()) + return false; + focused = id; + if (field(*widget)) { + auto& edit = edits[id]; + const auto text = widget->kind == Kind::NumberField + ? number(widget->value, widget->precision) + : widget->text; + edit.buffer.reset(text); + edit.original = text; + edit.original_value = widget->value; + edit.composition.clear(); + edit.scroll = 0; + if (widget->kind == Kind::NumberField) + edit.buffer.select_all(); + if (ime_enabled) + ime_enabled(true); + if (ime_rectangle) + ime_rectangle(widget->rect); + } else if (ime_enabled) + ime_enabled(false); + return true; + } + void preview() { + auto* widget = find(focused); + auto found = edits.find(focused); + if (!widget || found == edits.end()) + return; + widget->text = found->second.buffer.text(); + widget->error.clear(); + auto callback = widget->on_preview; + if (callback) + callback(*widget); + } + std::size_t text_position(const Widget& w, const Edit& e, float x) { + const auto local = x - w.rect.x - theme.padding * scale + e.scroll; + float previous_width = 0; + std::size_t previous_byte = 0; + for (std::size_t i = 0; i < e.buffer.text().size();) { + ++i; + while (i < e.buffer.text().size() && + (static_cast(e.buffer.text()[i]) & 0xc0) == 0x80) + ++i; + const auto width = font.measure(std::string_view(e.buffer.text()).substr(0, i), + theme.font_size * scale); + if (local < (previous_width + width) * .5f) + return previous_byte; + previous_width = width; + previous_byte = i; + } + return e.buffer.text().size(); + } + std::pair neighbors(Widget& divider) { + if (!divider.parent) + return {}; + auto& list = divider.parent->children; + for (std::size_t i = 1; i + 1 < list.size(); ++i) + if (list[i].get() == ÷r) + return {list[i - 1].get(), list[i + 1].get()}; + return {}; + } + void cancel_capture() { + if (auto* w = find(captured); w && w->kind == Kind::NumberField && dragging) { + w->value = down_value; + auto& edit = edits[w->id]; + edit.buffer.reset(number(w->value, w->precision)); + w->text = edit.buffer.text(); + auto callback = w->on_preview; + if (callback) + callback(*w); + } + if (auto* w = find(captured); w && w->kind == Kind::Divider && dragging) { + auto [before, after] = neighbors(*w); + if (before && after) { + before->layout = before_layout; + after->layout = after_layout; + arrange(root, {0, 0, width, height}, {0, 0, width, height}); + } + } + if (auto* w = find(captured); w && dragging) { + auto callback = w->on_cancel; + if (callback) + callback(*w); + } + captured.clear(); + payload = nullptr; + dragging = false; + } + void draw_widget(Widget& w, render::Snapshot& frame) { + if (!w.visible || w.clip.width <= 0 || w.clip.height <= 0) + return; + const bool hover = w.id == hovered, focus = w.id == focused; + auto ink = w.enabled ? theme.text : theme.muted; + const auto& rect = w.rect; + if (w.kind == Kind::Panel) { + fill(frame, rect, theme.surface, w.clip); + outline(frame, rect, theme.border, w.clip); + } else if (w.kind == Kind::Button) { + fill(frame, rect, hover && w.enabled ? theme.hover : theme.raised, w.clip); + outline(frame, rect, focus ? theme.accent : theme.border, w.clip); + } else if (w.kind == Kind::Tab || w.kind == Kind::TreeRow) { + if (w.selected || hover) + fill(frame, rect, w.selected ? theme.selection : theme.hover, w.clip); + if (w.selected) { + if (w.kind == Kind::Tab) + fill(frame, {rect.x, rect.y + rect.height - 2 * scale, rect.width, 2 * scale}, + theme.accent, w.clip); + else + fill(frame, {rect.x, rect.y, 2 * scale, rect.height}, theme.accent, w.clip); + } + if (focus) + outline(frame, rect, theme.accent, w.clip); + } else if (field(w)) { + fill(frame, rect, theme.background, w.clip); + outline(frame, rect, + !w.error.empty() ? theme.danger + : focus ? theme.accent + : theme.border, + w.clip); + } else if (w.kind == Kind::Divider) { + fill(frame, rect, hover || w.id == captured ? theme.accent : theme.background, w.clip); + } + float tx = rect.x + theme.padding * scale + w.indent * 14 * scale; + const auto font_size = theme.font_size * scale; + const auto ty = rect.y + std::max(0.f, (rect.height - font_size * 1.45f) * .5f); + if (w.kind == Kind::Checkbox) { + Rect box{tx, rect.y + (rect.height - 14 * scale) / 2, 14 * scale, 14 * scale}; + fill(frame, box, theme.background, w.clip); + outline(frame, box, focus ? theme.accent : theme.border, w.clip); + if (w.checked) + fill(frame, {box.x + 3 * scale, box.y + 3 * scale, 8 * scale, 8 * scale}, + theme.accent, w.clip); + tx += 23 * scale; + } + if (field(w) && focus && edits.contains(w.id)) { + auto& edit = edits[w.id]; + const auto available = std::max(1.f, rect.width - theme.padding * 2 * scale); + const auto selection_begin = std::min(edit.buffer.cursor(), edit.buffer.anchor()); + const auto selection_end = std::max(edit.buffer.cursor(), edit.buffer.anchor()); + auto composition_byte = [&](int codepoints) { + std::size_t byte = 0; + for (int i = 0; i < std::max(0, codepoints) && byte < edit.composition.size(); + ++i) { + ++byte; + while (byte < edit.composition.size() && + (static_cast(edit.composition[byte]) & 0xc0) == 0x80) + ++byte; + } + return byte; + }; + const auto prefix = edit.buffer.text().substr(0, selection_begin); + const auto composition_left = font.measure(prefix, font_size); + const auto composition_cursor = composition_byte(edit.composition_start); + const auto caret = + edit.composition.empty() + ? font.measure( + std::string_view(edit.buffer.text()).substr(0, edit.buffer.cursor()), + font_size) + : composition_left + + font.measure( + std::string_view(edit.composition).substr(0, composition_cursor), + font_size); + if (caret - edit.scroll > available - 2 * scale) + edit.scroll = caret - available + 2 * scale; + if (caret < edit.scroll) + edit.scroll = caret; + edit.scroll = std::max(0.f, edit.scroll); + const Rect text_clip = + w.clip.intersection({rect.x + 3 * scale, rect.y + 2 * scale, rect.width - 6 * scale, + rect.height - 4 * scale}); + tx -= edit.scroll; + if (edit.buffer.has_selection() && edit.composition.empty()) { + const auto begin = std::min(edit.buffer.cursor(), edit.buffer.anchor()), + end = std::max(edit.buffer.cursor(), edit.buffer.anchor()); + const auto left = font.measure( + std::string_view(edit.buffer.text()).substr(0, begin), font_size), + right = font.measure(std::string_view(edit.buffer.text()).substr(0, end), + font_size); + fill(frame, {tx + left, rect.y + 4 * scale, right - left, rect.height - 8 * scale}, + theme.selection, text_clip); + } + if (edit.composition.empty()) { + font.draw(frame, edit.buffer.text(), tx, ty, font_size, ink, text_clip); + } else { + const auto composition_width = font.measure(edit.composition, font_size); + const auto selection_width = font.measure( + std::string_view(edit.composition) + .substr(composition_cursor, + composition_byte(edit.composition_start + + std::max(0, edit.composition_length)) - + composition_cursor), + font_size); + if (selection_width > 0) + fill(frame, + {tx + caret, rect.y + 4 * scale, selection_width, rect.height - 8 * scale}, + theme.selection, text_clip); + font.draw(frame, prefix, tx, ty, font_size, ink, text_clip); + font.draw(frame, edit.composition, tx + composition_left, ty, font_size, + theme.accent, text_clip); + font.draw(frame, std::string_view(edit.buffer.text()).substr(selection_end), + tx + composition_left + composition_width, ty, font_size, ink, text_clip); + fill(frame, + {tx + composition_left, rect.y + rect.height - 4 * scale, composition_width, + scale}, + theme.accent, text_clip); + } + fill(frame, {tx + caret, rect.y + 5 * scale, scale, rect.height - 10 * scale}, + theme.accent, text_clip); + } else if (w.kind != Kind::Divider && w.kind != Kind::Viewport && !w.text.empty()) + font.draw(frame, w.kind == Kind::NumberField ? number(w.value, w.precision) : w.text, + tx, ty, font_size, ink, + w.clip.intersection( + {rect.x + 2 * scale, rect.y, rect.width - 4 * scale, rect.height})); + else if (w.kind == Kind::NumberField) + font.draw(frame, number(w.value, w.precision), tx, ty, font_size, ink, w.clip); + for (auto& child : w.children) + draw_widget(*child, frame); + if (w.layout.scroll && w.content_height > rect.height) { + const float track = rect.height - 8 * scale, + thumb = std::max(16 * scale, track * rect.height / w.content_height), + offset = (track - thumb) * w.scroll_y / + std::max(1.f, w.content_height - rect.height); + fill(frame, + {rect.x + rect.width - 5 * scale, rect.y + 4 * scale + offset, 3 * scale, thumb}, + theme.border, w.clip); + } + } +}; + +Context::Context(const std::filesystem::path& font) : impl_(std::make_unique(font)) {} +Context::~Context() = default; +Widget& Context::root() { + return impl_->root; +} +Widget* Context::find(std::string_view id) { + return impl_->find(id); +} +void Context::set_theme(Theme theme) { + impl_->theme = theme; +} +const Theme& Context::theme() const { + return impl_->theme; +} +FontAtlas& Context::font() { + return impl_->font; +} +void Context::apply_layout(const Json& document) { + const auto& definition = document.contains("root") ? document.at("root") : document; + std::set ids; + std::function validate = [&](const Json& j) { + const auto id = j.at("id").get(); + if (id.empty() || !ids.insert(id).second) + throw std::runtime_error("Duplicate layout widget ID"); + if (j.contains("kind")) { + const auto type = kind_from_string(j.at("kind")); + if (auto* existing = find(id); existing && existing->kind != type) + throw std::runtime_error("Hot layout cannot replace widget kind"); + } + if (j.contains("layout")) + parse_layout(j.at("layout")); + if (j.contains("children")) + for (const auto& child : j.at("children")) + validate(child); + }; + validate(definition); + std::function apply = [&](Widget& w, const Json& j) { + if (j.contains("text")) + update_text(w.id, j.at("text")); + if (j.contains("layout")) + w.layout = parse_layout(j.at("layout"), w.layout); + if (j.contains("children")) + for (const auto& child : j.at("children")) { + const auto id = child.at("id").get(); + auto& target = + w.add(child.contains("kind") ? kind_from_string(child.at("kind")) + : (w.find(id) ? w.find(id)->kind : Kind::Panel), + id); + apply(target, child); + } + }; + if (definition.at("id") != root().id) + throw std::runtime_error("Layout root ID must match retained root"); + apply(root(), definition); +} +void Context::layout(float width, float height, float scale) { + impl_->width = std::max(0.f, width); + impl_->height = std::max(0.f, height); + impl_->scale = std::clamp(scale, .5f, 4.f); + std::set ids; + std::function check = [&](Widget& w) { + if (w.id.empty() || !ids.insert(w.id).second) + throw std::runtime_error("Retained widget IDs must be unique"); + for (auto& child : w.children) { + child->parent = &w; + check(*child); + } + }; + check(root()); + impl_->arrange(root(), {0, 0, impl_->width, impl_->height}, + {0, 0, impl_->width, impl_->height}); + std::erase_if(impl_->edits, [&](const auto& entry) { return !ids.contains(entry.first); }); + auto* focused = impl_->find(impl_->focused); + if (!focused || !focusable(*focused)) + impl_->unfocus(false); + else if (field(*focused) && impl_->ime_rectangle) + impl_->ime_rectangle(focused->rect); +} +void Context::draw(render::Snapshot& snapshot) { + impl_->draw_widget(root(), snapshot); + if (!impl_->payload.is_null()) { + const Rect viewport{0, 0, impl_->width, impl_->height}; + const auto text = + impl_->payload.value("label", impl_->payload.value("panel", std::string("Move"))); + const auto width = + impl_->font.measure(text, impl_->theme.font_size * impl_->scale) + 20 * impl_->scale; + const Rect rect{impl_->mouse_x + 12 * impl_->scale, impl_->mouse_y + 12 * impl_->scale, + width, 28 * impl_->scale}; + fill(snapshot, rect, impl_->theme.raised, viewport); + outline(snapshot, rect, impl_->theme.accent, viewport); + impl_->font.draw(snapshot, text, rect.x + 8 * impl_->scale, rect.y + 3 * impl_->scale, + impl_->theme.font_size * impl_->scale, impl_->theme.text, viewport); + } +} +bool Context::update_text(const std::string& id, const std::string& value, bool force) { + auto* w = find(id); + if (!w) + return false; + if (!TextBuffer::valid_utf8(value)) + return false; + if (impl_->focused == id && impl_->edits.contains(id)) { + auto& edit = impl_->edits[id]; + if (!force && (edit.buffer.text() != edit.original || !edit.composition.empty())) + return false; + const auto cursor = edit.buffer.cursor(); + edit.buffer.reset(value); + edit.buffer.set_cursor(cursor); + edit.original = value; + edit.composition.clear(); + } + w->text = value; + return true; +} +bool Context::update_number(const std::string& id, double value, bool force) { + auto* widget = find(id); + if (!widget || widget->kind != Kind::NumberField || !std::isfinite(value)) + return false; + if (!update_text(id, number(value, widget->precision), force)) + return false; + widget->value = value; + if (impl_->focused == id && impl_->edits.contains(id)) + impl_->edits[id].original_value = value; + return true; +} +bool Context::focus(const std::string& id) { + return impl_->focus(id); +} +const std::string& Context::focused_id() const { + return impl_->focused; +} +void Context::clear_focus(bool commit) { + impl_->unfocus(commit); +} +bool Context::editing() const { + const auto* w = impl_->find(impl_->focused); + return w && field(*w); +} +void Context::set_clipboard(std::function read, + std::function write) { + impl_->clipboard_read = std::move(read); + impl_->clipboard_write = std::move(write); +} +void Context::set_ime(std::function enabled, std::function rectangle) { + impl_->ime_enabled = std::move(enabled); + impl_->ime_rectangle = std::move(rectangle); +} +void Context::set_docking(DockLayout* docking, std::function changed) { + impl_->docking = docking; + impl_->docking_changed = std::move(changed); +} + +bool Context::handle(const render::Event& event) { + auto& p = *impl_; + using Type = render::Event::Type; + if (event.type == Type::FocusLost) { + p.cancel_capture(); + if (auto found = p.edits.find(p.focused); found != p.edits.end()) + found->second.composition.clear(); + p.unfocus(true); + return false; + } + if (event.type == Type::MouseMove || event.type == Type::MouseDown || + event.type == Type::MouseUp) { + p.mouse_x = event.x; + p.mouse_y = event.y; + auto* hit = p.hit(p.root, event.x, event.y); + p.hovered = hit ? hit->id : ""; + } + if (event.type == Type::Wheel) { + auto* w = p.hit(p.root, p.mouse_x, p.mouse_y); + for (; w; w = w->parent) + if (w->layout.scroll) { + w->scroll_y = + std::clamp(w->scroll_y - event.y * p.theme.row_height * 3 * p.scale, 0.f, + std::max(0.f, w->content_height - w->rect.height + + 2 * w->layout.padding * p.scale)); + layout(p.width, p.height, p.scale); + return true; + } + return false; + } + if (event.type == Type::MouseDown && event.button == 1) { + auto* w = p.hit(p.root, event.x, event.y); + if (!w || w->kind == Kind::Viewport) { + p.unfocus(true); + return false; + } + if (!w->enabled) + return true; + if (focusable(*w)) { + if (!p.focus(w->id)) + return true; + } else { + if (!p.commit()) + return true; + p.unfocus(false); + } + p.captured = w->id; + p.down_x = event.x; + p.down_y = event.y; + p.down_value = w->value; + p.dragging = false; + p.payload = nullptr; + if (w->kind == Kind::TextField) { + auto& edit = p.edits[w->id]; + edit.buffer.set_cursor(p.text_position(*w, edit, event.x), event.shift); + } + if (w->kind == Kind::Divider) { + auto [before, after] = p.neighbors(*w); + if (before && after) { + const bool horizontal = w->parent->kind == Kind::Row; + p.before_size = (horizontal ? before->rect.width : before->rect.height) / p.scale; + p.after_size = (horizontal ? after->rect.width : after->rect.height) / p.scale; + p.before_layout = before->layout; + p.after_layout = after->layout; + } + } + return true; + } + if (event.type == Type::MouseMove && !p.captured.empty()) { + auto* w = p.find(p.captured); + if (!w) { + p.cancel_capture(); + return false; + } + const auto distance = std::hypot(event.x - p.down_x, event.y - p.down_y); + if (w->kind == Kind::NumberField && distance > 3 * p.scale) { + p.dragging = true; + w->value = + p.down_value + (event.x - p.down_x) / p.scale * w->step * (event.shift ? .1 : 1.0); + auto& edit = p.edits[w->id]; + edit.buffer.reset(number(w->value, w->precision)); + w->text = edit.buffer.text(); + auto callback = w->on_preview; + if (callback) + callback(*w); + return true; + } + if (w->kind == Kind::TextField) { + auto& edit = p.edits[w->id]; + edit.buffer.set_cursor(p.text_position(*w, edit, event.x), true); + return true; + } + if (w->kind == Kind::Divider) { + auto [before, after] = p.neighbors(*w); + if (before && after) { + const bool horizontal = w->parent->kind == Kind::Row; + const auto delta = (horizontal ? event.x - p.down_x : event.y - p.down_y) / p.scale; + const auto minimum_before = std::max(24.f, horizontal ? before->layout.min_width + : before->layout.min_height), + minimum_after = std::max(24.f, horizontal ? after->layout.min_width + : after->layout.min_height); + const auto low = minimum_before - p.before_size, + high = p.after_size - minimum_after; + const auto amount = low <= high ? std::clamp(delta, low, high) : 0.f; + if (before->layout.flex <= 0) { + if (horizontal) + before->layout.width = p.before_size + amount; + else + before->layout.height = p.before_size + amount; + } + if (after->layout.flex <= 0) { + if (horizontal) + after->layout.width = p.after_size - amount; + else + after->layout.height = p.after_size - amount; + } + if (before->layout.flex > 0 && after->layout.flex > 0) { + before->layout.flex = p.before_size + amount; + after->layout.flex = p.after_size - amount; + } + p.dragging = std::abs(amount) > .01f; + w->value = p.before_size + amount; + auto callback = w->on_preview; + if (callback) + callback(*w); + layout(p.width, p.height, p.scale); + } + return true; + } + if (distance > 5 * p.scale) { + if (!w->dock_panel.empty()) { + p.payload = {{"kind", "dock_panel"}, {"panel", w->dock_panel}, {"label", w->text}}; + p.dragging = true; + } else if (!w->drag_payload.is_null()) { + p.payload = w->drag_payload; + p.dragging = true; + } + } + return true; + } + if (event.type == Type::MouseUp && event.button == 1 && !p.captured.empty()) { + const auto id = p.captured; + auto* w = p.find(id); + const auto dragging = p.dragging; + auto payload = p.payload; + p.captured.clear(); + p.payload = nullptr; + p.dragging = false; + if (!w) + return true; + if (!payload.is_null()) { + auto* target = p.hit(p.root, event.x, event.y); + for (; target; target = target->parent) { + if (p.docking && payload.value("kind", std::string()) == "dock_panel" && + !target->dock_area.empty()) { + auto order = p.docking->panels(target->dock_area); + auto found = std::find(order.begin(), order.end(), target->dock_panel); + p.docking->move(payload.at("panel"), target->dock_area, + static_cast(found - order.begin())); + if (p.docking_changed) + p.docking_changed(); + break; + } + if (target->on_drop) { + auto callback = target->on_drop; + callback(*target, payload); + break; + } + } + return true; + } + if (w->kind == Kind::NumberField && dragging) { + p.commit(); + return true; + } + if (w->kind == Kind::Divider && dragging) { + auto callback = w->on_commit; + if (callback) + callback(*w); + return true; + } + if (!w->rect.contains(event.x, event.y)) + return true; + if (w->kind == Kind::Checkbox) { + w->checked = !w->checked; + auto callback = w->on_commit; + if (callback) + callback(*w); + w = p.find(id); + if (!w) + return true; + } + if (w->kind == Kind::Button || w->kind == Kind::Tab || w->kind == Kind::TreeRow || + w->kind == Kind::Checkbox) { + auto callback = w->on_click; + if (callback) + callback(*w); + } + return true; + } + if (event.type == Type::KeyDown && event.key == "Escape") { + if (!p.captured.empty()) { + p.cancel_capture(); + return true; + } + if (auto* w = p.find(p.focused); w && field(*w)) { + auto& edit = p.edits[w->id]; + w->text = edit.original; + w->value = edit.original_value; + w->error.clear(); + edit.buffer.reset(edit.original); + edit.composition.clear(); + auto callback = w->on_cancel; + if (callback) + callback(*w); + p.unfocus(false); + return true; + } + return false; + } + if (event.type == Type::KeyDown && event.key == "Tab") { + std::vector ids; + collect_focus(p.root, ids); + if (ids.empty()) + return false; + auto current = std::find(ids.begin(), ids.end(), p.focused); + std::size_t index = + current == ids.end() ? 0 : static_cast(current - ids.begin()); + if (current != ids.end()) + index = event.shift ? (index + ids.size() - 1) % ids.size() : (index + 1) % ids.size(); + return p.focus(ids[index]); + } + auto* w = p.find(p.focused); + if (!w) + return false; + if (field(*w)) { + auto& edit = p.edits[w->id]; + if (event.type == Type::TextEditing) { + if (TextBuffer::valid_utf8(event.text)) { + edit.composition = event.text; + edit.composition_start = event.edit_start; + edit.composition_length = event.edit_length; + } + return true; + } + if (event.type == Type::TextInput) { + auto text = event.text; + std::replace(text.begin(), text.end(), '\n', ' '); + std::replace(text.begin(), text.end(), '\r', ' '); + edit.composition.clear(); + if (edit.buffer.insert(text)) + p.preview(); + return true; + } + if (event.type != Type::KeyDown) + return false; + if (event.control && (event.key == "A" || event.key == "a")) { + edit.buffer.select_all(); + return true; + } + if (event.control && + (event.key == "C" || event.key == "c" || event.key == "X" || event.key == "x")) { + if (p.clipboard_write && edit.buffer.has_selection()) + p.clipboard_write(edit.buffer.selected_text()); + if ((event.key == "X" || event.key == "x") && edit.buffer.has_selection()) { + edit.buffer.insert(""); + p.preview(); + } + return true; + } + if (event.control && (event.key == "V" || event.key == "v")) { + if (p.clipboard_read) { + auto text = p.clipboard_read(); + std::replace(text.begin(), text.end(), '\n', ' '); + std::replace(text.begin(), text.end(), '\r', ' '); + if (edit.buffer.insert(text)) + p.preview(); + } + return true; + } + if (event.control && (event.key == "Z" || event.key == "z")) { + const auto changed = event.shift ? edit.buffer.redo() : edit.buffer.undo(); + if (changed) + p.preview(); + return changed; + } + if (event.control && (event.key == "Y" || event.key == "y")) { + const auto changed = edit.buffer.redo(); + if (changed) + p.preview(); + return changed; + } + if (event.key == "Left") { + edit.buffer.left(event.shift, event.control); + return true; + } + if (event.key == "Right") { + edit.buffer.right(event.shift, event.control); + return true; + } + if (event.key == "Home") { + edit.buffer.home(event.shift); + return true; + } + if (event.key == "End") { + edit.buffer.end(event.shift); + return true; + } + if (event.key == "Backspace") { + if (edit.buffer.backspace()) + p.preview(); + return true; + } + if (event.key == "Delete") { + if (edit.buffer.delete_forward()) + p.preview(); + return true; + } + if (event.key == "Return" || event.key == "Enter") { + if (p.commit()) { + auto* current = p.find(p.focused); + if (current && current->kind == Kind::NumberField) + p.edits[current->id].buffer.select_all(); + } + return true; + } + return false; + } + if (event.type == Type::KeyDown && (event.key == "Return" || event.key == "Space")) { + const auto id = w->id; + if (w->kind == Kind::Checkbox) { + w->checked = !w->checked; + auto callback = w->on_commit; + if (callback) + callback(*w); + w = p.find(id); + } + if (w) { + auto callback = w->on_click; + if (callback) + callback(*w); + } + return true; + } + return false; +} +} // namespace faset::ui diff --git a/tests/assets_pipeline.cpp b/tests/assets_pipeline.cpp index b66f866..7f122aa 100644 --- a/tests/assets_pipeline.cpp +++ b/tests/assets_pipeline.cpp @@ -1,73 +1,277 @@ -#include -#include #include #include +#include +#include #include #include #include using namespace faset::assets; -namespace fs=std::filesystem; +namespace fs = std::filesystem; namespace { -void require(bool value,const std::string& message){if(!value)throw std::runtime_error(message);} -void u32(std::vector& data,std::uint32_t value){for(int i=0;i<4;++i)data.push_back(static_cast(value>>(8*i)));} -void save(const fs::path& file,const std::vector& data){std::ofstream out(file,std::ios::binary);out.write(reinterpret_cast(data.data()),static_cast(data.size()));} -void save(const fs::path& file,const std::string& text){std::ofstream(file,std::ios::binary)< geometry(float x){std::vector bin;for(float f:{x,0.f,0.f,1.f,0.f,0.f,0.f,1.f,0.f})u32(bin,std::bit_cast(f));for(unsigned char c:{0,0,1,0,2,0})bin.push_back(c);return bin;} -Json document(const std::string& name,bool stable,bool second,float x=0) { - Json node{{"name",name},{"mesh",0}};if(stable)node["extras"]={{"faset_id","node-door"}}; - Json j={{"asset",{{"version","2.0"}}},{"scene",0},{"scenes",Json::array({{{"nodes",Json::array({0})}}})},{"nodes",Json::array({node})},{"buffers",Json::array({{{"byteLength",42}}})},{"bufferViews",Json::array({{{"buffer",0},{"byteOffset",0},{"byteLength",36},{"target",34962}},{{"buffer",0},{"byteOffset",36},{"byteLength",6},{"target",34963}}})},{"accessors",Json::array({{{"bufferView",0},{"componentType",5126},{"count",3},{"type","VEC3"},{"min",{std::min(0.f,x),0,0}},{"max",{std::max(1.f,x),1,0}}},{{"bufferView",1},{"componentType",5123},{"count",3},{"type","SCALAR"}}})},{"meshes",Json::array({{{"name","Triangle"},{"extras",{{"faset_id","mesh-triangle"}}},{"primitives",Json::array({{{"attributes",{{"POSITION",0}}},{"indices",1},{"material",0}}})}}})},{"materials",Json::array({{{"name","Red"},{"pbrMetallicRoughness",{{"baseColorFactor",{1.0,0.2,0.1,1.0}},{"metallicFactor",0.2},{"roughnessFactor",0.6}}}}})}}; - if(second){j["nodes"].push_back({{"name","Handle"},{"mesh",0},{"extras",{{"faset_id","node-handle"}}}});j["scenes"][0]["nodes"].push_back(1);} +void require(bool value, const std::string& message) { + if (!value) + throw std::runtime_error(message); +} +void u32(std::vector& data, std::uint32_t value) { + for (int i = 0; i < 4; ++i) + data.push_back(static_cast(value >> (8 * i))); +} +void save(const fs::path& file, const std::vector& data) { + std::ofstream out(file, std::ios::binary); + out.write(reinterpret_cast(data.data()), + static_cast(data.size())); +} +void save(const fs::path& file, const std::string& text) { + std::ofstream(file, std::ios::binary) << text; +} +std::vector geometry(float x) { + std::vector bin; + for (float f : {x, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f, 0.f}) + u32(bin, std::bit_cast(f)); + for (unsigned char c : {0, 0, 1, 0, 2, 0}) + bin.push_back(c); + return bin; +} +Json document(const std::string& name, bool stable, bool second, float x = 0) { + Json node{{"name", name}, {"mesh", 0}}; + if (stable) + node["extras"] = {{"faset_id", "node-door"}}; + Json j = { + {"asset", {{"version", "2.0"}}}, + {"scene", 0}, + {"scenes", Json::array({{{"nodes", Json::array({0})}}})}, + {"nodes", Json::array({node})}, + {"buffers", Json::array({{{"byteLength", 42}}})}, + {"bufferViews", + Json::array({{{"buffer", 0}, {"byteOffset", 0}, {"byteLength", 36}, {"target", 34962}}, + {{"buffer", 0}, {"byteOffset", 36}, {"byteLength", 6}, {"target", 34963}}})}, + {"accessors", + Json::array( + {{{"bufferView", 0}, + {"componentType", 5126}, + {"count", 3}, + {"type", "VEC3"}, + {"min", {std::min(0.f, x), 0, 0}}, + {"max", {std::max(1.f, x), 1, 0}}}, + {{"bufferView", 1}, {"componentType", 5123}, {"count", 3}, {"type", "SCALAR"}}})}, + {"meshes", Json::array({{{"name", "Triangle"}, + {"extras", {{"faset_id", "mesh-triangle"}}}, + {"primitives", Json::array({{{"attributes", {{"POSITION", 0}}}, + {"indices", 1}, + {"material", 0}}})}}})}, + {"materials", Json::array({{{"name", "Red"}, + {"pbrMetallicRoughness", + {{"baseColorFactor", {1.0, 0.2, 0.1, 1.0}}, + {"metallicFactor", 0.2}, + {"roughnessFactor", 0.6}}}}})}}; + if (second) { + j["nodes"].push_back( + {{"name", "Handle"}, {"mesh", 0}, {"extras", {{"faset_id", "node-handle"}}}}); + j["scenes"][0]["nodes"].push_back(1); + } return j; } -void glb(const fs::path& path,const std::string& name="Door",bool stable=true,bool second=false,float x=0) { - auto json=document(name,stable,second,x).dump();while(json.size()%4)json+=' '; - auto binary=geometry(x);while(binary.size()%4)binary.push_back(0); - std::vector result;u32(result,0x46546c67);u32(result,2);u32(result,static_cast(12+8+json.size()+8+binary.size()));u32(result,static_cast(json.size()));u32(result,0x4e4f534a);result.insert(result.end(),json.begin(),json.end());u32(result,static_cast(binary.size()));u32(result,0x004e4942);result.insert(result.end(),binary.begin(),binary.end());save(path,result); +void glb(const fs::path& path, const std::string& name = "Door", bool stable = true, + bool second = false, float x = 0) { + auto json = document(name, stable, second, x).dump(); + while (json.size() % 4) + json += ' '; + auto binary = geometry(x); + while (binary.size() % 4) + binary.push_back(0); + std::vector result; + u32(result, 0x46546c67); + u32(result, 2); + u32(result, static_cast(12 + 8 + json.size() + 8 + binary.size())); + u32(result, static_cast(json.size())); + u32(result, 0x4e4f534a); + result.insert(result.end(), json.begin(), json.end()); + u32(result, static_cast(binary.size())); + u32(result, 0x004e4942); + result.insert(result.end(), binary.begin(), binary.end()); + save(path, result); } -void success(const ImportResult& result){if(!result.ok()){std::string text="Import failed: ";for(const auto& d:result.diagnostics)text+=d+"; ";throw std::runtime_error(text);}} +void success(const ImportResult& result) { + if (!result.ok()) { + std::string text = "Import failed: "; + for (const auto& d : result.diagnostics) + text += d + "; "; + throw std::runtime_error(text); + } } +} // namespace int main() { - const auto root=fs::temp_directory_path()/("faset-assets-test-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + const auto root = fs::temp_directory_path() / + ("faset-assets-test-" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); fs::create_directories(root); try { - AssetPipeline pipeline(root/"cache");const auto source=root/"door.glb";glb(source); - auto first=pipeline.import_asset({source});success(first);require(!first.asset_id.empty(),"persistent identity missing"); - auto asset=pipeline.load_asset(first.asset_id);require(asset.meshes.size()==1&&asset.nodes.size()==1,"mesh/node extraction");require(asset.meshes[0].primitives[0].indices==std::vector({0,1,2}),"index extraction");require(asset.meshes[0].primitives[0].vertices[0].normal[2]==1,"generated normal");require(asset.materials[0].base_color[1]>.19f&&asset.materials[0].metallic==.2f,"PBR extraction"); - const auto node_id=asset.nodes[0].id; - const Json custom{{node_id,{{"gameplay",{{"locked",true}}},{"physics",{{"mass",12}}},{"material","custom-brass"}}}}; - pipeline.set_overrides(first.asset_id,custom); - auto unchanged=pipeline.import_asset({source});success(unchanged);require(unchanged.cache_hit&&unchanged.generation==first.generation,"content cache hit"); - glb(source,"Renamed panel",true,false,.25f);auto modified=pipeline.import_asset({source});success(modified);require(modified.asset_id==first.asset_id&&modified.generation!=first.generation,"stable asset identity and changed generation"); - asset=pipeline.load_asset(first.asset_id);require(asset.nodes[0].id==node_id&&asset.nodes[0].name=="Renamed panel","faset_id survives rename");require(asset.meshes[0].primitives[0].vertices[0].position[0]==.25f,"new binary geometry loaded");require(pipeline.overrides(first.asset_id)==custom,"reimport erased authoring overrides"); - glb(source,"Renamed panel",true,true,.25f);auto added=pipeline.import_asset({source});success(added); - glb(source,"Renamed panel",true,false,.25f);auto removed=pipeline.import_asset({source});require(removed.status==ImportStatus::conflict&&!removed.removed_output_ids.empty(),"deletion must conflict");require(pipeline.load_asset(first.asset_id).generation==added.generation,"conflict replaced active generation");require(pipeline.overrides(first.asset_id)==custom,"conflict erased overrides"); - ImportRequest resolve{source};resolve.allow_removed_outputs=true;auto resolved=pipeline.import_asset(resolve);success(resolved);require(pipeline.load_asset(first.asset_id).nodes.size()==1,"explicit deletion resolution"); - const auto active=resolved.generation; - save(source,std::string("not a GLB"));auto failed=pipeline.import_asset({source});require(failed.status==ImportStatus::failed,"invalid source accepted");require(pipeline.load_asset(first.asset_id).generation==active,"failed import replaced active"); - glb(source,"Renamed panel",true,false,.5f);ImportJob* job_ptr=nullptr;ImportJob job([&](const ImportProgress& p){if(p.fraction>=.9f)job_ptr->cancel();});job_ptr=&job; - auto cancelled=pipeline.import_asset({source},job);require(cancelled.status==ImportStatus::cancelled,"cancel before commit failed");require(pipeline.load_asset(first.asset_id).generation==active,"cancel replaced active"); - ImportRequest changed_settings{source};changed_settings.settings={{"target","test-profile"}};auto settings=pipeline.import_asset(changed_settings);success(settings);require(settings.generation!=active,"recipe omitted from cache key"); - const auto plain=root/"ordinary.glb";glb(plain,"Ordinary",false);auto standard=pipeline.import_asset({plain});success(standard);require(!pipeline.load_asset(standard.asset_id).nodes[0].stable_source_id,"ordinary GLB wrongly marked stable source"); - glb(plain,"Renamed without ID",false);require(pipeline.import_asset({plain}).status==ImportStatus::conflict,"ambiguous rename silently matched"); - auto duplicate=document("Duplicate",true,true);duplicate["nodes"][1]["extras"]["faset_id"]="node-door";duplicate["buffers"][0]["uri"]="mesh.bin";save(root/"mesh.bin",geometry(0));save(root/"duplicate.gltf",duplicate.dump());require(pipeline.import_asset({root/"duplicate.gltf"}).status==ImportStatus::failed,"duplicate source ID accepted"); - auto external=document("External",true,false);external["buffers"][0]["uri"]="mesh.bin";external["images"]=Json::array({{{"uri","pixel.png"},{"mimeType","image/png"}}});external["textures"]=Json::array({{{"source",0}}});external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"]={{"index",0}}; + AssetPipeline pipeline(root / "cache"); + const auto source = root / "door.glb"; + glb(source); + auto first = pipeline.import_asset({source}); + success(first); + require(!first.asset_id.empty(), "persistent identity missing"); + auto asset = pipeline.load_asset(first.asset_id); + require(asset.meshes.size() == 1 && asset.nodes.size() == 1, "mesh/node extraction"); + require(asset.meshes[0].primitives[0].indices == std::vector({0, 1, 2}), + "index extraction"); + require(asset.meshes[0].primitives[0].vertices[0].normal[2] == 1, "generated normal"); + require(asset.materials[0].base_color[1] > .19f && asset.materials[0].metallic == .2f, + "PBR extraction"); + const auto node_id = asset.nodes[0].id; + const Json custom{{node_id, + {{"gameplay", {{"locked", true}}}, + {"physics", {{"mass", 12}}}, + {"material", "custom-brass"}}}}; + pipeline.set_overrides(first.asset_id, custom); + auto unchanged = pipeline.import_asset({source}); + success(unchanged); + require(unchanged.cache_hit && unchanged.generation == first.generation, + "content cache hit"); + glb(source, "Renamed panel", true, false, .25f); + auto modified = pipeline.import_asset({source}); + success(modified); + require(modified.asset_id == first.asset_id && modified.generation != first.generation, + "stable asset identity and changed generation"); + asset = pipeline.load_asset(first.asset_id); + require(asset.nodes[0].id == node_id && asset.nodes[0].name == "Renamed panel", + "faset_id survives rename"); + require(asset.meshes[0].primitives[0].vertices[0].position[0] == .25f, + "new binary geometry loaded"); + require(pipeline.overrides(first.asset_id) == custom, + "reimport erased authoring overrides"); + glb(source, "Renamed panel", true, true, .25f); + auto added = pipeline.import_asset({source}); + success(added); + glb(source, "Renamed panel", true, false, .25f); + auto removed = pipeline.import_asset({source}); + require(removed.status == ImportStatus::conflict && !removed.removed_output_ids.empty(), + "deletion must conflict"); + require(pipeline.load_asset(first.asset_id).generation == added.generation, + "conflict replaced active generation"); + require(pipeline.overrides(first.asset_id) == custom, "conflict erased overrides"); + ImportRequest resolve{source}; + resolve.allow_removed_outputs = true; + auto resolved = pipeline.import_asset(resolve); + success(resolved); + require(pipeline.load_asset(first.asset_id).nodes.size() == 1, + "explicit deletion resolution"); + const auto active = resolved.generation; + save(source, std::string("not a GLB")); + auto failed = pipeline.import_asset({source}); + require(failed.status == ImportStatus::failed, "invalid source accepted"); + require(pipeline.load_asset(first.asset_id).generation == active, + "failed import replaced active"); + glb(source, "Renamed panel", true, false, .5f); + ImportJob* job_ptr = nullptr; + ImportJob job([&](const ImportProgress& p) { + if (p.fraction >= .9f) + job_ptr->cancel(); + }); + job_ptr = &job; + auto cancelled = pipeline.import_asset({source}, job); + require(cancelled.status == ImportStatus::cancelled, "cancel before commit failed"); + require(pipeline.load_asset(first.asset_id).generation == active, "cancel replaced active"); + ImportRequest changed_settings{source}; + changed_settings.settings = {{"target", "test-profile"}}; + auto settings = pipeline.import_asset(changed_settings); + success(settings); + require(settings.generation != active, "recipe omitted from cache key"); + const auto plain = root / "ordinary.glb"; + glb(plain, "Ordinary", false); + auto standard = pipeline.import_asset({plain}); + success(standard); + require(!pipeline.load_asset(standard.asset_id).nodes[0].stable_source_id, + "ordinary GLB wrongly marked stable source"); + glb(plain, "Renamed without ID", false); + require(pipeline.import_asset({plain}).status == ImportStatus::conflict, + "ambiguous rename silently matched"); + auto duplicate = document("Duplicate", true, true); + duplicate["nodes"][1]["extras"]["faset_id"] = "node-door"; + duplicate["buffers"][0]["uri"] = "mesh.bin"; + save(root / "mesh.bin", geometry(0)); + save(root / "duplicate.gltf", duplicate.dump()); + require(pipeline.import_asset({root / "duplicate.gltf"}).status == ImportStatus::failed, + "duplicate source ID accepted"); + auto external = document("External", true, false); + external["buffers"][0]["uri"] = "mesh.bin"; + external["images"] = Json::array({{{"uri", "pixel.png"}, {"mimeType", "image/png"}}}); + external["textures"] = Json::array({{{"source", 0}}}); + external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"] = {{"index", 0}}; // Real 1x1 PNG payload, transparent pixel; importer owns encoded bytes. - const std::vector png={137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,11,73,68,65,84,120,156,99,96,0,2,0,0,5,0,1,165,246,69,64,0,0,0,0,73,69,78,68,174,66,96,130}; - save(root/"pixel.png",png);save(root/"external.gltf",external.dump());auto ext=pipeline.import_asset({root/"external.gltf"});success(ext);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/"mesh.bin",geometry(.3f));auto dependent=pipeline.import_asset({root/"external.gltf"});success(dependent);require(dependent.generation!=ext.generation,"buffer dependency not invalidated"); + const std::vector png = { + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, + 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, + 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, + 1, 165, 246, 69, 64, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130}; + save(root / "pixel.png", png); + save(root / "external.gltf", external.dump()); + auto ext = pipeline.import_asset({root / "external.gltf"}); + success(ext); + 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 / "mesh.bin", geometry(.3f)); + auto dependent = pipeline.import_asset({root / "external.gltf"}); + success(dependent); + require(dependent.generation != ext.generation, "buffer dependency not invalidated"); // A Blender bundle keeps its logical source stable while immutable payload paths change. - const auto bundle_dir=root/"bundle";fs::create_directories(bundle_dir/"payload"); - glb(bundle_dir/"payload"/"first.glb","Bundle",true,false); - Json bundle{{"schema_version",1},{"asset_id","bundle-asset"},{"files",Json::array({{{"path","payload/first.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"first.glb")}}})}}; - save(bundle_dir/"manifest.json",bundle.dump());auto bundle_first=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_first);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());auto bundle_second=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_second);require(bundle_second.asset_id==bundle_first.asset_id&&bundle_second.generation!=bundle_first.generation,"bundle reimport identity/generation"); - bundle["files"][0]["sha256"]=std::string(64,'0');save(bundle_dir/"manifest.json",bundle.dump());require(pipeline.import_asset({bundle_dir/"manifest.json"}).status==ImportStatus::failed,"bundle checksum ignored");require(pipeline.load_asset("bundle-asset").generation==bundle_second.generation,"bad bundle replaced active"); + const auto bundle_dir = root / "bundle"; + fs::create_directories(bundle_dir / "payload"); + glb(bundle_dir / "payload" / "first.glb", "Bundle", true, false); + Json bundle{{"schema_version", 1}, + {"asset_id", "bundle-asset"}, + {"files", Json::array({{{"path", "payload/first.glb"}, + {"sha256", faset::sha256_file(bundle_dir / "payload" / + "first.glb")}}})}}; + save(bundle_dir / "manifest.json", bundle.dump()); + auto bundle_first = pipeline.import_asset({bundle_dir / "manifest.json"}); + success(bundle_first); + 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()); + auto bundle_second = pipeline.import_asset({bundle_dir / "manifest.json"}); + success(bundle_second); + require(bundle_second.asset_id == bundle_first.asset_id && + bundle_second.generation != bundle_first.generation, + "bundle reimport identity/generation"); + bundle["files"][0]["sha256"] = std::string(64, '0'); + save(bundle_dir / "manifest.json", bundle.dump()); + require(pipeline.import_asset({bundle_dir / "manifest.json"}).status == + ImportStatus::failed, + "bundle checksum ignored"); + require(pipeline.load_asset("bundle-asset").generation == bundle_second.generation, + "bad bundle replaced active"); // Changing a dependency after it was snapshotted cannot publish mixed content. - const auto dependency_active=pipeline.load_asset(ext.asset_id).generation; - ImportJob mutate([&](const ImportProgress& progress){if(progress.fraction==.5f)save(root/"mesh.bin",geometry(.7f));}); - require(pipeline.import_asset({root/"external.gltf"},mutate).status==ImportStatus::failed,"concurrent dependency edit accepted");require(pipeline.load_asset(ext.asset_id).generation==dependency_active,"concurrent edit changed active"); - fs::remove_all(root/"cache");auto restored=pipeline.import_asset({source});success(restored);require(restored.asset_id==first.asset_id,"cleared cache changed AssetId");require(restored.manifest["settings"]==changed_settings.settings,"persisted recipe lost");require(pipeline.overrides(first.asset_id)==custom,"cleared cache lost authoring overrides"); - fs::remove_all(root);std::cout<<"assets: geometry/PBR/texture, GLB/glTF, cache, rename, deletion, overrides, failure, cancellation OK\n";return 0; - } catch(const std::exception& e){std::cerr< #include +#include #include #include -#define CHECK(x) do {if(!(x))throw std::runtime_error("Check failed at line "+std::to_string(__LINE__)+": " #x);}while(false) -template void fails(Fn fn,const std::string& code) {try{fn();}catch(const faset::Error& error){CHECK(error.code()==code);return;}throw std::runtime_error("Expected error "+code);} -int main() { - using namespace faset;using namespace faset::authoring; - const auto root=std::filesystem::temp_directory_path()/("faset-authoring-"+new_id()); +#define CHECK(x) \ + do { \ + if (!(x)) \ + throw std::runtime_error("Check failed at line " + std::to_string(__LINE__) + \ + ": " #x); \ + } while (false) +template void fails(Fn fn, const std::string& code) { try { - auto schemas=builtin_schemas();AuthoringService service(root,schemas); - auto created=service.create("Courtyard",3);const std::string id=created["id"]; - auto first=make_entity(schemas,"Door");const std::string entity_id=first["id"],transform_id=first["components"][0]["id"]; - Json commands=Json::array({{{"op","entity.create"},{"entity",first}}}); - const auto after=service.transact(id,0,commands,"request-1");CHECK(after["revision"]==1);CHECK(after["scene"]["entities"].size()==1); - CHECK(service.transact(id,0,commands,"request-1")==after); - fails([&]{service.transact(id,0,commands);},"revision.conflict"); - fails([&]{service.transact(id,1,commands,"request-1");},"idempotency.conflict"); - auto bad=Json::array({{{"op","entity.rename"},{"entity",entity_id},{"name","Should not survive"}},{{"op","component.set"},{"entity",entity_id},{"component",transform_id},{"field","position"},{"value","not a vector"}}}); - fails([&]{service.transact(id,1,bad);},"validation.field_type");CHECK(service.query(id)==after); - auto edited=service.transact(id,1,Json::array({{{"op","component.set"},{"entity",entity_id},{"component",transform_id},{"field","position"},{"value",{4,0,2}}}})); - CHECK(edited["revision"]==2);CHECK(service.undo(id,2)["scene"]==after["scene"]);CHECK(service.redo(id,3)["scene"]==edited["scene"]); - CHECK(service.save(id,"Scenes/courtyard.scene.json")["dirty"]==false); - service.transact(id,4,Json::array({{{"op","entity.rename"},{"entity",entity_id},{"name","Дверь 世界"}}})); - AuthoringService restarted(root,schemas);const auto recovered=restarted.open("Scenes/courtyard.scene.json",true);CHECK(recovered["scene"]["entities"][0]["name"]=="Дверь 世界");CHECK(recovered["dirty"]==true); - atomic_write(root/"Scenes/courtyard.scene.json",read_text(root/"Scenes/courtyard.scene.json")+"\n"); - fails([&]{restarted.save(id);},"save.disk_conflict"); + fn(); + } catch (const faset::Error& error) { + CHECK(error.code() == code); + return; + } + throw std::runtime_error("Expected error " + code); +} +int main() { + using namespace faset; + using namespace faset::authoring; + const auto root = std::filesystem::temp_directory_path() / ("faset-authoring-" + new_id()); + try { + auto schemas = builtin_schemas(); + AuthoringService service(root, schemas); + auto created = service.create("Courtyard", 3); + const std::string id = created["id"]; + auto first = make_entity(schemas, "Door"); + const std::string entity_id = first["id"], transform_id = first["components"][0]["id"]; + Json commands = Json::array({{{"op", "entity.create"}, {"entity", first}}}); + const auto after = service.transact(id, 0, commands, "request-1"); + CHECK(after["revision"] == 1); + CHECK(after["scene"]["entities"].size() == 1); + CHECK(service.transact(id, 0, commands, "request-1") == after); + fails([&] { service.transact(id, 0, commands); }, "revision.conflict"); + fails([&] { service.transact(id, 1, commands, "request-1"); }, "idempotency.conflict"); + auto bad = Json::array( + {{{"op", "entity.rename"}, {"entity", entity_id}, {"name", "Should not survive"}}, + {{"op", "component.set"}, + {"entity", entity_id}, + {"component", transform_id}, + {"field", "position"}, + {"value", "not a vector"}}}); + fails([&] { service.transact(id, 1, bad); }, "validation.field_type"); + CHECK(service.query(id) == after); + auto edited = service.transact(id, 1, + Json::array({{{"op", "component.set"}, + {"entity", entity_id}, + {"component", transform_id}, + {"field", "position"}, + {"value", {4, 0, 2}}}})); + CHECK(edited["revision"] == 2); + CHECK(service.undo(id, 2)["scene"] == after["scene"]); + CHECK(service.redo(id, 3)["scene"] == edited["scene"]); + CHECK(service.save(id, "Scenes/courtyard.scene.json")["dirty"] == false); + service.transact( + id, 4, + Json::array( + {{{"op", "entity.rename"}, {"entity", entity_id}, {"name", "Дверь 世界"}}})); + AuthoringService restarted(root, schemas); + const auto recovered = restarted.open("Scenes/courtyard.scene.json", true); + CHECK(recovered["scene"]["entities"][0]["name"] == "Дверь 世界"); + CHECK(recovered["dirty"] == true); + AuthoringService opened_then_recovered(root, schemas); + opened_then_recovered.open("Scenes/courtyard.scene.json"); + fails([&] { opened_then_recovered.recover(id); }, "recovery.revision_required"); + CHECK(opened_then_recovered.recover(id, 0)["scene"]["entities"][0]["name"] == "Дверь 世界"); + auto fresh = service.create("Never saved", 2); + const std::string fresh_id = fresh.at("id"); + service.transact(fresh_id, 0, + Json::array({{{"op", "entity.create"}, {"name", "Recovered sprite"}}})); + AuthoringService recover_new(root, schemas); + const auto restored_new = recover_new.recover(fresh_id); + CHECK(restored_new["dirty"] == true && restored_new["path"] == ""); + CHECK(restored_new["scene"]["entities"].size() == 1); + fails([&] { recover_new.recover("../../outside"); }, "id.invalid"); + atomic_write(root / "Scenes/courtyard.scene.json", + read_text(root / "Scenes/courtyard.scene.json") + "\n"); + fails([&] { restarted.save(id); }, "save.disk_conflict"); // Parent cycles are rejected atomically; names never provide identity. - fails([&]{service.transact(id,5,Json::array({{{"op","entity.reparent"},{"entity",entity_id},{"parent",entity_id}}}));},"entity.cycle"); - CHECK(service.query(id)["revision"]==5); + fails( + [&] { + service.transact(id, 5, + Json::array({{{"op", "entity.reparent"}, + {"entity", entity_id}, + {"parent", entity_id}}})); + }, + "entity.cycle"); + CHECK(service.query(id)["revision"] == 5); // Unavailable extension data is retained, including fields unknown to this SDK. - auto unknown=make_entity(schemas,"Plugin object");unknown["components"].push_back({{"id",new_id()},{"type","plugin.future"},{"version",5},{"fields",{{"unknown",Json::array({1,2,3})}}}}); - service.transact(id,5,Json::array({{{"op","entity.create"},{"entity",unknown}}}));CHECK(service.query(id)["scene"]["entities"][1]==unknown); + auto unknown = make_entity(schemas, "Plugin object"); + unknown["components"].push_back({{"id", new_id()}, + {"type", "plugin.future"}, + {"version", 5}, + {"fields", {{"unknown", Json::array({1, 2, 3})}}}}); + service.transact(id, 5, Json::array({{{"op", "entity.create"}, {"entity", unknown}}})); + CHECK(service.query(id)["scene"]["entities"][1] == unknown); // Nested template addresses remain valid after source rename and source reparent. - auto source=make_scene("Door template");source["entities"].push_back(first); - auto middle=make_scene("Nested");middle["instances"].push_back({{"id","nested"},{"source","door"}}); - auto outer=make_scene("Level"); - Json address={{"path",Json::array({"nested"})},{"object",entity_id},{"component",transform_id},{"field","position"}}; - outer["instances"].push_back({{"id","one"},{"source","middle"},{"overrides",Json::array({{{"address",address},{"value",{8,0,0}}}})}}); - outer["instances"].push_back({{"id","two"},{"source","middle"}}); - auto loader=[&](const std::string& name){return name=="door"?source:middle;}; - auto resolved=resolve_templates(outer,schemas,loader);CHECK(resolved.conflicts.empty());CHECK(resolved.scene["entities"].size()==2); - CHECK(resolved.scene["entities"][0]["components"][0]["fields"]["position"]==Json::array({8,0,0})); - CHECK(resolved.scene["entities"][1]["components"][0]["fields"]["position"]==Json::array({0,0,0})); - const auto stable=resolved.scene["entities"][0]["id"];source["entities"][0]["name"]="Renamed"; - CHECK(resolve_templates(outer,schemas,loader).scene["entities"][0]["id"]==stable); - source["entities"]=Json::array();CHECK(resolve_templates(outer,schemas,loader).conflicts.size()==1);CHECK(outer["instances"][0]["overrides"].size()==1); - // Stable FieldId survives a label rename; incompatible migrations require an explicit decision. - SchemaRegistry newer;newer.register_schema({{"id","sample.type"},{"name","Sample"},{"version",2},{"fields",{{"speed",{{"id","speed"},{"name","Movement speed"},{"type","number"},{"default",2}}},{"enabled",{{"type","boolean"},{"default",true}}}}}}); - newer.add_migration("sample.type",1,{{"speed",{{"scale",0.01}}}}); - const auto migrated=newer.migrate_component({{"id",new_id()},{"type","sample.type"},{"version",1},{"fields",{{"speed",300},{"unrecognized","preserve"}}}}); - CHECK(migrated["fields"]["speed"]==3.0);CHECK(migrated["fields"]["enabled"]==true);CHECK(migrated["fields"]["unrecognized"]=="preserve"); - std::filesystem::remove_all(root);std::cout<<"Authoring transactions, conflict/retry, Undo, recovery, unknown fields, nested IDs and migrations passed\n";return 0; - } catch(const std::exception& error) {std::filesystem::remove_all(root);std::cerr<() - 2.0) < 1e-6); + CHECK(std::abs(position[1].get()) < 1e-6); + reparent_entity(hierarchy, child["id"], nullptr, true); + position = hierarchy["entities"][1]["components"][0]["fields"]["position"]; + CHECK(std::abs(position[0].get() - 10) < 1e-6); + CHECK(std::abs(position[1].get() - 4) < 1e-6); + std::filesystem::remove_all(root); + std::cout << "Authoring transactions, conflict/retry, Undo, recovery, unknown fields, " + "nested IDs and migrations passed\n"; + return 0; + } catch (const std::exception& error) { + std::filesystem::remove_all(root); + std::cerr << error.what() << '\n'; + return 1; + } } diff --git a/tests/blender/generate_fixture.py b/tests/blender/generate_fixture.py new file mode 100644 index 0000000..d58da8e --- /dev/null +++ b/tests/blender/generate_fixture.py @@ -0,0 +1,28 @@ +import bpy, sys, pathlib, shutil, json +root=pathlib.Path(sys.argv[sys.argv.index('--')+1]) +output=pathlib.Path(sys.argv[sys.argv.index('--')+2]) +sys.path.insert(0,str(root/'tools')) +import blender_addon +blender_addon.register() +bpy.ops.object.select_all(action='SELECT') +bpy.ops.object.delete(use_global=False) +bpy.ops.mesh.primitive_cube_add(location=(0,0,1)) +obj=bpy.context.object;obj.name='Door panel' +obj['faset_id']='1c4b69fb-1219-4430-b046-8185eb265a0f' +obj.data['faset_id']='2072eb21-2558-41ee-a843-82e676e6a44e' +mat=bpy.data.materials.new('Brass');mat.diffuse_color=(0.8,0.5,0.12,1);mat['faset_id']='31dc2a5b-3d1c-41b8-b4ef-ddc626087458';obj.data.materials.append(mat) +bpy.context.scene['faset_asset_id']='54c039e4-c19f-4365-a9f8-a38416ec6e3f' +bpy.context.scene.frame_set(17,subframe=.25) +for stage in ('initial','renamed','removed'): + directory=output/stage;directory.mkdir(parents=True,exist_ok=True) + if stage=='renamed': + obj.name='Renamed Door';obj.data.vertices[0].co.x-=.2 + if stage=='removed': + bpy.data.objects.remove(obj,do_unlink=True) + result=bpy.ops.export_scene.faset_bundle(filepath=str(directory/'manifest.json')) + assert 'FINISHED' in result,result + assert bpy.context.scene.frame_current==17 and bpy.context.scene.frame_subframe==.25 + if stage!='removed': + assert bpy.context.view_layer.objects.active==obj and obj.select_get() + bpy.ops.wm.save_as_mainfile(filepath=str(directory/'source.blend')) +print('FASET_BLENDER_ROUNDTRIP_EXPORT_OK') diff --git a/tests/build_service_tests.cpp b/tests/build_service_tests.cpp new file mode 100644 index 0000000..304aef9 --- /dev/null +++ b/tests/build_service_tests.cpp @@ -0,0 +1,289 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifndef _WIN32 +#include +#endif +using namespace faset; +namespace fs = std::filesystem; +void require(bool value, const char* message) { + if (!value) + throw std::runtime_error(message); +} +std::string collect(Process& process) { + std::string text; + while (true) { + auto poll = process.poll(); + text += poll.output; + if (!poll.running) { + require(poll.exit_code == 0, "Child process failed"); + return text; + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } +} +Json scene(int dimension) { + return {{"format", "faset.scene"}, {"version", 1}, {"id", "scene-test"}, + {"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()}, + {"instances", Json::array()}}; +} +int integration(const fs::path& root) { + fs::create_directories(root); + editor::BuildConfig config; + config.project_root = root / "project"; + config.engine_root = FASET_ENGINE_SOURCE; + config.build_directory = root / "native-build"; + config.cache_root = root / "project" / ".faset" / "cache"; + editor::BuildService service(config); + service.scaffold("Export integration", 3); + // This dedicated integration fixture is reset before testing incremental user edits. + atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", + read_text(config.engine_root / "tools" / "project_templates" / "Gameplay.cpp")); + auto wait = [&](const std::string& id) { + std::string stage; + while (true) { + auto job = service.job(id); + if (job.stage != stage) { + stage = job.stage; + std::cout << job.stage << std::endl; + } + if (job.finished()) { + if (job.state != "succeeded") { + std::cerr << job.error << '\n' << job.log; + throw std::runtime_error("Integration job failed"); + } + return job; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + }; + const auto assets = config.project_root / "Assets"; + std::string binary; + for (float value : {-1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 2.f, 0.f}) { + auto bits = std::bit_cast(value); + for (int i = 0; i < 4; ++i) + binary.push_back(static_cast(bits >> (8 * i))); + } + atomic_write(assets / "triangle.bin", binary); + Json gltf = { + {"asset", {{"version", "2.0"}}}, + {"scene", 0}, + {"scenes", Json::array({{{"nodes", {0}}}})}, + {"nodes", Json::array({{{"mesh", 0}, {"extras", {{"faset_id", "triangle-node"}}}}})}, + {"buffers", Json::array({{{"uri", "triangle.bin"}, {"byteLength", 36}}})}, + {"bufferViews", Json::array({{{"buffer", 0}, {"byteLength", 36}}})}, + {"accessors", Json::array({{{"bufferView", 0}, + {"componentType", 5126}, + {"count", 3}, + {"type", "VEC3"}, + {"min", {-1, 0, 0}}, + {"max", {1, 2, 0}}}})}, + {"meshes", + Json::array({{{"primitives", Json::array({{{"attributes", {{"POSITION", 0}}}}})}}})}}; + atomic_write_json(assets / "triangle.gltf", gltf); + assets::AssetPipeline importer(config.cache_root); + auto imported = importer.import_asset({assets / "triangle.gltf"}); + require(imported.ok(), "Integration asset import"); + for (int dimension : {2, 3}) { + auto document = scene(dimension); + auto component = [](std::string type, Json fields) { + return Json{ + {"id", type}, {"type", type}, {"version", 1}, {"fields", std::move(fields)}}; + }; + Json components = Json::array( + {component("faset.transform", + {{"position", {0, 0, 0}}, {"rotation", {0, 0, 0}}, {"scale", {1, 1, 1}}})}); + if (dimension == 2) + components.push_back( + component("faset.sprite", {{"size", {2, 2}}, {"color", {.2, .7, .9, 1}}})); + else + components.push_back(component( + "faset.mesh", {{"asset", imported.asset_id + "#" + + importer.load_asset(imported.asset_id).nodes.at(0).id}, + {"color", {.3, .8, .5, 1}}})); + document["entities"].push_back({{"id", "object"}, + {"name", "Object"}, + {"parent", nullptr}, + {"components", components}}); + auto result = + wait(service.start_export(document, root / ("export-" + std::to_string(dimension)))); + const auto directory = fs::path(result.result.at("directory").get()); + require(result.result.at("configuration") == "Release", + "Exports default to the Release profile"); + require(fs::path(result.result.at("build_directory").get()) == + config.build_directory / "Release", + "Export has a separate CMake directory"); + require(read_json(directory / "manifest.json").at("configuration") == "Release", + "Export manifest records the actual profile"); + Process player({{result.result.at("executable").get(), "--headless", + "--frames", "3", "--capture", (directory / "verification.ppm").string()}, + directory, + {}}); + std::cout << collect(player); + require(fs::file_size(directory / "verification.ppm") > 1000, + "Exported game rendered a frame"); + atomic_write_json(root / ("result-" + std::to_string(dimension) + ".json"), result.result); + } + auto source = read_text(config.project_root / "Scripts" / "Gameplay.cpp"); + auto position = source.find("Character"); + require(position != std::string::npos, "Template schema fixture"); + source.replace(position, 9, "Custom Character"); + atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", source); + auto release_cache = read_text(config.build_directory / "Release" / "CMakeCache.txt"); + auto rebuilt = wait(service.start_build()); + require(rebuilt.result.at("configuration") == "Debug", "Development builds remain Debug"); + require(fs::path(rebuilt.result.at("build_directory").get()) == + config.build_directory / "Debug", + "Development CMake directory is isolated"); + require(read_text(config.build_directory / "Release" / "CMakeCache.txt") == release_cache, + "Development build preserves the Release cache"); + auto schema = read_json(rebuilt.result.at("schema").get()); + bool updated{}; + for (const auto& type : schema.at("types")) + if (type.value("name", "") == "Custom Character") + updated = true; + require(updated, "Incremental C++ build produced new schema"); + auto last = read_text(config.cache_root / "last_build.json"); + atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", + source + "\n#error intentional_build_failure\n"); + auto failed = service.wait(service.start_build()); + require(failed.state == "failed", "Invalid user C++ must fail build"); + require(read_text(config.cache_root / "last_build.json") == last, + "Failed compile preserved last good build"); + atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", source); + std::cout << "Real 2D/3D exports, imported mesh packaging, native launches, incremental C++ " + "schema and failed-build recovery passed\n"; + return 0; +} +int main(int argc, char** argv) { + if (argc > 1 && std::string(argv[1]) == "--child") { + Json args = Json::array(); + for (int i = 2; i < argc; ++i) + args.push_back(argv[i]); + std::cout << Json{{"args", args}, + {"cwd", fs::current_path().string()}, + {"env", std::getenv("FASET_PROCESS_TEST") + ? std::getenv("FASET_PROCESS_TEST") + : ""}} + .dump() + << std::endl; + std::cerr << "stderr-sentinel\n"; + std::cout << std::string(100000, 'x') << std::endl; + return 0; + } + if (argc > 1 && std::string(argv[1]) == "--sleep") { +#ifndef _WIN32 + std::signal(SIGTERM, SIG_IGN); +#endif + std::cout << "ready\n" << std::flush; + std::this_thread::sleep_for(std::chrono::seconds(30)); + return 0; + } + if (argc == 3 && std::string(argv[1]) == "--integration") { + try { + return integration(fs::absolute(argv[2])); + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } + } + fs::path temporary = fs::temp_directory_path() / ("Faset build test " + new_id()); + try { + fs::create_directories(temporary); + auto executable = fs::absolute(argv[0]); + Process child({{executable.string(), "--child", "space argument", "quote\"backslash\\", + "$(touch not-executed); & |", ""}, + temporary, + {{"FASET_PROCESS_TEST", "value with spaces"}}}); + auto text = collect(child); + auto result = Json::parse(text.substr(0, text.find('\n'))); + require(result["args"] == Json::array({"space argument", "quote\"backslash\\", + "$(touch not-executed); & |", ""}), + "Arguments must remain literal"); + require(result["env"] == "value with spaces", "Child environment override"); + require(fs::equivalent(result["cwd"].get(), temporary), + "Child working directory"); + require(text.find("stderr-sentinel") != std::string::npos && text.size() > 100000, + "Combined pipe output drained fully"); + require(!fs::exists(temporary / "not-executed"), "No shell execution"); + Process sleeper({{executable.string(), "--sleep"}, temporary, {}}); + bool ready{}; + while (!ready) { + auto p = sleeper.poll(); + ready = p.output.find("ready") != std::string::npos; + require(p.running, "Sleeper exited early"); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + auto start = std::chrono::steady_clock::now(); + sleeper.cancel(); + require(!sleeper.poll().running, "Cancellation must reap the process"); + require(std::chrono::steady_clock::now() - start < std::chrono::seconds(3), + "Cancellation must finish promptly"); + editor::BuildConfig config; + config.project_root = temporary / "project"; + config.engine_root = FASET_ENGINE_SOURCE; + editor::BuildService service(config); + service.scaffold("Test project", 2); + atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", "// User code\n"); + service.scaffold("Another name", 3); + require(read_text(config.project_root / "Scripts" / "Gameplay.cpp") == "// User code\n", + "Scaffold preserves existing source"); + auto id = service.start_cook(scene(2)); + auto cooked = service.wait(id); + require(cooked.state == "succeeded", "Cook job succeeds"); + auto bytes = read_text(cooked.result.at("scene").get()); + require(bytes.substr(0, 8) == "FASETSCN" && bytes.size() > 20, "Cooked envelope magic"); + std::uint32_t version{}; + std::uint64_t size{}; + for (unsigned i = 0; i < 4; ++i) + version |= std::uint32_t(static_cast(bytes[8 + i])) << (8 * i); + for (unsigned i = 0; i < 8; ++i) + size |= std::uint64_t(static_cast(bytes[12 + i])) << (8 * i); + require(version == 1 && size == bytes.size() - 20, "Cooked envelope version and size"); + require(Json::from_cbor(bytes.begin() + 20, bytes.end()) == scene(2), + "Cooked scene preserves all values"); + auto previous = read_text(service.config().cache_root / "last_cook.json"); + auto broken = scene(3); + broken["version"] = 999; + auto bad = service.wait(service.start_cook(broken)); + require(bad.state == "failed", "Unsupported scene version rejected"); + require(read_text(service.config().cache_root / "last_cook.json") == previous, + "Failed cook preserves last good generation"); + broken = scene(3); + broken["entities"].push_back( + {{"id", "entity"}, + {"components", + Json::array({{{"type", "faset.mesh"}, {"fields", {{"asset", "missing-asset"}}}}})}}); + auto missing = service.wait(service.start_cook(broken)); + require(missing.state == "failed", "Missing asset blocks publication"); + require(read_text(service.config().cache_root / "last_cook.json") == previous, + "Missing asset preserves last good generation"); + broken = scene(3); + broken["entities"].push_back({{"id", "entity"}, + {"components", Json::array({{{"type", "faset.unknown"}, + {"version", 1}, + {"fields", Json::object()}}})}}); + auto unknown = service.wait(service.start_cook(broken)); + require(unknown.state == "failed" && + unknown.error.find("unresolved component") != std::string::npos, + "Unknown component type blocks cooking"); + require(read_text(service.config().cache_root / "last_cook.json") == previous, + "Unknown schema preserves last good generation"); + std::cout << "Literal process arguments, pipes, cancellation, scaffold and atomic cook " + "contracts passed\n"; + fs::remove_all(temporary); + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + std::error_code ignored; + fs::remove_all(temporary, ignored); + return 1; + } +} diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 5143ebf..ed3b618 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -1,27 +1,59 @@ +#include #include #include -#include #include #include -#define CHECK(x) do { if(!(x)) throw std::runtime_error("Check failed: " #x); } while(false) +#define CHECK(x) \ + do { \ + if (!(x)) \ + throw std::runtime_error("Check failed: " #x); \ + } while (false) int main() { - const auto directory=std::filesystem::temp_directory_path()/("faset-core-"+faset::new_id()); + const auto directory = + std::filesystem::temp_directory_path() / ("faset-core-" + faset::new_id()); try { - CHECK(faset::sha256("")=="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); - CHECK(faset::sha256("abc")=="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); - CHECK(faset::sha256(std::string(1000000,'a'))=="cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"); + CHECK(faset::sha256("") == + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + CHECK(faset::sha256("abc") == + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + CHECK(faset::sha256(std::string(1000000, 'a')) == + "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"); std::set ids; - for(int i=0;i<1000;++i) { const auto id=faset::new_id();CHECK(id.size()==36);CHECK(id[14]=='4');CHECK(ids.insert(id).second); } - faset::atomic_write(directory/"state.json","{\"value\":1}"); - faset::atomic_write_json(directory/"state.json",{{"value",2},{"text","Привет 世界"}}); - CHECK(faset::read_json(directory/"state.json").at("value")==2); - CHECK(faset::sha256_file(directory/"state.json")==faset::sha256(faset::read_text(directory/"state.json"))); - CHECK(std::filesystem::equivalent(faset::project_path(directory,"assets/../state.json"),directory/"state.json")); - bool rejected=false;try { faset::project_path(directory,"../escape"); } catch(const faset::Error&) {rejected=true;} CHECK(rejected); - rejected=false;try { faset::project_path(directory,directory/"state.json"); } catch(const faset::Error&) {rejected=true;} CHECK(rejected); + for (int i = 0; i < 1000; ++i) { + const auto id = faset::new_id(); + CHECK(id.size() == 36); + CHECK(id[14] == '4'); + CHECK(ids.insert(id).second); + } + faset::atomic_write(directory / "state.json", "{\"value\":1}"); + faset::atomic_write_json(directory / "state.json", {{"value", 2}, {"text", "Привет 世界"}}); + CHECK(faset::read_json(directory / "state.json").at("value") == 2); + CHECK(faset::sha256_file(directory / "state.json") == + faset::sha256(faset::read_text(directory / "state.json"))); + CHECK(std::filesystem::equivalent(faset::project_path(directory, "assets/../state.json"), + directory / "state.json")); + bool rejected = false; + try { + faset::project_path(directory, "../escape"); + } catch (const faset::Error&) { + rejected = true; + } + CHECK(rejected); + rejected = false; + try { + faset::project_path(directory, directory / "state.json"); + } catch (const faset::Error&) { + rejected = true; + } + CHECK(rejected); std::filesystem::remove_all(directory); - std::cout<<"Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path boundaries passed\n"; + std::cout << "Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path " + "boundaries passed\n"; return 0; - } catch(const std::exception& error) {std::filesystem::remove_all(directory);std::cerr< +#include +#include +#include +using namespace faset; +void check(bool value, const char* message) { + if (!value) + throw std::runtime_error(message); +} +render::Event key(std::string value, bool control = false, bool shift = false) { + render::Event e; + e.type = render::Event::Type::KeyDown; + e.key = std::move(value); + e.control = control; + e.shift = shift; + return e; +} +void click(editor::EditorUI& ui, const std::string& id) { + const auto* widget = ui.widgets().find(id); + check(widget != nullptr, "Missing widget"); + const auto rect = widget->rect.intersection(widget->clip); + check(rect.width > 0 && rect.height > 0, "Widget clipped"); + render::Event down; + down.type = render::Event::Type::MouseDown; + down.button = 1; + down.x = rect.x + rect.width * .5f; + down.y = rect.y + rect.height * .5f; + auto up = down; + up.type = render::Event::Type::MouseUp; + ui.frame({down, up}); +} +void text(editor::EditorUI& ui, const std::string& id, const std::string& value, + bool commit = true) { + click(ui, id); + render::Event e; + e.type = render::Event::Type::TextInput; + e.text = value; + std::vector events{key("A", true), e}; + if (commit) + events.push_back(key("Return")); + ui.frame(events); +} +int main() { + auto root = std::filesystem::temp_directory_path() / ("faset-ui-authoring-" + new_id()); + try { + std::filesystem::create_directories(root); + atomic_write_json(root / "project.faset.json", {{"format", "faset.project"}, + {"version", 1}, + {"name", "UI integration test"}, + {"dimension", 3}}); +#if defined(FASET_TEST_PLUGIN_DIRECTORY) + std::filesystem::create_directories(root / "Plugins"); + for (const auto& file : std::filesystem::directory_iterator(FASET_TEST_PLUGIN_DIRECTORY)) + if (file.is_regular_file()) + std::filesystem::copy_file(file.path(), root / "Plugins" / file.path().filename()); +#endif + editor::Session session({root, FASET_TEST_ENGINE, root}); + render::Renderer renderer({1280, 800, "Faset editor test", true, true}); + editor::EditorUI ui(session, renderer, + std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf", + std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json"); + ui.frame({}); + click(ui, "add-cube"); + auto state = session.authoring().query(ui.current_document()); + check(state["scene"]["entities"].size() == 1, "Add cube button must author entity"); + const auto eid = state["scene"]["entities"][0]["id"].get(); + check(ui.selected_entity() == eid, "New entity selected"); + text(ui, "object-name", "Дверь"); + state = session.authoring().query(ui.current_document()); + check(state["scene"]["entities"][0]["name"] == "Дверь", "Inspector Unicode rename"); + const auto cid = state["scene"]["entities"][0]["components"][0]["id"].get(); + const auto position = "field-" + cid + "-position-0"; + text(ui, position, "3.5"); + state = session.authoring().query(ui.current_document()); + check(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0] == 3.5, + "Inspector typed field transaction"); + click(ui, "undo"); + state = session.authoring().query(ui.current_document()); + check(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0] == 0, + "Toolbar Undo uses authoring history"); + click(ui, "redo"); + state = session.authoring().query(ui.current_document()); + check(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0] == 3.5, + "Toolbar Redo"); + // A concurrent MCP edit cannot be overwritten by an unfinished inspector + // edit. + text(ui, "object-name", "Unsaved local name", false); + state = session.authoring().query(ui.current_document()); + session.authoring().transact( + ui.current_document(), state.at("revision"), + Json::array({{{"op", "entity.rename"}, {"entity", eid}, {"name", "External rename"}}})); + ui.frame({key("Return")}); + state = session.authoring().query(ui.current_document()); + check(state["scene"]["entities"][0]["name"] == "External rename", + "Revision conflict preserves external edit"); + click(ui, "scene-root"); + check(ui.selected_entity().empty(), "Scene root deselects object"); + click(ui, "entity-" + eid); + check(ui.selected_entity() == eid, "Scene tree selection"); + const auto initial_revision = state.at("revision").get(); + auto* field = ui.widgets().find(position); + const auto r = field->rect; + render::Event down; + down.type = render::Event::Type::MouseDown; + down.button = 1; + down.x = r.x + 20; + down.y = r.y + 12; + auto move = down; + move.type = render::Event::Type::MouseMove; + move.x += 20; + auto move2 = move; + move2.x += 20; + auto up = move2; + up.type = render::Event::Type::MouseUp; + ui.frame({down, move, move2, up}); + state = session.authoring().query(ui.current_document()); + check(state["revision"] == initial_revision + 1, + "Numeric drag commits exactly one transaction"); + // Selecting and manipulating the actual rendered geometry uses viewport + // events. + auto screen = [&](render::Vec3 position) { + const auto& snap = ui.snapshot(); + const auto& m = snap.view_projection; + const auto w = m[3] * position[0] + m[7] * position[1] + m[11] * position[2] + m[15]; + const auto nx = + (m[0] * position[0] + m[4] * position[1] + m[8] * position[2] + m[12]) / w; + const auto ny = + (m[1] * position[0] + m[5] * position[1] + m[9] * position[2] + m[13]) / w; + return render::Vec2{snap.scene_rect[0] + (nx + 1) * snap.scene_rect[2] * .5f, + snap.scene_rect[1] + (ny + 1) * snap.scene_rect[3] * .5f}; + }; + const auto x = + state["scene"]["entities"][0]["components"][0]["fields"]["position"][0].get(); + ui.select_entity(""); + ui.frame({}); + auto center = screen({x, 0, 0}); + down.x = center[0]; + down.y = center[1]; + up = down; + up.type = render::Event::Type::MouseUp; + ui.frame({down, up}); + check(ui.selected_entity() == eid, "Viewport ray must select visible cooked geometry"); + auto tip = screen({x + 1.44f, 0, 0}); + down.x = (center[0] + tip[0]) * .5f; + down.y = (center[1] + tip[1]) * .5f; + move = down; + move.type = render::Event::Type::MouseMove; + move.x += 20; + up = move; + up.type = render::Event::Type::MouseUp; + const auto before_gizmo = state.at("revision").get(); + ui.frame({down}); + ui.frame({move}); + check(session.authoring().query(ui.current_document())["revision"] == before_gizmo, + "Gizmo preview must not mutate authoring"); + ui.frame({up}); + state = session.authoring().query(ui.current_document()); + check(state["revision"] == before_gizmo + 1, "Gizmo release commits one transaction"); + click(ui, "undo"); + state = session.authoring().query(ui.current_document()); + check(std::abs(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0] + .get() - + x) < .001f, + "Gizmo undo restores transform"); +#if defined(FASET_TEST_PLUGIN_DIRECTORY) + session.authoring().register_schemas(Json::parse( + R"([{"id":"example.beacon","name":"Beacon","version":1,"fields":{"speed":{"id":"speed","name":"Rotation speed","type":"number","default":1.0}}}])")); + ui.frame({}); + check(!session.plugin_panels().empty(), "Actual native plugin panel must load"); + const auto before_plugin = state["scene"]["entities"].size(); + click(ui, "tab-plugin-example.beacon.tools"); + click(ui, "plugin-action-example.beacon.tools"); + state = session.authoring().query(ui.current_document()); + check(state["scene"]["entities"].size() == before_plugin + 1, + "Plugin panel action must author Beacon through Commands"); + click(ui, "tab-assets"); +#endif + renderer.render(ui.snapshot()); + renderer.capture(root / "editor-ui.ppm"); + 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. " + "Screenshot: " + << (root / "editor-ui.ppm") << '\n'; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} diff --git a/tests/mcp_stdio_test.py b/tests/mcp_stdio_test.py new file mode 100644 index 0000000..f7cc9cb --- /dev/null +++ b/tests/mcp_stdio_test.py @@ -0,0 +1,84 @@ +"""Exercise the real headless Editor over newline-delimited MCP, without a GPU.""" +import json +import pathlib +import queue +import subprocess +import sys +import tempfile +import threading + + +def run(executable, project): + process = subprocess.Popen([executable, "--project", str(project), "--mcp"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding="utf-8") + output = queue.Queue() + def reader(): + for line in process.stdout: + output.put(json.loads(line)) # Any non-JSON stdout fails the test. + output.put(None) + threading.Thread(target=reader, daemon=True).start() + sequence = 0 + def request(method, params=None): + nonlocal sequence + sequence += 1 + message = {"jsonrpc": "2.0", "id": sequence, "method": method} + if params is not None: + message["params"] = params + process.stdin.write(json.dumps(message) + "\n") + process.stdin.flush() + result = output.get(timeout=20) + assert result and result["id"] == sequence, result + return result + def call(name, arguments=None, error=False): + response = request("tools/call", {"name": name, "arguments": arguments or {}})["result"] + assert response["isError"] == error, response + return response["structuredContent"] + try: + initialized = request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "faset-integration-test", "version": "1"}}) + assert initialized["result"]["serverInfo"]["name"] == "faset-editor" + process.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n') + process.stdin.flush() + names = {tool["name"] for tool in request("tools/list")["result"]["tools"]} + assert {"faset_scene_edit", "faset_export", "faset_job_cancel", "faset_plugins"} <= names + assert "faset_runtime_query" not in names and "faset_editor_capture" not in names + if (project / "Scenes/main.scene.json").exists(): + opened = call("faset_document_open", {"path": "Scenes/main.scene.json"}) + assert opened["scene"]["entities"][0]["name"] == "Door 世界" + return + scene = call("faset_document_create", {"name": "MCP integration", "dimension": 3}) + identity = scene["id"] + edit = {"document": identity, "revision": 0, + "operations": [{"op": "entity.create", "name": "Door 世界"}], + "idempotency_key": "create-door"} + changed = call("faset_scene_edit", edit) + assert call("faset_scene_edit", edit) == changed + conflict = dict(edit) + del conflict["idempotency_key"] + assert call("faset_scene_edit", conflict, True)["error"]["code"] == "revision.conflict" + undone = call("faset_undo", {"document": identity, "revision": 1}) + assert undone["scene"]["entities"] == [] + redone = call("faset_redo", {"document": identity, "revision": 2}) + assert redone["scene"] == changed["scene"] + saved = call("faset_document_save", {"document": identity, "path": "Scenes/main.scene.json"}) + assert not saved["dirty"] + call("faset_document_open", {"path": "../outside.json"}, True) + assert request("resources/read", {"uri": "faset://documents"})["result"]["contents"] + assert request("faset_runtime_query")["error"]["code"] == -32601 + finally: + process.stdin.close() + try: + code = process.wait(timeout=20) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise + errors = process.stderr.read() + assert code == 0, errors + + +with tempfile.TemporaryDirectory(prefix="faset-mcp-stdio-") as temporary: + run(sys.argv[1], pathlib.Path(temporary)) + run(sys.argv[1], pathlib.Path(temporary)) +print("Real MCP stdio lifecycle, clean stdout, revision conflict, retry, Undo/Redo, disk reopen and process shutdown passed") diff --git a/tests/mcp_tests.cpp b/tests/mcp_tests.cpp new file mode 100644 index 0000000..e960f70 --- /dev/null +++ b/tests/mcp_tests.cpp @@ -0,0 +1,76 @@ +#include +#include +#include + +#define CHECK(x) \ + do { \ + if (!(x)) \ + throw std::runtime_error("Check failed at " + std::to_string(__LINE__) + ": " #x); \ + } while (false) +int main() { + using namespace faset; + using namespace faset::editor; + const auto root = std::filesystem::temp_directory_path() / ("faset-mcp-" + new_id()); + try { + authoring::AuthoringService service(root); + Commands commands(service); + McpServer server(commands); + auto request = [&](std::string method, Json params = Json::object()) { + auto result = server.handle( + {{"jsonrpc", "2.0"}, {"id", 1}, {"method", method}, {"params", params}}); + CHECK(result.has_value()); + return *result; + }; + CHECK(request("tools/list")["error"]["code"] == -32002); + CHECK(request("initialize", + {{"protocolVersion", "2025-06-18"}, + {"capabilities", Json::object()}, + {"clientInfo", + {{"name", "test"}, {"version", "1"}}}})["result"]["protocolVersion"] == + "2025-06-18"); + CHECK(!server.handle({{"jsonrpc", "2.0"}, {"method", "notifications/initialized"}})); + const auto listed = request("tools/list")["result"]["tools"]; + CHECK(listed.size() >= 10); + for (const auto& tool : listed) { + const std::string name = tool["name"]; + CHECK(name.find("runtime") == std::string::npos); + CHECK(tool["inputSchema"]["additionalProperties"] == false); + } + auto call = [&](std::string name, Json arguments = Json::object()) { + return request("tools/call", {{"name", name}, {"arguments", arguments}})["result"]; + }; + const auto created = + call("faset_document_create", {{"name", "MCP scene"}, {"dimension", 2}}); + CHECK(created["isError"] == false); + const std::string id = created["structuredContent"]["id"]; + Json args = {{"document", id}, + {"revision", 0}, + {"operations", Json::array({{{"op", "entity.create"}, {"name", "Player"}}})}, + {"idempotency_key", "first"}}; + const auto first = call("faset_scene_edit", args); + CHECK(first["isError"] == false); + CHECK(call("faset_scene_edit", args) == first); + args.erase("idempotency_key"); + CHECK(call("faset_scene_edit", args)["structuredContent"]["error"]["code"] == + "revision.conflict"); + CHECK(service.query(id)["scene"] == first["structuredContent"]["scene"]); + CHECK(call("faset_scene_edit", {{"document", id}, + {"revision", -1}, + {"operations", args["operations"]}})["isError"] == true); + CHECK(call("faset_document_open", + {{"path", "../outside.json"}})["structuredContent"]["error"]["code"] == + "path.outside_project"); + CHECK(call("faset_runtime_query")["isError"] == true); + CHECK(request("resources/read", {{"uri", "faset://schema"}}).contains("result")); + CHECK(request("not/a/method")["error"]["code"] == -32601); + CHECK(server.handle(Json::array())->at("error").at("code") == -32600); + std::filesystem::remove_all(root); + std::cout << "MCP lifecycle, shared authoring, retries/conflicts, tool schemas and " + "editor-only boundary passed\n"; + return 0; + } catch (const std::exception& error) { + std::filesystem::remove_all(root); + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/plugin_tests.cpp b/tests/plugin_tests.cpp new file mode 100644 index 0000000..6481d0f --- /dev/null +++ b/tests/plugin_tests.cpp @@ -0,0 +1,87 @@ +#include "../examples/extensions/beacon/Beacon.hpp" +#include +#include +#include +#define CHECK(x) \ + do { \ + if (!(x)) \ + throw std::runtime_error("Check failed: " #x); \ + } while (false) +int main() { + using namespace faset; + using namespace faset::editor; + const auto root = std::filesystem::temp_directory_path() / ("faset-plugins-" + new_id()); + try { + std::filesystem::create_directories(root); + const auto folder = root / "Plugins"; + std::filesystem::copy(FASET_TEST_PLUGIN_DIRECTORY, folder, + std::filesystem::copy_options::recursive); + authoring::AuthoringService authoring(root); + authoring.register_schemas(Json::array({beacon::schema()})); + Commands commands(authoring); + const auto document = authoring.create("Extension test"); + const auto id = document.at("id"); + { + PluginManager plugins(commands, [](auto) {}); + plugins.load(folder); + CHECK(plugins.status().size() == 1); + CHECK(plugins.status()[0]["state"] == "loaded"); + CHECK(plugins.panels().size() == 1); + const auto edited = commands.call("plugin_example_beacon_create", {{"document", id}}); + CHECK(edited["revision"] == 1); + CHECK(edited["scene"]["entities"][0]["components"][2]["type"] == "example.beacon"); + authoring.undo(id, 1); + CHECK(authoring.query(id)["scene"]["entities"].empty()); + authoring.redo(id, 2); + authoring.save(id, "Scenes/plugin.scene.json"); + // The extension's runtime component runs without loading its Editor DLL/SO. + runtime::Runtime world; + beacon::register_behavior(world); + world.load(edited.at("scene")); + world.advance(1.0 / 60); + CHECK(world.transform(world.find(edited["scene"]["entities"][0]["id"])).rotation[1] > + 0); + } + for (const auto& command : commands.list()) + CHECK(command["name"] != "plugin_example_beacon_create"); + authoring::AuthoringService absent(root); + CHECK(absent.open("Scenes/plugin.scene.json")["scene"]["entities"][0]["components"][2] + ["fields"]["speed"] == 1.0); + const auto file = folder / "beacon.faset-plugin.json"; + const auto original = read_json(file); + auto wrong = original; + wrong["build_fingerprint"] = "wrong-build"; + atomic_write_json(file, wrong); + { + PluginManager plugins(commands, [](auto) {}); + plugins.load(folder); + CHECK(plugins.status()[0]["state"] == "failed"); + CHECK(plugins.panels().empty()); + } + auto cycle = original; + cycle["dependencies"] = Json::array({{{"id", "example.beacon"}, {"version", "1.0.0"}}}); + atomic_write_json(file, cycle); + { + PluginManager plugins(commands, [](auto) {}); + plugins.load(folder); + CHECK(plugins.status()[0]["state"] == "failed"); + CHECK(plugins.panels().empty()); + } + auto missing = original; + missing["dependencies"] = Json::array({{{"id", "missing"}, {"version", "1.0.0"}}}); + atomic_write_json(file, missing); + { + PluginManager plugins(commands, [](auto) {}); + plugins.load(folder); + CHECK(plugins.status()[0]["state"] == "failed"); + } + std::filesystem::remove_all(root); + std::cout << "Native plugin ABI, ownership, commands, runtime component, missing package " + "preservation and dependency validation passed\n"; + return 0; + } catch (const std::exception& error) { + std::filesystem::remove_all(root); + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/render_tests.cpp b/tests/render_tests.cpp index 8457eb3..389c038 100644 --- a/tests/render_tests.cpp +++ b/tests/render_tests.cpp @@ -1,26 +1,132 @@ -#include -#include #include +#include +#include #include #include using namespace faset::render; -void require(bool test,const char* message){if(!test)throw std::runtime_error(message);} -int main(int argc,char** argv){try{ - if(argc>1&&std::string(argv[1])=="--unit"){ - int count{};RenderGraph invalid;invalid.add("consumer",{"missing"},{},[&]{++count;});bool caught{};try{invalid.execute();}catch(const std::runtime_error&){caught=true;}require(caught&&count==0,"Graph must validate before side effects");RenderGraph graph;graph.import("external");graph.add("first",{"external"},{"color"},[&]{require(count==0,"Pass order");++count;});graph.add("second",{"color"},{},[&]{++count;});graph.execute();require(count==2,"Pass execution count");auto t=transform({2,3,4},{},{2,3,4});require(t[12]==2&&t[13]==3&&t[14]==4,"Transform translation");auto m=multiply(identity,t);require(m==t,"Matrix multiplication identity");require(cube_mesh()->indices.size()==36,"Cube triangle topology");std::cout<<"Render graph and math contracts passed\n";return 0; +void require(bool test, const char* message) { + if (!test) + throw std::runtime_error(message); +} +int main(int argc, char** argv) { + try { + if (argc > 1 && std::string(argv[1]) == "--unit") { + int count{}; + RenderGraph invalid; + invalid.add("consumer", {"missing"}, {}, [&] { ++count; }); + bool caught{}; + try { + invalid.execute(); + } catch (const std::runtime_error&) { + caught = true; + } + require(caught && count == 0, "Graph must validate before side effects"); + RenderGraph graph; + graph.import("external"); + graph.add("first", {"external"}, {"color"}, [&] { + require(count == 0, "Pass order"); + ++count; + }); + graph.add("second", {"color"}, {}, [&] { ++count; }); + graph.execute(); + require(count == 2, "Pass execution count"); + auto t = transform({2, 3, 4}, {}, {2, 3, 4}); + require(t[12] == 2 && t[13] == 3 && t[14] == 4, "Transform translation"); + auto m = multiply(identity, t); + require(m == t, "Matrix multiplication identity"); + require(cube_mesh()->indices.size() == 36, "Cube triangle topology"); + std::cout << "Render graph and math contracts passed\n"; + return 0; + } + bool visible = argc > 1 && std::string(argv[1]) == "--visible"; + Renderer renderer({320, 240, "Faset render validation", !visible, true}); + Snapshot scene; + scene.eye = {4, 3, 5}; + scene.view_projection = + multiply(perspective(.85f, 320.f / 240.f, .1f, 100), look_at(scene.eye, {0, 0, 0})); + scene.draws.push_back( + {cube_mesh(), transform({0, 0, 0}), {.2f, .65f, .95f, 1}, .4f, .15f, true}); + scene.draws.push_back({cube_mesh(), + transform({0, -1, 0}, {}, {10, 1, 10}), + {.45f, .48f, .5f, 1}, + .8f, + 0, + true}); + scene.ui_quads.push_back({8, 8, 70, 16, {.8f, .1f, .15f, 1}}); + auto texture = std::make_shared(); + 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}); + renderer.render(scene); + require(renderer.stats().validation_errors == 0, "Vulkan validation reported an error"); + 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"); + texture->srgb = true; + ++texture->revision; + renderer.render(scene); + auto srgb_pixels = renderer.pixels(); + require(std::abs(int(srgb_pixels[index + 1]) - 220) <= 1, + "Unlit sRGB texture retains its display-space color"); + texture->srgb = false; + ++texture->revision; + auto baked_scene = scene; + auto baked_mesh = std::make_shared(*cube_mesh()); + for (auto& vertex : baked_mesh->vertices) { + vertex.position[0] *= 10; + vertex.position[2] *= 10; + } + baked_scene.draws[1].mesh = baked_mesh; + baked_scene.draws[1].model = transform({0, -1, 0}); + renderer.render(baked_scene); + auto baked_pixels = renderer.pixels(); + std::size_t normal_difference{}; + for (std::size_t i = 0; i < pixels.size(); ++i) + if (std::abs(int(pixels[i]) - int(baked_pixels[i])) > 2) + ++normal_difference; + require(normal_difference < 10, + "Nonuniformly scaled normals must match baked geometry lighting"); + auto shadowed = pixels; + if (argc > 2) + renderer.capture(std::string(argv[2]) + ".shadowed.ppm"); + for (auto& draw : scene.draws) + draw.cast_shadow = false; + renderer.render(scene); + pixels = renderer.pixels(); + std::size_t shadow_difference{}; + for (std::size_t i = 0; i < pixels.size(); i += 4) + if (pixels[i] > shadowed[i] + 8) + ++shadow_difference; + if (shadow_difference <= 20) { + std::cerr << "Shadow difference pixels: " << shadow_difference << "\n"; + if (argc > 2) + renderer.capture(std::string(argv[2]) + ".unshadowed.ppm"); + } + require(shadow_difference > 20, "Directional shadow must darken rendered surface pixels"); + for (auto& draw : scene.draws) + draw.cast_shadow = true; + std::string reload_error; + require(renderer.reload_shaders(reload_error), "Compatible shader pipeline reload"); + texture->rgba = {40, 30, 230, 255}; + ++texture->revision; + renderer.render(scene); + pixels = renderer.pixels(); + require(pixels[index + 2] > 220, "Texture revision upload"); + if (argc > 2) + renderer.capture(argv[2]); + renderer.resize(400, 300); + renderer.poll_events(); + renderer.render(scene); + require(renderer.width() == 400 && renderer.height() == 300, "Render target resize"); + require(renderer.stats().validation_errors == 0, "Resize validation error"); + std::cout << "Vulkan frame, shadow/PBR, atlas upload, readback and resize passed on " + << renderer.stats().device << '\n'; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; } - bool visible=argc>1&&std::string(argv[1])=="--visible"; - Renderer renderer({320,240,"Faset render validation",!visible,true}); - Snapshot scene;scene.eye={4,3,5};scene.view_projection=multiply(perspective(.85f,320.f/240.f,.1f,100),look_at(scene.eye,{0,0,0}));scene.draws.push_back({cube_mesh(),transform({0,0,0}),{.2f,.65f,.95f,1},.4f,.15f,true});scene.draws.push_back({cube_mesh(),transform({0,-1,0},{},{8,.2f,8}),{.45f,.48f,.5f,1},.8f,0,true});scene.ui_quads.push_back({8,8,70,16,{.8f,.1f,.15f,1}}); - auto texture=std::make_shared();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}); - renderer.render(scene);require(renderer.stats().validation_errors==0,"Vulkan validation reported an error");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"); - auto shadowed=pixels; - if(argc>2)renderer.capture(std::string(argv[2])+".shadowed.ppm"); - for(auto& draw:scene.draws)draw.cast_shadow=false; - renderer.render(scene);pixels=renderer.pixels();std::size_t shadow_difference{};for(std::size_t i=0;ishadowed[i]+8)++shadow_difference;if(shadow_difference<=20){std::cerr<<"Shadow difference pixels: "<2)renderer.capture(std::string(argv[2])+".unshadowed.ppm");}require(shadow_difference>20,"Directional shadow must darken rendered surface pixels"); - for(auto& draw:scene.draws)draw.cast_shadow=true; - std::string reload_error;require(renderer.reload_shaders(reload_error),"Compatible shader pipeline reload"); - texture->rgba={40,30,230,255};++texture->revision;renderer.render(scene);pixels=renderer.pixels();require(pixels[index+2]>220,"Texture revision upload"); - if(argc>2)renderer.capture(argv[2]);renderer.resize(400,300);renderer.poll_events();renderer.render(scene);require(renderer.width()==400&&renderer.height()==300,"Render target resize");require(renderer.stats().validation_errors==0,"Resize validation error"); - std::cout<<"Vulkan frame, shadow/PBR, atlas upload, readback and resize passed on "< +#include +#include +#include +#include +#include +#include +#include + +using Json = nlohmann::json; +namespace { +void check(bool value, const char* message) { + if (!value) + throw std::runtime_error(message); +} +template void rejects(F&& function, const char* message) { + bool caught = false; + try { + function(); + } catch (const std::exception&) { + caught = true; + } + check(caught, message); +} +Json component(std::string type, Json fields) { + return {{"id", type}, {"type", type}, {"version", 1}, {"fields", fields}}; +} +Json entity(std::string id, Json parent, Json components) { + return {{"id", id}, {"name", id}, {"parent", parent}, {"components", components}}; +} +void run() { + const auto folder = + std::filesystem::temp_directory_path() / ("faset-player-test-" + faset::new_id()); + std::filesystem::create_directories(folder); + struct Cleanup { + std::filesystem::path path; + ~Cleanup() { + std::error_code error; + std::filesystem::remove_all(path, error); + } + } cleanup{folder}; + Json scene{ + {"format", "faset.scene"}, + {"version", 1}, + {"id", "test"}, + {"name", "Test"}, + {"dimension", 3}, + {"instances", Json::array()}, + {"entities", + Json::array( + {entity("parent", nullptr, + Json::array({component("faset.transform", {{"position", {1, 2, 3}}})})), + entity("child", "parent", + Json::array({component("faset.transform", {{"position", {2, 0, 0}}}), + component("faset.mesh", {{"asset", "builtin:cube"}})}))})}}; + faset::atomic_write_json(folder / "scene.json", scene); + check(faset::player::readScene(folder / "scene.json") == scene, "JSON scene roundtrip"); + const auto cbor = Json::to_cbor(scene); + std::string cooked = "FASETSCN"; + for (int i = 0; i < 4; ++i) + cooked.push_back(static_cast(std::uint32_t{1} >> (8 * i))); + for (int i = 0; i < 8; ++i) + cooked.push_back(static_cast(std::uint64_t(cbor.size()) >> (8 * i))); + cooked.append(reinterpret_cast(cbor.data()), cbor.size()); + faset::atomic_write(folder / "scene.fscene", cooked); + check(faset::player::readScene(folder / "scene.fscene") == scene, + "CBOR cooked scene roundtrip"); + auto truncated = cooked.substr(0, cooked.size() - 1); + faset::atomic_write(folder / "truncated.fscene", truncated); + rejects([&] { faset::player::readScene(folder / "truncated.fscene"); }, + "reject cooked size mismatch"); + auto version = cooked; + version[8] = 2; + faset::atomic_write(folder / "version.fscene", version); + rejects([&] { faset::player::readScene(folder / "version.fscene"); }, "reject cooked version"); + faset::player::SceneView view(folder); + auto snapshot = view.build(scene, 16.f / 9.f); + check(snapshot.draws.size() == 1, "SceneView builtin mesh"); + check(snapshot.draws[0].model[12] == 3 && snapshot.draws[0].model[13] == 2 && + snapshot.draws[0].model[14] == 3, + "hierarchy local transforms composed"); + faset::runtime::Runtime world; + world.load(scene); + auto pose = world.transform(world.find("child")); + pose.position[0] = 5; + world.setTransform(world.find("child"), pose); + snapshot = view.build(world.snapshotJson(), 1); + check(snapshot.draws[0].model[12] == 6, + "runtime presentation overrides original authoring pose"); + auto bad = scene; + bad["entities"][0]["parent"] = "child"; + rejects([&] { view.build(bad, 1); }, "view rejects hierarchy cycle"); + bad = scene; + bad["entities"][1]["components"][1]["fields"]["asset"] = "missing-asset"; + view.build(bad, 1); + check(!view.diagnostics().empty() && view.diagnostics()[0].starts_with("error:"), + "missing asset is diagnostic, not silent success"); + auto camera = faset::player::CameraSettings{}; + camera.eye = camera.target; + rejects([&] { view.build(scene, 1, camera); }, "reject degenerate camera"); + auto two = scene; + two["dimension"] = 2; + two["entities"] = Json::array( + {entity("high", nullptr, + Json::array({component("faset.sprite", {{"layer", 10}, {"color", {1, 0, 0, 1}}})})), + entity( + "low", nullptr, + Json::array({component("faset.sprite", {{"layer", 0}, {"color", {0, 1, 0, 1}}})}))}); + snapshot = view.build(two, 1); + check(snapshot.sprites.size() == 2 && snapshot.sprites[0].color[1] == 1, + "2D sprites sorted by layer"); + + // Import a real textured glTF fixture, then consume only its cooked generation. + std::string geometry; + auto word = [&](std::uint32_t v) { + for (int i = 0; i < 4; ++i) + geometry.push_back(static_cast(v >> (8 * i))); + }; + for (float value : {0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f, 0.f}) + word(std::bit_cast(value)); + for (char value : {0, 0, 1, 0, 2, 0}) + geometry.push_back(value); + faset::atomic_write(folder / "geometry.bin", geometry); + const std::vector png{ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, + 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, + 13, 73, 68, 65, 84, 120, 156, 99, 248, 16, 32, 242, 31, 0, 5, 220, 2, 84, + 184, 210, 98, 74, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130}; + faset::atomic_write(folder / "pixel.png", + std::string_view(reinterpret_cast(png.data()), png.size())); + Json gltf = { + {"asset", {{"version", "2.0"}}}, + {"scene", 0}, + {"scenes", Json::array({{{"nodes", Json::array({0})}}})}, + {"nodes", Json::array({{{"mesh", 0}, {"translation", {2, 0, 0}}}})}, + {"buffers", Json::array({{{"uri", "geometry.bin"}, {"byteLength", 42}}})}, + {"bufferViews", Json::array({{{"buffer", 0}, {"byteOffset", 0}, {"byteLength", 36}}, + {{"buffer", 0}, {"byteOffset", 36}, {"byteLength", 6}}})}, + {"accessors", + Json::array( + {{{"bufferView", 0}, + {"componentType", 5126}, + {"count", 3}, + {"type", "VEC3"}, + {"min", {0, 0, 0}}, + {"max", {1, 1, 0}}}, + {{"bufferView", 1}, {"componentType", 5123}, {"count", 3}, {"type", "SCALAR"}}})}, + {"meshes", Json::array({{{"primitives", Json::array({{{"attributes", {{"POSITION", 0}}}, + {"indices", 1}, + {"material", 0}}})}}})}, + {"materials", Json::array({{{"pbrMetallicRoughness", + {{"baseColorFactor", {1, 1, 1, 1}}, + {"baseColorTexture", {{"index", 0}}}, + {"metallicFactor", 0}, + {"roughnessFactor", 0.5}}}}})}, + {"images", Json::array({{{"uri", "pixel.png"}}})}, + {"textures", Json::array({{{"source", 0}}})}}; + faset::atomic_write_json(folder / "triangle.gltf", gltf); + faset::assets::AssetPipeline pipeline(folder); + auto imported = pipeline.import_asset({folder / "triangle.gltf"}); + check(imported.ok(), "real textured glTF fixture import"); + auto importedScene = scene; + importedScene["entities"][1]["components"][1]["fields"]["asset"] = imported.asset_id; + snapshot = view.build(importedScene, 1); + check(view.diagnostics().empty(), "valid cooked texture/material produces no error"); + check(snapshot.draws.size() == 1 && snapshot.draws[0].mesh->vertices.size() == 3, + "cooked mesh reaches render snapshot"); + check(snapshot.draws[0].model[12] == 5, "asset node transform composed with scene hierarchy"); + check(snapshot.draws[0].texture && snapshot.draws[0].texture->srgb && + snapshot.draws[0].texture->rgba == std::vector({240, 80, 20, 255}), + "PNG decoded into sRGB base-color texture"); +} +} // namespace +int main() { + try { + run(); + std::cout << "Player JSON/CBOR, hierarchy, runtime snapshot, camera, sprite ordering and " + "asset error contracts passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/runtime_tests.cpp b/tests/runtime_tests.cpp index ac48c2f..5618e76 100644 --- a/tests/runtime_tests.cpp +++ b/tests/runtime_tests.cpp @@ -1,6 +1,6 @@ -#include #include "Gameplay.hpp" #include +#include #include #include #include @@ -8,82 +8,321 @@ #include using namespace faset::runtime; -using Json=nlohmann::json; +using Json = nlohmann::json; namespace { -void check(bool result,const char* text){if(!result)throw std::runtime_error(text);} -void near(float actual,float expected,float tolerance,const char* text){check(std::abs(actual-expected)void rejects(F&& fn,const char* message){bool caught=false;try{fn();}catch(const std::exception&){caught=true;}check(caught,message);} -Json component(std::string type,Json fields=Json::object()){return {{"id",type+"-id"},{"type",type},{"version",1},{"fields",fields}};} -Json entity(std::string id,float y=0){return {{"id",id},{"name",id},{"parent",nullptr},{"components",Json::array({component("faset.transform",{{"position",{0,y,0}}})})}};} -Json scene(int dim=2){return {{"format","faset.scene"},{"version",1},{"id","test-scene"},{"name","Test"},{"dimension",dim},{"entities",Json::array()},{"instances",Json::array()}};} -void physics(int dimension){ - Runtime world;auto doc=scene(dimension);auto floor=entity("ground",-0.5f);auto falling=entity("falling",4); - auto type=dimension==2?"faset.rigid_body_2d":"faset.rigid_body_3d"; - Json extents=dimension==2?Json{10,0.5}:Json{10,0.5,10}; - floor["components"].push_back(component(type,{{"body_type","static"},{"half_extents",extents}})); - falling["components"].push_back(component(type));doc["entities"]=Json::array({floor,falling});world.load(doc);auto h=world.find("falling"); - bool contact=false; - for(int i=0;i<240;++i){world.advance(1.0/60);for(const auto& event:world.collisions())contact=contact||event.began;} - near(world.transform(h).position[1],0.5f,0.09f,"body must fall and settle on actual solver floor");check(contact,"native contact event must be delivered"); - auto pose=world.transform(h);pose.position[1]=6;world.teleport(h,pose); - near(world.presentation(h).position[1],6,0.0001f,"teleport resets interpolation"); - rejects([&]{world.setTransform(h,pose);},"physics transform cannot be casually overwritten"); - world.applyImpulse(h,{0,2,0});check(world.velocity(h)[1]>0,"impulse changes solver velocity"); +void check(bool result, const char* text) { + if (!result) + throw std::runtime_error(text); } -void lifecycle(){ - Runtime world;std::vector events;bool spawned=false;float presented=-1;int pressedTicks=0; +void near(float actual, float expected, float tolerance, const char* text) { + check(std::abs(actual - expected) < tolerance, text); +} +template void rejects(F&& fn, const char* message) { + bool caught = false; + try { + fn(); + } catch (const std::exception&) { + caught = true; + } + check(caught, message); +} +Json component(std::string type, Json fields = Json::object()) { + return {{"id", type + "-id"}, {"type", type}, {"version", 1}, {"fields", fields}}; +} +Json entity(std::string id, float y = 0) { + return {{"id", id}, + {"name", id}, + {"parent", nullptr}, + {"components", Json::array({component("faset.transform", {{"position", {0, y, 0}}})})}}; +} +Json scene(int dim = 2) { + return {{"format", "faset.scene"}, {"version", 1}, + {"id", "test-scene"}, {"name", "Test"}, + {"dimension", dim}, {"entities", Json::array()}, + {"instances", Json::array()}}; +} +void physics(int dimension) { + Runtime world; + auto doc = scene(dimension); + auto floor = entity("ground", -0.5f); + auto falling = entity("falling", 4); + auto type = dimension == 2 ? "faset.rigid_body_2d" : "faset.rigid_body_3d"; + Json extents = dimension == 2 ? Json{10, 0.5} : Json{10, 0.5, 10}; + floor["components"].push_back( + component(type, {{"body_type", "static"}, {"half_extents", extents}})); + falling["components"].push_back(component(type)); + doc["entities"] = Json::array({floor, falling}); + world.load(doc); + auto h = world.find("falling"); + bool contact = false; + for (int i = 0; i < 240; ++i) { + world.advance(1.0 / 60); + for (const auto& event : world.collisions()) + contact = contact || event.began; + } + near(world.transform(h).position[1], 0.5f, 0.09f, + "body must fall and settle on actual solver floor"); + check(contact, "native contact event must be delivered"); + check(world.grounded(h), "settled body must have a supporting native contact"); + auto pose = world.transform(h); + pose.position[1] = 6; + world.teleport(h, pose); + near(world.presentation(h).position[1], 6, 0.0001f, "teleport resets interpolation"); + check(!world.grounded(h), "teleport off the floor removes grounded state"); + rejects([&] { world.setTransform(h, pose); }, + "physics transform cannot be casually overwritten"); + world.applyImpulse(h, {0, 2, 0}); + check(world.velocity(h)[1] > 0, "impulse changes solver velocity"); +} +void lifecycle() { + Runtime world; + std::vector events; + bool spawned = false; + float presented = -1; + int pressedTicks = 0; Behavior behavior; - behavior.onStart=[&](Runtime&,EntityHandle,double){events.push_back("start");}; - behavior.fixedUpdate=[&](Runtime& r,EntityHandle h,double){events.push_back("fixed");if(r.input().jumpPressed)++pressedTicks;auto t=r.transform(h);t.position[0]+=1;r.setTransform(h,t);if(!spawned){r.spawn(entity("spawned"));spawned=true;}}; - behavior.update=[&](Runtime&,EntityHandle,double){events.push_back("update");}; - behavior.lateUpdate=[&](Runtime& r,EntityHandle h,double){events.push_back("late");presented=r.presentation(h).position[0];}; - behavior.onDestroy=[&](Runtime& r,EntityHandle h,double){check(r.valid(h),"OnDestroy still sees a valid handle");events.push_back("destroy");}; - world.registerBehavior("test.behavior",behavior);auto doc=scene();auto object=entity("main");object["components"].push_back(component("test.behavior"));doc["entities"].push_back(object);world.load(doc); - check(events==std::vector{"start"},"load runs OnStart once"); - world.advance(1.0/120,{0,0,true,false});check(!world.find("spawned"),"zero-tick frame applies no structural commands"); - world.advance(1.0/60);check(!world.find("spawned"),"FixedUpdate spawn must wait until next tick");near(presented,0.5f,0.001f,"LateUpdate receives interpolated transform");check(pressedTicks==1,"input edge preserved across zero-tick frame"); - world.advance(3.0/60);check(bool(world.find("spawned")),"spawn appears next tick");check(pressedTicks==1,"edge not repeated in catchup ticks"); - auto old=world.find("main");world.destroy(old);check(world.valid(old),"destroy deferred");world.singleStep();check(!world.valid(old),"handle invalid after removal"); - check(events.back()=="destroy","destroy lifecycle runs exactly at barrier"); - world.spawn(entity("replacement"));world.singleStep();check(!world.valid(old),"reused slot never revives a stale handle"); - auto replacement=world.find("replacement");world.load(doc);check(!world.valid(replacement),"load creates a new session"); + behavior.onStart = [&](Runtime&, EntityHandle, double) { events.push_back("start"); }; + behavior.fixedUpdate = [&](Runtime& r, EntityHandle h, double) { + events.push_back("fixed"); + if (r.input().jumpPressed) + ++pressedTicks; + auto t = r.transform(h); + t.position[0] += 1; + r.setTransform(h, t); + if (!spawned) { + r.spawn(entity("spawned")); + spawned = true; + } + }; + behavior.update = [&](Runtime&, EntityHandle, double) { events.push_back("update"); }; + behavior.lateUpdate = [&](Runtime& r, EntityHandle h, double) { + events.push_back("late"); + presented = r.presentation(h).position[0]; + }; + behavior.onDestroy = [&](Runtime& r, EntityHandle h, double) { + check(r.valid(h), "OnDestroy still sees a valid handle"); + events.push_back("destroy"); + }; + world.registerBehavior("test.behavior", behavior); + auto doc = scene(); + auto object = entity("main"); + object["components"].push_back(component("test.behavior")); + doc["entities"].push_back(object); + world.load(doc); + check(events == std::vector{"start"}, "load runs OnStart once"); + world.advance(1.0 / 120, {0, 0, true, false}); + check(!world.find("spawned"), "zero-tick frame applies no structural commands"); + world.advance(1.0 / 60); + check(!world.find("spawned"), "FixedUpdate spawn must wait until next tick"); + near(presented, 0.5f, 0.001f, "LateUpdate receives interpolated transform"); + check(pressedTicks == 1, "input edge preserved across zero-tick frame"); + world.advance(3.0 / 60); + check(bool(world.find("spawned")), "spawn appears next tick"); + check(pressedTicks == 1, "edge not repeated in catchup ticks"); + auto old = world.find("main"); + world.destroy(old); + check(world.valid(old), "destroy deferred"); + world.singleStep(); + check(!world.valid(old), "handle invalid after removal"); + check(events.back() == "destroy", "destroy lifecycle runs exactly at barrier"); + world.spawn(entity("replacement")); + world.singleStep(); + check(!world.valid(old), "reused slot never revives a stale handle"); + auto replacement = world.find("replacement"); + world.load(doc); + check(!world.valid(replacement), "load creates a new session"); // Ensure captured state remains alive while Runtime's destructor calls OnDestroy. world.clear(); } -void clockAndValidation(){ - Runtime world;auto doc=scene();doc["entities"].push_back(entity("object"));world.load(doc); - auto stats=world.advance(1.0);check(stats.fixedTicks==4,"catchup bounded to four ticks");check(stats.droppedTime>0.9,"excess time reported");check(stats.interpolationAlpha>=0&&stats.interpolationAlpha<1,"interpolation fraction bounded"); - auto tick=stats.tick;world.setPaused(true);world.advance(100);check(world.snapshot().tick==tick,"pause does not accumulate");world.singleStep();check(world.snapshot().tick==tick+1,"single-step advances exactly once");world.setPaused(false);check(world.advance(0).fixedTicks==0,"resume does not catch up pause"); - auto old=world.find("object");auto invalid=doc;invalid["entities"][0]["parent"]="object";rejects([&]{world.load(invalid);},"reject hierarchy cycle");check(world.valid(old),"invalid load preserves old world"); - invalid=doc;invalid["entities"][0]["components"].push_back(component("faset.rigid_body_3d"));rejects([&]{world.load(invalid);},"reject physics dimension mismatch"); - rejects([&]{world.advance(-1);},"reject negative time"); - world.addComponent(old,component("faset.sprite"));check(!world.snapshot().entities[0].sprite,"component addition deferred");world.singleStep();check(world.snapshot().entities[0].sprite.has_value(),"component added at barrier");world.removeComponent(old,"faset.sprite");world.singleStep();check(!world.snapshot().entities[0].sprite,"component removed at barrier"); - Runtime other;other.load(doc);check(!other.valid(old),"handle cannot cross worlds"); - auto schema=faset::gameplay::schema();check(schema.size()==2,"sample has explicit metadata without world"); +void clockAndValidation() { + Runtime world; + auto doc = scene(); + doc["entities"].push_back(entity("object")); + world.load(doc); + auto stats = world.advance(1.0); + check(stats.fixedTicks == 4, "catchup bounded to four ticks"); + check(stats.droppedTime > 0.9, "excess time reported"); + check(stats.interpolationAlpha >= 0 && stats.interpolationAlpha < 1, + "interpolation fraction bounded"); + auto tick = stats.tick; + world.setPaused(true); + world.advance(100); + check(world.snapshot().tick == tick, "pause does not accumulate"); + world.singleStep(); + check(world.snapshot().tick == tick + 1, "single-step advances exactly once"); + world.setPaused(false); + check(world.advance(0).fixedTicks == 0, "resume does not catch up pause"); + auto old = world.find("object"); + auto invalid = doc; + invalid["entities"][0]["parent"] = "object"; + rejects([&] { world.load(invalid); }, "reject hierarchy cycle"); + check(world.valid(old), "invalid load preserves old world"); + invalid = doc; + invalid["entities"][0]["components"].push_back(component("faset.rigid_body_3d")); + rejects([&] { world.load(invalid); }, "reject physics dimension mismatch"); + rejects([&] { world.advance(-1); }, "reject negative time"); + world.addComponent(old, component("faset.sprite")); + check(!world.snapshot().entities[0].sprite, "component addition deferred"); + world.singleStep(); + check(world.snapshot().entities[0].sprite.has_value(), "component added at barrier"); + world.removeComponent(old, "faset.sprite"); + world.singleStep(); + check(!world.snapshot().entities[0].sprite, "component removed at barrier"); + Runtime other; + other.load(doc); + check(!other.valid(old), "handle cannot cross worlds"); + auto schema = faset::gameplay::schema(); + check(schema.size() == 2, "sample has explicit metadata without world"); } -void structuralFailuresAndCallbacks(){ - Runtime world;auto doc=scene();doc["entities"].push_back(entity("object"));world.load(doc);auto h=world.find("object"); - world.addComponent(h,component("faset.sprite",{{"size",{-1,2}}}));world.singleStep();check(!world.snapshot().entities[0].sprite,"invalid deferred component leaves entity unchanged");check(!world.diagnostics().empty(),"invalid deferred command reports diagnostic"); - auto zero=world.transform(h);zero.scale[0]=0;world.setTransform(h,zero);world.addComponent(h,component("faset.rigid_body_2d"));world.singleStep();rejects([&]{world.fields(h,"faset.rigid_body_2d");},"invalid runtime collider scale must not half-add component"); - zero.scale[0]=1;world.setTransform(h,zero);world.addComponent(h,component("faset.rigid_body_2d"));world.singleStep();check(world.velocity(h)[1]<0,"deferred body runs actual physics"); - world.removeComponent(h,"faset.rigid_body_2d");world.singleStep();rejects([&]{world.velocity(h);},"removed physics adapter no longer accessible"); - world.destroy(h);world.destroy(h);world.singleStep();check(!world.valid(h),"repeated deferred destroy is safe"); - rejects([&]{world.advance(std::numeric_limits::quiet_NaN());},"nonfinite time rejected"); - rejects([&]{world.advance(0,{std::numeric_limits::infinity(),0,false,false});},"nonfinite input rejected"); - Runtime callbacks;std::vector order; - Behavior b;b.onStart=[&](Runtime& r,EntityHandle,double){order.push_back("start");rejects([&]{r.singleStep();},"OnStart cannot recursively advance");}; - b.fixedUpdate=[&](Runtime&,EntityHandle,double){order.push_back("fixed");throw std::runtime_error("intentional callback failure");}; - b.update=[&](Runtime&,EntityHandle,double){order.push_back("update");}; - b.lateUpdate=[&](Runtime& r,EntityHandle h,double){order.push_back("late");auto p=r.presentation(h);p.position[2]=9;r.setPresentation(h,p);}; - callbacks.registerBehavior("test",b);auto object=entity("callbacks");object["components"].push_back(component("test"));doc["entities"]=Json::array({object});callbacks.load(doc);callbacks.singleStep(); - check(order==std::vector{"start","fixed","update","late"},"callback failure does not skip remaining phases");check(callbacks.diagnostics().size()==1,"callback exception diagnostic");near(callbacks.snapshot().entities[0].transform.position[2],9,0.001f,"LateUpdate changes final presentation only");near(callbacks.transform(callbacks.find("callbacks")).position[2],0,0.001f,"presentation does not overwrite simulation");callbacks.clear(); +void structuralFailuresAndCallbacks() { + Runtime world; + auto doc = scene(); + doc["entities"].push_back(entity("object")); + world.load(doc); + auto h = world.find("object"); + world.addComponent(h, component("faset.sprite", {{"size", {-1, 2}}})); + world.singleStep(); + check(!world.snapshot().entities[0].sprite, + "invalid deferred component leaves entity unchanged"); + check(!world.diagnostics().empty(), "invalid deferred command reports diagnostic"); + auto zero = world.transform(h); + zero.scale[0] = 0; + world.setTransform(h, zero); + world.addComponent(h, component("faset.rigid_body_2d")); + world.singleStep(); + rejects([&] { world.fields(h, "faset.rigid_body_2d"); }, + "invalid runtime collider scale must not half-add component"); + zero.scale[0] = 1; + world.setTransform(h, zero); + world.addComponent(h, component("faset.rigid_body_2d")); + world.singleStep(); + check(world.velocity(h)[1] < 0, "deferred body runs actual physics"); + world.removeComponent(h, "faset.rigid_body_2d"); + world.singleStep(); + rejects([&] { world.velocity(h); }, "removed physics adapter no longer accessible"); + world.destroy(h); + world.destroy(h); + world.singleStep(); + check(!world.valid(h), "repeated deferred destroy is safe"); + rejects([&] { world.advance(std::numeric_limits::quiet_NaN()); }, + "nonfinite time rejected"); + rejects([&] { world.advance(0, {std::numeric_limits::infinity(), 0, false, false}); }, + "nonfinite input rejected"); + Runtime callbacks; + std::vector order; + Behavior b; + b.onStart = [&](Runtime& r, EntityHandle, double) { + order.push_back("start"); + rejects([&] { r.singleStep(); }, "OnStart cannot recursively advance"); + }; + b.fixedUpdate = [&](Runtime&, EntityHandle, double) { + order.push_back("fixed"); + throw std::runtime_error("intentional callback failure"); + }; + b.update = [&](Runtime&, EntityHandle, double) { order.push_back("update"); }; + b.lateUpdate = [&](Runtime& r, EntityHandle h, double) { + order.push_back("late"); + auto p = r.presentation(h); + p.position[2] = 9; + r.setPresentation(h, p); + }; + callbacks.registerBehavior("test", b); + auto object = entity("callbacks"); + object["components"].push_back(component("test")); + doc["entities"] = Json::array({object}); + callbacks.load(doc); + callbacks.singleStep(); + check(order == std::vector{"start", "fixed", "update", "late"}, + "callback failure does not skip remaining phases"); + check(callbacks.diagnostics().size() == 1, "callback exception diagnostic"); + near(callbacks.snapshot().entities[0].transform.position[2], 9, 0.001f, + "LateUpdate changes final presentation only"); + near(callbacks.transform(callbacks.find("callbacks")).position[2], 0, 0.001f, + "presentation does not overwrite simulation"); + callbacks.clear(); } -void sampleGameplay(){ - Runtime world;faset::gameplay::registerGameplay(world);auto doc=scene(3);auto door=entity("door");door["components"].push_back(component("gameplay.door",{{"speed",2.0}}));doc["entities"]=Json::array({door});world.load(doc); - world.advance(1.0/60,{0,0,false,true});for(int i=0;i<59;++i)world.advance(1.0/60); - near(world.transform(world.find("door")).rotation[1],1.5707963f,0.001f,"sample door opens through real static gameplay callback"); - world.advance(1.0/60,{0,0,false,true});for(int i=0;i<59;++i)world.advance(1.0/60); - near(world.transform(world.find("door")).rotation[1],0,0.001f,"sample door toggles closed"); +void sampleGameplay() { + Runtime world; + faset::gameplay::registerGameplay(world); + auto doc = scene(3); + auto door = entity("door"); + door["components"].push_back(component("gameplay.door", {{"speed", 2.0}})); + doc["entities"] = Json::array({door}); + world.load(doc); + world.advance(1.0 / 60, {0, 0, false, true}); + for (int i = 0; i < 59; ++i) + world.advance(1.0 / 60); + near(world.transform(world.find("door")).rotation[1], 1.5707963f, 0.001f, + "sample door opens through real static gameplay callback"); + world.advance(1.0 / 60, {0, 0, false, true}); + for (int i = 0; i < 59; ++i) + world.advance(1.0 / 60); + near(world.transform(world.find("door")).rotation[1], 0, 0.001f, "sample door toggles closed"); } +void groundedJump() { + Runtime world; + faset::gameplay::registerGameplay(world); + auto doc = scene(2); + auto ground = entity("ground", -0.5f); + ground["components"].push_back( + component("faset.rigid_body_2d", {{"body_type", "static"}, {"half_extents", {10, .5}}})); + auto character = entity("character", 0.5f); + character["components"].push_back(component("faset.rigid_body_2d")); + character["components"].push_back(component("gameplay.character")); + doc["entities"] = Json::array({ground, character}); + world.load(doc); + auto h = world.find("character"); + for (int i = 0; i < 10; ++i) + world.advance(1.0 / 60); + check(world.grounded(h), "character starts supported"); + world.advance(1.0 / 60, {0, 0, true, false}); + check(world.velocity(h)[1] > 4, "grounded jump sets upward velocity"); + for (int i = 0; i < 90 && world.velocity(h)[1] > 0.1f; ++i) + world.advance(1.0 / 60); + check(!world.grounded(h), "apex is not grounded"); + auto before = world.velocity(h)[1]; + world.advance(1.0 / 60, {0, 0, true, false}); + check(world.velocity(h)[1] < before, "jump at apex must not create a second impulse"); + for (int i = 0; i < 180; ++i) + world.advance(1.0 / 60); + check(world.grounded(h), "character regains ground after landing"); + Runtime wall; + auto wallScene = scene(2); + auto obstacle = entity("wall"); + obstacle["components"].push_back( + component("faset.rigid_body_2d", {{"body_type", "static"}, {"half_extents", {.5, 5}}})); + auto body = entity("side"); + body["components"][0]["fields"]["position"] = {1, 0, 0}; + body["components"].push_back(component("faset.rigid_body_2d", {{"gravity_scale", 0}})); + wallScene["entities"] = Json::array({obstacle, body}); + wall.load(wallScene); + for (int i = 0; i < 5; ++i) + wall.advance(1.0 / 60); + check(!wall.grounded(wall.find("side")), "wall contact is not a supporting floor contact"); +} +} // namespace +int main() { + try { + auto run = [](const char* name, auto fn) { + try { + fn(); + std::cout << name << " passed\n"; + } catch (const std::exception& error) { + throw std::runtime_error(std::string(name) + ": " + error.what()); + } + }; + run("Box2D", [] { physics(2); }); + run("Box3D", [] { physics(3); }); + run("Lifecycle", lifecycle); + run("Clock/validation", clockAndValidation); + run("Structural failures", structuralFailuresAndCallbacks); + run("Sample gameplay", sampleGameplay); + run("Grounded jump", groundedJump); + std::cout << "runtime contracts passed: actual Box2D/Box3D collisions, lifecycle, handles, " + "interpolation, deferred mutation, catchup, pause, validation, gameplay\n"; + return 0; + } catch (const std::exception& ex) { + std::cerr << ex.what() << '\n'; + return 1; + } } -int main(){try{auto run=[](const char* name,auto fn){try{fn();std::cout< +#include +#include +#include +#include +#include + +namespace { +void check(bool value, const char* message) { + if (!value) + throw std::runtime_error(message); +} +void near(float a, float b, const char* message) { + check(std::abs(a - b) < 0.002f, message); +} +} // namespace +int main() { + try { + nlohmann::json scene; + std::ifstream input(FASET_TUTORIAL_SCENE); + input >> scene; + std::set stableIds; + for (const auto& entity : scene.at("entities")) { + check(stableIds.insert(entity.at("id").get()).second, + "entity stable ID must be globally unique"); + for (const auto& component : entity.at("components")) + check(stableIds.insert(component.at("id").get()).second, + "component stable ID must be globally unique for Editor authoring"); + } + const auto types = faset::gameplay::schema(); + check(types.is_array() && !types.empty(), "tutorial must export real component schemas"); + for (const auto& type : types) + for (const auto& [id, field] : type.at("fields").items()) + check(field.at("id") == id && field.contains("default"), + "schema FieldId/default contract"); + faset::runtime::Runtime world; + faset::gameplay::registerGameplay(world); + world.load(scene); + const std::string tutorial = FASET_TUTORIAL_NAME; + if (tutorial == "moving") { + for (int i = 0; i < 60; ++i) + world.advance(1.0 / 60); + near(world.transform(world.find("actor")).position[0], 2, + "60 Hz motion covers two metres per second"); + world.load(scene); + for (int i = 0; i < 30; ++i) + world.advance(1.0 / 30); + near(world.transform(world.find("actor")).position[0], 2, + "30 Hz motion covers the same distance"); + } else if (tutorial == "following") { + world.advance(1.5 / 60); + near(world.presentation(world.find("actor")).position[0], 1.0f / 60, + "presentation halfway between completed fixed poses"); + near(world.presentation(world.find("camera")).position[0], + world.presentation(world.find("actor")).position[0], + "LateUpdate follows interpolated target"); + near(world.transform(world.find("camera")).position[0], 0, + "following does not change simulation transform"); + const auto target = world.find("actor"); + world.destroy(target); + world.singleStep(); + check(!world.valid(target), "target handle invalid after destruction"); + } else if (tutorial == "spawning") { + check(!world.find("temporary-box"), "OnStart spawn deferred until first tick"); + world.singleStep(); + auto spawned = world.find("temporary-box"); + check(world.valid(spawned), "child created at first barrier"); + for (int i = 0; i < 90; ++i) + world.singleStep(); + check(!world.valid(spawned) && !world.find("temporary-box"), + "lifetime removes temporary entity"); + world.load(scene); + world.singleStep(); + check(world.valid(world.find("temporary-box")) && !world.valid(spawned), + "module state and handles work across scene restart"); + } else if (tutorial == "physics") { + auto self = world.find("actor"); + for (int i = 0; i < 10; ++i) + world.advance(1.0 / 60); + check(world.grounded(self), "controller starts on floor"); + world.advance(1.0 / 60, {1, 0, true, false}); + check(world.velocity(self)[0] > 3.5f && world.velocity(self)[1] > 4, + "input applies velocity and grounded jump"); + for (int i = 0; i < 90 && world.velocity(self)[1] > 0.1f; ++i) + world.advance(1.0 / 60); + check(!world.grounded(self), "apex has no ground contact"); + const float before = world.velocity(self)[1]; + world.advance(1.0 / 60, {0, 0, true, false}); + check(world.velocity(self)[1] < before, "controller refuses air jump"); + for (int i = 0; i < 180; ++i) + world.advance(1.0 / 60); + check(world.grounded(self), "controller lands again"); + } else + throw std::runtime_error("Unknown compiled tutorial"); + check(world.diagnostics().empty(), "tutorial callbacks must not silently report errors"); + std::cout << tutorial << " tutorial compiled and passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/ui_render_test.cpp b/tests/ui_render_test.cpp new file mode 100644 index 0000000..69a4900 --- /dev/null +++ b/tests/ui_render_test.cpp @@ -0,0 +1,115 @@ +#include +#include +#include +using namespace faset; +int main(int argc, char** argv) { + try { + render::Renderer renderer({1280, 800, "Faset UI reference implementation", true, true}); + ui::Context ui(FASET_TEST_FONT); + ui.set_theme(ui::Theme::load(FASET_UI_THEME)); + ui.apply_layout( + read_json(std::filesystem::path(FASET_UI_THEME).parent_path() / "editor-layout.json")); + auto& menu = ui.find("menubar")->add(ui::Kind::Row, "menuitems"); + for (const auto& name : {"Faset", "File", "Edit", "Scene", "View", "Help"}) + menu.add(ui::Kind::Button, "menu-" + std::string(name), name).layout.width = 65; + auto& tools = ui.find("toolbar")->add(ui::Kind::Row, "tools"); + for (const auto& name : + {"Workshop", "Courtyard", "Save", "Undo", "Redo", "Play", "Stop", "Build"}) + tools.add(ui::Kind::Button, "tool-" + std::string(name), name).layout.width = 80; + auto& scene = *ui.find("scene_panel"); + scene.add(ui::Kind::Tab, "scene-tab", "Scene").selected = true; + auto& tree = scene.add(ui::Kind::Column, "scene-tree"); + tree.layout.flex = 1; + tree.layout.scroll = true; + tree.layout.gap = 0; + for (const auto& name : + {"Courtyard", "Camera", "Sun", "Ground", "Player", "Door", "Crates"}) { + auto& row = tree.add(ui::Kind::TreeRow, "object-" + std::string(name), name); + row.indent = std::string(name) == "Courtyard" ? 0 : 1; + row.selected = std::string(name) == "Door"; + } + auto& inspector = *ui.find("inspector_panel"); + inspector.add(ui::Kind::Tab, "inspector-tab", "Inspector").selected = true; + auto& body = inspector.add(ui::Kind::Column, "properties"); + body.layout.padding = 10; + body.layout.scroll = true; + body.layout.flex = 1; + body.layout.gap = 8; + body.add(ui::Kind::TextField, "object-name", "Door"); + body.add(ui::Kind::Label, "transform-title", "Transform"); + for (const auto& name : {"Position", "Rotation", "Scale"}) { + auto& row = body.add(ui::Kind::Row, "property-" + std::string(name)); + row.layout.height = 30; + row.add(ui::Kind::Label, "label-" + std::string(name), name).layout.width = 64; + for (int axis = 0; axis < 3; ++axis) { + auto& field = + row.add(ui::Kind::NumberField, std::string(name) + std::to_string(axis)); + field.layout.flex = 1; + field.layout.min_width = 40; + field.value = std::string(name) == "Scale" ? 1 : 0; + } + } + body.add(ui::Kind::Label, "mesh-title", "Mesh"); + body.add(ui::Kind::Button, "mesh-asset", "Door.glb"); + body.add(ui::Kind::Label, "body-title", "Rigid Body"); + auto& check = body.add(ui::Kind::Checkbox, "static", "Static"); + check.checked = true; + body.add(ui::Kind::Label, "controller-title", "Door Controller (C++)"); + auto& speed = body.add(ui::Kind::NumberField, "speed"); + speed.value = 2; + body.add(ui::Kind::Button, "add-component", "Add Component"); + body.add(ui::Kind::Label, "unicode-check", "Cyrillic: Дверь, сцена"); + auto& bottom = *ui.find("bottom_panel"); + auto& tabs = bottom.add(ui::Kind::Row, "asset-tabs"); + tabs.layout.height = 30; + tabs.add(ui::Kind::Tab, "assets-tab", "Assets").selected = true; + tabs.add(ui::Kind::Tab, "console-tab", "Console"); + auto& search = bottom.add(ui::Kind::Row, "asset-search"); + search.layout.height = 30; + search.add(ui::Kind::Label, "breadcrumb", "Assets / Models").layout.flex = 1; + search.add(ui::Kind::TextField, "search", "Search assets...").layout.width = 260; + for (const auto& name : {"Door.glb", "Crate.glb", "Ground.material", "Courtyard.scene"}) { + auto& row = bottom.add(ui::Kind::TreeRow, "asset-" + std::string(name), name); + row.layout.height = 26; + row.indent = 1; + } + ui.find("statusbar") + ->add(ui::Kind::Label, "status", + "Ready Vulkan 1.3 | C++ " + "gameplay | Local project"); + ui.layout(1280, 800); + render::Snapshot snapshot; + const auto rect = ui.find("viewport")->rect; + snapshot.scene_rect = {rect.x, rect.y, rect.width, rect.height}; + snapshot.eye = {5, 4, 7}; + snapshot.view_projection = + render::multiply(render::perspective(.75f, rect.width / rect.height, .1f, 100), + render::look_at(snapshot.eye, {0, 1, 0})); + render::DrawItem ground; + ground.mesh = render::cube_mesh(); + ground.model = render::transform({0, -.25f, 0}, {}, {8, .5f, 8}); + ground.color = {.25f, .29f, .32f, 1}; + snapshot.draws.push_back(ground); + render::DrawItem door; + door.mesh = render::cube_mesh(); + door.model = render::transform({0, 1.25f, 0}, {}, {1.6f, 2.5f, .3f}); + door.color = {.46f, .28f, .13f, 1}; + snapshot.draws.push_back(door); + render::DrawItem crate; + crate.mesh = render::cube_mesh(); + crate.model = render::transform({-2, .6f, 1}, {0, .2f, 0}, {1.2f, 1.2f, 1.2f}); + crate.color = {.38f, .25f, .14f, 1}; + snapshot.draws.push_back(crate); + ui.draw(snapshot); + renderer.render(snapshot); + renderer.capture(argc > 1 ? argv[1] : "ui-test.ppm"); + if (renderer.stats().validation_errors) + throw std::runtime_error("Vulkan validation reported UI rendering errors"); + std::cout << "UI glyph atlas and retained panels rendered on " << renderer.stats().device + << '\n'; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} diff --git a/tests/ui_tests.cpp b/tests/ui_tests.cpp new file mode 100644 index 0000000..3d4b2cf --- /dev/null +++ b/tests/ui_tests.cpp @@ -0,0 +1,239 @@ +#include +#include +#include +#include +using namespace faset; +namespace { +void check(bool value, const char* message) { + if (!value) + throw std::runtime_error(message); +} +render::Event key(std::string name, bool control = false, bool shift = false) { + render::Event e; + e.type = render::Event::Type::KeyDown; + e.key = std::move(name); + e.control = control; + e.shift = shift; + return e; +} +render::Event text(std::string value) { + render::Event e; + e.type = render::Event::Type::TextInput; + e.text = std::move(value); + return e; +} +render::Event mouse(render::Event::Type type, float x, float y) { + render::Event e; + e.type = type; + e.x = x; + e.y = y; + e.button = 1; + return e; +} +void click(ui::Context& context, const ui::Widget& widget) { + const auto r = widget.rect; + context.handle(mouse(render::Event::Type::MouseDown, r.x + 5, r.y + 5)); + context.handle(mouse(render::Event::Type::MouseUp, r.x + 5, r.y + 5)); +} +} // namespace +int main() { + try { + ui::TextBuffer buffer("Привет"); + check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint"); + check(buffer.undo() && buffer.text() == "Привет", "text undo"); + buffer.select_all(); + buffer.insert("Дверь"); + buffer.left(true); + check(buffer.selected_text() == "ь", "UTF-8 selection"); + buffer.insert("ца"); + check(buffer.text() == "Дверца", "selection replacement"); + check(!buffer.insert(std::string("\xc0\x80", 2)), "overlong UTF-8 accepted"); + buffer.home(); + buffer.delete_forward(); + check(buffer.text() == "верца", "UTF-8 delete"); + buffer.undo(); + check(buffer.text() == "Дверца", "undo delete"); + ui::Context context(FASET_TEST_FONT); + auto& root = context.root(); + root.layout.gap = 4; + auto& name = root.add(ui::Kind::TextField, "name", "Door"); + name.layout.height = 32; + int commits = 0; + name.on_commit = [&](ui::Widget&) { ++commits; }; + auto& number = root.add(ui::Kind::NumberField, "number"); + number.value = 4; + number.step = .5; + number.layout.height = 32; + int number_commits = 0, previews = 0; + number.on_commit = [&](ui::Widget&) { ++number_commits; }; + number.on_preview = [&](ui::Widget&) { ++previews; }; + auto& checkbox = root.add(ui::Kind::Checkbox, "enabled", "Enabled"); + int checks = 0; + checkbox.on_commit = [&](ui::Widget&) { ++checks; }; + auto& button = root.add(ui::Kind::Button, "save", "Save"); + int clicks = 0; + button.on_click = [&](ui::Widget&) { ++clicks; }; + context.layout(320, 240); + std::string clipboard; + context.set_clipboard([&] { return clipboard; }, + [&](const std::string& s) { clipboard = s; }); + bool ime = false; + int ime_rectangles = 0; + context.set_ime([&](bool enabled) { ime = enabled; }, + [&](ui::Rect r) { + check(r.width > 0, "IME area"); + ++ime_rectangles; + }); + check(context.focus("name") && ime, "field focus enables IME"); + context.handle(key("A", true)); + context.handle(text("Привет")); + check(!context.update_text("name", "External"), "document refresh erased dirty edit"); + context.handle(key("C", true)); + context.handle(key("A", true)); + context.handle(key("C", true)); + check(clipboard == "Привет", "clipboard copied wrong selection"); + clipboard = "Дверь"; + context.handle(key("V", true)); + check(name.text == "Дверь", "UTF-8 paste"); + context.handle(key("Z", true)); + check(name.text == "Привет", "local Ctrl-Z"); + context.handle(key("Return")); + check(commits == 1 && name.text == "Привет", "text commits once"); + context.handle(key("Return")); + check(commits == 1, "unchanged text recommitted"); + context.handle(key("End")); + render::Event composition; + composition.type = render::Event::Type::TextEditing; + composition.text = "й"; + composition.edit_length = 1; + context.handle(composition); + check(name.text == "Привет", "IME preedit changed document field"); + context.handle(text("й")); + context.handle(key("Return")); + check(name.text == "Приветй" && commits == 2, "IME commit"); + context.focus("number"); + const auto r = number.rect; + context.handle(mouse(render::Event::Type::MouseDown, r.x + 20, r.y + 12)); + context.handle(mouse(render::Event::Type::MouseMove, r.x + 40, r.y + 12)); + context.handle(mouse(render::Event::Type::MouseMove, r.x + 50, r.y + 12)); + context.handle(mouse(render::Event::Type::MouseUp, r.x + 50, r.y + 12)); + check(number.value == 19 && number_commits == 1 && previews == 2, + "numeric drag must commit once at release"); + context.focus("save"); + check(number_commits == 1, "blur duplicated drag commit"); + context.focus("number"); + context.handle(key("A", true)); + context.handle(text("NaN")); + context.handle(key("Return")); + check(number_commits == 1 && !number.error.empty(), "invalid numeric input committed"); + context.handle(key("Escape")); + check(number.value == 19, "cancel numeric input changed value"); + click(context, checkbox); + check(checkbox.checked && checks == 1, "checkbox commit"); + click(context, button); + check(clicks == 1, "button click"); + context.handle(key("Return")); + check(clicks == 2, "keyboard button activation"); + context.handle(key("Tab")); + check(context.focused_id() == "name", "focus wraps predictably"); + check(ime_rectangles > 0, "IME rectangle never sent"); + ui::Context split(FASET_TEST_FONT); + auto& row = split.root().add(ui::Kind::Row, "row"); + row.layout.flex = 1; + row.layout.gap = 0; + auto& left = row.add(ui::Kind::Panel, "left"); + left.layout.width = 100; + left.layout.min_width = 50; + auto& divider = row.add(ui::Kind::Divider, "divider"); + divider.layout.width = 5; + auto& right = row.add(ui::Kind::Panel, "right"); + right.layout.flex = 1; + right.layout.min_width = 50; + int resize_commit = 0; + divider.on_commit = [&](ui::Widget&) { ++resize_commit; }; + split.layout(300, 100); + const auto d = divider.rect; + split.handle(mouse(render::Event::Type::MouseDown, d.x + 2, 20)); + split.handle(mouse(render::Event::Type::MouseMove, d.x + 32, 20)); + split.handle(mouse(render::Event::Type::MouseUp, d.x + 32, 20)); + check(left.layout.width == 130 && right.rect.width == 165 && resize_commit == 1, + "divider resize"); + const auto moved_divider = divider.rect; + split.handle(mouse(render::Event::Type::MouseDown, moved_divider.x + 2, 20)); + split.handle(mouse(render::Event::Type::MouseMove, moved_divider.x + 42, 20)); + split.handle(key("Escape")); + check(left.layout.width == 130 && resize_commit == 1, + "Escape must restore divider without committing"); + int cancellations = 0; + number.on_cancel = [&](ui::Widget&) { ++cancellations; }; + context.handle(mouse(render::Event::Type::MouseDown, r.x + 10, r.y + 12)); + context.handle(mouse(render::Event::Type::MouseMove, r.x + 50, r.y + 12)); + context.handle(key("Escape")); + check(number.value == 19 && number_commits == 1 && cancellations == 1, + "Escape must cancel numeric drag"); + ui::Context scrolling(FASET_TEST_FONT); + auto& list = scrolling.root().add(ui::Kind::Panel, "list"); + list.layout.height = 80; + list.layout.scroll = true; + list.layout.gap = 0; + for (int i = 0; i < 10; ++i) + list.add(ui::Kind::Label, "row" + std::to_string(i), "Объект " + std::to_string(i)); + scrolling.layout(300, 200); + scrolling.handle(mouse(render::Event::Type::MouseMove, 30, 30)); + render::Event wheel; + wheel.type = render::Event::Type::Wheel; + wheel.y = -2; + check(scrolling.handle(wheel) && list.scroll_y > 0, "scroll event"); + render::Snapshot frame; + scrolling.draw(frame); + check(!frame.ui_quads.empty(), "retained UI emitted no quads"); + for (const auto& q : frame.ui_quads) + check(q.x >= 0 && q.y >= 0 && q.x + q.width <= 300.01f && q.y + q.height <= 80.01f, + "CPU clipping escaped scroll panel"); + check(scrolling.font().measure("Привет", 14) > 20, "Cyrillic shaping failed"); + ui::DockLayout docks; + docks.move("Scene", "left", 0); + docks.move("Assets", "left", 1); + docks.move("Assets", "left", 0); + docks.set_size("Scene", 224); + check(docks.panels("left") == std::vector({"Assets", "Scene"}), + "dock reordering"); + const auto state = docks.to_json(); + ui::DockLayout restored; + restored.from_json(state); + check(restored.to_json() == state, "dock serialization"); + auto invalid = state; + invalid["areas"]["right"] = {"Scene"}; + bool rejected = false; + try { + restored.from_json(invalid); + } catch (...) { + rejected = true; + } + check(rejected && restored.to_json() == state, "invalid dock load mutated existing layout"); + ui::Context declarative(FASET_TEST_FONT); + declarative.apply_layout({{"id", "root"}, + {"kind", "column"}, + {"children", ui::Json::array({{{"id", "run"}, + {"kind", "button"}, + {"text", "Play"}, + {"layout", {{"height", 30}}}}})}}); + int action = 0; + declarative.find("run")->on_click = [&](ui::Widget&) { ++action; }; + declarative.apply_layout({{"id", "root"}, + {"kind", "column"}, + {"children", ui::Json::array({{{"id", "run"}, + {"kind", "button"}, + {"text", "Run"}, + {"layout", {{"height", 34}}}}})}}); + declarative.layout(200, 100); + click(declarative, *declarative.find("run")); + check(action == 1 && declarative.find("run")->text == "Run", "layout reload lost callback"); + std::cout << "UI: UTF-8, shaping, text/IME/clipboard, focus, transactions, " + "layout, clipping, docking OK\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tools/project_templates/Gameplay.cpp b/tools/project_templates/Gameplay.cpp new file mode 100644 index 0000000..40c568c --- /dev/null +++ b/tools/project_templates/Gameplay.cpp @@ -0,0 +1,71 @@ +#include "Gameplay.hpp" +#include +#include +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& engine) { + runtime::Behavior character; + character.fixedUpdate = [](runtime::Runtime& world, runtime::EntityHandle self, double) { + const auto fields = world.fields(self, "gameplay.character"); + auto velocity = world.velocity(self); + const auto input = world.input(); + velocity[0] = input.horizontal * fields.value("speed", 4.0f); + // Support comes from native contact normals, not velocity at the jump apex. + if (input.jumpPressed && world.grounded(self)) + velocity[1] = fields.value("jump_speed", 5.0f); + world.setVelocity(self, velocity); + }; + engine.registerBehavior("gameplay.character", std::move(character)); + + runtime::Behavior door; + auto open = std::make_shared, bool>>(); + door.onStart = [open](runtime::Runtime&, runtime::EntityHandle self, double) { + (*open)[{self.session, self.slot}] = false; + }; + door.onDestroy = [open](runtime::Runtime&, runtime::EntityHandle self, double) { + open->erase({self.session, self.slot}); + }; + door.fixedUpdate = [open](runtime::Runtime& world, runtime::EntityHandle self, double dt) { + const auto fields = world.fields(self, "gameplay.door"); + auto pose = world.transform(self); + auto& opened = (*open)[{self.session, self.slot}]; + if (world.input().interactPressed) + opened = !opened; + const float target = + opened ? fields.value("open_angle", 1.5707963f) : fields.value("closed_angle", 0.0f); + const float distance = target - pose.rotation[1]; + const float amount = std::max(0.0f, fields.value("speed", 1.5f)) * static_cast(dt); + pose.rotation[1] += std::clamp(distance, -amount, amount); + world.setTransform(self, pose); + }; + engine.registerBehavior("gameplay.door", std::move(door)); +} + +nlohmann::json schema() { + // Explicit declarations shared by Player and SchemaExporter. This function + // constructs descriptions only: no Runtime, physics world or lifecycle. + return nlohmann::json::array( + {{{"id", "gameplay.character"}, + {"version", 1}, + {"name", "Character"}, + {"fields", + {{"speed", {{"id", "speed"}, {"type", "number"}, {"default", 4.0}, {"min", 0.0}}}, + {"jump_speed", + {{"id", "jump_speed"}, {"type", "number"}, {"default", 5.0}, {"min", 0.0}}}}}}, + {{"id", "gameplay.door"}, + {"version", 1}, + {"name", "Door"}, + {"fields", + {{"open_angle", + {{"id", "open_angle"}, {"type", "number"}, {"default", 1.5707963}, {"units", "rad"}}}, + {"closed_angle", + {{"id", "closed_angle"}, {"type", "number"}, {"default", 0.0}, {"units", "rad"}}}, + {"speed", + {{"id", "speed"}, + {"type", "number"}, + {"default", 1.5}, + {"min", 0.0}, + {"units", "rad/s"}}}}}}}); +} +} // namespace faset::gameplay diff --git a/tools/project_templates/Gameplay.hpp b/tools/project_templates/Gameplay.hpp new file mode 100644 index 0000000..536ab72 --- /dev/null +++ b/tools/project_templates/Gameplay.hpp @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace faset::gameplay { +void registerGameplay(runtime::Runtime& runtime); +nlohmann::json schema(); +} // namespace faset::gameplay diff --git a/tools/verify_blender_roundtrip.py b/tools/verify_blender_roundtrip.py new file mode 100644 index 0000000..b12a6e4 --- /dev/null +++ b/tools/verify_blender_roundtrip.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Run unmodified Blender and the real Editor importer; verify rename and failure recovery.""" +import argparse +import json +import pathlib +import shutil +import subprocess +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def verify(blender, editor, output): + fixture = output / "fixture" + result = subprocess.run([str(blender), "--background", "--factory-startup", "--python-exit-code", "1", + "--python", str(ROOT / "tests/blender/generate_fixture.py"), "--", str(ROOT), str(fixture)], + check=True, capture_output=True, text=True) + (output / "blender.log").write_text(result.stdout + result.stderr) + assert "FASET_BLENDER_ROUNDTRIP_EXPORT_OK" in result.stdout + project = output / "project" + source = project / "Assets/door" + source.mkdir(parents=True, exist_ok=True) + def command(name, arguments): + process = subprocess.run([str(editor), "--project", str(project), "--command", + json.dumps({"name": name, "arguments": arguments}), "--wait"], capture_output=True, text=True) + assert process.stdout, process.stderr + return json.loads(process.stdout) + def import_stage(stage): + shutil.copytree(fixture / stage, source, dirs_exist_ok=True) + return command("faset_import", {"path": "Assets/door/manifest.json"}) + first = import_stage("initial") + assert first["state"] == "succeeded", first + identity = first["result"]["asset_id"] + manifest = first["result"]["manifest"] + # Keep an authoring scene with independently owned placement/material values. + document = command("faset_document_create", {"name": "Roundtrip", "dimension": 3}) + scene = document["scene"] + scene["entities"] = [{"id": "door", "name": "Gameplay door", "parent": None, "components": [ + {"id": "door-pose", "type": "faset.transform", "version": 1, + "fields": {"position": [4, 0, 2], "rotation": [0, 0, 0], "scale": [1, 1, 1]}}, + {"id": "door-mesh", "type": "faset.mesh", "version": 1, + "fields": {"asset": identity, "primitive": "asset", "color": [0.5, 0.8, 0.3, 1]}}]}] + scene_file = project / "roundtrip.scene.json" + scene_file.write_text(json.dumps(scene)) + before = scene_file.read_bytes() + renamed = import_stage("renamed") + assert renamed["state"] == "succeeded", renamed + assert renamed["result"]["asset_id"] == identity + assert renamed["result"]["generation"] != first["result"]["generation"] + def ids(value): + return sorted(value["outputs"]) + assert ids(manifest) == ids(renamed["result"]["manifest"]), (manifest, renamed) + active = command("faset_assets", {}) + removed = import_stage("removed") + assert removed["state"] == "conflict", removed + assert command("faset_assets", {}) == active + (source / "manifest.json").write_text('{"broken":true}') + failed = command("faset_import", {"path": "Assets/door/manifest.json"}) + assert failed["state"] == "failed", failed + assert command("faset_assets", {}) == active + assert scene_file.read_bytes() == before + report = {"blender": subprocess.check_output([str(blender), "--version"], text=True).splitlines()[0], + "asset_id": identity, "initial_generation": first["result"]["generation"], + "renamed_generation": renamed["result"]["generation"], + "stable_output_ids": True, "removed_output_conflict": True, + "failed_import_keeps_generation": True, "authoring_preserved": True} + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--blender", type=pathlib.Path, required=True) + parser.add_argument("--editor", type=pathlib.Path, required=True) + parser.add_argument("--output", type=pathlib.Path) + args = parser.parse_args() + if args.output: + args.output.resolve().mkdir(parents=True, exist_ok=True) + verify(args.blender.resolve(), args.editor.resolve(), args.output.resolve()) + else: + with tempfile.TemporaryDirectory(prefix="faset-blender-") as temporary: + verify(args.blender.resolve(), args.editor.resolve(), pathlib.Path(temporary))