Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export
This commit is contained in:
+181
-94
@@ -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
@@ -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()&¤t.requests.contains(key)) {
|
||||
const auto& request=current.requests.at(key);require(request.first==fingerprint,"idempotency.conflict","Idempotency key was used with another payload");return request.second;
|
||||
Json AuthoringService::transact(const std::string& id, std::uint64_t revision,
|
||||
const Json& operations, const std::string& key) {
|
||||
std::lock_guard lock(mutex_);
|
||||
auto& current = state(id);
|
||||
require(operations.is_array() && !operations.empty(), "transaction.empty",
|
||||
"Transaction requires an array of operations");
|
||||
const auto fingerprint =
|
||||
sha256(Json{{"revision", revision}, {"operations", operations}}.dump());
|
||||
if (!key.empty() && current.requests.contains(key)) {
|
||||
const auto& request = current.requests.at(key);
|
||||
require(request.first == fingerprint, "idempotency.conflict",
|
||||
"Idempotency key was used with another payload");
|
||||
return request.second;
|
||||
}
|
||||
check_revision(current.revision,revision);State candidate=current;
|
||||
for(const auto& operation:operations)apply(candidate.data,operation);
|
||||
validate_scene(candidate.data,schemas_);
|
||||
candidate.undo.push_back(current.data);if(candidate.undo.size()>100)candidate.undo.erase(candidate.undo.begin());candidate.redo.clear();++candidate.revision;
|
||||
journal(candidate);auto result=summary(candidate);
|
||||
if(!key.empty()) {if(candidate.requests.size()>=256)candidate.requests.erase(candidate.requests.begin());candidate.requests[key]={fingerprint,result};}
|
||||
current=std::move(candidate);return result;
|
||||
}
|
||||
Json AuthoringService::history(const std::string& id,std::uint64_t revision,bool forward) {
|
||||
std::lock_guard lock(mutex_);auto& current=state(id);check_revision(current.revision,revision);State candidate=current;
|
||||
auto& source=forward?candidate.redo:candidate.undo;auto& target=forward?candidate.undo:candidate.redo;
|
||||
require(!source.empty(),"history.empty",forward?"Nothing to redo":"Nothing to undo");target.push_back(candidate.data);candidate.data=source.back();source.pop_back();++candidate.revision;journal(candidate);current=std::move(candidate);return summary(current);
|
||||
}
|
||||
Json AuthoringService::undo(const std::string& id,std::uint64_t revision){return history(id,revision,false);}
|
||||
Json AuthoringService::redo(const std::string& id,std::uint64_t revision){return history(id,revision,true);}
|
||||
Json AuthoringService::save(const std::string& id,const std::filesystem::path& relative) {
|
||||
std::lock_guard lock(mutex_);auto& current=state(id);const auto selected=relative.empty()?current.path:relative.lexically_normal();require(!selected.empty(),"save.path","Choose a scene path before saving");
|
||||
const auto path=project_path(root_,selected);
|
||||
if(std::filesystem::exists(path)) {
|
||||
require(selected==current.path&&!current.disk_hash.empty(),"save.exists","Save As will not overwrite another file");
|
||||
require(sha256_file(path)==current.disk_hash,"save.disk_conflict","File changed outside the Editor; reload or save to another path");
|
||||
check_revision(current.revision, revision);
|
||||
State candidate = current;
|
||||
for (const auto& operation : operations)
|
||||
apply(candidate.data, operation);
|
||||
validate_scene(candidate.data, schemas_);
|
||||
candidate.undo.push_back(current.data);
|
||||
if (candidate.undo.size() > 100)
|
||||
candidate.undo.erase(candidate.undo.begin());
|
||||
candidate.redo.clear();
|
||||
++candidate.revision;
|
||||
journal(candidate);
|
||||
auto result = summary(candidate);
|
||||
if (!key.empty()) {
|
||||
if (candidate.requests.size() >= 256)
|
||||
candidate.requests.erase(candidate.requests.begin());
|
||||
candidate.requests[key] = {fingerprint, result};
|
||||
}
|
||||
atomic_write_json(path,current.data);current.path=selected;current.saved_hash=sha256(current.data.dump());current.disk_hash=sha256_file(path);
|
||||
journal(current);return summary(current);
|
||||
current = std::move(candidate);
|
||||
return result;
|
||||
}
|
||||
Json AuthoringService::recovery_documents()const {
|
||||
std::lock_guard lock(mutex_);Json result=Json::array();const auto path=project_path(root_,".faset/recovery");if(!std::filesystem::exists(path))return result;
|
||||
for(const auto& entry:std::filesystem::directory_iterator(path))if(entry.is_regular_file()&&entry.path().extension()==".json") {
|
||||
try {const auto value=read_json(entry.path());result.push_back({{"id",value.at("scene").at("id")},{"name",value.at("scene").at("name")},{"path",value.at("path")},{"dirty",sha256(value.at("scene").dump())!=value.value("saved_hash",std::string())}});}catch(const std::exception&) {result.push_back({{"error","Invalid recovery record"},{"file",entry.path().filename().string()}});}
|
||||
}return result;
|
||||
Json AuthoringService::history(const std::string& id, std::uint64_t revision, bool forward) {
|
||||
std::lock_guard lock(mutex_);
|
||||
auto& current = state(id);
|
||||
check_revision(current.revision, revision);
|
||||
State candidate = current;
|
||||
auto& source = forward ? candidate.redo : candidate.undo;
|
||||
auto& target = forward ? candidate.undo : candidate.redo;
|
||||
require(!source.empty(), "history.empty", forward ? "Nothing to redo" : "Nothing to undo");
|
||||
target.push_back(candidate.data);
|
||||
candidate.data = source.back();
|
||||
source.pop_back();
|
||||
++candidate.revision;
|
||||
journal(candidate);
|
||||
current = std::move(candidate);
|
||||
return summary(current);
|
||||
}
|
||||
Json AuthoringService::undo(const std::string& id, std::uint64_t revision) {
|
||||
return history(id, revision, false);
|
||||
}
|
||||
Json AuthoringService::redo(const std::string& id, std::uint64_t revision) {
|
||||
return history(id, revision, true);
|
||||
}
|
||||
Json AuthoringService::save(const std::string& id, const std::filesystem::path& relative) {
|
||||
std::lock_guard lock(mutex_);
|
||||
auto& current = state(id);
|
||||
const auto selected = relative.empty() ? current.path : relative.lexically_normal();
|
||||
require(!selected.empty(), "save.path", "Choose a scene path before saving");
|
||||
const auto path = project_path(root_, selected);
|
||||
if (std::filesystem::exists(path)) {
|
||||
require(selected == current.path && !current.disk_hash.empty(), "save.exists",
|
||||
"Save As will not overwrite another file");
|
||||
require(sha256_file(path) == current.disk_hash, "save.disk_conflict",
|
||||
"File changed outside the Editor; reload or save to another path");
|
||||
}
|
||||
atomic_write_json(path, current.data);
|
||||
current.path = selected;
|
||||
current.saved_hash = sha256(current.data.dump());
|
||||
current.disk_hash = sha256_file(path);
|
||||
journal(current);
|
||||
return summary(current);
|
||||
}
|
||||
Json AuthoringService::recover(const std::string& id,
|
||||
std::optional<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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user