Checkpoint 3: deliver playable samples and complete native authoring workflows
This commit is contained in:
+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()}});
|
||||
|
||||
Reference in New Issue
Block a user