Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export

This commit is contained in:
Emil
2026-09-18 03:40:15 +03:00
parent 5c6b24d34d
commit 999686a896
125 changed files with 16086 additions and 1714 deletions
+181 -94
View File
@@ -1,120 +1,207 @@
#include <faset/authoring/schema.hpp>
#include <array>
#include <cmath>
#include <faset/authoring/schema.hpp>
#include <set>
namespace faset::authoring {
void validate_field(const Json& value,const Json& descriptor) {
const auto kind=descriptor.value("type",std::string("any"));
bool valid=true;
if(kind=="number"||kind=="float") valid=value.is_number()&&std::isfinite(value.get<double>());
else if(kind=="integer"||kind=="int") valid=value.is_number_integer();
else if(kind=="boolean"||kind=="bool") valid=value.is_boolean();
else if(kind=="string"||kind=="asset_ref"||kind=="entity_ref") valid=value.is_string();
else if(kind=="vec2"||kind=="vec3"||kind=="vec4"||kind=="color") {
const auto size=kind=="vec2"?2u:(kind=="vec3"?3u:4u);
valid=value.is_array()&&value.size()==size;
if(valid) for(const auto& entry:value) valid=valid&&entry.is_number()&&std::isfinite(entry.get<double>());
} else if(kind=="array") valid=value.is_array();
else if(kind=="object") valid=value.is_object();
else require(kind=="any","schema.field_type","Unsupported schema field type: "+kind);
require(valid,"validation.field_type","Invalid value for field "+descriptor.value("id",std::string("?"))+" (expected "+kind+")");
if(value.is_number()) {
if(descriptor.contains("min")) require(value.get<double>()>=descriptor["min"].get<double>(),"validation.minimum","Field is below its minimum");
if(descriptor.contains("max")) require(value.get<double>()<=descriptor["max"].get<double>(),"validation.maximum","Field exceeds its maximum");
void validate_field(const Json& value, const Json& descriptor) {
const auto kind = descriptor.value("type", std::string("any"));
bool valid = true;
if (kind == "number" || kind == "float")
valid = value.is_number() && std::isfinite(value.get<double>());
else if (kind == "integer" || kind == "int")
valid = value.is_number_integer();
else if (kind == "boolean" || kind == "bool")
valid = value.is_boolean();
else if (kind == "string" || kind == "asset_ref" || kind == "entity_ref")
valid = value.is_string();
else if (kind == "vec2" || kind == "vec3" || kind == "vec4" || kind == "color") {
const auto size = kind == "vec2" ? 2u : (kind == "vec3" ? 3u : 4u);
valid = value.is_array() && value.size() == size;
if (valid)
for (const auto& entry : value)
valid = valid && entry.is_number() && std::isfinite(entry.get<double>());
} else if (kind == "array")
valid = value.is_array();
else if (kind == "object")
valid = value.is_object();
else
require(kind == "any", "schema.field_type", "Unsupported schema field type: " + kind);
require(valid, "validation.field_type",
"Invalid value for field " + descriptor.value("id", std::string("?")) + " (expected " +
kind + ")");
if (value.is_number()) {
if (descriptor.contains("min"))
require(value.get<double>() >= descriptor["min"].get<double>(), "validation.minimum",
"Field is below its minimum");
if (descriptor.contains("max"))
require(value.get<double>() <= descriptor["max"].get<double>(), "validation.maximum",
"Field exceeds its maximum");
}
if(descriptor.contains("enum")) {
bool found=false;for(const auto& option:descriptor["enum"])found=found||option==value;
require(found,"validation.enum","Field value is not an allowed choice");
if (descriptor.contains("enum")) {
bool found = false;
for (const auto& option : descriptor["enum"])
found = found || option == value;
require(found, "validation.enum", "Field value is not an allowed choice");
}
}
void SchemaRegistry::register_schema(const Json& value) {
require(value.is_object()&&value.contains("id")&&value["id"].is_string()&&value.contains("fields")&&value["fields"].is_object(),"schema.invalid","Invalid component schema");
Json normalized=value;
const auto id=value.at("id").get<std::string>();
require(!id.empty(),"schema.invalid","TypeId cannot be empty");
require(value.value("version",1)>0,"schema.invalid","Schema version must be positive");
for(auto& [key,field]:normalized["fields"].items()) {
require(field.is_object()&&field.contains("default"),"schema.invalid","Each field requires a typed default");
require(field.value("id",key)==key,"schema.field_id","Field map keys must be stable FieldIds");
field["id"]=key;validate_field(field["default"],field);
require(value.is_object() && value.contains("id") && value["id"].is_string() &&
value.contains("fields") && value["fields"].is_object(),
"schema.invalid", "Invalid component schema");
Json normalized = value;
const auto id = value.at("id").get<std::string>();
require(!id.empty(), "schema.invalid", "TypeId cannot be empty");
require(value.value("version", 1) > 0, "schema.invalid", "Schema version must be positive");
for (auto& [key, field] : normalized["fields"].items()) {
require(field.is_object() && field.contains("default"), "schema.invalid",
"Each field requires a typed default");
require(field.value("id", key) == key, "schema.field_id",
"Field map keys must be stable FieldIds");
field["id"] = key;
validate_field(field["default"], field);
}
if(auto found=schemas_.find(id);found!=schemas_.end()) require(found->second==normalized,"schema.duplicate_type","A different schema is already registered for "+id);
schemas_[id]=std::move(normalized);
if (auto found = schemas_.find(id); found != schemas_.end())
require(found->second == normalized, "schema.duplicate_type",
"A different schema is already registered for " + id);
schemas_[id] = std::move(normalized);
}
void SchemaRegistry::register_schemas(const Json& values) {
const auto& array=values.is_array()?values:values.at("types");
auto candidate=*this;for(const auto& schema:array)candidate.register_schema(schema);*this=std::move(candidate);
const auto& array = values.is_array() ? values : values.at("types");
auto candidate = *this;
for (const auto& schema : array)
candidate.register_schema(schema);
*this = std::move(candidate);
}
bool SchemaRegistry::contains(const std::string& type)const{return schemas_.contains(type);}
Json SchemaRegistry::schema(const std::string& type)const {
const auto found=schemas_.find(type);require(found!=schemas_.end(),"schema.missing","Component schema unavailable: "+type);return found->second;
bool SchemaRegistry::contains(const std::string& type) const {
return schemas_.contains(type);
}
Json SchemaRegistry::manifest()const {Json types=Json::array();for(const auto& [id,type]:schemas_)types.push_back(type);return {{"format","faset.schema"},{"version",1},{"types",types}};}
Json SchemaRegistry::default_fields(const std::string& type)const {Json fields=Json::object();const auto metadata=schema(type);for(const auto& [id,field]:metadata["fields"].items())fields[id]=field["default"];return fields;}
void SchemaRegistry::validate_component(const Json& component)const {
require(component.is_object()&&component.contains("type")&&component["type"].is_string()&&component.contains("fields")&&component["fields"].is_object(),"component.invalid","Invalid component record");
const auto type=component.at("type").get<std::string>();
if(!contains(type))return;
const auto metadata=schema(type);
Json SchemaRegistry::schema(const std::string& type) const {
const auto found = schemas_.find(type);
require(found != schemas_.end(), "schema.missing", "Component schema unavailable: " + type);
return found->second;
}
Json SchemaRegistry::manifest() const {
Json types = Json::array();
for (const auto& [id, type] : schemas_)
types.push_back(type);
return {{"format", "faset.schema"}, {"version", 1}, {"types", types}};
}
Json SchemaRegistry::default_fields(const std::string& type) const {
Json fields = Json::object();
const auto metadata = schema(type);
for (const auto& [id, field] : metadata["fields"].items())
fields[id] = field["default"];
return fields;
}
void SchemaRegistry::validate_component(const Json& component) const {
require(component.is_object() && component.contains("type") && component["type"].is_string() &&
component.contains("fields") && component["fields"].is_object(),
"component.invalid", "Invalid component record");
const auto type = component.at("type").get<std::string>();
if (!contains(type))
return;
const auto metadata = schema(type);
// Future or missing-module schemas are preserved, not interpreted with the wrong version.
if(component.value("version",1)!=metadata.value("version",1))return;
for(const auto& [id,value]:component["fields"].items())if(metadata["fields"].contains(id))validate_field(value,metadata["fields"][id]);
if (component.value("version", 1) != metadata.value("version", 1))
return;
for (const auto& [id, value] : component["fields"].items())
if (metadata["fields"].contains(id))
validate_field(value, metadata["fields"][id]);
}
void SchemaRegistry::add_migration(const std::string& type,int from_version,Json rules) {
require(from_version>0&&rules.is_object(),"migration.invalid","Invalid migration");
require(!migrations_.contains({type,from_version}),"migration.duplicate","Migration already exists");
migrations_[{type,from_version}]=std::move(rules);
void SchemaRegistry::add_migration(const std::string& type, int from_version, Json rules) {
require(from_version > 0 && rules.is_object(), "migration.invalid", "Invalid migration");
require(!migrations_.contains({type, from_version}), "migration.duplicate",
"Migration already exists");
migrations_[{type, from_version}] = std::move(rules);
}
Json SchemaRegistry::migrate_component(const Json& source)const {
Json result=source;const auto type=result.at("type").get<std::string>();
if(!contains(type))return result;
const auto current=schema(type).value("version",1);
auto version=result.value("version",1);
if(version>current)return result;
while(version<current) {
const auto found=migrations_.find({type,version});
require(found!=migrations_.end(),"migration.required","Explicit migration required for "+type);
for(const auto& [field,rule]:found->second.items()) {
if(rule.contains("default")&&!result["fields"].contains(field))result["fields"][field]=rule["default"];
if(rule.contains("scale")&&result["fields"].contains(field)) {
require(result["fields"][field].is_number(),"migration.type","Cannot scale a nonnumeric field");
result["fields"][field]=result["fields"][field].get<double>()*rule["scale"].get<double>();
Json SchemaRegistry::migrate_component(const Json& source) const {
Json result = source;
const auto type = result.at("type").get<std::string>();
if (!contains(type))
return result;
const auto current = schema(type).value("version", 1);
auto version = result.value("version", 1);
if (version > current)
return result;
while (version < current) {
const auto found = migrations_.find({type, version});
require(found != migrations_.end(), "migration.required",
"Explicit migration required for " + type);
for (const auto& [field, rule] : found->second.items()) {
if (rule.contains("default") && !result["fields"].contains(field))
result["fields"][field] = rule["default"];
if (rule.contains("scale") && result["fields"].contains(field)) {
require(result["fields"][field].is_number(), "migration.type",
"Cannot scale a nonnumeric field");
result["fields"][field] =
result["fields"][field].get<double>() * rule["scale"].get<double>();
}
if(rule.value("require_manual",false)&&result["fields"].contains(field))throw Error("migration.manual","Field requires explicit manual migration",{{"type",type},{"field",field}});
if (rule.value("require_manual", false) && result["fields"].contains(field))
throw Error("migration.manual", "Field requires explicit manual migration",
{{"type", type}, {"field", field}});
}
result["version"]=++version;
result["version"] = ++version;
}
const auto metadata=schema(type);
for(const auto& [field,descriptor]:metadata["fields"].items())if(!result["fields"].contains(field))result["fields"][field]=descriptor["default"];
validate_component(result);return result;
const auto metadata = schema(type);
for (const auto& [field, descriptor] : metadata["fields"].items())
if (!result["fields"].contains(field))
result["fields"][field] = descriptor["default"];
validate_component(result);
return result;
}
SchemaRegistry builtin_schemas() {
SchemaRegistry registry;
struct Transform {std::array<float,3> position,rotation,scale;};
TypeRegistration<Transform>(registry,"faset.transform","Transform")
.field("position","Position",&Transform::position,std::array<float,3>{0,0,0},"vec3")
.field("rotation","Rotation",&Transform::rotation,std::array<float,3>{0,0,0},"vec3",{{"unit","radians"}})
.field("scale","Scale",&Transform::scale,std::array<float,3>{1,1,1},"vec3").commit();
auto add=[&](std::string id,std::string name,Json fields){registry.register_schema({{"id",id},{"name",name},{"version",1},{"fields",fields}});};
auto field=[](std::string type,Json value){return Json{{"type",type},{"default",value}};};
add("faset.sprite","Sprite",{{"color",field("color",{0.65,0.6,0.85,1.0})},{"size",field("vec2",{1,1})},{"texture",field("asset_ref","")},{"layer",field("integer",0)}});
add("faset.mesh","Mesh",{{"asset",field("asset_ref","")},{"color",field("color",{0.65,0.65,0.68,1.0})},{"primitive",Json{{"type","string"},{"default","cube"},{"enum",{"cube","plane","asset"}}}}});
add("faset.camera","Camera",{{"fov",Json{{"type","number"},{"default",60.0},{"min",1.0},{"max",179.0}}},{"near",Json{{"type","number"},{"default",0.1},{"min",0.001}}},{"far",Json{{"type","number"},{"default",1000.0},{"min",0.01}}}});
add("faset.light","Directional Light",{{"color",field("color",{1,1,1,1})},{"intensity",Json{{"type","number"},{"default",1.0},{"min",0.0}}}});
for(int dimension:{2,3}) {
Json vector=dimension==2?Json{0,0}:Json{0,0,0};Json extents=dimension==2?Json{0.5,0.5}:Json{0.5,0.5,0.5};
add("faset.rigid_body_"+std::to_string(dimension)+"d","Rigid Body "+std::to_string(dimension)+"D",{
{"body_type",Json{{"type","string"},{"default","dynamic"},{"enum",{"static","dynamic","kinematic"}}}},
{"half_extents",field(dimension==2?"vec2":"vec3",extents)},
{"linear_velocity",field(dimension==2?"vec2":"vec3",vector)},
{"density",Json{{"type","number"},{"default",1.0},{"min",0.001}}},
{"friction",Json{{"type","number"},{"default",0.5},{"min",0.0}}},
{"restitution",Json{{"type","number"},{"default",0.0},{"min",0.0},{"max",1.0}}},
{"gravity_scale",field("number",1.0)},
{"category_bits",Json{{"type","integer"},{"default",1},{"min",0}}},
{"mask_bits",Json{{"type","integer"},{"default",65535},{"min",0}}}});
struct Transform {
std::array<float, 3> position, rotation, scale;
};
TypeRegistration<Transform>(registry, "faset.transform", "Transform")
.field("position", "Position", &Transform::position, std::array<float, 3>{0, 0, 0}, "vec3")
.field("rotation", "Rotation", &Transform::rotation, std::array<float, 3>{0, 0, 0}, "vec3",
{{"unit", "radians"}})
.field("scale", "Scale", &Transform::scale, std::array<float, 3>{1, 1, 1}, "vec3")
.commit();
auto add = [&](std::string id, std::string name, Json fields) {
registry.register_schema({{"id", id}, {"name", name}, {"version", 1}, {"fields", fields}});
};
auto field = [](std::string type, Json value) {
return Json{{"type", type}, {"default", value}};
};
add("faset.sprite", "Sprite",
{{"color", field("color", {0.65, 0.6, 0.85, 1.0})},
{"size", field("vec2", {1, 1})},
{"texture", field("asset_ref", "")},
{"layer", field("integer", 0)}});
add("faset.mesh", "Mesh",
{{"asset", field("asset_ref", "")},
{"color", field("color", {0.65, 0.65, 0.68, 1.0})},
{"primitive",
Json{{"type", "string"}, {"default", "cube"}, {"enum", {"cube", "plane", "asset"}}}}});
add("faset.camera", "Camera",
{{"fov", Json{{"type", "number"}, {"default", 60.0}, {"min", 1.0}, {"max", 179.0}}},
{"near", Json{{"type", "number"}, {"default", 0.1}, {"min", 0.001}}},
{"far", Json{{"type", "number"}, {"default", 1000.0}, {"min", 0.01}}}});
add("faset.light", "Directional Light",
{{"color", field("color", {1, 1, 1, 1})},
{"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}}});
for (int dimension : {2, 3}) {
Json vector = dimension == 2 ? Json{0, 0} : Json{0, 0, 0};
Json extents = dimension == 2 ? Json{0.5, 0.5} : Json{0.5, 0.5, 0.5};
add("faset.rigid_body_" + std::to_string(dimension) + "d",
"Rigid Body " + std::to_string(dimension) + "D",
{{"body_type", Json{{"type", "string"},
{"default", "dynamic"},
{"enum", {"static", "dynamic", "kinematic"}}}},
{"half_extents", field(dimension == 2 ? "vec2" : "vec3", extents)},
{"linear_velocity", field(dimension == 2 ? "vec2" : "vec3", vector)},
{"density", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.001}}},
{"friction", Json{{"type", "number"}, {"default", 0.5}, {"min", 0.0}}},
{"restitution",
Json{{"type", "number"}, {"default", 0.0}, {"min", 0.0}, {"max", 1.0}}},
{"gravity_scale", field("number", 1.0)},
{"category_bits", Json{{"type", "integer"}, {"default", 1}, {"min", 0}}},
{"mask_bits", Json{{"type", "integer"}, {"default", 65535}, {"min", 0}}}});
}
return registry;
}
}
} // namespace faset::authoring
+474 -164
View File
@@ -1,201 +1,511 @@
#include <faset/authoring/service.hpp>
#include <faset/core/io.hpp>
#include <faset/core/hash.hpp>
#include <algorithm>
#include <cmath>
#include <faset/authoring/service.hpp>
#include <faset/authoring/transforms.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <set>
namespace faset::authoring {
namespace {
Json& entity(Json& scene,const std::string& id) {
for(auto& item:scene["entities"])if(item.at("id")==id)return item;
throw Error("entity.missing","Entity does not exist",{{"entity",id}});
Json& entity(Json& scene, const std::string& id) {
for (auto& item : scene["entities"])
if (item.at("id") == id)
return item;
throw Error("entity.missing", "Entity does not exist", {{"entity", id}});
}
Json& component(Json& item,const std::string& id) {
for(auto& value:item["components"])if(value.at("id")==id)return value;
throw Error("component.missing","Component does not exist",{{"component",id}});
Json& component(Json& item, const std::string& id) {
for (auto& value : item["components"])
if (value.at("id") == id)
return value;
throw Error("component.missing", "Component does not exist", {{"component", id}});
}
std::string parent_id(const Json& item) {return item.contains("parent")&&!item["parent"].is_null()?item["parent"].get<std::string>():"";}
void check_revision(std::uint64_t current,std::uint64_t expected) {
if(current!=expected)throw Error("revision.conflict","Document changed since it was read",{{"expected",expected},{"current",current}});
std::string parent_id(const Json& item) {
return item.contains("parent") && !item["parent"].is_null() ? item["parent"].get<std::string>()
: "";
}
void check_revision(std::uint64_t current, std::uint64_t expected) {
if (current != expected)
throw Error("revision.conflict", "Document changed since it was read",
{{"expected", expected}, {"current", current}});
}
bool finite_json(const Json& value) {
if(value.is_number_float())return std::isfinite(value.get<double>());
if(value.is_structured())for(const auto& child:value)if(!finite_json(child))return false;
if (value.is_number_float())
return std::isfinite(value.get<double>());
if (value.is_structured())
for (const auto& child : value)
if (!finite_json(child))
return false;
return true;
}
bool valid_id(const Json& value) {
if (!value.is_string())
return false;
const auto& text = value.get_ref<const std::string&>();
return !text.empty() && text.size() <= 128 &&
text.find_first_not_of(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.:") ==
std::string::npos;
}
Json make_scene(std::string name,int dimension) {
require(dimension==2||dimension==3,"scene.dimension","Scene dimension must be 2 or 3");
return {{"format","faset.scene"},{"version",1},{"id",new_id()},{"name",std::move(name)},{"dimension",dimension},{"entities",Json::array()},{"instances",Json::array()}};
} // namespace
Json make_scene(std::string name, int dimension) {
require(dimension == 2 || dimension == 3, "scene.dimension", "Scene dimension must be 2 or 3");
return {{"format", "faset.scene"}, {"version", 1}, {"id", new_id()},
{"name", std::move(name)}, {"dimension", dimension}, {"entities", Json::array()},
{"instances", Json::array()}};
}
Json make_entity(const SchemaRegistry& schemas,std::string name,const std::string& parent) {
Json transform={{"id",new_id()},{"type","faset.transform"},{"version",1},{"fields",schemas.default_fields("faset.transform")}};
return {{"id",new_id()},{"name",std::move(name)},{"parent",parent.empty()?Json(nullptr):Json(parent)},{"components",Json::array({transform})}};
Json make_entity(const SchemaRegistry& schemas, std::string name, const std::string& parent) {
Json transform = {{"id", new_id()},
{"type", "faset.transform"},
{"version", 1},
{"fields", schemas.default_fields("faset.transform")}};
return {{"id", new_id()},
{"name", std::move(name)},
{"parent", parent.empty() ? Json(nullptr) : Json(parent)},
{"components", Json::array({transform})}};
}
void validate_scene(const Json& scene,const SchemaRegistry& schemas) {
require(scene.is_object()&&scene.value("format",std::string())=="faset.scene","scene.format","Expected a Faset scene");
require(scene.value("version",0)==1,"scene.version","Unsupported scene format version");
require(scene.contains("id")&&scene["id"].is_string()&&!scene["id"].get<std::string>().empty(),"scene.id","Scene requires a stable ID");
require(scene.contains("name")&&scene["name"].is_string(),"scene.name","Scene name must be text");
require(scene.value("dimension",0)==2||scene.value("dimension",0)==3,"scene.dimension","Scene dimension must be 2 or 3");
require(scene.contains("entities")&&scene["entities"].is_array(),"scene.entities","Scene entities must be an array");
require(finite_json(scene),"validation.finite","Scene contains a non-finite number");
std::set<std::string> ids;std::map<std::string,std::string> parents;
auto insert_id=[&](const Json& value) {require(value.is_string()&&!value.get<std::string>().empty(),"id.invalid","ID must be nonempty text");require(ids.insert(value.get<std::string>()).second,"id.duplicate","Duplicate document ID");};
for(const auto& item:scene["entities"]) {
require(item.is_object()&&item.contains("id")&&item.contains("name")&&item["name"].is_string(),"entity.invalid","Invalid entity record");
insert_id(item["id"]);parents[item["id"].get<std::string>()]=parent_id(item);
require(item.contains("components")&&item["components"].is_array(),"entity.components","Entity components must be an array");
void validate_scene(const Json& scene, const SchemaRegistry& schemas) {
require(scene.is_object() && scene.value("format", std::string()) == "faset.scene",
"scene.format", "Expected a Faset scene");
require(scene.value("version", 0) == 1, "scene.version", "Unsupported scene format version");
require(scene.contains("id") && valid_id(scene["id"]), "scene.id",
"Scene requires a safe stable ID");
require(scene.contains("name") && scene["name"].is_string(), "scene.name",
"Scene name must be text");
require(scene.value("dimension", 0) == 2 || scene.value("dimension", 0) == 3, "scene.dimension",
"Scene dimension must be 2 or 3");
require(scene.contains("entities") && scene["entities"].is_array(), "scene.entities",
"Scene entities must be an array");
require(finite_json(scene), "validation.finite", "Scene contains a non-finite number");
std::set<std::string> ids;
std::map<std::string, std::string> parents;
auto insert_id = [&](const Json& value) {
require(valid_id(value), "id.invalid",
"ID must contain at most 128 ASCII identifier characters");
require(ids.insert(value.get<std::string>()).second, "id.duplicate",
"Duplicate document ID");
};
for (const auto& item : scene["entities"]) {
require(item.is_object() && item.contains("id") && item.contains("name") &&
item["name"].is_string(),
"entity.invalid", "Invalid entity record");
insert_id(item["id"]);
parents[item["id"].get<std::string>()] = parent_id(item);
require(item.contains("components") && item["components"].is_array(), "entity.components",
"Entity components must be an array");
std::set<std::string> types;
for(const auto& value:item["components"]) {
require(value.contains("id"),"component.id","Component requires stable ID");insert_id(value["id"]);schemas.validate_component(value);
require(types.insert(value.at("type").get<std::string>()).second,"component.duplicate_type","One component of each type is supported per entity");
for (const auto& value : item["components"]) {
require(value.contains("id"), "component.id", "Component requires stable ID");
insert_id(value["id"]);
schemas.validate_component(value);
require(types.insert(value.at("type").get<std::string>()).second,
"component.duplicate_type",
"One component of each type is supported per entity");
}
}
for(const auto& [id,parent]:parents) {
std::set<std::string> visited{id};auto current=parent;
while(!current.empty()) {require(parents.contains(current),"entity.parent_missing","Parent entity is missing");require(visited.insert(current).second,"entity.cycle","Hierarchy contains a cycle");current=parents.at(current);}
for (const auto& [id, parent] : parents) {
std::set<std::string> visited{id};
auto current = parent;
while (!current.empty()) {
require(parents.contains(current), "entity.parent_missing", "Parent entity is missing");
require(visited.insert(current).second, "entity.cycle", "Hierarchy contains a cycle");
current = parents.at(current);
}
}
if(scene.contains("instances")) {
require(scene["instances"].is_array(),"template.instances","Template instances must be an array");
for(const auto& instance:scene["instances"]) {
require(instance.contains("id")&&instance.contains("source")&&instance["source"].is_string(),"template.instance","Invalid template instance");
if (scene.contains("instances")) {
require(scene["instances"].is_array(), "template.instances",
"Template instances must be an array");
for (const auto& instance : scene["instances"]) {
require(instance.contains("id") && instance.contains("source") &&
instance["source"].is_string(),
"template.instance", "Invalid template instance");
insert_id(instance["id"]);
}
}
}
AuthoringService::AuthoringService(std::filesystem::path root,SchemaRegistry schemas):root_(std::filesystem::absolute(std::move(root)).lexically_normal()),schemas_(std::move(schemas)) {std::filesystem::create_directories(root_);}
AuthoringService::State& AuthoringService::state(const std::string& id) {auto found=documents_.find(id);require(found!=documents_.end(),"document.missing","Document is not open");return found->second;}
const AuthoringService::State& AuthoringService::state(const std::string& id)const {auto found=documents_.find(id);require(found!=documents_.end(),"document.missing","Document is not open");return found->second;}
Json AuthoringService::summary(const State& value,bool include_data)const {
Json result={{"id",value.data.at("id")},{"name",value.data.at("name")},{"revision",value.revision},{"dirty",sha256(value.data.dump())!=value.saved_hash},{"path",value.path.generic_string()},{"can_undo",!value.undo.empty()},{"can_redo",!value.redo.empty()}};
if(include_data) result["scene"]=value.data;
AuthoringService::AuthoringService(std::filesystem::path root, SchemaRegistry schemas)
: root_(std::filesystem::absolute(std::move(root)).lexically_normal()),
schemas_(std::move(schemas)) {
std::filesystem::create_directories(root_);
}
AuthoringService::State& AuthoringService::state(const std::string& id) {
auto found = documents_.find(id);
require(found != documents_.end(), "document.missing", "Document is not open");
return found->second;
}
const AuthoringService::State& AuthoringService::state(const std::string& id) const {
auto found = documents_.find(id);
require(found != documents_.end(), "document.missing", "Document is not open");
return found->second;
}
Json AuthoringService::summary(const State& value, bool include_data) const {
Json result = {{"id", value.data.at("id")},
{"name", value.data.at("name")},
{"revision", value.revision},
{"dirty", sha256(value.data.dump()) != value.saved_hash},
{"path", value.path.generic_string()},
{"can_undo", !value.undo.empty()},
{"can_redo", !value.redo.empty()}};
if (include_data)
result["scene"] = value.data;
return result;
}
void AuthoringService::journal(const State& value)const {
atomic_write_json(project_path(root_,std::filesystem::path(".faset/recovery")/(value.data.at("id").get<std::string>()+".json")),{{"format","faset.recovery"},{"version",1},{"path",value.path.generic_string()},{"revision",value.revision},{"saved_hash",value.saved_hash},{"disk_hash",value.disk_hash},{"scene",value.data}});
void AuthoringService::journal(const State& value) const {
atomic_write_json(project_path(root_, std::filesystem::path(".faset/recovery") /
(value.data.at("id").get<std::string>() + ".json")),
{{"format", "faset.recovery"},
{"version", 1},
{"path", value.path.generic_string()},
{"revision", value.revision},
{"saved_hash", value.saved_hash},
{"disk_hash", value.disk_hash},
{"scene", value.data}});
}
Json AuthoringService::create(std::string name,int dimension) {
std::lock_guard lock(mutex_);State value;value.data=make_scene(std::move(name),dimension);journal(value);
const auto id=value.data["id"].get<std::string>();documents_.emplace(id,std::move(value));return summary(state(id));
Json AuthoringService::create(std::string name, int dimension) {
std::lock_guard lock(mutex_);
State value;
value.data = make_scene(std::move(name), dimension);
journal(value);
const auto id = value.data["id"].get<std::string>();
documents_.emplace(id, std::move(value));
return summary(state(id));
}
Json AuthoringService::open(const std::filesystem::path& relative,bool recover) {
std::lock_guard lock(mutex_);auto path=project_path(root_,relative);Json data=read_json(path);validate_scene(data,schemas_);
const auto id=data.at("id").get<std::string>();
if(documents_.contains(id)) {require(state(id).path==relative.lexically_normal(),"document.id_collision","Another open file has the same document ID");return summary(state(id));}
State value;value.data=data;value.path=relative.lexically_normal();value.saved_hash=sha256(data.dump());value.disk_hash=sha256_file(path);
const auto recovery=project_path(root_,std::filesystem::path(".faset/recovery")/(id+".json"));
if(recover&&std::filesystem::exists(recovery)) {
const auto recovered=read_json(recovery);
require(recovered.value("disk_hash",std::string())==value.disk_hash,"recovery.disk_conflict","Scene file changed since recovery was written");
validate_scene(recovered.at("scene"),schemas_);value.data=recovered.at("scene");value.revision=recovered.value("revision",0u);
Json AuthoringService::open(const std::filesystem::path& relative, bool recover) {
std::lock_guard lock(mutex_);
auto path = project_path(root_, relative);
Json data = read_json(path);
validate_scene(data, schemas_);
const auto id = data.at("id").get<std::string>();
if (documents_.contains(id)) {
require(state(id).path == relative.lexically_normal(), "document.id_collision",
"Another open file has the same document ID");
return recover ? this->recover(id, state(id).revision) : summary(state(id));
}
for(auto& item:value.data["entities"])for(auto& component:item["components"])component=schemas_.migrate_component(component);
documents_.emplace(id,std::move(value));return summary(state(id));
State value;
value.data = data;
value.path = relative.lexically_normal();
value.saved_hash = sha256(data.dump());
value.disk_hash = sha256_file(path);
const auto recovery =
project_path(root_, std::filesystem::path(".faset/recovery") / (id + ".json"));
if (recover && std::filesystem::exists(recovery)) {
const auto recovered = read_json(recovery);
require(recovered.value("disk_hash", std::string()) == value.disk_hash,
"recovery.disk_conflict", "Scene file changed since recovery was written");
validate_scene(recovered.at("scene"), schemas_);
require(recovered.at("scene").at("id") == id, "recovery.id",
"Recovery ID does not match its document");
value.data = recovered.at("scene");
value.revision = recovered.value("revision", 0u);
}
for (auto& item : value.data["entities"])
for (auto& component : item["components"])
component = schemas_.migrate_component(component);
documents_.emplace(id, std::move(value));
return summary(state(id));
}
Json AuthoringService::query(const std::string& id)const {std::lock_guard lock(mutex_);return summary(state(id));}
Json AuthoringService::documents()const {std::lock_guard lock(mutex_);Json result=Json::array();for(const auto& [id,value]:documents_)result.push_back(summary(value,false));return result;}
void AuthoringService::register_schemas(const Json& manifest) {std::lock_guard lock(mutex_);schemas_.register_schemas(manifest);}
void AuthoringService::apply(Json& scene,const Json& command) {
require(command.is_object()&&command.contains("op")&&command["op"].is_string(),"command.invalid","Command requires an operation name");
const auto op=command.at("op").get<std::string>();
if(op=="entity.create") {
Json value=command.contains("entity")&&command["entity"].is_object()?command["entity"]:make_entity(schemas_,command.value("name",std::string("Object")),command.value("parent",std::string()));
if(!value.contains("id")) value["id"]=new_id();
Json AuthoringService::query(const std::string& id) const {
std::lock_guard lock(mutex_);
return summary(state(id));
}
Json AuthoringService::documents() const {
std::lock_guard lock(mutex_);
Json result = Json::array();
for (const auto& [id, value] : documents_)
result.push_back(summary(value, false));
return result;
}
void AuthoringService::register_schemas(const Json& manifest) {
std::lock_guard lock(mutex_);
schemas_.register_schemas(manifest);
}
void AuthoringService::replace_external_schemas(const Json& manifest) {
std::lock_guard lock(mutex_);
auto candidate = builtin_schemas();
candidate.register_schemas(manifest);
schemas_ = std::move(candidate);
}
void AuthoringService::apply(Json& scene, const Json& command) {
require(command.is_object() && command.contains("op") && command["op"].is_string(),
"command.invalid", "Command requires an operation name");
const auto op = command.at("op").get<std::string>();
if (op == "entity.create") {
Json value = command.contains("entity") && command["entity"].is_object()
? command["entity"]
: make_entity(schemas_, command.value("name", std::string("Object")),
command.value("parent", std::string()));
if (!value.contains("id"))
value["id"] = new_id();
scene["entities"].push_back(std::move(value));
} else if(op=="entity.rename") {
entity(scene,command.at("entity").get<std::string>())["name"]=command.at("name");
} else if(op=="entity.delete") {
const auto id=command.at("entity").get<std::string>();entity(scene,id);
std::set<std::string> removed{id};bool changed=true;
while(changed) {changed=false;for(const auto& item:scene["entities"])if(removed.contains(parent_id(item)))changed=removed.insert(item.at("id").get<std::string>()).second||changed;}
auto& values=scene["entities"];values.erase(std::remove_if(values.begin(),values.end(),[&](const Json& value){return removed.contains(value.at("id").get<std::string>());}),values.end());
} else if(op=="entity.reparent") {
auto& value=entity(scene,command.at("entity").get<std::string>());
require(!command.value("keep_world",false),"transform.unsupported","World-preserving reparent requires the transform resolver");
const auto parent=command.value("parent",Json(nullptr));if(!parent.is_null())entity(scene,parent.get<std::string>());value["parent"]=parent;
} else if(op=="component.add") {
auto& value=entity(scene,command.at("entity").get<std::string>());const auto type=command.at("type").get<std::string>();
const auto metadata=schemas_.schema(type);Json fields=schemas_.default_fields(type);if(command.contains("fields"))fields.update(command["fields"]);
value["components"].push_back({{"id",command.value("id",new_id())},{"type",type},{"version",metadata.value("version",1)},{"fields",fields}});
} else if(op=="component.remove") {
auto& values=entity(scene,command.at("entity").get<std::string>())["components"];const auto id=command.at("component").get<std::string>();
auto found=std::find_if(values.begin(),values.end(),[&](const Json& value){return value.at("id")==id;});require(found!=values.end(),"component.missing","Component does not exist");values.erase(found);
} else if(op=="component.set") {
auto& value=component(entity(scene,command.at("entity").get<std::string>()),command.at("component").get<std::string>());
const auto field=command.at("field").get<std::string>();require(!field.empty(),"field.invalid","FieldId cannot be empty");value["fields"][field]=command.at("value");
} else if(op=="entity.duplicate") {
const auto id=command.at("entity").get<std::string>();entity(scene,id);
std::set<std::string> subtree{id};bool changed=true;
while(changed) {changed=false;for(const auto& item:scene["entities"])if(subtree.contains(parent_id(item)))changed=subtree.insert(item.at("id").get<std::string>()).second||changed;}
std::map<std::string,std::string> mapping;
for(const auto& item:scene["entities"])if(subtree.contains(item.at("id").get<std::string>())) {mapping[item.at("id")]=new_id();for(const auto& component:item["components"])mapping[component.at("id")]=new_id();}
Json duplicates=Json::array();
for(const auto& item:scene["entities"])if(subtree.contains(item.at("id").get<std::string>())) {
auto copy=item;copy["id"]=mapping.at(item.at("id").get<std::string>());const auto parent=parent_id(item);if(mapping.contains(parent))copy["parent"]=mapping.at(parent);
if(item.at("id")==id)copy["name"]=item.at("name").get<std::string>()+" Copy";
for(auto& component:copy["components"]) {
component["id"]=mapping.at(component.at("id").get<std::string>());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()&&mapping.contains(value.get<std::string>()))value=mapping.at(value.get<std::string>());
}
duplicates.push_back(std::move(copy));
} else if (op == "entity.rename") {
entity(scene, command.at("entity").get<std::string>())["name"] = command.at("name");
} else if (op == "entity.delete") {
const auto id = command.at("entity").get<std::string>();
entity(scene, id);
std::set<std::string> removed{id};
bool changed = true;
while (changed) {
changed = false;
for (const auto& item : scene["entities"])
if (removed.contains(parent_id(item)))
changed = removed.insert(item.at("id").get<std::string>()).second || changed;
}
for(auto& value:duplicates)scene["entities"].push_back(std::move(value));
} else if(op=="scene.rename")scene["name"]=command.at("name");
else if(op=="template.instance") {
Json value=command.at("instance");if(!value.contains("id"))value["id"]=new_id();if(!scene.contains("instances"))scene["instances"]=Json::array();scene["instances"].push_back(std::move(value));
} else if(op=="template.override"||op=="template.revert"||op=="template.suppress"||op=="template.add"||op=="template.reparent") {
auto& instances=scene["instances"];const auto id=command.at("instance").get<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");
const std::string key=op=="template.suppress"?"suppressed":op=="template.add"?"additions":op=="template.reparent"?"reparents":"overrides";
if(!found->contains(key)) (*found)[key]=Json::array();
auto& records=(*found)[key];
if(key=="overrides") {
const auto address=command.at("address");
auto old=std::find_if(records.begin(),records.end(),[&](const Json& value){return value.at("address")==address;});
if(old!=records.end())records.erase(old);
if(op!="template.revert")records.push_back({{"address",address},{"value",command.at("value")}});
} else records.push_back(command.at("value"));
} else throw Error("command.unknown","Unknown authoring command: "+op);
auto& values = scene["entities"];
values.erase(std::remove_if(values.begin(), values.end(),
[&](const Json& value) {
return removed.contains(value.at("id").get<std::string>());
}),
values.end());
} else if (op == "entity.reparent") {
reparent_entity(scene, command.at("entity").get<std::string>(),
command.value("parent", Json(nullptr)), command.value("keep_world", false));
} else if (op == "component.add") {
auto& value = entity(scene, command.at("entity").get<std::string>());
const auto type = command.at("type").get<std::string>();
const auto metadata = schemas_.schema(type);
Json fields = schemas_.default_fields(type);
if (command.contains("fields"))
fields.update(command["fields"]);
value["components"].push_back({{"id", command.value("id", new_id())},
{"type", type},
{"version", metadata.value("version", 1)},
{"fields", fields}});
} else if (op == "component.remove") {
auto& values = entity(scene, command.at("entity").get<std::string>())["components"];
const auto id = command.at("component").get<std::string>();
auto found = std::find_if(values.begin(), values.end(),
[&](const Json& value) { return value.at("id") == id; });
require(found != values.end(), "component.missing", "Component does not exist");
values.erase(found);
} else if (op == "component.set") {
auto& value = component(entity(scene, command.at("entity").get<std::string>()),
command.at("component").get<std::string>());
const auto field = command.at("field").get<std::string>();
require(!field.empty(), "field.invalid", "FieldId cannot be empty");
value["fields"][field] = command.at("value");
} else if (op == "entity.duplicate") {
const auto id = command.at("entity").get<std::string>();
entity(scene, id);
std::set<std::string> subtree{id};
bool changed = true;
while (changed) {
changed = false;
for (const auto& item : scene["entities"])
if (subtree.contains(parent_id(item)))
changed = subtree.insert(item.at("id").get<std::string>()).second || changed;
}
std::map<std::string, std::string> mapping;
for (const auto& item : scene["entities"])
if (subtree.contains(item.at("id").get<std::string>())) {
mapping[item.at("id")] = new_id();
for (const auto& component : item["components"])
mapping[component.at("id")] = new_id();
}
Json duplicates = Json::array();
for (const auto& item : scene["entities"])
if (subtree.contains(item.at("id").get<std::string>())) {
auto copy = item;
copy["id"] = mapping.at(item.at("id").get<std::string>());
const auto parent = parent_id(item);
if (mapping.contains(parent))
copy["parent"] = mapping.at(parent);
if (item.at("id") == id)
copy["name"] = item.at("name").get<std::string>() + " Copy";
for (auto& component : copy["components"]) {
component["id"] = mapping.at(component.at("id").get<std::string>());
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() && mapping.contains(value.get<std::string>()))
value = mapping.at(value.get<std::string>());
}
duplicates.push_back(std::move(copy));
}
for (auto& value : duplicates)
scene["entities"].push_back(std::move(value));
} else if (op == "scene.rename")
scene["name"] = command.at("name");
else if (op == "template.instance") {
Json value = command.at("instance");
if (!value.contains("id"))
value["id"] = new_id();
if (!scene.contains("instances"))
scene["instances"] = Json::array();
scene["instances"].push_back(std::move(value));
} else if (op == "template.override" || op == "template.revert" || op == "template.suppress" ||
op == "template.add" || op == "template.reparent") {
auto& instances = scene["instances"];
const auto id = command.at("instance").get<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");
const std::string key = op == "template.suppress" ? "suppressed"
: op == "template.add" ? "additions"
: op == "template.reparent" ? "reparents"
: "overrides";
if (!found->contains(key))
(*found)[key] = Json::array();
auto& records = (*found)[key];
if (key == "overrides") {
const auto address = command.at("address");
auto old = std::find_if(records.begin(), records.end(), [&](const Json& value) {
return value.at("address") == address;
});
if (old != records.end())
records.erase(old);
if (op != "template.revert")
records.push_back({{"address", address}, {"value", command.at("value")}});
} else
records.push_back(command.at("value"));
} else
throw Error("command.unknown", "Unknown authoring command: " + op);
}
Json AuthoringService::transact(const std::string& id,std::uint64_t revision,const Json& operations,const std::string& key) {
std::lock_guard lock(mutex_);auto& current=state(id);require(operations.is_array()&&!operations.empty(),"transaction.empty","Transaction requires an array of operations");
const auto fingerprint=sha256(Json{{"revision",revision},{"operations",operations}}.dump());
if(!key.empty()&&current.requests.contains(key)) {
const auto& request=current.requests.at(key);require(request.first==fingerprint,"idempotency.conflict","Idempotency key was used with another payload");return request.second;
Json AuthoringService::transact(const std::string& id, std::uint64_t revision,
const Json& operations, const std::string& key) {
std::lock_guard lock(mutex_);
auto& current = state(id);
require(operations.is_array() && !operations.empty(), "transaction.empty",
"Transaction requires an array of operations");
const auto fingerprint =
sha256(Json{{"revision", revision}, {"operations", operations}}.dump());
if (!key.empty() && current.requests.contains(key)) {
const auto& request = current.requests.at(key);
require(request.first == fingerprint, "idempotency.conflict",
"Idempotency key was used with another payload");
return request.second;
}
check_revision(current.revision,revision);State candidate=current;
for(const auto& operation:operations)apply(candidate.data,operation);
validate_scene(candidate.data,schemas_);
candidate.undo.push_back(current.data);if(candidate.undo.size()>100)candidate.undo.erase(candidate.undo.begin());candidate.redo.clear();++candidate.revision;
journal(candidate);auto result=summary(candidate);
if(!key.empty()) {if(candidate.requests.size()>=256)candidate.requests.erase(candidate.requests.begin());candidate.requests[key]={fingerprint,result};}
current=std::move(candidate);return result;
}
Json AuthoringService::history(const std::string& id,std::uint64_t revision,bool forward) {
std::lock_guard lock(mutex_);auto& current=state(id);check_revision(current.revision,revision);State candidate=current;
auto& source=forward?candidate.redo:candidate.undo;auto& target=forward?candidate.undo:candidate.redo;
require(!source.empty(),"history.empty",forward?"Nothing to redo":"Nothing to undo");target.push_back(candidate.data);candidate.data=source.back();source.pop_back();++candidate.revision;journal(candidate);current=std::move(candidate);return summary(current);
}
Json AuthoringService::undo(const std::string& id,std::uint64_t revision){return history(id,revision,false);}
Json AuthoringService::redo(const std::string& id,std::uint64_t revision){return history(id,revision,true);}
Json AuthoringService::save(const std::string& id,const std::filesystem::path& relative) {
std::lock_guard lock(mutex_);auto& current=state(id);const auto selected=relative.empty()?current.path:relative.lexically_normal();require(!selected.empty(),"save.path","Choose a scene path before saving");
const auto path=project_path(root_,selected);
if(std::filesystem::exists(path)) {
require(selected==current.path&&!current.disk_hash.empty(),"save.exists","Save As will not overwrite another file");
require(sha256_file(path)==current.disk_hash,"save.disk_conflict","File changed outside the Editor; reload or save to another path");
check_revision(current.revision, revision);
State candidate = current;
for (const auto& operation : operations)
apply(candidate.data, operation);
validate_scene(candidate.data, schemas_);
candidate.undo.push_back(current.data);
if (candidate.undo.size() > 100)
candidate.undo.erase(candidate.undo.begin());
candidate.redo.clear();
++candidate.revision;
journal(candidate);
auto result = summary(candidate);
if (!key.empty()) {
if (candidate.requests.size() >= 256)
candidate.requests.erase(candidate.requests.begin());
candidate.requests[key] = {fingerprint, result};
}
atomic_write_json(path,current.data);current.path=selected;current.saved_hash=sha256(current.data.dump());current.disk_hash=sha256_file(path);
journal(current);return summary(current);
current = std::move(candidate);
return result;
}
Json AuthoringService::recovery_documents()const {
std::lock_guard lock(mutex_);Json result=Json::array();const auto path=project_path(root_,".faset/recovery");if(!std::filesystem::exists(path))return result;
for(const auto& entry:std::filesystem::directory_iterator(path))if(entry.is_regular_file()&&entry.path().extension()==".json") {
try {const auto value=read_json(entry.path());result.push_back({{"id",value.at("scene").at("id")},{"name",value.at("scene").at("name")},{"path",value.at("path")},{"dirty",sha256(value.at("scene").dump())!=value.value("saved_hash",std::string())}});}catch(const std::exception&) {result.push_back({{"error","Invalid recovery record"},{"file",entry.path().filename().string()}});}
}return result;
Json AuthoringService::history(const std::string& id, std::uint64_t revision, bool forward) {
std::lock_guard lock(mutex_);
auto& current = state(id);
check_revision(current.revision, revision);
State candidate = current;
auto& source = forward ? candidate.redo : candidate.undo;
auto& target = forward ? candidate.undo : candidate.redo;
require(!source.empty(), "history.empty", forward ? "Nothing to redo" : "Nothing to undo");
target.push_back(candidate.data);
candidate.data = source.back();
source.pop_back();
++candidate.revision;
journal(candidate);
current = std::move(candidate);
return summary(current);
}
Json AuthoringService::undo(const std::string& id, std::uint64_t revision) {
return history(id, revision, false);
}
Json AuthoringService::redo(const std::string& id, std::uint64_t revision) {
return history(id, revision, true);
}
Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative) {
std::lock_guard lock(mutex_);
auto& current = state(id);
const auto selected = relative.empty() ? current.path : relative.lexically_normal();
require(!selected.empty(), "save.path", "Choose a scene path before saving");
const auto path = project_path(root_, selected);
if (std::filesystem::exists(path)) {
require(selected == current.path && !current.disk_hash.empty(), "save.exists",
"Save As will not overwrite another file");
require(sha256_file(path) == current.disk_hash, "save.disk_conflict",
"File changed outside the Editor; reload or save to another path");
}
atomic_write_json(path, current.data);
current.path = selected;
current.saved_hash = sha256(current.data.dump());
current.disk_hash = sha256_file(path);
journal(current);
return summary(current);
}
Json AuthoringService::recover(const std::string& id,
std::optional<std::uint64_t> expected_revision) {
std::lock_guard lock(mutex_);
require(valid_id(Json(id)), "id.invalid", "Invalid recovery document ID");
const auto record =
read_json(project_path(root_, std::filesystem::path(".faset/recovery") / (id + ".json")));
require(record.value("format", std::string()) == "faset.recovery" &&
record.value("version", 0) == 1,
"recovery.format", "Unsupported recovery record");
State candidate;
candidate.data = record.at("scene");
validate_scene(candidate.data, schemas_);
require(candidate.data.at("id") == id, "recovery.id",
"Recovery ID does not match its document");
candidate.path = record.at("path").get<std::string>();
candidate.saved_hash = record.value("saved_hash", std::string());
candidate.disk_hash = record.value("disk_hash", std::string());
candidate.revision = record.value("revision", std::uint64_t(0));
if (!candidate.path.empty()) {
const auto disk = project_path(root_, candidate.path);
require(std::filesystem::exists(disk) && sha256_file(disk) == candidate.disk_hash,
"recovery.disk_conflict",
"Scene file changed or disappeared since recovery was written");
}
for (auto& item : candidate.data["entities"])
for (auto& component : item["components"])
component = schemas_.migrate_component(component);
validate_scene(candidate.data, schemas_);
if (documents_.contains(id)) {
const auto& current = state(id);
require(expected_revision.has_value(), "recovery.revision_required",
"Recovering an open document requires its current revision");
check_revision(current.revision, *expected_revision);
require(current.path == candidate.path, "document.id_collision",
"Recovery path differs from the open document");
if (candidate.data == current.data)
return summary(current);
candidate.undo = current.undo;
candidate.undo.push_back(current.data);
candidate.revision = std::max(current.revision, candidate.revision) + 1;
} else if (!candidate.path.empty())
candidate.undo.push_back(read_json(project_path(root_, candidate.path)));
journal(candidate);
documents_[id] = std::move(candidate);
return summary(state(id));
}
Json AuthoringService::recovery_documents() const {
std::lock_guard lock(mutex_);
Json result = Json::array();
const auto path = project_path(root_, ".faset/recovery");
if (!std::filesystem::exists(path))
return result;
for (const auto& entry : std::filesystem::directory_iterator(path))
if (entry.is_regular_file() && entry.path().extension() == ".json") {
try {
const auto value = read_json(entry.path());
result.push_back({{"id", value.at("scene").at("id")},
{"name", value.at("scene").at("name")},
{"path", value.at("path")},
{"dirty", sha256(value.at("scene").dump()) !=
value.value("saved_hash", std::string())}});
} catch (const std::exception&) {
result.push_back({{"error", "Invalid recovery record"},
{"file", entry.path().filename().string()}});
}
}
return result;
}
} // namespace faset::authoring
+169 -69
View File
@@ -1,99 +1,199 @@
#include <faset/authoring/templates.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp>
#include <algorithm>
#include <faset/authoring/service.hpp>
#include <faset/authoring/templates.hpp>
#include <faset/authoring/transforms.hpp>
#include <faset/core/hash.hpp>
#include <set>
namespace faset::authoring {
namespace {
std::string scoped_id(const std::string& root,const Json& path,const std::string& source) {
const auto digest=sha256(Json::array({root,path,source}).dump());
return digest.substr(0,8)+"-"+digest.substr(8,4)+"-5"+digest.substr(13,3)+"-a"+digest.substr(17,3)+"-"+digest.substr(20,12);
std::string scoped_id(const std::string& root, const Json& path, const std::string& source) {
const auto digest = sha256(Json::array({root, path, source}).dump());
return digest.substr(0, 8) + "-" + digest.substr(8, 4) + "-5" + digest.substr(13, 3) + "-a" +
digest.substr(17, 3) + "-" + digest.substr(20, 12);
}
struct Resolver {
const SchemaRegistry& schemas;
const SceneLoader& loader;
std::string root;
Json conflicts=Json::array();
Json conflicts = Json::array();
std::set<std::string> sources;
void conflict(const Json& path,std::string code,const Json& record) {conflicts.push_back({{"instance_path",path},{"code",std::move(code)},{"record",record}});}
Json* target(Json& entities,const Json& path,const Json& address) {
Json full=path;for(const auto& entry:address.value("path",Json::array()))full.push_back(entry);
for(auto& item:entities)if(item.at("origin").at("path")==full&&item.at("origin").at("object")==address.at("object"))return &item;
void conflict(const Json& path, std::string code, const Json& record) {
conflicts.push_back(
{{"instance_path", path}, {"code", std::move(code)}, {"record", record}});
}
Json* target(Json& entities, const Json& path, const Json& address) {
Json full = path;
for (const auto& entry : address.value("path", Json::array()))
full.push_back(entry);
for (auto& item : entities)
if (item.at("origin").at("path") == full &&
item.at("origin").at("object") == address.at("object"))
return &item;
return nullptr;
}
Json expand(const Json& scene,const Json& path) {
require(path.size()<=32,"template.depth","Maximum template nesting depth exceeded");
validate_scene(scene,schemas);
Json output=Json::array();std::map<std::string,std::string> 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);
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);}
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;
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);
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);
}
}
for(const auto& source:scene["entities"]) {
Json item=source;item["id"]=ids.at(source.at("id").get<std::string>());
item["origin"]={{"path",path},{"object",source.at("id")},{"scene",scene.at("id")}};
if(source.contains("parent")&&!source["parent"].is_null())item["parent"]=ids.at(source["parent"].get<std::string>());
for(auto& component:item["components"]) {
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>());
for (const auto& source : scene["entities"]) {
Json item = source;
item["id"] = ids.at(source.at("id").get<std::string>());
item["origin"] = {
{"path", path}, {"object", source.at("id")}, {"scene", scene.at("id")}};
if (source.contains("parent") && !source["parent"].is_null())
item["parent"] = ids.at(source["parent"].get<std::string>());
for (auto& component : item["components"]) {
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>());
}
output.push_back(std::move(item));
}
for(const auto& instance:scene.value("instances",Json::array())) {
Json nested_path=path;nested_path.push_back(instance.at("id"));const auto source_name=instance.at("source").get<std::string>();
Json expanded=Json::array();std::string source_id;
for (const auto& instance : scene.value("instances", Json::array())) {
Json nested_path = path;
nested_path.push_back(instance.at("id"));
const auto source_name = instance.at("source").get<std::string>();
Json expanded = Json::array();
std::string source_id;
bool inserted = false;
try {
const auto source=loader(source_name);source_id=source.at("id").get<std::string>();
require(sources.insert(source_id).second,"template.cycle","Template source cycle detected");
expanded=expand(source,nested_path);sources.erase(source_id);
} catch(const std::exception& error) {
if(!source_id.empty())sources.erase(source_id);
conflict(nested_path,"template.source_unavailable",{{"source",source_name},{"message",error.what()}});continue;
const auto source = loader(source_name);
source_id = source.at("id").get<std::string>();
inserted = sources.insert(source_id).second;
require(inserted, "template.cycle", "Template source cycle detected");
expanded = expand(source, nested_path);
sources.erase(source_id);
} catch (const std::exception& error) {
if (inserted)
sources.erase(source_id);
conflict(nested_path, "template.source_unavailable",
{{"source", source_name}, {"message", error.what()}});
continue;
}
for(const auto& addition:instance.value("additions",Json::array())) {
Json item=addition;const auto id=item.at("id").get<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>());
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);}
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>());
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);
}
expanded.push_back(std::move(item));
}
for(const auto& change:instance.value("overrides",Json::array())) {
const auto& address=change.at("address");auto* item=target(expanded,nested_path,address);
if(!item){conflict(nested_path,"override.object_missing",change);continue;}
auto found=std::find_if((*item)["components"].begin(),(*item)["components"].end(),[&](const Json& value){return value.at("source_id")==address.at("component");});
if(found==(*item)["components"].end()){conflict(nested_path,"override.component_missing",change);continue;}
const auto type=found->at("type").get<std::string>();const auto field=address.at("field").get<std::string>();
if(!schemas.contains(type)||!schemas.schema(type)["fields"].contains(field)){conflict(nested_path,"override.field_unavailable",change);continue;}
try {validate_field(change.at("value"),schemas.schema(type)["fields"][field]);(*found)["fields"][field]=change.at("value");}
catch(const std::exception& error){conflict(nested_path,"override.invalid",{{"change",change},{"message",error.what()}});}
for (const auto& change : instance.value("overrides", Json::array())) {
const auto& address = change.at("address");
auto* item = target(expanded, nested_path, address);
if (!item) {
conflict(nested_path, "override.object_missing", change);
continue;
}
auto found =
std::find_if((*item)["components"].begin(), (*item)["components"].end(),
[&](const Json& value) {
return value.at("source_id") == address.at("component");
});
if (found == (*item)["components"].end()) {
conflict(nested_path, "override.component_missing", change);
continue;
}
const auto type = found->at("type").get<std::string>();
const auto field = address.at("field").get<std::string>();
if (!schemas.contains(type) || !schemas.schema(type)["fields"].contains(field)) {
conflict(nested_path, "override.field_unavailable", change);
continue;
}
try {
validate_field(change.at("value"), schemas.schema(type)["fields"][field]);
(*found)["fields"][field] = change.at("value");
} catch (const std::exception& error) {
conflict(nested_path, "override.invalid",
{{"change", change}, {"message", error.what()}});
}
}
std::set<std::string> suppressed;
for(const auto& address:instance.value("suppressed",Json::array())) {
auto* item=target(expanded,nested_path,address);if(item)suppressed.insert(item->at("id").get<std::string>());else conflict(nested_path,"suppression.object_missing",address);
for (const auto& address : instance.value("suppressed", Json::array())) {
auto* item = target(expanded, nested_path, address);
if (item)
suppressed.insert(item->at("id").get<std::string>());
else
conflict(nested_path, "suppression.object_missing", address);
}
bool changed=true;
while(changed) {changed=false;for(const auto& item:expanded)if(item.contains("parent")&&item["parent"].is_string()&&suppressed.contains(item["parent"].get<std::string>()))changed=suppressed.insert(item.at("id").get<std::string>()).second||changed;}
expanded.erase(std::remove_if(expanded.begin(),expanded.end(),[&](const Json& item){return suppressed.contains(item.at("id").get<std::string>());}),expanded.end());
for(const auto& reparent:instance.value("reparents",Json::array())) {
auto* item=target(expanded,nested_path,reparent.at("object"));
auto* parent=reparent.at("parent").is_null()?nullptr:target(expanded,nested_path,reparent.at("parent"));
if(!item||(!reparent.at("parent").is_null()&&!parent)){conflict(nested_path,"reparent.target_missing",reparent);continue;}
if(reparent.value("keep_world",false)){conflict(nested_path,"reparent.world_transform_required",reparent);continue;}
(*item)["parent"]=parent?parent->at("id"):Json(nullptr);
bool changed = true;
while (changed) {
changed = false;
for (const auto& item : expanded)
if (item.contains("parent") && item["parent"].is_string() &&
suppressed.contains(item["parent"].get<std::string>()))
changed =
suppressed.insert(item.at("id").get<std::string>()).second || changed;
}
for(auto& item:expanded)output.push_back(std::move(item));
require(output.size()<=100000,"template.size","Resolved scene exceeds object limit");
expanded.erase(std::remove_if(expanded.begin(), expanded.end(),
[&](const Json& item) {
return suppressed.contains(
item.at("id").get<std::string>());
}),
expanded.end());
for (const auto& reparent : instance.value("reparents", Json::array())) {
auto* item = target(expanded, nested_path, reparent.at("object"));
auto* parent = reparent.at("parent").is_null()
? nullptr
: target(expanded, nested_path, reparent.at("parent"));
if (!item || (!reparent.at("parent").is_null() && !parent)) {
conflict(nested_path, "reparent.target_missing", reparent);
continue;
}
const auto id = item->at("id").get<std::string>();
const Json parent_id = parent ? parent->at("id") : Json(nullptr);
Json temporary = {{"entities", expanded}};
try {
reparent_entity(temporary, id, parent_id, reparent.value("keep_world", false));
expanded = temporary["entities"];
} catch (const std::exception& error) {
conflict(nested_path, "reparent.transform_conflict",
{{"record", reparent}, {"message", error.what()}});
}
}
for (auto& item : expanded)
output.push_back(std::move(item));
require(output.size() <= 100000, "template.size",
"Resolved scene exceeds object limit");
}
return output;
}
};
} // namespace
ResolvedScene resolve_templates(const Json& scene, const SchemaRegistry& schemas,
const SceneLoader& loader) {
Resolver resolver{schemas, loader, scene.at("id").get<std::string>()};
resolver.sources.insert(scene.at("id").get<std::string>());
Json output = scene;
output["entities"] = resolver.expand(scene, Json::array());
output["instances"] = Json::array();
validate_scene(output, schemas);
return {output, resolver.conflicts};
}
ResolvedScene resolve_templates(const Json& scene,const SchemaRegistry& schemas,const SceneLoader& loader) {
Resolver resolver{schemas,loader,scene.at("id").get<std::string>()};resolver.sources.insert(scene.at("id").get<std::string>());
Json output=scene;output["entities"]=resolver.expand(scene,Json::array());output["instances"]=Json::array();
validate_scene(output,schemas);return {output,resolver.conflicts};
}
}
} // namespace faset::authoring
+156
View File
@@ -0,0 +1,156 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <faset/authoring/transforms.hpp>
#include <faset/core/error.hpp>
#include <set>
namespace faset::authoring {
namespace {
using Matrix = std::array<double, 16>;
constexpr Matrix identity{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
Json& find_entity(Json& scene, const std::string& id) {
for (auto& item : scene["entities"])
if (item.at("id") == id)
return item;
throw Error("entity.missing", "Entity does not exist: " + id);
}
Json* transform(Json& entity) {
for (auto& component : entity["components"])
if (component.at("type") == "faset.transform")
return &component;
return nullptr;
}
Matrix multiply(const Matrix& a, const Matrix& b) {
Matrix result{};
for (int row = 0; row < 4; ++row)
for (int column = 0; column < 4; ++column)
for (int k = 0; k < 4; ++k)
result[column * 4 + row] += a[k * 4 + row] * b[column * 4 + k];
return result;
}
Matrix local(Json& entity) {
const auto* component = transform(entity);
if (!component)
return identity;
const auto& fields = component->at("fields");
const auto p = fields.value("position", std::array<double, 3>{0, 0, 0}),
r = fields.value("rotation", std::array<double, 3>{0, 0, 0}),
s = fields.value("scale", std::array<double, 3>{1, 1, 1});
const auto cx = std::cos(r[0]), sx = std::sin(r[0]), cy = std::cos(r[1]), sy = std::sin(r[1]),
cz = std::cos(r[2]), sz = std::sin(r[2]);
return {cz * cy * s[0],
sz * cy * s[0],
-sy * s[0],
0,
(cz * sy * sx - sz * cx) * s[1],
(sz * sy * sx + cz * cx) * s[1],
cy * sx * s[1],
0,
(cz * sy * cx + sz * sx) * s[2],
(sz * sy * cx - cz * sx) * s[2],
cy * cx * s[2],
0,
p[0],
p[1],
p[2],
1};
}
Matrix world(Json& scene, const std::string& id, std::set<std::string>& visited) {
require(visited.insert(id).second, "entity.cycle", "Hierarchy contains a cycle");
auto& item = find_entity(scene, id);
auto result = local(item);
if (item.contains("parent") && !item["parent"].is_null())
result = multiply(world(scene, item["parent"].get<std::string>(), visited), result);
return result;
}
Matrix world(Json& scene, const std::string& id) {
std::set<std::string> visited;
return world(scene, id, visited);
}
Matrix inverse(const Matrix& matrix) {
std::array<std::array<double, 8>, 4> rows{};
for (int r = 0; r < 4; ++r) {
for (int c = 0; c < 4; ++c)
rows[r][c] = matrix[c * 4 + r];
rows[r][r + 4] = 1;
}
for (int column = 0; column < 4; ++column) {
int pivot = column;
for (int row = column + 1; row < 4; ++row)
if (std::abs(rows[row][column]) > std::abs(rows[pivot][column]))
pivot = row;
require(std::abs(rows[pivot][column]) > 1e-12, "transform.singular",
"Cannot preserve world transform under a non-invertible parent");
std::swap(rows[pivot], rows[column]);
const auto scale = rows[column][column];
for (auto& entry : rows[column])
entry /= scale;
for (int row = 0; row < 4; ++row)
if (row != column) {
const auto factor = rows[row][column];
for (int c = 0; c < 8; ++c)
rows[row][c] -= factor * rows[column][c];
}
}
Matrix result{};
for (int row = 0; row < 4; ++row)
for (int column = 0; column < 4; ++column)
result[column * 4 + row] = rows[row][column + 4];
return result;
}
Json decompose(const Matrix& matrix) {
std::array<double, 3> scale{};
Matrix rotation = matrix;
for (int c = 0; c < 3; ++c) {
scale[c] = std::sqrt(matrix[c * 4] * matrix[c * 4] + matrix[c * 4 + 1] * matrix[c * 4 + 1] +
matrix[c * 4 + 2] * matrix[c * 4 + 2]);
require(scale[c] > 1e-12, "transform.singular", "Cannot decompose zero scale");
}
const double determinant = matrix[0] * (matrix[5] * matrix[10] - matrix[9] * matrix[6]) -
matrix[4] * (matrix[1] * matrix[10] - matrix[9] * matrix[2]) +
matrix[8] * (matrix[1] * matrix[6] - matrix[5] * matrix[2]);
if (determinant < 0)
scale[0] = -scale[0];
for (int c = 0; c < 3; ++c)
for (int r = 0; r < 3; ++r)
rotation[c * 4 + r] /= scale[c];
for (int a = 0; a < 3; ++a)
for (int b = a + 1; b < 3; ++b) {
double dot = 0;
for (int r = 0; r < 3; ++r)
dot += rotation[a * 4 + r] * rotation[b * 4 + r];
require(std::abs(dot) < 1e-5, "transform.shear",
"Preserving this world transform would require shear; choose keep local or "
"change parent scale");
}
std::array<double, 3> angles{};
angles[1] = std::asin(std::clamp(-rotation[2], -1.0, 1.0));
if (std::abs(std::cos(angles[1])) > 1e-7) {
angles[0] = std::atan2(rotation[6], rotation[10]);
angles[2] = std::atan2(rotation[1], rotation[0]);
} else {
angles[0] = std::atan2(-rotation[9], rotation[5]);
angles[2] = 0;
}
return {
{"position", {matrix[12], matrix[13], matrix[14]}}, {"rotation", angles}, {"scale", scale}};
}
} // namespace
void reparent_entity(Json& scene, const std::string& id, const Json& parent, bool keep_world) {
auto& item = find_entity(scene, id);
if (!parent.is_null())
find_entity(scene, parent.get<std::string>());
if (keep_world) {
auto* component = transform(item);
require(component != nullptr, "transform.required",
"World-preserving reparent requires a Transform component");
const auto previous = world(scene, id);
const auto basis = parent.is_null() ? identity : world(scene, parent.get<std::string>());
const auto fields = decompose(multiply(inverse(basis), previous));
for (const auto& [field, value] : fields.items())
(*component)["fields"][field] = value;
}
item["parent"] = parent;
}
} // namespace faset::authoring