Checkpoint 4: harden native paths, authoring workflows and Player lifecycle

This commit is contained in:
Emil
2026-09-18 05:14:50 +03:00
parent 5ac4db438d
commit 45bc352d46
88 changed files with 12606 additions and 504 deletions
+44 -37
View File
@@ -74,7 +74,7 @@ std::set<std::string> asset_references(const Json& scene) {
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());
path_to_utf8(source));
fs::create_directories(target.parent_path());
fs::copy_file(source, target, fs::copy_options::overwrite_existing);
}
@@ -248,9 +248,9 @@ struct BuildService::Impl {
fs::create_directories(native_directory);
std::vector<std::string> arguments = {config.cmake,
"-S",
config.engine_root.string(),
path_to_utf8(config.engine_root),
"-B",
native_directory.string(),
path_to_utf8(native_directory),
"-G",
config.generator,
"-DCMAKE_BUILD_TYPE=" + configuration,
@@ -260,7 +260,7 @@ struct BuildService::Impl {
"-DFASET_BUILD_RUNTIME=ON",
"-DFASET_BUILD_ASSETS=ON",
"-DFASET_GAMEPLAY_SOURCE_DIR=" +
(config.project_root / "Scripts").string()};
path_to_utf8(config.project_root / "Scripts")};
bool compiler_overridden = false;
for (const auto& arg : config.configure_arguments)
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
@@ -268,11 +268,11 @@ struct BuildService::Impl {
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());
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler));
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler));
#else
arguments.push_back("-DCMAKE_C_COMPILER=" + find_executable("clang").string());
arguments.push_back("-DCMAKE_CXX_COMPILER=" + find_executable("clang++").string());
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(find_executable("clang")));
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(find_executable("clang++")));
#endif
}
arguments.insert(arguments.end(), config.configure_arguments.begin(),
@@ -281,7 +281,7 @@ struct BuildService::Impl {
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,
{config.cmake, "--build", path_to_utf8(native_directory), "--config", configuration,
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
config.project_root);
checkpoint(job, "Exporting gameplay schema", .58);
@@ -292,7 +292,8 @@ struct BuildService::Impl {
fs::create_directories(staging);
try {
const auto schema_file = staging / "schema.json";
run(job, {exporter.string(), "--output", schema_file.string()}, config.project_root);
run(job, {path_to_utf8(exporter), "--output", path_to_utf8(schema_file)},
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())
@@ -324,11 +325,11 @@ struct BuildService::Impl {
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()},
{"directory", path_to_utf8(generation)},
{"build_directory", path_to_utf8(native_directory)},
{"configuration", configuration},
{"player", (generation / ("faset_player" + executable_suffix())).string()},
{"schema", (generation / "schema.json").string()},
{"player", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
{"schema", path_to_utf8(generation / "schema.json")},
{"fingerprint", fingerprint}};
} catch (...) {
std::error_code error;
@@ -346,7 +347,7 @@ struct BuildService::Impl {
{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();
auto extension = path_to_utf8(entry.path().extension());
for (auto& c : extension)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (entry.is_regular_file() && extension == ".dll")
@@ -356,7 +357,8 @@ struct BuildService::Impl {
(void)job;
#else
const auto output =
run(job, {find_executable("ldd").string(), executable.string()}, config.project_root);
run(job, {path_to_utf8(find_executable("ldd")), path_to_utf8(executable)},
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.
@@ -427,8 +429,8 @@ struct BuildService::Impl {
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()}};
{"scene", path_to_utf8(directory / "scene.fscene")},
{"directory", path_to_utf8(directory)}};
} catch (...) {
std::error_code error;
fs::remove_all(staging, error);
@@ -451,13 +453,14 @@ struct BuildService::Impl {
for (const auto& entry : fs::directory_iterator(source)) {
if (!entry.is_regular_file())
continue;
auto filename = entry.path().filename().string();
auto filename = path_to_utf8(entry.path().filename());
std::string upper = filename;
for (auto& c : upper)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (upper.starts_with("LICENSE") || upper.starts_with("COPYING") ||
upper.starts_with("NOTICE")) {
copy_required_file(entry.path(), destination / name / filename);
copy_required_file(entry.path(),
destination / name / entry.path().filename());
copied = true;
}
}
@@ -487,7 +490,7 @@ struct BuildService::Impl {
"generations" / generation);
auto target = destination / "assets" / id / "generations" / generation;
for (const auto& file : manifest.at("files")) {
auto relative = fs::path(file.at("path").get<std::string>());
auto relative = path_from_utf8(file.at("path").get<std::string>());
copy_required_file(project_path(source_directory, relative),
project_path(target, relative));
}
@@ -508,7 +511,8 @@ struct BuildService::Impl {
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<std::string>()));
validate_component_types(job.scene,
read_json(path_from_utf8(built.at("schema").get<std::string>())));
auto output = fs::absolute(job.output);
if (output.empty())
throw std::runtime_error("An export destination is required");
@@ -519,7 +523,7 @@ struct BuildService::Impl {
try {
checkpoint(job, "Cooking export snapshot", .72);
write_cooked_scene(staging / "scene.fscene", job.scene);
auto build_directory = fs::path(built.at("directory").get<std::string>());
auto build_directory = path_from_utf8(built.at("directory").get<std::string>());
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
staging / ("faset_player" + executable_suffix()));
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
@@ -528,7 +532,7 @@ struct BuildService::Impl {
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();
auto extension = path_to_utf8(entry.path().extension());
for (auto& c : extension)
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (entry.is_regular_file() && extension == ".dll")
@@ -536,7 +540,8 @@ struct BuildService::Impl {
}
checkpoint(job, "Packaging assets and notices", .80);
package_assets(job, staging);
package_notices(staging / "Notices", built.at("build_directory").get<std::string>());
package_notices(staging / "Notices",
path_from_utf8(built.at("build_directory").get<std::string>()));
atomic_write(
staging / "README.txt",
"Run faset_player" + executable_suffix() +
@@ -553,8 +558,9 @@ struct BuildService::Impl {
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()},
{path_to_utf8(staging / ("faset_player" + executable_suffix())), "--validate",
"--scene", path_to_utf8(staging / "scene.fscene"), "--assets",
path_to_utf8(staging)},
staging);
Json files = Json::array();
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
@@ -562,7 +568,7 @@ struct BuildService::Impl {
throw std::runtime_error("Export contains a symlink");
if (entry.is_regular_file())
files.push_back(
{{"path", entry.path().lexically_relative(staging).generic_string()},
{{"path", generic_path_to_utf8(entry.path().lexically_relative(staging))},
{"sha256", sha256_file(entry.path())},
{"size", entry.file_size()}});
}
@@ -613,15 +619,16 @@ struct BuildService::Impl {
{"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")}};
return {
{"directory", path_to_utf8(generation)},
{"executable", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
{"manifest", path_to_utf8(generation / "manifest.json")},
{"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);
+5 -4
View File
@@ -68,9 +68,9 @@ Json Commands::call(const std::string& name, const Json& arguments) {
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) {
const auto relative = std::filesystem::path(path).lexically_normal();
const auto relative = path_from_utf8(path).lexically_normal();
for (const auto& document : authoring_.documents())
if (document.at("path") == relative.generic_string())
if (document.at("path") == generic_path_to_utf8(relative))
return authoring_.query(document.at("id")).at("scene");
return read_json(project_path(authoring_.root(), relative));
});
@@ -92,7 +92,7 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
"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<std::string>(),
return authoring_.open(path_from_utf8(args.at("path").get<std::string>()),
args.value("recover", false));
});
add(
@@ -104,7 +104,8 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
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()));
return authoring_.save(args.at("document"),
path_from_utf8(args.value("path", std::string())));
});
add(
"faset_schema",
+203 -67
View File
@@ -174,6 +174,7 @@ struct EditorUI::Impl {
std::uint64_t shown_revision = std::numeric_limits<std::uint64_t>::max();
std::map<std::string, std::uint64_t> edit_revisions;
std::map<std::string, Json> preview_fields;
std::map<std::string, std::string> import_retries;
std::filesystem::path layout_path;
std::filesystem::path theme_source_path, layout_source_path;
std::string attempted_theme, attempted_layout, applied_theme, applied_layout,
@@ -196,6 +197,14 @@ struct EditorUI::Impl {
std::uint64_t gizmo_revision = 0;
std::string gizmo_component;
ui::Rect viewport;
float ui_scale = 1;
unsigned layout_width = 0, layout_height = 0;
float logical_width() const {
return float(renderer.width()) / ui_scale;
}
float logical_height() const {
return float(renderer.height()) / ui_scale;
}
Vec3 gizmo_origin{};
render::Vec2 gizmo_drag_start{}, gizmo_drag_end{};
float gizmo_world_length = 1;
@@ -253,12 +262,13 @@ struct EditorUI::Impl {
document = session.authoring()
.create("Untitled", session.project().value("dimension", 3))
.at("id");
ui_scale = std::clamp(renderer.display_scale(), .5f, 4.f);
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);
dock.set_size("scene", ui.find("scene_panel")->rect.width / ui_scale);
dock.set_size("inspector", ui.find("inspector_panel")->rect.width / ui_scale);
dock.set_size("bottom", ui.find("bottom_panel")->rect.height / ui_scale);
try {
dock.save(layout_path);
} catch (const std::exception& e) {
@@ -897,7 +907,8 @@ struct EditorUI::Impl {
}
if (!open)
try {
source_scene = read_json(project_path(session.config().project_root, source));
source_scene = read_json(
project_path(session.config().project_root, path_from_utf8(source)));
} catch (...) {
return source;
}
@@ -983,7 +994,8 @@ struct EditorUI::Impl {
}
void instance_scene(const std::string& path) {
try {
auto data = read_json(project_path(session.config().project_root, path));
auto data =
read_json(project_path(session.config().project_root, path_from_utf8(path)));
authoring::validate_scene(data, session.authoring().schemas());
if (data.at("id") == document)
throw std::runtime_error("A scene cannot instance itself");
@@ -1019,7 +1031,7 @@ struct EditorUI::Impl {
subtree.insert(item.at("id").get<std::string>()).second || changed;
}
auto result = call("faset_document_create",
{{"name", std::filesystem::path(path).stem().string()},
{{"name", path_to_utf8(path_from_utf8(path).stem())},
{"dimension", current.at("scene").value("dimension", 3)}});
if (result.is_null())
return;
@@ -1075,9 +1087,9 @@ struct EditorUI::Impl {
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()
? path_to_utf8(
path_from_utf8(manifest.value("source", std::string("Imported asset")))
.stem())
: "Imported asset";
auto object = authoring::make_entity(session.authoring().schemas(), name);
const auto entity_id = object.at("id").get<std::string>();
@@ -1218,7 +1230,7 @@ struct EditorUI::Impl {
std::function<void(Json, int)> append_group = [&](Json path, int depth) {
const auto source = instance_source(path);
auto& row = tree.add(Kind::TreeRow, "instance-" + path.dump(),
"[T] " + std::filesystem::path(source).filename().string());
"[T] " + path_to_utf8(path_from_utf8(source).filename()));
row.indent = depth;
row.selected = instance_selection == path;
row.on_click = [this, path](Widget&) { select_instance(path); };
@@ -1378,7 +1390,7 @@ struct EditorUI::Impl {
const auto path = e->at("origin").at("path");
const auto object = e->at("origin").at("object").get<std::string>();
label(body, "object-origin",
"Source: " + std::filesystem::path(instance_source(path)).filename().string());
"Source: " + path_to_utf8(path_from_utf8(instance_source(path)).filename()));
keep.insert("object-origin");
button(body, "object-open-source", "Open source",
[this, path, object] { open_template_source(path, object); });
@@ -1565,9 +1577,8 @@ struct EditorUI::Impl {
break;
if (!entry.is_regular_file())
continue;
auto relative =
std::filesystem::relative(entry.path(), session.config().project_root)
.generic_string();
auto relative = generic_path_to_utf8(
std::filesystem::relative(entry.path(), session.config().project_root));
if (relative.find(".faset-") != std::string::npos)
continue;
if (!asset_filter.empty() && relative.find(asset_filter) == std::string::npos)
@@ -1602,11 +1613,10 @@ struct EditorUI::Impl {
}
for (const auto& asset : assets) {
const auto id = asset.at("id").get<std::string>();
const auto name = asset.contains("manifest")
? std::filesystem::path(asset["manifest"].value("source", id))
.filename()
.string()
: id;
const auto name =
asset.contains("manifest")
? path_to_utf8(path_from_utf8(asset["manifest"].value("source", id)).filename())
: id;
auto& row = list.add(Kind::TreeRow, "asset-" + id, "Imported / " + name);
row.layout.height = 25;
row.indent = 1;
@@ -1624,6 +1634,8 @@ struct EditorUI::Impl {
trim_children(list, keep);
}
void refresh_bottom() {
const auto jobs_response = call("faset_jobs");
const auto all_jobs = jobs_response.is_null() ? Json::array() : jobs_response.at("jobs");
for (const auto& panel : session.plugin_panels()) {
const auto panel_id = panel.at("id").get<std::string>();
const auto dock_id = "plugin-" + panel_id;
@@ -1674,8 +1686,6 @@ struct EditorUI::Impl {
ui.find("console-items")->visible = active_bottom == "console";
ui.find("job-items")->visible = active_bottom == "jobs";
ui.find("conflict-items")->visible = active_bottom == "conflicts";
ui.find("tab-conflicts")->text =
"Conflicts (" + std::to_string(template_conflicts.size()) + ")";
auto& conflict_list = *ui.find("conflict-items");
std::set<std::string> conflict_keep;
std::size_t conflict_index = 0;
@@ -1687,7 +1697,7 @@ struct EditorUI::Impl {
const auto path = conflict.at("instance_path");
auto& text = row.add(
Kind::Label, id + "-text",
code + " · " + std::filesystem::path(instance_source(path)).filename().string());
code + " · " + path_to_utf8(path_from_utf8(instance_source(path)).filename()));
text.layout.flex = 1;
button(
row, id + "-source", "Open source", [this, path] { open_template_source(path); },
@@ -1716,9 +1726,113 @@ struct EditorUI::Impl {
}
conflict_keep.insert(id);
}
std::size_t import_conflicts = 0;
for (const auto& job : all_jobs) {
if (job.value("kind", std::string()) != "import" ||
job.value("state", std::string()) != "conflict")
continue;
const auto job_id = job.at("id").get<std::string>();
const auto& result = job.at("result");
const auto request = job.value("request", Json::object());
bool pending = false, superseded = false;
std::string retry_error;
if (const auto found = import_retries.find(job_id); found != import_retries.end())
for (const auto& retry : all_jobs)
if (retry.at("id") == found->second) {
const auto state = retry.value("state", std::string());
pending = state == "queued" || state == "running";
superseded = state == "succeeded" || state == "conflict";
if (state == "failed" || state == "cancelled")
retry_error = retry.value("error", std::string("Retry cancelled"));
}
for (const auto& asset : assets)
if (asset.at("id") == result.value("asset_id", std::string()) &&
asset.contains("manifest") &&
asset.at("manifest").at("generation") ==
result.value("generation", std::string()))
superseded = true;
if (superseded)
continue;
++import_conflicts;
const auto id = "import-conflict-" + job_id;
auto& group = conflict_list.add(Kind::Column, id);
group.layout.padding = 6;
group.layout.gap = 3;
conflict_keep.insert(id);
label(group, id + "-title", "Import removal: " + request.value("path", job_id));
label(group, id + "-note",
"The previous asset stays active. Update affected references before accepting.");
label(group, id + "-generation",
"Reviewed generation: " + result.value("generation", std::string()));
auto diagnostic = job.value("error", std::string());
std::replace(diagnostic.begin(), diagnostic.end(), '\n', ' ');
label(group, id + "-diagnostic", diagnostic);
const auto removed = result.value("removed_output_ids", Json::array());
auto& outputs = group.add(Kind::Column, id + "-outputs");
outputs.layout.height = std::min(150.f, std::max(26.f, float(removed.size()) * 26));
outputs.layout.scroll = true;
outputs.layout.gap = 0;
std::set<std::string> output_keep;
for (const auto& output : removed) {
const auto output_id = output.get<std::string>();
std::string name;
for (const auto& asset : assets)
if (asset.at("id") == result.value("asset_id", std::string()) &&
asset.contains("manifest"))
for (const auto* collection : {"nodes", "meshes", "materials", "textures"})
for (const auto& item :
asset.at("manifest").value(collection, Json::array()))
if (item.at("id") == output_id)
name = item.value("name", std::string());
const auto output_widget = id + "-output-" + output_id;
label(outputs, output_widget, output_id + (name.empty() ? "" : " / " + name));
outputs.find(output_widget)->layout.height = 26;
output_keep.insert(output_widget);
}
trim_children(outputs, output_keep);
auto& actions = group.add(Kind::Row, id + "-actions");
actions.layout.height = 30;
const auto retry = [this, job_id, request, result](bool accept) {
Json arguments = {{"path", request.at("path")}, {"allow_removed_outputs", accept}};
if (request.contains("settings") && !request.at("settings").is_null())
arguments["settings"] = request.at("settings");
if (accept) {
arguments["expected_generation"] = result.at("generation");
arguments["expected_active_generation"] = result.at("previous_generation");
}
auto submitted = call("faset_import", arguments);
if (!submitted.is_null()) {
import_retries[job_id] = submitted.at("job");
status =
accept
? "Publishing the reviewed import; source changes require a new review"
: "Reimporting source for a fresh review";
}
};
button(
actions, id + "-retry", "Reimport / review again", [retry] { retry(false); }, 185)
.enabled = !pending && request.contains("path");
button(
actions, id + "-accept", "Accept reviewed removal", [retry] { retry(true); }, 190)
.enabled = !pending && request.contains("path") && !removed.empty();
button(
actions, id + "-copy", "Copy removed IDs",
[this, removed] { renderer.set_clipboard(removed.dump(2)); }, 145);
auto detail = pending ? "Import in progress; the previous generation remains active."
: "Accepting removes these outputs. Scene references are not "
"remapped automatically.";
label(group, id + "-detail", detail);
if (!retry_error.empty()) {
std::replace(retry_error.begin(), retry_error.end(), '\n', ' ');
label(group, id + "-error", retry_error);
} else
group.remove(id + "-error");
}
ui.find("tab-conflicts")->text =
"Conflicts (" + std::to_string(template_conflicts.size() + import_conflicts) + ")";
if (conflict_keep.empty()) {
label(conflict_list, "conflicts-empty",
"No template conflicts. Missing targets preserve their overrides here.");
"No template or import conflicts. Missing targets preserve their data here.");
conflict_keep.insert("conflicts-empty");
}
trim_children(conflict_list, conflict_keep);
@@ -1738,27 +1852,28 @@ struct EditorUI::Impl {
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<std::string>();
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";
}
for (const auto& job : all_jobs) {
const auto id = job.at("id").get<std::string>();
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";
auto& review = button(
row, "job-review-" + id, "Review removals", [this] { active_bottom = "conflicts"; },
135);
review.visible = job.value("kind", std::string()) == "import" && state == "conflict";
}
if (keep.empty()) {
label(jobs, "jobs-empty", "No active import, build or export jobs.");
keep.insert("jobs-empty");
@@ -1788,8 +1903,8 @@ struct EditorUI::Impl {
: "Project switching is unavailable while MCP is connected";
auto* switching = ui.find("project-switch-dialog");
switching->visible = project_switch_warning;
switching->layout.x = std::max(0.f, (float(renderer.width()) - 500) * .5f);
switching->layout.y = std::max(0.f, (float(renderer.height()) - 220) * .5f);
switching->layout.x = std::max(0.f, (logical_width() - 500) * .5f);
switching->layout.y = std::max(0.f, (logical_height() - 220) * .5f);
for (const auto* id : {"menu-duplicate", "menu-delete"})
ui.find(id)->visible = edit;
for (const auto* id : {"help-one", "help-two", "help-three"})
@@ -1805,7 +1920,7 @@ struct EditorUI::Impl {
: 5;
auto& command = *ui.find("palette");
command.visible = palette;
command.layout.x = std::max(0.f, (float(renderer.width()) - 570) / 2);
command.layout.x = std::max(0.f, (logical_width() - 570) / 2);
command.layout.y = 90;
auto& list = *ui.find("palette-list");
std::set<std::string> keep;
@@ -1855,10 +1970,9 @@ struct EditorUI::Impl {
if (++visited > 4000)
break;
if (entry.is_regular_file() &&
entry.path().filename().string().ends_with(".scene.json"))
saved.insert(
std::filesystem::relative(entry.path(), session.config().project_root)
.generic_string());
path_to_utf8(entry.path().filename()).ends_with(".scene.json"))
saved.insert(generic_path_to_utf8(std::filesystem::relative(
entry.path(), session.config().project_root)));
}
}
} catch (const std::exception& error) {
@@ -1867,7 +1981,8 @@ struct EditorUI::Impl {
std::size_t index = 0;
for (const auto& path : saved) {
try {
const auto scene = read_json(project_path(session.config().project_root, path));
const auto scene =
read_json(project_path(session.config().project_root, path_from_utf8(path)));
if (scene.value("format", "") != "faset.scene" || scene.value("version", 0) != 1)
continue;
} catch (...) {
@@ -1914,10 +2029,10 @@ struct EditorUI::Impl {
void refresh_project_settings() {
auto* panel = ui.find("project-settings-panel");
panel->visible = project_settings_open;
panel->layout.width = std::min(610.f, std::max(340.f, float(renderer.width()) - 40));
panel->layout.height = std::min(550.f, std::max(300.f, float(renderer.height()) - 40));
panel->layout.x = std::max(0.f, (float(renderer.width()) - panel->layout.width) * .5f);
panel->layout.y = std::max(0.f, (float(renderer.height()) - panel->layout.height) * .5f);
panel->layout.width = std::min(610.f, std::max(340.f, logical_width() - 40));
panel->layout.height = std::min(550.f, std::max(300.f, logical_height() - 40));
panel->layout.x = std::max(0.f, (logical_width() - panel->layout.width) * .5f);
panel->layout.y = std::max(0.f, (logical_height() - panel->layout.height) * .5f);
ui.find("project-settings-2d")->selected = project_settings_dimension == 2;
ui.find("project-settings-3d")->selected = project_settings_dimension == 3;
ui.find("project-settings-error")->text = project_settings_error;
@@ -1928,7 +2043,7 @@ struct EditorUI::Impl {
void refresh_simulation() {
auto& panel = *ui.find("simulation-panel");
panel.visible = simulation_open;
panel.layout.x = std::max(0.f, (float(renderer.width()) - 470) / 2);
panel.layout.x = std::max(0.f, (logical_width() - 470) / 2);
panel.layout.y = 90;
if (!simulation_open)
return;
@@ -1986,7 +2101,7 @@ struct EditorUI::Impl {
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.x = std::max(0.f, (logical_width() - 470) / 2);
panel.layout.y = 100;
std::set<std::string> keep;
label(panel, "recovery-title", "Unsaved authoring recovery");
@@ -2152,11 +2267,16 @@ struct EditorUI::Impl {
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);
line(gizmo_screen[0], gizmo_screen[axis + 1], colors[axis], 2 * ui_scale);
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}});
rendered.ui_quads.push_back({end[0] - 4 * ui_scale,
end[1] - 4 * ui_scale,
8 * ui_scale,
8 * ui_scale,
colors[axis],
{},
{0, 0, 1, 1}});
}
}
}
@@ -2196,12 +2316,12 @@ struct EditorUI::Impl {
const auto* c = component(*e, "faset.transform");
if (!c || c->value("version", 1) != 1)
return false;
float best = 8;
float best = 8 * ui_scale;
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)
if (l < 16 * ui_scale * ui_scale)
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);
@@ -2282,8 +2402,8 @@ struct EditorUI::Impl {
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);
yaw -= dx / ui_scale * .008f;
pitch = std::clamp(pitch + dy / ui_scale * .008f, -1.5f, 1.5f);
} else {
const auto right = Vec3{std::cos(yaw), 0, -std::sin(yaw)};
const auto up =
@@ -2367,7 +2487,7 @@ struct EditorUI::Impl {
}
}
if (event.type == Type::MouseDown && !menu.empty() &&
!ui.find("menu-popup")->rect.contains(event.x, event.y) && event.y > 36)
!ui.find("menu-popup")->rect.contains(event.x, event.y) && event.y > 36 * ui_scale)
menu.clear();
if (event.type == Type::MouseMove || event.type == Type::MouseDown) {
mouse_x = event.x;
@@ -2427,15 +2547,31 @@ struct EditorUI::Impl {
}
}
void frame(const std::vector<render::Event>& events_) {
const auto next_scale = std::clamp(renderer.display_scale(), .5f, 4.f);
const bool geometry_changed = next_scale != ui_scale || layout_width != renderer.width() ||
layout_height != renderer.height();
if (next_scale != ui_scale) {
camera_drag = 0;
if (gizmo_axis >= 0) {
const auto field = gizmo_mode == "Move" ? "position"
: gizmo_mode == "Rotate" ? "rotation"
: "scale";
preview_fields.erase(gizmo_component + "/" + field);
gizmo_axis = -1;
}
}
ui_scale = next_scale;
layout_width = renderer.width();
layout_height = renderer.height();
session.poll();
poll_presentation();
refresh();
ui.layout(float(renderer.width()), float(renderer.height()));
if (rendered.scene_rect[2] == 0)
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
if (rendered.scene_rect[2] == 0 || geometry_changed)
build_snapshot();
events(events_);
refresh();
ui.layout(float(renderer.width()), float(renderer.height()));
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
build_snapshot();
}
};
+6 -4
View File
@@ -202,7 +202,8 @@ struct PluginManager::Impl {
module->owner = this;
module->manifest = manifest;
module->directory = directory;
const auto path = project_path(directory, manifest.at("library").get<std::string>());
const auto path =
project_path(directory, path_from_utf8(manifest.at("library").get<std::string>()));
require(std::filesystem::is_regular_file(path), "plugin.library",
"Plugin library is missing");
#ifdef _WIN32
@@ -307,8 +308,8 @@ void PluginManager::load(const std::filesystem::path& directory) {
};
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();
path_to_utf8(entry.path().filename()).ends_with(".faset-plugin.json")) {
std::string id = path_to_utf8(entry.path().filename());
try {
const auto manifest = read_json(entry.path());
id = manifest.at("id");
@@ -324,7 +325,8 @@ void PluginManager::load(const std::filesystem::path& directory) {
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<std::string>());
project_path(entry.path().parent_path(),
path_from_utf8(manifest.at("library").get<std::string>()));
sources.emplace(id, Source{manifest, entry.path().parent_path()});
} catch (const std::exception& error) {
failure(id, error.what());
+59 -21
View File
@@ -34,7 +34,7 @@ struct Session::ImportTask {
std::shared_ptr<assets::ImportJob> job = std::make_shared<assets::ImportJob>();
mutable std::mutex mutex;
std::string state = "queued", error;
Json result = Json::object();
Json result = Json::object(), request = Json::object();
Json json() const {
std::lock_guard lock(mutex);
const auto progress = job->progress();
@@ -44,6 +44,7 @@ struct Session::ImportTask {
{"stage", progress.stage},
{"progress", progress.fraction},
{"error", error},
{"request", request},
{"result", result}};
}
};
@@ -97,13 +98,13 @@ Json Session::project() const {
"Project start_scene must be a relative path");
const auto scene = value.at("start_scene").get<std::string>();
if (!scene.empty())
project_path(config_.project_root, scene);
project_path(config_.project_root, path_from_utf8(scene));
}
return value;
}
return {{"format", "faset.project"},
{"version", 1},
{"name", config_.project_root.filename().string()},
{"name", path_to_utf8(config_.project_root.filename())},
{"dimension", 3}};
}
void Session::scaffold(const std::string& name, int dimension) {
@@ -117,12 +118,12 @@ Json Session::assets_list() const {
for (const auto& entry : std::filesystem::directory_iterator(directory))
if (entry.is_directory()) {
try {
const auto id = entry.path().filename().string();
const auto id = path_to_utf8(entry.path().filename());
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()}});
{{"id", path_to_utf8(entry.path().filename())}, {"error", error.what()}});
}
}
return {{"assets", list}};
@@ -137,8 +138,8 @@ std::string Session::source_signature() const {
std::sort(files.begin(), files.end());
std::string contents;
for (const auto& file : files)
contents +=
file.lexically_relative(directory).generic_string() + ":" + sha256_file(file) + "\n";
contents += generic_path_to_utf8(file.lexically_relative(directory)) + ":" +
sha256_file(file) + "\n";
return sha256(contents);
}
Json Session::schema_status() const {
@@ -181,8 +182,8 @@ void Session::launch_player(Json scene, const std::filesystem::path& executable)
control_sequence_ = 0;
ProcessOptions options;
options.arguments = {
executable.string(), "--scene", snapshot.string(), "--assets",
assets_.cache_root().string(), "--control", control_path_.string()};
path_to_utf8(executable), "--scene", path_to_utf8(snapshot), "--assets",
path_to_utf8(assets_.cache_root()), "--control", path_to_utf8(control_path_)};
options.working_directory = config_.project_root;
player_ = std::make_unique<Process>(options);
log("Play started in a separate Player process");
@@ -194,10 +195,34 @@ void Session::stop_player() {
pending_play_scene_ = nullptr;
}
if (player_) {
player_->cancel();
const auto result = player_->poll();
log(result.output);
// Allow the normal Player shutdown path (including gameplay OnDestroy) first.
// Keep Stop synchronous and bounded so starting a new Play cannot overlap this one.
bool finished = false;
try {
atomic_write_json(control_path_,
{{"sequence", ++control_sequence_}, {"command", "stop"}});
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
for (;;) {
const auto result = player_->poll();
log(result.output);
if (!result.running) {
finished = true;
break;
}
if (std::chrono::steady_clock::now() >= deadline)
break;
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
} catch (const std::exception& error) {
log(std::string("Graceful Player stop failed: ") + error.what());
}
if (!finished) {
log("Player did not finish graceful shutdown; terminating the process");
player_->cancel();
log(player_->poll().output);
}
player_.reset();
control_path_.clear();
log("Play stopped; authoring scene unchanged");
}
}
@@ -224,7 +249,7 @@ void Session::poll() {
log(value.kind + " completed");
if (value.result.contains("schema"))
try {
load_schema(value.result.at("schema").get<std::string>());
load_schema(path_from_utf8(value.result.at("schema").get<std::string>()));
// This signature represents the sources submitted with this job, not later
// edits.
if (value.result.contains("source_signature"))
@@ -245,10 +270,9 @@ void Session::poll() {
if (value.state == "succeeded" && schema_valid)
try {
const auto executable =
value.result.value("player", (builds_.config().build_directory /
executable_name("faset_player"))
.string());
launch_player(pending_play_scene_, executable);
value.result.value("player", path_to_utf8(builds_.config().build_directory /
executable_name("faset_player")));
launch_player(pending_play_scene_, path_from_utf8(executable));
} catch (const std::exception& error) {
log(std::string("Play failed: ") + error.what());
}
@@ -332,7 +356,8 @@ void Session::register_commands() {
else {
require(field.is_string() && !field.get<std::string>().empty(),
"project.start_scene", "Choose a saved scene inside the project");
const auto file = project_path(config_.project_root, field.get<std::string>());
const auto file = project_path(config_.project_root,
path_from_utf8(field.get<std::string>()));
require(std::filesystem::is_regular_file(file), "project.start_scene",
"Save the start scene before selecting it in Project settings");
const auto scene = read_json(file);
@@ -358,15 +383,25 @@ void Session::register_commands() {
"a cancellable job ID; failure retains the last successful generation.",
schema({{"path", text},
{"settings", {{"type", "object"}}},
{"allow_removed_outputs", boolean}},
{"allow_removed_outputs", boolean},
{"expected_generation", text},
{"expected_active_generation", text}},
{"path"}),
[&](const Json& args) {
assets::ImportRequest request;
request.source = project_path(config_.project_root, args.at("path").get<std::string>());
request.source = project_path(config_.project_root,
path_from_utf8(args.at("path").get<std::string>()));
request.settings = args.value("settings", Json(nullptr));
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
request.expected_generation = args.value("expected_generation", std::string());
request.expected_active_generation =
args.value("expected_active_generation", std::string());
auto task = std::make_shared<ImportTask>();
task->id = "import-" + new_id();
task->request = {{"path", generic_path_to_utf8(std::filesystem::relative(
request.source, config_.project_root))},
{"settings", request.settings},
{"allow_removed_outputs", request.allow_removed_outputs}};
imports_[task->id] = task;
workers_.emplace_back([this, task, request] {
{
@@ -382,6 +417,8 @@ void Session::register_commands() {
: "failed";
task->result = {{"asset_id", result.asset_id},
{"generation", result.generation},
{"previous_generation", result.previous_generation},
{"removed_output_ids", result.removed_output_ids},
{"diagnostics", result.diagnostics},
{"cache_hit", result.cache_hit},
{"manifest", result.manifest}};
@@ -417,7 +454,8 @@ void Session::register_commands() {
const auto signature = source_signature();
const auto id = builds_.start_export(
resolved_or_throw(commands_, args.at("document")),
project_path(config_.project_root, args.at("output").get<std::string>()));
project_path(config_.project_root,
path_from_utf8(args.at("output").get<std::string>())));
submitted_sources_[id] = signature;
return Json{{"job", id}};
});