Checkpoint 3: deliver playable samples and complete native authoring workflows
This commit is contained in:
@@ -168,6 +168,10 @@ CookedAsset AssetStore::load_asset(const std::string& id) const {
|
||||
asset.meshes.push_back(std::move(mesh));
|
||||
}
|
||||
for (const auto& j : m.at("materials")) {
|
||||
// Early development manifests embedded v1 materials without a tag.
|
||||
// Preserve readability, but never interpret an explicitly unknown version.
|
||||
if (j.value("format", "faset.material") != "faset.material" || j.value("version", 1) != 1)
|
||||
throw std::runtime_error("Unsupported cooked material format or version");
|
||||
Material material;
|
||||
material.id = j.at("id");
|
||||
material.name = j.at("name");
|
||||
|
||||
@@ -316,7 +316,9 @@ void validate_generation(const fs::path& directory, const Json& manifest) {
|
||||
}
|
||||
}
|
||||
Json material_json(const Material& m) {
|
||||
return {{"id", m.id},
|
||||
return {{"format", "faset.material"},
|
||||
{"version", 1},
|
||||
{"id", m.id},
|
||||
{"name", m.name},
|
||||
{"base_color", m.base_color},
|
||||
{"metallic", m.metallic},
|
||||
@@ -407,6 +409,10 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
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();
|
||||
if (!metadata.is_object() ||
|
||||
(!metadata.empty() && metadata.value("schema_version", 0) != 1))
|
||||
throw std::runtime_error(
|
||||
"Unsupported import settings version; source metadata is unchanged");
|
||||
const auto settings = request.settings.is_null()
|
||||
? metadata.value("settings", Json::object())
|
||||
: request.settings;
|
||||
@@ -446,6 +452,17 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
throw std::runtime_error("Encoded image exceeds decoder limit");
|
||||
const auto* encoded = reinterpret_cast<const stbi_uc*>(source_bytes.data());
|
||||
const auto length = static_cast<int>(source_bytes.size());
|
||||
const bool png_signature =
|
||||
source_bytes.size() >= 8 && source_bytes[0] == std::byte{137} &&
|
||||
source_bytes[1] == std::byte{80} && source_bytes[2] == std::byte{78} &&
|
||||
source_bytes[3] == std::byte{71} && source_bytes[4] == std::byte{13} &&
|
||||
source_bytes[5] == std::byte{10} && source_bytes[6] == std::byte{26} &&
|
||||
source_bytes[7] == std::byte{10};
|
||||
const bool jpeg_signature =
|
||||
source_bytes.size() >= 3 && source_bytes[0] == std::byte{255} &&
|
||||
source_bytes[1] == std::byte{216} && source_bytes[2] == std::byte{255};
|
||||
if ((extension == ".png" && !png_signature) || (extension != ".png" && !jpeg_signature))
|
||||
throw std::runtime_error("Image content does not match PNG/JPEG extension");
|
||||
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");
|
||||
@@ -677,10 +694,13 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
asset.textures.push_back(std::move(out));
|
||||
}
|
||||
}
|
||||
Json key{{"source", source_hash},
|
||||
{"settings", settings},
|
||||
{"importer", recipe_version},
|
||||
{"dependencies", Json::object()}};
|
||||
Json key{
|
||||
{"source", source_hash},
|
||||
{"settings", settings},
|
||||
{"importer", recipe_version},
|
||||
{"target_profile", standalone_image ? "desktop-image-v1" : "desktop-static-pbr-v1"},
|
||||
{"toolchain", {{"cgltf", FASET_CGLTF_COMMIT}, {"stb", FASET_STB_COMMIT}}},
|
||||
{"dependencies", Json::object()}};
|
||||
if (source != logical_source)
|
||||
key["bundle_sha256"] = logical_hash;
|
||||
for (const auto& [name, item] : dependencies)
|
||||
|
||||
+140
-1
@@ -48,6 +48,12 @@ bool valid_id(const Json& value) {
|
||||
std::string::npos;
|
||||
}
|
||||
} // namespace
|
||||
Json default_simulation_settings() {
|
||||
return {{"fixed_delta", 1.0 / 60.0},
|
||||
{"max_catch_up_ticks", 4},
|
||||
{"physics_substeps", 4},
|
||||
{"gravity", {0, -9.81, 0}}};
|
||||
}
|
||||
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()},
|
||||
@@ -77,6 +83,26 @@ void validate_scene(const Json& scene, const SchemaRegistry& schemas) {
|
||||
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");
|
||||
if (scene.contains("simulation")) {
|
||||
const auto& settings = scene.at("simulation");
|
||||
require(settings.is_object(), "simulation.object", "Simulation settings must be an object");
|
||||
if (settings.contains("fixed_delta"))
|
||||
require(settings["fixed_delta"].is_number() &&
|
||||
settings["fixed_delta"].get<double>() > 0 &&
|
||||
settings["fixed_delta"].get<double>() <= 1,
|
||||
"simulation.fixed_delta",
|
||||
"Fixed delta must be greater than zero and at most one second");
|
||||
for (const auto& [name, maximum] :
|
||||
std::map<std::string, int>{{"max_catch_up_ticks", 1024}, {"physics_substeps", 128}})
|
||||
if (settings.contains(name)) {
|
||||
const auto& value = settings[name];
|
||||
require(value.is_number_integer() && value.get<double>() >= 1 &&
|
||||
value.get<double>() <= maximum,
|
||||
"simulation.integer", "Invalid simulation setting: " + name);
|
||||
}
|
||||
if (settings.contains("gravity"))
|
||||
validate_field(settings["gravity"], {{"type", "vec3"}});
|
||||
}
|
||||
std::set<std::string> ids;
|
||||
std::map<std::string, std::string> parents;
|
||||
auto insert_id = [&](const Json& value) {
|
||||
@@ -120,6 +146,70 @@ void validate_scene(const Json& scene, const SchemaRegistry& schemas) {
|
||||
instance["source"].is_string(),
|
||||
"template.instance", "Invalid template instance");
|
||||
insert_id(instance["id"]);
|
||||
require(!instance["source"].get_ref<const std::string&>().empty(), "template.source",
|
||||
"Template source cannot be empty");
|
||||
auto address = [&](const Json& value, bool field) {
|
||||
require(value.is_object() && value.contains("object") && valid_id(value["object"]),
|
||||
"template.address", "Template address needs a stable object ID");
|
||||
if (value.contains("path")) {
|
||||
require(value["path"].is_array(), "template.address",
|
||||
"Instance path must be an array");
|
||||
for (const auto& part : value["path"])
|
||||
require(valid_id(part), "template.address",
|
||||
"Instance path needs stable IDs");
|
||||
}
|
||||
if (field)
|
||||
require(value.contains("component") && valid_id(value["component"]) &&
|
||||
value.contains("field") && value["field"].is_string() &&
|
||||
!value["field"].get_ref<const std::string&>().empty(),
|
||||
"template.address",
|
||||
"Field address needs stable component and field IDs");
|
||||
};
|
||||
for (const auto* collection : {"overrides", "suppressed", "reparents"})
|
||||
if (instance.contains(collection))
|
||||
require(instance[collection].is_array(), "template.records",
|
||||
"Template records must be an array");
|
||||
for (const auto& record : instance.value("overrides", Json::array())) {
|
||||
require(record.is_object() && record.contains("address") &&
|
||||
record.contains("value"),
|
||||
"template.override", "Override needs an address and value");
|
||||
address(record["address"], true);
|
||||
}
|
||||
for (const auto& record : instance.value("suppressed", Json::array()))
|
||||
address(record, false);
|
||||
for (const auto& record : instance.value("reparents", Json::array())) {
|
||||
require(record.is_object() && record.contains("object") &&
|
||||
record.contains("parent"),
|
||||
"template.reparent", "Reparent needs object and parent addresses");
|
||||
address(record["object"], false);
|
||||
if (!record["parent"].is_null())
|
||||
address(record["parent"], false);
|
||||
if (record.contains("keep_world"))
|
||||
require(record["keep_world"].is_boolean(), "template.reparent",
|
||||
"keep_world must be a boolean");
|
||||
}
|
||||
|
||||
if (instance.contains("additions")) {
|
||||
require(instance["additions"].is_array(), "template.additions",
|
||||
"Local additions must be an array");
|
||||
auto additions = make_scene("Local additions");
|
||||
additions["entities"] = instance["additions"];
|
||||
// Local cycles are rejected here; parents in the source are checked during
|
||||
// resolution.
|
||||
std::set<std::string> local_ids;
|
||||
for (const auto& item : additions["entities"])
|
||||
if (item.contains("id") && item["id"].is_string())
|
||||
local_ids.insert(item["id"].get<std::string>());
|
||||
for (auto& item : additions["entities"]) {
|
||||
if (item.contains("parent") && !item["parent"].is_null())
|
||||
require(valid_id(item["parent"]), "template.addition_parent",
|
||||
"Local parent must be a stable object ID or null");
|
||||
if (!item.contains("parent") || item["parent"].is_null() ||
|
||||
!local_ids.contains(item["parent"].get<std::string>()))
|
||||
item["parent"] = nullptr;
|
||||
}
|
||||
validate_scene(additions, schemas);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,6 +329,16 @@ void AuthoringService::apply(Json& scene, const Json& command) {
|
||||
scene["entities"].push_back(std::move(value));
|
||||
} else if (op == "entity.rename") {
|
||||
entity(scene, command.at("entity").get<std::string>())["name"] = command.at("name");
|
||||
} else if (op == "scene.simulation") {
|
||||
const auto& value = command.at("value");
|
||||
require(value.is_object(), "simulation.object", "Simulation settings must be an object");
|
||||
const auto defaults = default_simulation_settings();
|
||||
for (const auto& [key, setting] : value.items())
|
||||
require(defaults.contains(key), "simulation.setting",
|
||||
"Unknown simulation setting: " + key);
|
||||
if (!scene.contains("simulation"))
|
||||
scene["simulation"] = defaults;
|
||||
scene["simulation"].update(value);
|
||||
} else if (op == "entity.delete") {
|
||||
const auto id = command.at("entity").get<std::string>();
|
||||
entity(scene, id);
|
||||
@@ -317,6 +417,8 @@ void AuthoringService::apply(Json& scene, const Json& command) {
|
||||
if (!schemas_.contains(type))
|
||||
continue;
|
||||
const auto metadata = schemas_.schema(type);
|
||||
if (component.value("version", 1) != metadata.value("version", 1))
|
||||
continue;
|
||||
for (auto& [field, value] : component["fields"].items())
|
||||
if (metadata["fields"].contains(field) &&
|
||||
metadata["fields"][field].value("type", std::string()) ==
|
||||
@@ -338,12 +440,49 @@ void AuthoringService::apply(Json& scene, const Json& command) {
|
||||
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") {
|
||||
op == "template.add" || op == "template.reparent" || op == "template.remove" ||
|
||||
op == "template.restore" || op == "template.source_set" ||
|
||||
op == "template.addition_set") {
|
||||
auto& instances = scene["instances"];
|
||||
const auto id = command.at("instance").get<std::string>();
|
||||
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");
|
||||
if (op == "template.addition_set") {
|
||||
const auto& replacement = command.at("value");
|
||||
const auto addition_id = replacement.at("id");
|
||||
auto& additions = (*found)["additions"];
|
||||
require(additions.is_array(), "template.addition_missing",
|
||||
"Instance has no local additions");
|
||||
auto addition =
|
||||
std::find_if(additions.begin(), additions.end(),
|
||||
[&](const Json& value) { return value.at("id") == addition_id; });
|
||||
require(addition != additions.end(), "template.addition_missing",
|
||||
"Local addition not found");
|
||||
*addition = replacement;
|
||||
return;
|
||||
}
|
||||
if (op == "template.remove") {
|
||||
instances.erase(found);
|
||||
return;
|
||||
}
|
||||
if (op == "template.source_set") {
|
||||
const auto source = command.at("source").get<std::string>();
|
||||
project_path(root_, source);
|
||||
(*found)["source"] = source;
|
||||
return;
|
||||
}
|
||||
if (op == "template.restore") {
|
||||
auto& records = (*found)["suppressed"];
|
||||
require(records.is_array(), "template.suppression_missing",
|
||||
"Instance has no suppressed objects");
|
||||
const auto address = command.at("address");
|
||||
const auto before = records.size();
|
||||
records.erase(std::remove(records.begin(), records.end(), address), records.end());
|
||||
require(records.size() != before, "template.suppression_missing",
|
||||
"Suppressed object address not found");
|
||||
return;
|
||||
}
|
||||
const std::string key = op == "template.suppress" ? "suppressed"
|
||||
: op == "template.add" ? "additions"
|
||||
: op == "template.reparent" ? "reparents"
|
||||
|
||||
+56
-14
@@ -32,14 +32,30 @@ struct Resolver {
|
||||
return &item;
|
||||
return nullptr;
|
||||
}
|
||||
void remap_references(Json& entity, const std::map<std::string, std::string>& mapping) {
|
||||
for (auto& component : entity["components"]) {
|
||||
const auto type = component.at("type").get<std::string>();
|
||||
if (!schemas.contains(type))
|
||||
continue;
|
||||
const auto metadata = schemas.schema(type);
|
||||
if (component.value("version", 1) != metadata.value("version", 1))
|
||||
continue;
|
||||
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<std::string>()))
|
||||
value = mapping.at(value.get<std::string>());
|
||||
}
|
||||
}
|
||||
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<std::string, std::string> ids;
|
||||
std::map<std::string, std::string> ids, entity_ids;
|
||||
for (const auto& item : scene["entities"]) {
|
||||
const auto id = item.at("id").get<std::string>();
|
||||
ids[id] = path.empty() ? id : scoped_id(root, path, id);
|
||||
entity_ids[id] = ids[id];
|
||||
for (const auto& component : item["components"]) {
|
||||
const auto cid = component.at("id").get<std::string>();
|
||||
ids[cid] = path.empty() ? cid : scoped_id(root, path, cid);
|
||||
@@ -56,16 +72,8 @@ struct Resolver {
|
||||
const auto source_id = component.at("id").get<std::string>();
|
||||
component["id"] = ids.at(source_id);
|
||||
component["source_id"] = source_id;
|
||||
const auto type = component.at("type").get<std::string>();
|
||||
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<std::string>()))
|
||||
value = ids.at(value.get<std::string>());
|
||||
}
|
||||
remap_references(item, entity_ids);
|
||||
output.push_back(std::move(item));
|
||||
}
|
||||
for (const auto& instance : scene.value("instances", Json::array())) {
|
||||
@@ -89,19 +97,37 @@ struct Resolver {
|
||||
{{"source", source_name}, {"message", error.what()}});
|
||||
continue;
|
||||
}
|
||||
std::map<std::string, std::string> local_ids;
|
||||
for (const auto& entity : expanded)
|
||||
if (entity.at("origin").at("path") == nested_path)
|
||||
local_ids[entity.at("origin").at("object").get<std::string>()] =
|
||||
entity.at("id").get<std::string>();
|
||||
for (const auto& addition : instance.value("additions", Json::array())) {
|
||||
const auto id = addition.at("id").get<std::string>();
|
||||
require(!local_ids.contains(id), "template.addition_id_collision",
|
||||
"Local addition reuses a source object ID");
|
||||
local_ids[id] = scoped_id(root, nested_path, id);
|
||||
}
|
||||
for (const auto& addition : instance.value("additions", Json::array())) {
|
||||
Json item = addition;
|
||||
const auto id = item.at("id").get<std::string>();
|
||||
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<std::string>());
|
||||
if (item.contains("parent") && !item["parent"].is_null()) {
|
||||
const auto parent = item["parent"].get<std::string>();
|
||||
if (local_ids.contains(parent))
|
||||
item["parent"] = local_ids.at(parent);
|
||||
else {
|
||||
conflict(nested_path, "addition.parent_missing", addition);
|
||||
item["parent"] = nullptr;
|
||||
}
|
||||
}
|
||||
for (auto& component : item["components"]) {
|
||||
const auto cid = component.at("id").get<std::string>();
|
||||
component["source_id"] = cid;
|
||||
component["id"] = scoped_id(root, nested_path, cid);
|
||||
}
|
||||
remap_references(item, local_ids);
|
||||
expanded.push_back(std::move(item));
|
||||
}
|
||||
for (const auto& change : instance.value("overrides", Json::array())) {
|
||||
@@ -126,9 +152,25 @@ struct Resolver {
|
||||
conflict(nested_path, "override.field_unavailable", change);
|
||||
continue;
|
||||
}
|
||||
if (found->value("version", 1) != schemas.schema(type).value("version", 1)) {
|
||||
conflict(nested_path, "override.schema_version", change);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
validate_field(change.at("value"), schemas.schema(type)["fields"][field]);
|
||||
(*found)["fields"][field] = change.at("value");
|
||||
auto value = change.at("value");
|
||||
if (schemas.schema(type)["fields"][field].value("type", std::string()) ==
|
||||
"entity_ref" &&
|
||||
value.is_string()) {
|
||||
for (const auto& target_entity : expanded)
|
||||
if (target_entity.at("origin").at("path") ==
|
||||
item->at("origin").at("path") &&
|
||||
target_entity.at("origin").at("object") == value) {
|
||||
value = target_entity.at("id");
|
||||
break;
|
||||
}
|
||||
}
|
||||
(*found)["fields"][field] = std::move(value);
|
||||
} catch (const std::exception& error) {
|
||||
conflict(nested_path, "override.invalid",
|
||||
{{"change", change}, {"message", error.what()}});
|
||||
|
||||
@@ -306,7 +306,9 @@ struct BuildService::Impl {
|
||||
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"})
|
||||
for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
|
||||
"vertexMain.reflection.json", "fragmentMain.reflection.json",
|
||||
"shadowMain.reflection.json"})
|
||||
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
|
||||
copy_runtime_libraries(job, player, staging, native_directory, configuration);
|
||||
Json manifest{{"format", "faset.build"},
|
||||
@@ -520,7 +522,9 @@ struct BuildService::Impl {
|
||||
auto build_directory = fs::path(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"})
|
||||
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
|
||||
"vertexMain.reflection.json", "fragmentMain.reflection.json",
|
||||
"shadowMain.reflection.json"})
|
||||
copy_required_file(build_directory / "shaders" / shader,
|
||||
staging / "shaders" / shader);
|
||||
for (const auto& entry : fs::directory_iterator(build_directory)) {
|
||||
|
||||
+36
-3
@@ -67,8 +67,13 @@ 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) { return read_json(project_path(authoring_.root(), path)); });
|
||||
authoring_.query(id).at("scene"), authoring_.schemas(), [&](const std::string& path) {
|
||||
const auto relative = std::filesystem::path(path).lexically_normal();
|
||||
for (const auto& document : authoring_.documents())
|
||||
if (document.at("path") == relative.generic_string())
|
||||
return authoring_.query(document.at("id")).at("scene");
|
||||
return read_json(project_path(authoring_.root(), relative));
|
||||
});
|
||||
return {{"scene", result.scene}, {"conflicts", result.conflicts}};
|
||||
}
|
||||
Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authoring) {
|
||||
@@ -109,7 +114,10 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
|
||||
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 "
|
||||
"scene.rename/simulation; "
|
||||
"template.instance/override/revert/suppress/restore/add/addition_set/reparent/remove/"
|
||||
"source_set. Use "
|
||||
"persistent IDs "
|
||||
"from document_query and schema. An idempotency_key retries the same payload in this "
|
||||
"session.",
|
||||
object_schema({{"document", text},
|
||||
@@ -138,6 +146,31 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
|
||||
"is not a live game query.",
|
||||
object_schema({{"document", text}}, {"document"}),
|
||||
[&](const Json& args) { return resolved_scene(args.at("document")); }, true);
|
||||
add(
|
||||
"faset_simulation_get",
|
||||
"Read scene simulation settings with defaults: fixed_delta seconds, max_catch_up_ticks, "
|
||||
"physics_substeps and gravity.",
|
||||
object_schema({{"document", text}}, {"document"}),
|
||||
[&](const Json& args) {
|
||||
const auto document = authoring_.query(args.at("document"));
|
||||
auto settings = authoring::default_simulation_settings();
|
||||
settings.update(document.at("scene").value("simulation", Json::object()));
|
||||
return Json{{"document", document.at("id")},
|
||||
{"revision", document.at("revision")},
|
||||
{"settings", settings}};
|
||||
},
|
||||
true);
|
||||
add("faset_simulation_set",
|
||||
"Update scene simulation settings as one Undo transaction. Values apply when the Player "
|
||||
"next starts.",
|
||||
object_schema(
|
||||
{{"document", text}, {"revision", integer}, {"settings", {{"type", "object"}}}},
|
||||
{"document", "revision", "settings"}),
|
||||
[&](const Json& args) {
|
||||
return authoring_.transact(
|
||||
args.at("document"), args.at("revision"),
|
||||
Json::array({{{"op", "scene.simulation"}, {"value", args.at("settings")}}}));
|
||||
});
|
||||
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.",
|
||||
|
||||
+1029
-105
File diff suppressed because it is too large
Load Diff
+49
-4
@@ -1,11 +1,13 @@
|
||||
#include <algorithm>
|
||||
#include <faset/editor/mcp.hpp>
|
||||
#include <iostream>
|
||||
#ifdef _WIN32
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <cerrno>
|
||||
#include <csignal>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
@@ -126,7 +128,6 @@ std::vector<std::string> StdioTransport::poll() {
|
||||
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);
|
||||
@@ -169,7 +170,51 @@ std::vector<std::string> StdioTransport::poll() {
|
||||
return lines;
|
||||
}
|
||||
void StdioTransport::send(const Json& value) {
|
||||
std::cout << value.dump() << '\n';
|
||||
std::cout.flush();
|
||||
const auto bytes = value.dump() + '\n';
|
||||
std::size_t offset = 0;
|
||||
#ifdef _WIN32
|
||||
const auto output = GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
while (offset < bytes.size()) {
|
||||
DWORD written = 0;
|
||||
const auto count = static_cast<DWORD>(std::min<std::size_t>(bytes.size() - offset, 65536));
|
||||
if (!WriteFile(output, bytes.data() + offset, count, &written, nullptr) || written == 0) {
|
||||
closed_ = true;
|
||||
return;
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
#else
|
||||
// A disconnected MCP client must not terminate the GUI with SIGPIPE. Block
|
||||
// it only on this thread during the write, preserving the process signal
|
||||
// policy and any SIGPIPE that was already pending for the caller.
|
||||
sigset_t blocked, previous, pending;
|
||||
sigemptyset(&blocked);
|
||||
sigaddset(&blocked, SIGPIPE);
|
||||
if (pthread_sigmask(SIG_BLOCK, &blocked, &previous) != 0) {
|
||||
closed_ = true;
|
||||
return;
|
||||
}
|
||||
sigpending(&pending);
|
||||
const bool alreadyPending = sigismember(&pending, SIGPIPE) == 1;
|
||||
bool brokenPipe = false;
|
||||
while (offset < bytes.size()) {
|
||||
const auto written = ::write(STDOUT_FILENO, bytes.data() + offset, bytes.size() - offset);
|
||||
if (written > 0)
|
||||
offset += static_cast<std::size_t>(written);
|
||||
else if (written < 0 && errno == EINTR)
|
||||
continue;
|
||||
else {
|
||||
brokenPipe = written < 0 && errno == EPIPE;
|
||||
closed_ = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (brokenPipe && !alreadyPending) {
|
||||
const timespec noWait{};
|
||||
while (sigtimedwait(&blocked, nullptr, &noWait) == -1 && errno == EINTR) {
|
||||
}
|
||||
}
|
||||
pthread_sigmask(SIG_SETMASK, &previous, nullptr);
|
||||
#endif
|
||||
}
|
||||
} // namespace faset::editor
|
||||
|
||||
+23
-7
@@ -14,11 +14,25 @@
|
||||
|
||||
namespace faset::editor {
|
||||
namespace {
|
||||
void append(void* context, const char* bytes, std::uint64_t size) {
|
||||
auto& output = *static_cast<std::string*>(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<std::size_t>(size));
|
||||
struct ResponseBuffer {
|
||||
std::string bytes;
|
||||
bool failed = false;
|
||||
};
|
||||
void append(void* context, const char* bytes, std::uint64_t size) noexcept {
|
||||
auto& output = *static_cast<ResponseBuffer*>(context);
|
||||
if (output.failed)
|
||||
return;
|
||||
if ((!bytes && size) || size > 8 * 1024 * 1024 ||
|
||||
output.bytes.size() + size > 8 * 1024 * 1024) {
|
||||
output.failed = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (size)
|
||||
output.bytes.append(bytes, static_cast<std::size_t>(size));
|
||||
} catch (...) {
|
||||
output.failed = true;
|
||||
}
|
||||
}
|
||||
Json response(const std::string& bytes) {
|
||||
auto value = Json::parse(bytes);
|
||||
@@ -237,11 +251,13 @@ struct PluginManager::Impl {
|
||||
commands.add(
|
||||
name, descriptor.at("description"), descriptor.at("inputSchema"),
|
||||
[registration](const Json& arguments) {
|
||||
std::string output;
|
||||
ResponseBuffer output;
|
||||
const auto input = arguments.dump();
|
||||
const int result =
|
||||
registration.callback(registration.user, input.c_str(), append, &output);
|
||||
const auto value = response(output);
|
||||
require(!output.failed, "plugin.output_limit",
|
||||
"Cannot collect plugin response (maximum 8 MiB)");
|
||||
const auto value = response(output.bytes);
|
||||
if (result != 0)
|
||||
throw Error(value.value("code", std::string("plugin.failed")),
|
||||
value.value("message", std::string("Plugin command failed")),
|
||||
|
||||
+171
-52
@@ -58,6 +58,10 @@ Session::Session(SessionConfig config)
|
||||
if (std::filesystem::exists(schema))
|
||||
try {
|
||||
load_schema(schema);
|
||||
const auto state = config_.project_root / ".faset/schema-state.json";
|
||||
if (std::filesystem::exists(state))
|
||||
schema_source_signature_ =
|
||||
read_json(state).value("source_signature", std::string());
|
||||
} catch (const std::exception& error) {
|
||||
log(std::string("Schema load failed: ") + error.what());
|
||||
}
|
||||
@@ -77,8 +81,26 @@ void Session::log(std::string value) {
|
||||
}
|
||||
Json Session::project() const {
|
||||
const auto path = config_.project_root / "project.faset.json";
|
||||
if (std::filesystem::exists(path))
|
||||
return read_json(path);
|
||||
if (std::filesystem::exists(path)) {
|
||||
const auto value = read_json(path);
|
||||
require(value.is_object() && value.value("format", "") == "faset.project" &&
|
||||
value.value("version", 0) == 1,
|
||||
"project.version", "Unsupported project format or version");
|
||||
require(value.contains("name") && value.at("name").is_string() &&
|
||||
!value.at("name").get<std::string>().empty(),
|
||||
"project.name", "Project name must be a nonempty string");
|
||||
const auto dimension = value.value("dimension", 3);
|
||||
require(dimension == 2 || dimension == 3, "project.dimension",
|
||||
"Project dimension must be 2 or 3");
|
||||
if (value.contains("start_scene")) {
|
||||
require(value.at("start_scene").is_string(), "project.start_scene",
|
||||
"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);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return {{"format", "faset.project"},
|
||||
{"version", 1},
|
||||
{"name", config_.project_root.filename().string()},
|
||||
@@ -105,6 +127,26 @@ Json Session::assets_list() const {
|
||||
}
|
||||
return {{"assets", list}};
|
||||
}
|
||||
std::string Session::source_signature() const {
|
||||
const auto directory = config_.project_root / "Scripts";
|
||||
std::vector<std::filesystem::path> files;
|
||||
if (std::filesystem::exists(directory))
|
||||
for (const auto& file : std::filesystem::recursive_directory_iterator(directory))
|
||||
if (file.is_regular_file())
|
||||
files.push_back(file.path());
|
||||
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";
|
||||
return sha256(contents);
|
||||
}
|
||||
Json Session::schema_status() const {
|
||||
return {{"loaded", schema_loaded_},
|
||||
{"stale", !schema_loaded_ || schema_source_signature_ != source_signature() ||
|
||||
!schema_error_.empty()},
|
||||
{"error", schema_error_}};
|
||||
}
|
||||
Json Session::jobs() const {
|
||||
Json list = Json::array();
|
||||
for (const auto& item : builds_.jobs())
|
||||
@@ -124,6 +166,8 @@ void Session::load_schema(const std::filesystem::path& path) {
|
||||
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);
|
||||
schema_loaded_ = true;
|
||||
schema_error_.clear();
|
||||
log("Gameplay schema loaded");
|
||||
}
|
||||
void Session::launch_player(Json scene, const std::filesystem::path& executable) {
|
||||
@@ -170,20 +214,35 @@ void Session::poll() {
|
||||
if (observed_jobs_[value.id] == value.state)
|
||||
continue;
|
||||
observed_jobs_[value.id] = value.state;
|
||||
if (value.state == "failed")
|
||||
log(value.kind + " failed: " + value.error);
|
||||
bool schema_valid = true;
|
||||
if (value.state == "failed") {
|
||||
schema_error_ = value.error;
|
||||
log(value.kind + " failed: " + value.error +
|
||||
"; previous gameplay metadata remains available and is marked stale");
|
||||
}
|
||||
if (value.state == "succeeded") {
|
||||
log(value.kind + " completed");
|
||||
if (value.result.contains("schema"))
|
||||
try {
|
||||
load_schema(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"))
|
||||
schema_source_signature_ =
|
||||
value.result.at("source_signature").get<std::string>();
|
||||
else if (submitted_sources_.contains(value.id))
|
||||
schema_source_signature_ = submitted_sources_.at(value.id);
|
||||
atomic_write_json(config_.project_root / ".faset/schema-state.json",
|
||||
{{"source_signature", schema_source_signature_}});
|
||||
} catch (const std::exception& error) {
|
||||
schema_valid = false;
|
||||
schema_error_ = error.what();
|
||||
log(std::string("Schema update failed: ") + error.what());
|
||||
}
|
||||
}
|
||||
if (value.id == pending_play_job_ && value.finished()) {
|
||||
pending_play_job_.clear();
|
||||
if (value.state == "succeeded")
|
||||
if (value.state == "succeeded" && schema_valid)
|
||||
try {
|
||||
const auto executable =
|
||||
value.result.value("player", (builds_.config().build_directory /
|
||||
@@ -242,67 +301,125 @@ void Session::register_commands() {
|
||||
commands_.add(
|
||||
"faset_project", "Read the authoring project's settings.", schema(Json::object()),
|
||||
[&](const Json&) { return project(); }, true);
|
||||
commands_.add(
|
||||
"faset_project_settings_get", "Read project settings and their content revision.",
|
||||
schema(Json::object()),
|
||||
[&](const Json&) {
|
||||
const auto value = project();
|
||||
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
|
||||
},
|
||||
true);
|
||||
commands_.add(
|
||||
"faset_project_settings_set",
|
||||
"Save project name, initial scene dimension or start scene with an expected content "
|
||||
"revision. Applies on the next project open; does not change the active scene or its Undo "
|
||||
"history.",
|
||||
schema({{"revision", text}, {"settings", {{"type", "object"}}}}, {"revision", "settings"}),
|
||||
[&](const Json& args) {
|
||||
auto value = project();
|
||||
require(args.at("revision") == sha256(value.dump()), "revision.conflict",
|
||||
"Project settings changed; reload them before saving");
|
||||
const auto& changes = args.at("settings");
|
||||
for (const auto& [key, field] : changes.items()) {
|
||||
require(key == "name" || key == "dimension" || key == "start_scene",
|
||||
"project.setting", "Unknown editable project setting: " + key);
|
||||
if (key == "name")
|
||||
require(field.is_string() && !field.get<std::string>().empty(), "project.name",
|
||||
"Project name must be a nonempty string");
|
||||
else if (key == "dimension")
|
||||
require(field.is_number_integer() && (field == 2 || field == 3),
|
||||
"project.dimension", "Initial scene dimension must be 2 or 3");
|
||||
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>());
|
||||
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);
|
||||
require(scene.value("format", "") == "faset.scene" &&
|
||||
scene.value("version", 0) == 1,
|
||||
"project.start_scene",
|
||||
"The start scene must be a supported Faset scene");
|
||||
}
|
||||
value[key] = field;
|
||||
}
|
||||
if (!value.contains("id"))
|
||||
value["id"] = new_id();
|
||||
atomic_write_json(config_.project_root / "project.faset.json", value);
|
||||
log("Project settings saved; changes apply on next project open");
|
||||
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
|
||||
});
|
||||
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<std::string>());
|
||||
request.settings = args.value("settings", Json(nullptr));
|
||||
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
|
||||
auto task = std::make_shared<ImportTask>();
|
||||
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"
|
||||
commands_.add(
|
||||
"faset_import",
|
||||
"Import PNG/JPEG, 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<std::string>());
|
||||
request.settings = args.value("settings", Json(nullptr));
|
||||
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
|
||||
auto task = std::make_shared<ImportTask>();
|
||||
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}};
|
||||
});
|
||||
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_schema_status",
|
||||
"Report whether the last successful gameplay schema matches current project scripts. "
|
||||
"Failed builds retain metadata but mark it stale.",
|
||||
schema(Json::object()), [&](const Json&) { return schema_status(); }, true);
|
||||
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()}}; });
|
||||
schema(Json::object()), [&](const Json&) {
|
||||
const auto signature = source_signature();
|
||||
const auto id = builds_.start_build();
|
||||
submitted_sources_[id] = signature;
|
||||
return Json{{"job", id}};
|
||||
});
|
||||
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<std::string>()))}};
|
||||
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>()));
|
||||
submitted_sources_[id] = signature;
|
||||
return Json{{"job", id}};
|
||||
});
|
||||
commands_.add(
|
||||
"faset_jobs", "List editor import/build/export jobs and their progress.",
|
||||
@@ -326,7 +443,9 @@ void Session::register_commands() {
|
||||
schema({{"document", text}}, {"document"}), [&](const Json& args) {
|
||||
stop_player();
|
||||
pending_play_scene_ = resolved_or_throw(commands_, args.at("document"));
|
||||
const auto signature = source_signature();
|
||||
pending_play_job_ = builds_.start_build();
|
||||
submitted_sources_[pending_play_job_] = signature;
|
||||
return Json{{"job", pending_play_job_}, {"play_pending", true}};
|
||||
});
|
||||
commands_.add(
|
||||
|
||||
@@ -17,12 +17,19 @@ namespace faset::player {
|
||||
namespace {
|
||||
using Json = nlohmann::json;
|
||||
Json properties(const Json& entity, const std::string& name) {
|
||||
if (entity.contains("components")) {
|
||||
for (const auto& component : entity["components"])
|
||||
if (component.at("type") == "faset." + name) {
|
||||
// Future authoring schemas remain opaque until an explicit migration.
|
||||
if (component.value("version", 1) != 1)
|
||||
return Json{};
|
||||
return component.at("fields");
|
||||
}
|
||||
return Json{};
|
||||
}
|
||||
// Runtime presentation snapshots already contain validated typed components.
|
||||
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::size_t N>
|
||||
@@ -235,9 +242,21 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
|
||||
if (!entities.is_array())
|
||||
throw std::invalid_argument("Scene entities must be an array");
|
||||
std::unordered_map<std::string, const Json*> byId;
|
||||
for (const auto& entity : entities)
|
||||
if (!byId.emplace(entity.at("id").get<std::string>(), &entity).second)
|
||||
for (const auto& entity : entities) {
|
||||
const auto id = entity.at("id").get<std::string>();
|
||||
if (!byId.emplace(id, &entity).second)
|
||||
throw std::invalid_argument("Duplicate scene ID");
|
||||
for (const auto& component : entity.value("components", Json::array())) {
|
||||
const auto type = component.at("type").get<std::string>();
|
||||
if (component.value("version", 1) != 1 &&
|
||||
(type == "faset.transform" || type == "faset.sprite" || type == "faset.mesh" ||
|
||||
type == "faset.camera" || type == "faset.light"))
|
||||
impl_->messages.push_back("warning: preview skips unsupported " + type +
|
||||
" version " +
|
||||
std::to_string(component.at("version").get<int>()) +
|
||||
" on entity " + id + "; opaque data is preserved");
|
||||
}
|
||||
}
|
||||
std::unordered_map<std::string, render::Mat4> matrices;
|
||||
std::set<std::string> active;
|
||||
auto world = [&](auto&& self, const Json& entity) -> render::Mat4 {
|
||||
@@ -375,4 +394,77 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
|
||||
impl_->messages.end());
|
||||
return out;
|
||||
}
|
||||
void SceneView::appendPhysicsDebug(render::Snapshot& snapshot, const Json& scene,
|
||||
float thickness) const {
|
||||
if (!std::isfinite(thickness) || thickness <= 0)
|
||||
throw std::invalid_argument("Physics debug thickness must be positive");
|
||||
const int dimension = scene.at("dimension");
|
||||
if (dimension != 2 && dimension != 3)
|
||||
throw std::invalid_argument("Physics debug dimension must be 2 or 3");
|
||||
static const auto edgeMesh = [] {
|
||||
auto mesh = std::make_shared<render::Mesh>(*render::cube_mesh());
|
||||
for (auto& vertex : mesh->vertices)
|
||||
vertex.normal = {0, 0, 0}; // Unlit debug color.
|
||||
return mesh;
|
||||
}();
|
||||
for (const auto& entity : scene.at("entities")) {
|
||||
const auto body = properties(entity, dimension == 2 ? "rigid_body_2d" : "rigid_body_3d");
|
||||
if (body.is_null())
|
||||
continue;
|
||||
if (entity.contains("parent") && !entity.at("parent").is_null())
|
||||
throw std::invalid_argument(
|
||||
"Physics debug bodies must be roots, like the runtime adapters");
|
||||
const auto pose = properties(entity, "transform");
|
||||
const auto position = vec<3>(pose, "position", {0, 0, 0});
|
||||
const auto rotation = vec<3>(pose, "rotation", {0, 0, 0});
|
||||
const auto scale = vec<3>(pose, "scale", {1, 1, 1});
|
||||
if (dimension == 2 && (rotation[0] != 0 || rotation[1] != 0))
|
||||
throw std::invalid_argument("2D physics debug rotates only around Z");
|
||||
render::Vec3 half{};
|
||||
if (dimension == 2) {
|
||||
const auto value = vec<2>(body, "half_extents", {.5f, .5f});
|
||||
half = {value[0], value[1], 0};
|
||||
} else
|
||||
half = vec<3>(body, "half_extents", {.5f, .5f, .5f});
|
||||
for (int axis = 0; axis < dimension; ++axis) {
|
||||
if (half[axis] <= 0 || std::abs(scale[axis]) <= .00001f)
|
||||
throw std::invalid_argument("Invalid physics debug box extent/scale");
|
||||
half[axis] *= std::abs(scale[axis]);
|
||||
if (!std::isfinite(half[axis]))
|
||||
throw std::invalid_argument("Nonfinite physics debug box");
|
||||
}
|
||||
const auto type = body.value("body_type", std::string("dynamic"));
|
||||
const render::Color color = type == "static" ? render::Color{.2f, 1, .3f, 1}
|
||||
: type == "kinematic" ? render::Color{1, .7f, .15f, 1}
|
||||
: render::Color{.15f, .85f, 1, 1};
|
||||
const auto model = render::transform(position, rotation);
|
||||
auto edge = [&](render::Vec3 center, render::Vec3 size) {
|
||||
snapshot.draws.push_back({edgeMesh,
|
||||
render::multiply(model, render::transform(center, {}, size)),
|
||||
color,
|
||||
.65f,
|
||||
0,
|
||||
false,
|
||||
{}});
|
||||
};
|
||||
if (dimension == 2) {
|
||||
for (float side : {-1.0f, 1.0f}) {
|
||||
edge({0, side * half[1], 0}, {2 * half[0], thickness, thickness});
|
||||
edge({side * half[0], 0, 0}, {thickness, 2 * half[1], thickness});
|
||||
}
|
||||
} else {
|
||||
for (int axis = 0; axis < 3; ++axis) {
|
||||
const int first = (axis + 1) % 3, second = (axis + 2) % 3;
|
||||
for (float a : {-1.0f, 1.0f})
|
||||
for (float b : {-1.0f, 1.0f}) {
|
||||
render::Vec3 center{}, size{thickness, thickness, thickness};
|
||||
center[first] = a * half[first];
|
||||
center[second] = b * half[second];
|
||||
size[axis] = 2 * half[axis];
|
||||
edge(center, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace faset::player
|
||||
|
||||
+69
-39
@@ -1,3 +1,4 @@
|
||||
#include "shader_contract.hpp"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3/SDL_vulkan.h>
|
||||
#include <algorithm>
|
||||
@@ -41,13 +42,14 @@ std::array<float, 4> point(const Mat4& m, std::array<float, 4> p) {
|
||||
struct Buffer {
|
||||
VkBuffer handle{};
|
||||
VkDeviceMemory memory{};
|
||||
VkDeviceSize size{};
|
||||
VkDeviceSize size{}, allocation_size{};
|
||||
};
|
||||
struct Image {
|
||||
VkImage handle{};
|
||||
VkDeviceMemory memory{};
|
||||
VkImageView view{};
|
||||
VkImageLayout layout{VK_IMAGE_LAYOUT_UNDEFINED};
|
||||
VkDeviceSize allocation_size{};
|
||||
};
|
||||
struct Batch {
|
||||
std::uint32_t first{}, count{};
|
||||
@@ -74,6 +76,7 @@ struct Renderer::Impl {
|
||||
float timestamp_period{};
|
||||
std::uint32_t timestamp_bits{};
|
||||
VkSemaphore acquired{}, present_ready{};
|
||||
std::array<std::string, 3> shader_layouts{};
|
||||
VkSwapchainKHR swapchain{};
|
||||
VkFormat swap_format{};
|
||||
VkExtent2D swap_extent{};
|
||||
@@ -186,16 +189,22 @@ struct Renderer::Impl {
|
||||
if (sdl)
|
||||
SDL_QuitSubSystem(SDL_INIT_VIDEO);
|
||||
}
|
||||
std::uint32_t memory_type(std::uint32_t bits, VkMemoryPropertyFlags properties) {
|
||||
std::uint32_t memory_type(std::uint32_t bits, VkMemoryPropertyFlags properties,
|
||||
VkMemoryPropertyFlags preferred = 0) {
|
||||
VkPhysicalDeviceMemoryProperties p{};
|
||||
vkGetPhysicalDeviceMemoryProperties(physical, &p);
|
||||
if (preferred)
|
||||
for (std::uint32_t i = 0; i < p.memoryTypeCount; ++i)
|
||||
if ((bits & (1u << i)) && (p.memoryTypes[i].propertyFlags &
|
||||
(properties | preferred)) == (properties | preferred))
|
||||
return i;
|
||||
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) {
|
||||
VkMemoryPropertyFlags properties, VkMemoryPropertyFlags preferred = 0) {
|
||||
Buffer b{};
|
||||
b.size = bytes;
|
||||
VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
||||
@@ -208,8 +217,9 @@ struct Renderer::Impl {
|
||||
vkGetBufferMemoryRequirements(device, b.handle, &req);
|
||||
VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
|
||||
alloc.allocationSize = req.size;
|
||||
alloc.memoryTypeIndex = memory_type(req.memoryTypeBits, properties);
|
||||
alloc.memoryTypeIndex = memory_type(req.memoryTypeBits, properties, preferred);
|
||||
check(vkAllocateMemory(device, &alloc, nullptr, &b.memory), "Allocate buffer memory");
|
||||
b.allocation_size = req.size;
|
||||
check(vkBindBufferMemory(device, b.handle, b.memory, 0), "Bind buffer memory");
|
||||
} catch (...) {
|
||||
destroy(b);
|
||||
@@ -240,6 +250,7 @@ struct Renderer::Impl {
|
||||
memory_type(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
check(vkAllocateMemory(device, &alloc, nullptr, &image.memory),
|
||||
"Allocate image memory");
|
||||
image.allocation_size = req.size;
|
||||
check(vkBindImageMemory(device, image.handle, image.memory, 0), "Bind image memory");
|
||||
VkImageViewCreateInfo view{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO};
|
||||
view.image = image.handle;
|
||||
@@ -338,6 +349,7 @@ struct Renderer::Impl {
|
||||
bool validation = c.validation && std::any_of(layers.begin(), layers.end(), [](auto& p) {
|
||||
return std::strcmp(p.layerName, "VK_LAYER_KHRONOS_validation") == 0;
|
||||
});
|
||||
statistics.validation_enabled = validation;
|
||||
if (c.validation && !validation)
|
||||
std::cerr << "[Faset] Vulkan validation layer not installed; diagnostics disabled.\n";
|
||||
if (validation)
|
||||
@@ -488,9 +500,11 @@ struct Renderer::Impl {
|
||||
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);
|
||||
// CPU reads this allocation every frame. Prefer cached coherent memory when available.
|
||||
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);
|
||||
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
|
||||
VK_MEMORY_PROPERTY_HOST_CACHED_BIT);
|
||||
last_pixels.clear();
|
||||
}
|
||||
void make_swapchain() {
|
||||
@@ -680,37 +694,34 @@ struct Renderer::Impl {
|
||||
auto [inserted, _] = textures.emplace(source.get(), std::move(texture));
|
||||
return inserted->second.descriptor;
|
||||
}
|
||||
VkShaderModule shader(const char* name) {
|
||||
std::filesystem::path shader_directory() const {
|
||||
if (!config.shader_directory.empty())
|
||||
return config.shader_directory;
|
||||
std::vector<std::filesystem::path> 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<std::uint32_t> bytes(static_cast<std::size_t>(size) / 4);
|
||||
file.seekg(0);
|
||||
file.read(reinterpret_cast<char*>(bytes.data()), size);
|
||||
for (const auto& root : roots)
|
||||
if (std::filesystem::is_regular_file(root / "vertexMain.spv"))
|
||||
return root;
|
||||
throw std::runtime_error("Compiled Slang shader bundle is missing");
|
||||
}
|
||||
VkShaderModule shader(const detail::ShaderCode& code) {
|
||||
VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
|
||||
ci.codeSize = static_cast<std::size_t>(size);
|
||||
ci.pCode = bytes.data();
|
||||
ci.codeSize = code.words.size() * sizeof(std::uint32_t);
|
||||
ci.pCode = code.words.data();
|
||||
VkShaderModule result{};
|
||||
check(vkCreateShaderModule(device, &ci, nullptr, &result), "Create shader module");
|
||||
return result;
|
||||
}
|
||||
void make_pipelines() {
|
||||
const auto shaders = detail::load_shader_bundle(shader_directory());
|
||||
for (std::size_t i = 0; i < shaders.size(); ++i)
|
||||
if (!shader_layouts[i].empty() && shader_layouts[i] != shaders[i].layout_fingerprint)
|
||||
throw std::runtime_error(
|
||||
"Shader layout changed; the current pipeline was preserved");
|
||||
VkPushConstantRange push{VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0,
|
||||
sizeof(Push)};
|
||||
VkPipelineLayoutCreateInfo li{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
|
||||
@@ -722,9 +733,9 @@ struct Renderer::Impl {
|
||||
"Create pipeline layout");
|
||||
VkShaderModule vertex{}, fragment{}, shadow_vertex{};
|
||||
try {
|
||||
vertex = shader("vertexMain");
|
||||
fragment = shader("fragmentMain");
|
||||
shadow_vertex = shader("shadowMain");
|
||||
vertex = shader(shaders[0]);
|
||||
fragment = shader(shaders[1]);
|
||||
shadow_vertex = shader(shaders[2]);
|
||||
for (int mode = 0; mode < 3; ++mode) {
|
||||
bool shadow_pass = mode == 2, ui = mode == 1;
|
||||
VkPipelineShaderStageCreateInfo stages[2]{};
|
||||
@@ -821,6 +832,8 @@ struct Renderer::Impl {
|
||||
vkDestroyShaderModule(device, shadow_vertex, nullptr);
|
||||
throw;
|
||||
}
|
||||
for (std::size_t i = 0; i < shaders.size(); ++i)
|
||||
shader_layouts[i] = shaders[i].layout_fingerprint;
|
||||
vkDestroyShaderModule(device, vertex, nullptr);
|
||||
vkDestroyShaderModule(device, fragment, nullptr);
|
||||
vkDestroyShaderModule(device, shadow_vertex, nullptr);
|
||||
@@ -953,12 +966,17 @@ struct Renderer::Impl {
|
||||
void render(const Snapshot& snapshot) {
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
statistics.draw_calls = statistics.culled_meshes = 0;
|
||||
bool can_present = surface != VK_NULL_HANDLE;
|
||||
if (surface) {
|
||||
// A capture may render between normal event-loop iterations. Keep the window
|
||||
// system progressing without consuming events intended for the editor.
|
||||
SDL_PumpEvents();
|
||||
int w{}, h{};
|
||||
SDL_GetWindowSizeInPixels(window, &w, &h);
|
||||
if (w <= 0 || h <= 0)
|
||||
return;
|
||||
if (dirty_swapchain || !swapchain)
|
||||
can_present =
|
||||
w > 0 && h > 0 &&
|
||||
!(SDL_GetWindowFlags(window) & (SDL_WINDOW_HIDDEN | SDL_WINDOW_MINIMIZED));
|
||||
if (can_present && (dirty_swapchain || !swapchain))
|
||||
make_swapchain();
|
||||
}
|
||||
// Retire atlas/image resources no longer retained by a caller.
|
||||
@@ -1077,19 +1095,21 @@ struct Renderer::Impl {
|
||||
{direction[0], direction[1], direction[2], 0},
|
||||
{snapshot.eye[0], snapshot.eye[1], snapshot.eye[2], 1}};
|
||||
std::optional<std::uint32_t> swap_index;
|
||||
if (surface) {
|
||||
if (can_present) {
|
||||
std::uint32_t index{};
|
||||
auto result = vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, acquired,
|
||||
VK_NULL_HANDLE, &index);
|
||||
// Compositors can withhold images while a window is occluded. Rendering and
|
||||
// editor capture must remain available even when presentation cannot advance.
|
||||
const auto result =
|
||||
vkAcquireNextImageKHR(device, swapchain, 0, 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
|
||||
} else if (result == VK_SUCCESS || result == VK_SUBOPTIMAL_KHR) {
|
||||
swap_index = index;
|
||||
if (result == VK_SUBOPTIMAL_KHR)
|
||||
dirty_swapchain = true;
|
||||
} else if (result != VK_NOT_READY && result != VK_TIMEOUT) {
|
||||
check(result, "Acquire swapchain image");
|
||||
swap_index = index;
|
||||
}
|
||||
}
|
||||
begin();
|
||||
if (timestamp_pool) {
|
||||
@@ -1255,12 +1275,22 @@ struct Renderer::Impl {
|
||||
check(result, "Present frame");
|
||||
check(vkQueueWaitIdle(queue), "Wait presentation");
|
||||
}
|
||||
const auto readback_started = std::chrono::steady_clock::now();
|
||||
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.readback_cpu_ms = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - readback_started)
|
||||
.count();
|
||||
++statistics.frame;
|
||||
statistics.gpu_allocated_bytes = vertices.allocation_size + readback.allocation_size +
|
||||
color.allocation_size + depth.allocation_size +
|
||||
shadow.allocation_size;
|
||||
statistics.texture_count = static_cast<std::uint32_t>(textures.size());
|
||||
for (const auto& [_, texture] : textures)
|
||||
statistics.gpu_allocated_bytes += texture.image.allocation_size;
|
||||
statistics.validation_errors = validation_errors.load();
|
||||
statistics.cpu_ms =
|
||||
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#include "shader_contract.hpp"
|
||||
#include <cstring>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <stdexcept>
|
||||
#include <string_view>
|
||||
|
||||
namespace faset::render {
|
||||
namespace {
|
||||
using faset::Json;
|
||||
void require(bool value, const std::string& message) {
|
||||
if (!value)
|
||||
throw std::runtime_error("Shader contract: " + message);
|
||||
}
|
||||
std::string read_bounded(const std::filesystem::path& path, std::uintmax_t maximum) {
|
||||
require(std::filesystem::is_regular_file(path), "missing " + path.string());
|
||||
require(std::filesystem::file_size(path) <= maximum, "oversized " + path.string());
|
||||
return faset::read_text(path);
|
||||
}
|
||||
void locations(const Json& fields, std::initializer_list<const char*> types, const char* label) {
|
||||
require(fields.is_array() && fields.size() == types.size(),
|
||||
std::string(label) + " count changed");
|
||||
std::size_t index{};
|
||||
for (const auto* type : types) {
|
||||
require(fields[index].at("location") == index && fields[index].at("type") == type,
|
||||
std::string(label) + " location/type changed");
|
||||
++index;
|
||||
}
|
||||
}
|
||||
void validate_layout(const Json& layout, std::string_view entry) {
|
||||
const bool fragment = entry == "fragmentMain";
|
||||
require(layout.at("stage") == (fragment ? "fragment" : "vertex"), "shader stage changed");
|
||||
const auto& descriptors = layout.at("descriptors");
|
||||
require(descriptors.is_array() && descriptors.size() == 4, "descriptor count changed");
|
||||
for (std::size_t i = 0; i < descriptors.size(); ++i) {
|
||||
const auto& binding = descriptors[i];
|
||||
require(binding.at("set") == 0 && binding.at("binding") == i && binding.at("count") == 1,
|
||||
"descriptor set, binding or array count changed");
|
||||
require(binding.at("type") == (i % 2 ? "sampler" : "sampled_image_2d"),
|
||||
"descriptor type changed");
|
||||
require(fragment || !binding.at("used").get<bool>(),
|
||||
"vertex texture bindings are unsupported");
|
||||
}
|
||||
const auto& constants = layout.at("push_constants");
|
||||
require(constants.is_array() && constants.size() == 1 && constants[0].at("offset") == 0 &&
|
||||
constants[0].at("size") == 96,
|
||||
"push-constant block changed");
|
||||
const auto& members = constants[0].at("members");
|
||||
require(members.is_array() && members.size() == 3, "push-constant member count changed");
|
||||
const int offsets[] = {0, 64, 80}, sizes[] = {64, 16, 16};
|
||||
const char* types[] = {"float32x4x4", "float32x4", "float32x4"};
|
||||
for (std::size_t i = 0; i < 3; ++i)
|
||||
require(members[i].at("offset") == offsets[i] && members[i].at("size") == sizes[i] &&
|
||||
members[i].at("type") == types[i],
|
||||
"push-constant member layout changed");
|
||||
const auto& blocks = layout.at("spirv_push_constants");
|
||||
require(blocks.is_array() && blocks.size() <= 1, "SPIR-V push-constant block count changed");
|
||||
for (const auto& block : blocks) {
|
||||
const auto& actual = block.at("members");
|
||||
require(actual.is_array() && actual.size() == 3, "SPIR-V push-constant members changed");
|
||||
for (std::size_t i = 0; i < 3; ++i)
|
||||
require(actual[i].at("member") == i && actual[i].at("offset") == offsets[i],
|
||||
"SPIR-V push-constant offsets changed");
|
||||
// Slang lowers column-major host matrices to a transposed SPIR-V matrix type.
|
||||
require(actual[0].at("matrix_layout") == "row-major" && actual[0].at("matrix_stride") == 16,
|
||||
"SPIR-V matrix storage convention changed");
|
||||
}
|
||||
if (fragment) {
|
||||
locations(layout.at("inputs"),
|
||||
{"float32x3", "float32x3", "float32x4", "float32x2", "float32x2"},
|
||||
"fragment inputs");
|
||||
locations(layout.at("outputs"), {"float32x4"}, "fragment outputs");
|
||||
} else {
|
||||
locations(layout.at("inputs"),
|
||||
{"float32x4", "float32x3", "float32x3", "float32x4", "float32x2", "float32x2"},
|
||||
"vertex inputs");
|
||||
if (entry == "vertexMain")
|
||||
locations(layout.at("outputs"),
|
||||
{"float32x3", "float32x3", "float32x4", "float32x2", "float32x2"},
|
||||
"vertex outputs");
|
||||
else
|
||||
locations(layout.at("outputs"), {}, "shadow outputs");
|
||||
}
|
||||
}
|
||||
void validate_spirv(const std::vector<std::uint32_t>& words, bool fragment) {
|
||||
require(words.size() >= 5 && words[0] == 0x07230203 && words[1] >= 0x00010000 &&
|
||||
words[1] <= 0x00010600 && words[3] > 0 && words[3] < (1u << 20) && words[4] == 0,
|
||||
"invalid SPIR-V header");
|
||||
bool entry_found{};
|
||||
for (std::size_t offset = 5; offset < words.size();) {
|
||||
const auto count = words[offset] >> 16;
|
||||
const auto opcode = words[offset] & 0xffff;
|
||||
require(count > 0 && count <= words.size() - offset, "malformed SPIR-V instruction");
|
||||
if (opcode == 15) { // OpEntryPoint
|
||||
require(count >= 4, "malformed SPIR-V entry point");
|
||||
const char* name = reinterpret_cast<const char*>(&words[offset + 3]);
|
||||
const auto available = (count - 3) * sizeof(std::uint32_t);
|
||||
const auto* terminator = static_cast<const char*>(std::memchr(name, 0, available));
|
||||
require(terminator != nullptr, "unterminated SPIR-V entry name");
|
||||
if (std::string_view(name, terminator - name) == "main") {
|
||||
require(words[offset + 1] == (fragment ? 4u : 0u), "SPIR-V entry stage changed");
|
||||
entry_found = true;
|
||||
}
|
||||
}
|
||||
offset += count;
|
||||
}
|
||||
require(entry_found, "SPIR-V main entry point missing");
|
||||
}
|
||||
detail::ShaderCode load(const std::filesystem::path& directory, const char* entry) {
|
||||
const auto bytes = read_bounded(directory / (std::string(entry) + ".spv"), 16 * 1024 * 1024);
|
||||
require(bytes.size() >= 20 && bytes.size() % 4 == 0, "invalid SPIR-V byte length");
|
||||
const auto metadata = Json::parse(
|
||||
read_bounded(directory / (std::string(entry) + ".reflection.json"), 1024 * 1024));
|
||||
require(metadata.at("format") == "faset.shader-reflection" && metadata.at("version") == 1,
|
||||
"unsupported reflection version");
|
||||
require(metadata.at("source_entry") == entry && metadata.at("entry_point") == "main",
|
||||
"reflection entry point mismatch");
|
||||
require(metadata.at("spirv_sha256") == faset::sha256(bytes), "SPIR-V/reflection hash mismatch");
|
||||
const auto& layout = metadata.at("layout");
|
||||
const auto fingerprint = faset::sha256(layout.dump());
|
||||
require(metadata.at("layout_fingerprint") == fingerprint, "layout fingerprint mismatch");
|
||||
validate_layout(layout, entry);
|
||||
detail::ShaderCode result;
|
||||
result.layout_fingerprint = fingerprint;
|
||||
result.words.resize(bytes.size() / 4);
|
||||
std::memcpy(result.words.data(), bytes.data(), bytes.size());
|
||||
validate_spirv(result.words, std::string_view(entry) == "fragmentMain");
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
std::array<detail::ShaderCode, 3>
|
||||
detail::load_shader_bundle(const std::filesystem::path& directory) {
|
||||
return {load(directory, "vertexMain"), load(directory, "fragmentMain"),
|
||||
load(directory, "shadowMain")};
|
||||
}
|
||||
void validate_shader_bundle(const std::filesystem::path& directory) {
|
||||
(void)detail::load_shader_bundle(directory);
|
||||
}
|
||||
} // namespace faset::render
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace faset::render::detail {
|
||||
struct ShaderCode {
|
||||
std::vector<std::uint32_t> words;
|
||||
std::string layout_fingerprint;
|
||||
};
|
||||
std::array<ShaderCode, 3> load_shader_bundle(const std::filesystem::path& directory);
|
||||
} // namespace faset::render::detail
|
||||
+48
-14
@@ -115,6 +115,8 @@ Layout parse_layout(const Json& j, Layout l = {}) {
|
||||
}
|
||||
} // namespace
|
||||
Theme Theme::from_json(const Json& j) {
|
||||
if (!j.is_object())
|
||||
throw std::runtime_error("Theme must be an object");
|
||||
Theme t;
|
||||
#define UI_COLOR(name) t.name = color(j, #name, t.name)
|
||||
UI_COLOR(background);
|
||||
@@ -277,7 +279,9 @@ struct Context::Impl {
|
||||
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 font.measure(w.text,
|
||||
(w.font_size > 0 ? w.font_size : theme.font_size) * scale) +
|
||||
theme.padding * 2 * scale;
|
||||
return 80 * scale;
|
||||
}
|
||||
if (w.kind == Kind::Panel || w.kind == Kind::Column || w.kind == Kind::Row) {
|
||||
@@ -367,6 +371,8 @@ struct Context::Impl {
|
||||
Widget* hit(Widget& w, float x, float y) {
|
||||
if (!w.visible || !w.clip.contains(x, y))
|
||||
return nullptr;
|
||||
if (!w.enabled)
|
||||
return w.rect.contains(x, y) ? &w : nullptr;
|
||||
for (auto child = w.children.rbegin(); child != w.children.rend(); ++child)
|
||||
if (auto* target = hit(**child, x, y))
|
||||
return target;
|
||||
@@ -463,8 +469,9 @@ struct Context::Impl {
|
||||
while (i < e.buffer.text().size() &&
|
||||
(static_cast<unsigned char>(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);
|
||||
const auto width =
|
||||
font.measure(std::string_view(e.buffer.text()).substr(0, i),
|
||||
(w.font_size > 0 ? w.font_size : theme.font_size) * scale);
|
||||
if (local < (previous_width + width) * .5f)
|
||||
return previous_byte;
|
||||
previous_width = width;
|
||||
@@ -518,7 +525,11 @@ struct Context::Impl {
|
||||
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);
|
||||
fill(frame, rect,
|
||||
hover && w.enabled ? theme.hover
|
||||
: w.selected ? theme.selection
|
||||
: 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)
|
||||
@@ -543,7 +554,7 @@ struct Context::Impl {
|
||||
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 font_size = (w.font_size > 0 ? w.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};
|
||||
@@ -666,30 +677,55 @@ const Theme& Context::theme() const {
|
||||
FontAtlas& Context::font() {
|
||||
return impl_->font;
|
||||
}
|
||||
void Context::apply_layout(const Json& document) {
|
||||
void Context::validate_layout(const Json& document) const {
|
||||
if (!document.is_object())
|
||||
throw std::runtime_error("Layout must be an object");
|
||||
const auto& definition = document.contains("root") ? document.at("root") : document;
|
||||
if (definition.at("id") != impl_->root.id)
|
||||
throw std::runtime_error("Layout root ID must match retained root");
|
||||
std::set<std::string> ids;
|
||||
std::function<void(const Json&)> validate = [&](const Json& j) {
|
||||
std::function<void(const Json&, const std::string&)> validate = [&](const Json& j,
|
||||
const std::string& parent) {
|
||||
const auto id = j.at("id").get<std::string>();
|
||||
if (id.empty() || !ids.insert(id).second)
|
||||
throw std::runtime_error("Duplicate layout widget ID");
|
||||
const auto* existing = impl_->find(id);
|
||||
if (existing && ((existing->parent ? existing->parent->id : std::string()) != parent))
|
||||
throw std::runtime_error("Hot layout cannot reparent existing widget: " + id);
|
||||
if (j.contains("kind")) {
|
||||
const auto type = kind_from_string(j.at("kind"));
|
||||
if (auto* existing = find(id); existing && existing->kind != type)
|
||||
if (existing && existing->kind != type)
|
||||
throw std::runtime_error("Hot layout cannot replace widget kind");
|
||||
}
|
||||
if (j.contains("text") &&
|
||||
(!j.at("text").is_string() || !TextBuffer::valid_utf8(j.at("text").get<std::string>())))
|
||||
throw std::runtime_error("Widget text must be valid UTF-8");
|
||||
if (j.contains("layout"))
|
||||
parse_layout(j.at("layout"));
|
||||
if (j.contains("children"))
|
||||
parse_layout(j.at("layout"), existing ? existing->layout : Layout{});
|
||||
if (j.contains("font_size")) {
|
||||
const auto size = j.at("font_size").get<float>();
|
||||
if (!std::isfinite(size) || size < 0 || size > 128)
|
||||
throw std::runtime_error("Invalid widget font size");
|
||||
}
|
||||
if (j.contains("children")) {
|
||||
if (!j.at("children").is_array())
|
||||
throw std::runtime_error("Layout children must be an array");
|
||||
for (const auto& child : j.at("children"))
|
||||
validate(child);
|
||||
validate(child, id);
|
||||
}
|
||||
};
|
||||
validate(definition);
|
||||
validate(definition, "");
|
||||
}
|
||||
void Context::apply_layout(const Json& document) {
|
||||
validate_layout(document);
|
||||
const auto& definition = document.contains("root") ? document.at("root") : document;
|
||||
std::function<void(Widget&, const Json&)> 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("font_size"))
|
||||
w.font_size = j.at("font_size");
|
||||
if (j.contains("children"))
|
||||
for (const auto& child : j.at("children")) {
|
||||
const auto id = child.at("id").get<std::string>();
|
||||
@@ -700,8 +736,6 @@ void Context::apply_layout(const Json& document) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user