Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <faset/assets/asset_data.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace faset::assets {
|
||||
namespace fs = std::filesystem;
|
||||
namespace {
|
||||
void valid_id(const std::string& id) {
|
||||
if (id.empty() || id.size() > 128 || !std::all_of(id.begin(), id.end(), [](unsigned char c) {
|
||||
return std::isalnum(c) || c == '-' || c == '_';
|
||||
}))
|
||||
throw std::runtime_error("Invalid AssetId");
|
||||
}
|
||||
std::vector<std::byte> read_bytes(const fs::path& path) {
|
||||
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
||||
if (!file)
|
||||
throw std::runtime_error("Cannot read cooked file: " + path.string());
|
||||
auto length = file.tellg();
|
||||
if (length < 0 || static_cast<std::uint64_t>(length) > 1024ull * 1024 * 1024)
|
||||
throw std::runtime_error("Cooked file exceeds 1 GiB limit");
|
||||
std::vector<std::byte> bytes(static_cast<std::size_t>(length));
|
||||
file.seekg(0);
|
||||
if (!bytes.empty() && !file.read(reinterpret_cast<char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size())))
|
||||
throw std::runtime_error("Truncated cooked file");
|
||||
return bytes;
|
||||
}
|
||||
Json read_json(const fs::path& path) {
|
||||
auto bytes = read_bytes(path);
|
||||
if (bytes.empty())
|
||||
throw std::runtime_error("Empty cooked JSON manifest");
|
||||
return Json::parse(reinterpret_cast<const char*>(bytes.data()),
|
||||
reinterpret_cast<const char*>(bytes.data() + bytes.size()));
|
||||
}
|
||||
fs::path cooked_path(const fs::path& directory, const std::string& name) {
|
||||
if (name.empty())
|
||||
throw std::runtime_error("Empty cooked path");
|
||||
return faset::project_path(directory, fs::path(name));
|
||||
}
|
||||
void validate_generation(const fs::path& directory, const Json& manifest) {
|
||||
if (manifest.at("schema_version") != 1)
|
||||
throw std::runtime_error("Unsupported asset manifest version");
|
||||
std::set<std::string> files;
|
||||
for (const auto& file : manifest.at("files")) {
|
||||
auto name = file.at("path").get<std::string>();
|
||||
if (!files.insert(name).second)
|
||||
throw std::runtime_error("Duplicate cooked file path");
|
||||
auto bytes = read_bytes(cooked_path(directory, name));
|
||||
if (bytes.size() != file.at("size").get<std::size_t>() ||
|
||||
faset::sha256(std::span<const std::byte>(bytes)) !=
|
||||
file.at("sha256").get<std::string>())
|
||||
throw std::runtime_error("Corrupt cooked file: " + name);
|
||||
}
|
||||
for (const auto& mesh : manifest.at("meshes"))
|
||||
for (const auto& primitive : mesh.at("primitives"))
|
||||
if (!files.contains(primitive.at("path").get<std::string>()))
|
||||
throw std::runtime_error("Mesh payload is missing from its manifest");
|
||||
for (const auto& texture : manifest.at("textures"))
|
||||
if (!files.contains(texture.at("path").get<std::string>()))
|
||||
throw std::runtime_error("Texture payload is missing from its manifest");
|
||||
}
|
||||
struct BinaryReader {
|
||||
const std::vector<std::byte>& bytes;
|
||||
std::size_t cursor = 0;
|
||||
std::uint32_t u32() {
|
||||
if (bytes.size() - cursor < 4)
|
||||
throw std::runtime_error("Truncated cooked mesh");
|
||||
std::uint32_t v = 0;
|
||||
for (int i = 0; i < 4; ++i)
|
||||
v |= std::to_integer<std::uint32_t>(bytes[cursor++]) << (8 * i);
|
||||
return v;
|
||||
}
|
||||
float number() {
|
||||
auto v = std::bit_cast<float>(u32());
|
||||
if (!std::isfinite(v))
|
||||
throw std::runtime_error("Invalid cooked float");
|
||||
return v;
|
||||
}
|
||||
};
|
||||
Primitive decode_primitive(const std::vector<std::byte>& bytes, int material) {
|
||||
BinaryReader in{bytes};
|
||||
if (in.u32() != 0x48534d46 || in.u32() != 1)
|
||||
throw std::runtime_error("Unsupported cooked mesh format");
|
||||
const auto nv = in.u32(), ni = in.u32();
|
||||
if (static_cast<std::uint64_t>(nv) * 32 + static_cast<std::uint64_t>(ni) * 4 + 16 !=
|
||||
bytes.size())
|
||||
throw std::runtime_error("Invalid cooked mesh size");
|
||||
Primitive p;
|
||||
p.material = material;
|
||||
p.vertices.resize(nv);
|
||||
p.indices.resize(ni);
|
||||
for (auto& v : p.vertices) {
|
||||
for (auto& x : v.position)
|
||||
x = in.number();
|
||||
for (auto& x : v.normal)
|
||||
x = in.number();
|
||||
for (auto& x : v.uv)
|
||||
x = in.number();
|
||||
}
|
||||
for (auto& i : p.indices) {
|
||||
i = in.u32();
|
||||
if (i >= nv)
|
||||
throw std::runtime_error("Cooked mesh index out of range");
|
||||
}
|
||||
return p;
|
||||
}
|
||||
} // namespace
|
||||
AssetStore::AssetStore(fs::path root)
|
||||
: cache_root_(fs::absolute(std::move(root)).lexically_normal()) {}
|
||||
fs::path AssetStore::generation_directory(const std::string& id) const {
|
||||
valid_id(id);
|
||||
const auto root = faset::project_path(cache_root_, fs::path("assets") / id);
|
||||
const auto pointer = read_json(root / "current.json");
|
||||
const auto generation = pointer.at("generation").get<std::string>();
|
||||
valid_id(generation);
|
||||
return faset::project_path(root, fs::path("generations") / generation);
|
||||
}
|
||||
Json AssetStore::current_manifest(const std::string& id) const {
|
||||
valid_id(id);
|
||||
const auto root = faset::project_path(cache_root_, fs::path("assets") / id);
|
||||
const auto pointer = read_json(root / "current.json");
|
||||
const auto generation = pointer.at("generation").get<std::string>();
|
||||
valid_id(generation);
|
||||
auto manifest = read_json(
|
||||
faset::project_path(root, fs::path("generations") / generation / "manifest.json"));
|
||||
if (manifest.at("asset_id").get<std::string>() != id ||
|
||||
manifest.at("generation").get<std::string>() != generation)
|
||||
throw std::runtime_error("Cooked manifest identity does not match its generation");
|
||||
// One pointer snapshot prevents mixing two concurrently published generations.
|
||||
manifest["source"] = pointer.at("source");
|
||||
return manifest;
|
||||
}
|
||||
CookedAsset AssetStore::load_asset(const std::string& id) const {
|
||||
const auto directory = generation_directory(id);
|
||||
const auto m = read_json(directory / "manifest.json");
|
||||
validate_generation(directory, m);
|
||||
if (m.at("asset_id").get<std::string>() != id ||
|
||||
m.at("generation").get<std::string>() != directory.filename().string())
|
||||
throw std::runtime_error("Cooked asset identity does not match its generation");
|
||||
CookedAsset asset;
|
||||
asset.asset_id = m.at("asset_id");
|
||||
asset.generation = m.at("generation");
|
||||
for (const auto& n : m.at("nodes")) {
|
||||
Node node;
|
||||
node.id = n.at("id");
|
||||
node.name = n.at("name");
|
||||
node.parent_id = n.at("parent_id");
|
||||
node.mesh = n.at("mesh");
|
||||
node.local_transform = n.at("local_transform").get<std::array<float, 16>>();
|
||||
node.stable_source_id = n.at("stable_source_id");
|
||||
asset.nodes.push_back(std::move(node));
|
||||
}
|
||||
for (const auto& j : m.at("meshes")) {
|
||||
Mesh mesh;
|
||||
mesh.id = j.at("id");
|
||||
mesh.name = j.at("name");
|
||||
for (const auto& primitive : j.at("primitives"))
|
||||
mesh.primitives.push_back(decode_primitive(
|
||||
read_bytes(cooked_path(directory, primitive.at("path").get<std::string>())),
|
||||
primitive.at("material")));
|
||||
asset.meshes.push_back(std::move(mesh));
|
||||
}
|
||||
for (const auto& j : m.at("materials")) {
|
||||
Material material;
|
||||
material.id = j.at("id");
|
||||
material.name = j.at("name");
|
||||
material.base_color = j.at("base_color").get<std::array<float, 4>>();
|
||||
material.emissive = j.at("emissive").get<std::array<float, 3>>();
|
||||
material.metallic = j.at("metallic");
|
||||
material.roughness = j.at("roughness");
|
||||
material.alpha_mode = j.at("alpha_mode");
|
||||
material.alpha_cutoff = j.at("alpha_cutoff");
|
||||
material.double_sided = j.at("double_sided");
|
||||
material.unlit = j.at("unlit");
|
||||
material.base_color_texture = j.at("base_color_texture");
|
||||
material.metallic_roughness_texture = j.at("metallic_roughness_texture");
|
||||
material.normal_texture = j.at("normal_texture");
|
||||
material.occlusion_texture = j.at("occlusion_texture");
|
||||
material.emissive_texture = j.at("emissive_texture");
|
||||
asset.materials.push_back(std::move(material));
|
||||
}
|
||||
for (const auto& j : m.at("textures")) {
|
||||
Texture texture;
|
||||
texture.id = j.at("id");
|
||||
texture.name = j.at("name");
|
||||
texture.mime_type = j.at("mime_type");
|
||||
texture.bytes = read_bytes(cooked_path(directory, j.at("path").get<std::string>()));
|
||||
texture.wrap_s = j.at("wrap_s");
|
||||
texture.wrap_t = j.at("wrap_t");
|
||||
texture.min_filter = j.at("min_filter");
|
||||
texture.mag_filter = j.at("mag_filter");
|
||||
asset.textures.push_back(std::move(texture));
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
} // namespace faset::assets
|
||||
+716
-284
File diff suppressed because it is too large
Load Diff
+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
|
||||
+92
-48
@@ -1,72 +1,116 @@
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/error.hpp>
|
||||
#include <array>
|
||||
#include <bit>
|
||||
#include <cstdint>
|
||||
#include <faset/core/error.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
namespace faset {
|
||||
namespace {
|
||||
constexpr std::array<std::uint32_t,64> constants = {
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
||||
};
|
||||
constexpr std::array<std::uint32_t, 64> constants = {
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
|
||||
class Digest {
|
||||
std::array<std::uint32_t,8> state_{0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19};
|
||||
std::array<std::uint8_t,64> pending_{};
|
||||
std::uint64_t count_=0;
|
||||
std::size_t used_=0;
|
||||
std::array<std::uint32_t, 8> state_{0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
|
||||
std::array<std::uint8_t, 64> pending_{};
|
||||
std::uint64_t count_ = 0;
|
||||
std::size_t used_ = 0;
|
||||
void block() {
|
||||
std::array<std::uint32_t,64> w{};
|
||||
for (int i=0;i<16;++i) w[i]=(std::uint32_t(pending_[i*4])<<24)|(std::uint32_t(pending_[i*4+1])<<16)|(std::uint32_t(pending_[i*4+2])<<8)|pending_[i*4+3];
|
||||
for (int i=16;i<64;++i) {
|
||||
const auto x=w[i-15],y=w[i-2];
|
||||
w[i]=w[i-16]+(std::rotr(x,7)^std::rotr(x,18)^(x>>3))+w[i-7]+(std::rotr(y,17)^std::rotr(y,19)^(y>>10));
|
||||
std::array<std::uint32_t, 64> w{};
|
||||
for (int i = 0; i < 16; ++i)
|
||||
w[i] = (std::uint32_t(pending_[i * 4]) << 24) |
|
||||
(std::uint32_t(pending_[i * 4 + 1]) << 16) |
|
||||
(std::uint32_t(pending_[i * 4 + 2]) << 8) | pending_[i * 4 + 3];
|
||||
for (int i = 16; i < 64; ++i) {
|
||||
const auto x = w[i - 15], y = w[i - 2];
|
||||
w[i] = w[i - 16] + (std::rotr(x, 7) ^ std::rotr(x, 18) ^ (x >> 3)) + w[i - 7] +
|
||||
(std::rotr(y, 17) ^ std::rotr(y, 19) ^ (y >> 10));
|
||||
}
|
||||
auto a=state_[0],b=state_[1],c=state_[2],d=state_[3],e=state_[4],f=state_[5],g=state_[6],h=state_[7];
|
||||
for (int i=0;i<64;++i) {
|
||||
const auto t1=h+(std::rotr(e,6)^std::rotr(e,11)^std::rotr(e,25))+((e&f)^(~e&g))+constants[i]+w[i];
|
||||
const auto t2=(std::rotr(a,2)^std::rotr(a,13)^std::rotr(a,22))+((a&b)^(a&c)^(b&c));
|
||||
h=g;g=f;f=e;e=d+t1;d=c;c=b;b=a;a=t1+t2;
|
||||
auto a = state_[0], b = state_[1], c = state_[2], d = state_[3], e = state_[4],
|
||||
f = state_[5], g = state_[6], h = state_[7];
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
const auto t1 = h + (std::rotr(e, 6) ^ std::rotr(e, 11) ^ std::rotr(e, 25)) +
|
||||
((e & f) ^ (~e & g)) + constants[i] + w[i];
|
||||
const auto t2 = (std::rotr(a, 2) ^ std::rotr(a, 13) ^ std::rotr(a, 22)) +
|
||||
((a & b) ^ (a & c) ^ (b & c));
|
||||
h = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = d + t1;
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = t1 + t2;
|
||||
}
|
||||
state_[0]+=a;state_[1]+=b;state_[2]+=c;state_[3]+=d;state_[4]+=e;state_[5]+=f;state_[6]+=g;state_[7]+=h;
|
||||
state_[0] += a;
|
||||
state_[1] += b;
|
||||
state_[2] += c;
|
||||
state_[3] += d;
|
||||
state_[4] += e;
|
||||
state_[5] += f;
|
||||
state_[6] += g;
|
||||
state_[7] += h;
|
||||
}
|
||||
public:
|
||||
|
||||
public:
|
||||
void update(std::span<const std::byte> bytes) {
|
||||
count_+=bytes.size();
|
||||
for (auto byte:bytes) {
|
||||
pending_[used_++]=std::to_integer<std::uint8_t>(byte);
|
||||
if (used_==64) { block();used_=0; }
|
||||
count_ += bytes.size();
|
||||
for (auto byte : bytes) {
|
||||
pending_[used_++] = std::to_integer<std::uint8_t>(byte);
|
||||
if (used_ == 64) {
|
||||
block();
|
||||
used_ = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::string finish() {
|
||||
const std::uint64_t bits=count_*8;
|
||||
pending_[used_++]=0x80;
|
||||
if (used_>56) { while(used_<64) pending_[used_++]=0;block();used_=0; }
|
||||
while(used_<56) pending_[used_++]=0;
|
||||
for (int i=7;i>=0;--i) pending_[used_++]=std::uint8_t(bits>>(i*8));
|
||||
const std::uint64_t bits = count_ * 8;
|
||||
pending_[used_++] = 0x80;
|
||||
if (used_ > 56) {
|
||||
while (used_ < 64)
|
||||
pending_[used_++] = 0;
|
||||
block();
|
||||
used_ = 0;
|
||||
}
|
||||
while (used_ < 56)
|
||||
pending_[used_++] = 0;
|
||||
for (int i = 7; i >= 0; --i)
|
||||
pending_[used_++] = std::uint8_t(bits >> (i * 8));
|
||||
block();
|
||||
constexpr char hex[]="0123456789abcdef";
|
||||
std::string result;result.reserve(64);
|
||||
for (auto word:state_) for (int i=7;i>=0;--i) result+=hex[(word>>(i*4))&15];
|
||||
constexpr char hex[] = "0123456789abcdef";
|
||||
std::string result;
|
||||
result.reserve(64);
|
||||
for (auto word : state_)
|
||||
for (int i = 7; i >= 0; --i)
|
||||
result += hex[(word >> (i * 4)) & 15];
|
||||
return result;
|
||||
}
|
||||
};
|
||||
}
|
||||
std::string sha256(std::span<const std::byte> bytes) { Digest digest;digest.update(bytes);return digest.finish(); }
|
||||
std::string sha256_file(const std::filesystem::path& path) {
|
||||
std::ifstream stream(path,std::ios::binary);
|
||||
require(bool(stream),"io.open","Cannot open file for hashing: "+path.string());
|
||||
Digest digest;std::array<char,65536> buffer{};
|
||||
while(stream) { stream.read(buffer.data(),buffer.size());digest.update(std::as_bytes(std::span(buffer.data(),static_cast<std::size_t>(stream.gcount())))); }
|
||||
require(stream.eof(),"io.read","Cannot read file for hashing: "+path.string());
|
||||
} // namespace
|
||||
std::string sha256(std::span<const std::byte> bytes) {
|
||||
Digest digest;
|
||||
digest.update(bytes);
|
||||
return digest.finish();
|
||||
}
|
||||
std::string sha256_file(const std::filesystem::path& path) {
|
||||
std::ifstream stream(path, std::ios::binary);
|
||||
require(bool(stream), "io.open", "Cannot open file for hashing: " + path.string());
|
||||
Digest digest;
|
||||
std::array<char, 65536> buffer{};
|
||||
while (stream) {
|
||||
stream.read(buffer.data(), buffer.size());
|
||||
digest.update(
|
||||
std::as_bytes(std::span(buffer.data(), static_cast<std::size_t>(stream.gcount()))));
|
||||
}
|
||||
require(stream.eof(), "io.read", "Cannot read file for hashing: " + path.string());
|
||||
return digest.finish();
|
||||
}
|
||||
} // namespace faset
|
||||
|
||||
+97
-43
@@ -1,9 +1,9 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/error.hpp>
|
||||
#include <array>
|
||||
#include <faset/core/error.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#ifdef _WIN32
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
@@ -16,57 +16,111 @@ namespace faset {
|
||||
std::string new_id() {
|
||||
static std::mutex mutex;
|
||||
static std::random_device random;
|
||||
std::array<unsigned char,16> bytes{};
|
||||
{ std::lock_guard lock(mutex); for(auto& byte:bytes) byte=static_cast<unsigned char>(random()); }
|
||||
bytes[6]=(bytes[6]&0x0f)|0x40;bytes[8]=(bytes[8]&0x3f)|0x80;
|
||||
constexpr char hex[]="0123456789abcdef";
|
||||
std::string result;result.reserve(36);
|
||||
for(std::size_t i=0;i<bytes.size();++i) { if(i==4||i==6||i==8||i==10)result+='-';result+=hex[bytes[i]>>4];result+=hex[bytes[i]&15]; }
|
||||
std::array<unsigned char, 16> bytes{};
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
for (auto& byte : bytes)
|
||||
byte = static_cast<unsigned char>(random());
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
constexpr char hex[] = "0123456789abcdef";
|
||||
std::string result;
|
||||
result.reserve(36);
|
||||
for (std::size_t i = 0; i < bytes.size(); ++i) {
|
||||
if (i == 4 || i == 6 || i == 8 || i == 10)
|
||||
result += '-';
|
||||
result += hex[bytes[i] >> 4];
|
||||
result += hex[bytes[i] & 15];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::string read_text(const std::filesystem::path& path) {
|
||||
std::ifstream stream(path,std::ios::binary);
|
||||
require(bool(stream),"io.open","Cannot open file: "+path.string());
|
||||
std::string value((std::istreambuf_iterator<char>(stream)),{});
|
||||
require(!stream.bad(),"io.read","Cannot read file: "+path.string());return value;
|
||||
std::ifstream stream(path, std::ios::binary);
|
||||
require(bool(stream), "io.open", "Cannot open file: " + path.string());
|
||||
std::string value((std::istreambuf_iterator<char>(stream)), {});
|
||||
require(!stream.bad(), "io.read", "Cannot read file: " + path.string());
|
||||
return value;
|
||||
}
|
||||
Json read_json(const std::filesystem::path& path) {
|
||||
try { return Json::parse(read_text(path)); }
|
||||
catch(const Json::exception& error) { throw Error("format.json","Invalid JSON in "+path.string(),{{"reason",error.what()}}); }
|
||||
try {
|
||||
return Json::parse(read_text(path));
|
||||
} catch (const Json::exception& error) {
|
||||
throw Error("format.json", "Invalid JSON in " + path.string(), {{"reason", error.what()}});
|
||||
}
|
||||
}
|
||||
void atomic_write(const std::filesystem::path& path,std::string_view bytes) {
|
||||
const auto parent=path.has_parent_path()?path.parent_path():std::filesystem::path(".");
|
||||
void atomic_write(const std::filesystem::path& path, std::string_view bytes) {
|
||||
const auto parent = path.has_parent_path() ? path.parent_path() : std::filesystem::path(".");
|
||||
std::filesystem::create_directories(parent);
|
||||
const auto temporary=parent/(path.filename().string()+".tmp-"+new_id());
|
||||
const auto temporary = parent / (path.filename().string() + ".tmp-" + new_id());
|
||||
try {
|
||||
#ifdef _WIN32
|
||||
HANDLE file=CreateFileW(temporary.c_str(),GENERIC_WRITE,0,nullptr,CREATE_NEW,FILE_ATTRIBUTE_NORMAL,nullptr);
|
||||
require(file!=INVALID_HANDLE_VALUE,"io.create","Cannot create temporary file");
|
||||
bool ok=true;std::size_t offset=0;
|
||||
while(offset<bytes.size()) { DWORD written=0; const auto count=static_cast<DWORD>(std::min<std::size_t>(bytes.size()-offset,1u<<30)); if(!WriteFile(file,bytes.data()+offset,count,&written,nullptr)||written==0){ok=false;break;} offset+=written; }
|
||||
ok=FlushFileBuffers(file)&&ok;CloseHandle(file);
|
||||
require(ok,"io.write","Cannot flush temporary file");
|
||||
require(MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)!=0,"io.replace","Cannot publish file: "+path.string());
|
||||
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW,
|
||||
FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
require(file != INVALID_HANDLE_VALUE, "io.create", "Cannot create temporary file");
|
||||
bool ok = true;
|
||||
std::size_t offset = 0;
|
||||
while (offset < bytes.size()) {
|
||||
DWORD written = 0;
|
||||
const auto count =
|
||||
static_cast<DWORD>(std::min<std::size_t>(bytes.size() - offset, 1u << 30));
|
||||
if (!WriteFile(file, bytes.data() + offset, count, &written, nullptr) || written == 0) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
ok = FlushFileBuffers(file) && ok;
|
||||
CloseHandle(file);
|
||||
require(ok, "io.write", "Cannot flush temporary file");
|
||||
require(MoveFileExW(temporary.c_str(), path.c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0,
|
||||
"io.replace", "Cannot publish file: " + path.string());
|
||||
#else
|
||||
const int fd=::open(temporary.c_str(),O_WRONLY|O_CREAT|O_EXCL,0644);
|
||||
require(fd>=0,"io.create","Cannot create temporary file");
|
||||
bool ok=true;std::size_t offset=0;
|
||||
while(offset<bytes.size()) { const auto count=::write(fd,bytes.data()+offset,bytes.size()-offset);if(count<0&&errno==EINTR)continue;if(count<=0){ok=false;break;}offset+=static_cast<std::size_t>(count); }
|
||||
ok=(::fsync(fd)==0)&&ok;const auto closed=::close(fd);ok=ok&&(closed==0);
|
||||
require(ok,"io.write","Cannot flush temporary file");
|
||||
std::filesystem::rename(temporary,path);
|
||||
const int directory=::open(parent.c_str(),O_RDONLY|O_DIRECTORY);
|
||||
if(directory>=0){::fsync(directory);::close(directory);}
|
||||
const int fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644);
|
||||
require(fd >= 0, "io.create", "Cannot create temporary file");
|
||||
bool ok = true;
|
||||
std::size_t offset = 0;
|
||||
while (offset < bytes.size()) {
|
||||
const auto count = ::write(fd, bytes.data() + offset, bytes.size() - offset);
|
||||
if (count < 0 && errno == EINTR)
|
||||
continue;
|
||||
if (count <= 0) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
offset += static_cast<std::size_t>(count);
|
||||
}
|
||||
ok = (::fsync(fd) == 0) && ok;
|
||||
const auto closed = ::close(fd);
|
||||
ok = ok && (closed == 0);
|
||||
require(ok, "io.write", "Cannot flush temporary file");
|
||||
std::filesystem::rename(temporary, path);
|
||||
const int directory = ::open(parent.c_str(), O_RDONLY | O_DIRECTORY);
|
||||
if (directory >= 0) {
|
||||
::fsync(directory);
|
||||
::close(directory);
|
||||
}
|
||||
#endif
|
||||
} catch(...) { std::error_code ignored;std::filesystem::remove(temporary,ignored);throw; }
|
||||
} catch (...) {
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove(temporary, ignored);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
void atomic_write_json(const std::filesystem::path& path,const Json& value) { atomic_write(path,value.dump(2)+"\n"); }
|
||||
std::filesystem::path project_path(const std::filesystem::path& root,const std::filesystem::path& relative) {
|
||||
require(!relative.is_absolute(),"path.outside_project","Expected a path relative to the project");
|
||||
const auto canonical=std::filesystem::weakly_canonical(root);
|
||||
const auto target=std::filesystem::weakly_canonical(canonical/relative);
|
||||
auto a=canonical.begin(),b=target.begin();
|
||||
for(;a!=canonical.end();++a,++b) require(b!=target.end()&&*a==*b,"path.outside_project","Path escapes the project root");
|
||||
void atomic_write_json(const std::filesystem::path& path, const Json& value) {
|
||||
atomic_write(path, value.dump(2) + "\n");
|
||||
}
|
||||
std::filesystem::path project_path(const std::filesystem::path& root,
|
||||
const std::filesystem::path& relative) {
|
||||
require(!relative.is_absolute(), "path.outside_project",
|
||||
"Expected a path relative to the project");
|
||||
const auto canonical = std::filesystem::weakly_canonical(root);
|
||||
const auto target = std::filesystem::weakly_canonical(canonical / relative);
|
||||
auto a = canonical.begin(), b = target.begin();
|
||||
for (; a != canonical.end(); ++a, ++b)
|
||||
require(b != target.end() && *a == *b, "path.outside_project",
|
||||
"Path escapes the project root");
|
||||
return target;
|
||||
}
|
||||
}
|
||||
} // namespace faset
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#ifdef _WIN32
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <cerrno>
|
||||
#include <csignal>
|
||||
#include <fcntl.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
extern char** environ;
|
||||
#endif
|
||||
|
||||
namespace faset {
|
||||
namespace {
|
||||
#ifdef _WIN32
|
||||
std::wstring widen(const std::string& value) {
|
||||
if (value.empty())
|
||||
return {};
|
||||
int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
|
||||
static_cast<int>(value.size()), nullptr, 0);
|
||||
if (!length)
|
||||
throw std::runtime_error("Invalid UTF-8 process argument");
|
||||
std::wstring result(length, 0);
|
||||
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast<int>(value.size()),
|
||||
result.data(), length);
|
||||
return result;
|
||||
}
|
||||
std::wstring quote(const std::wstring& value) {
|
||||
std::wstring result = L"\"";
|
||||
std::size_t slashes{};
|
||||
for (wchar_t c : value) {
|
||||
if (c == L'\\') {
|
||||
++slashes;
|
||||
continue;
|
||||
}
|
||||
if (c == L'\"') {
|
||||
result.append(slashes * 2 + 1, L'\\');
|
||||
result += c;
|
||||
} else {
|
||||
result.append(slashes, L'\\');
|
||||
result += c;
|
||||
}
|
||||
slashes = 0;
|
||||
}
|
||||
result.append(slashes * 2, L'\\');
|
||||
result += L'\"';
|
||||
return result;
|
||||
}
|
||||
struct CaseInsensitive {
|
||||
bool operator()(const std::wstring& a, const std::wstring& b) const {
|
||||
return _wcsicmp(a.c_str(), b.c_str()) < 0;
|
||||
}
|
||||
};
|
||||
#else
|
||||
std::filesystem::path resolve_program(const std::string& name, const std::string& path,
|
||||
const std::filesystem::path& cwd) {
|
||||
if (name.find('/') != std::string::npos) {
|
||||
auto file = std::filesystem::path(name);
|
||||
if (file.is_relative())
|
||||
file = cwd / file;
|
||||
if (::access(file.c_str(), X_OK) == 0 && !std::filesystem::is_directory(file))
|
||||
return std::filesystem::absolute(file);
|
||||
throw std::runtime_error("Executable is missing or not executable: " + name);
|
||||
}
|
||||
std::size_t from{};
|
||||
do {
|
||||
auto end = path.find(':', from);
|
||||
auto directory = path.substr(from, end == std::string::npos ? end : end - from);
|
||||
auto file = (directory.empty() ? cwd : std::filesystem::path(directory)) / name;
|
||||
if (file.is_relative())
|
||||
file = cwd / file;
|
||||
if (::access(file.c_str(), X_OK) == 0 && !std::filesystem::is_directory(file))
|
||||
return std::filesystem::absolute(file);
|
||||
if (end == std::string::npos)
|
||||
break;
|
||||
from = end + 1;
|
||||
} while (true);
|
||||
throw std::runtime_error("Executable not found on PATH: " + name);
|
||||
}
|
||||
#endif
|
||||
} // namespace
|
||||
std::filesystem::path find_executable(const std::string& name) {
|
||||
#ifdef _WIN32
|
||||
auto wide = widen(name);
|
||||
std::vector<wchar_t> buffer(32768);
|
||||
DWORD length = SearchPathW(nullptr, wide.c_str(), L".exe", static_cast<DWORD>(buffer.size()),
|
||||
buffer.data(), nullptr);
|
||||
if (length == 0 || length >= buffer.size())
|
||||
throw std::runtime_error("Executable not found on PATH: " + name);
|
||||
return std::filesystem::path(std::wstring(buffer.data(), length));
|
||||
#else
|
||||
const char* path = std::getenv("PATH");
|
||||
return resolve_program(name, path ? path : "", std::filesystem::current_path());
|
||||
#endif
|
||||
}
|
||||
struct Process::Impl {
|
||||
#ifdef _WIN32
|
||||
HANDLE process{}, thread{}, job{}, output{};
|
||||
#else
|
||||
pid_t pid{-1};
|
||||
int output{-1};
|
||||
#endif
|
||||
bool running{};
|
||||
std::optional<int> exit_code;
|
||||
~Impl() {
|
||||
try {
|
||||
cancel();
|
||||
} catch (...) {
|
||||
}
|
||||
#ifdef _WIN32
|
||||
if (output)
|
||||
CloseHandle(output);
|
||||
if (thread)
|
||||
CloseHandle(thread);
|
||||
if (process)
|
||||
CloseHandle(process);
|
||||
if (job)
|
||||
CloseHandle(job);
|
||||
#else
|
||||
if (output >= 0)
|
||||
::close(output);
|
||||
#endif
|
||||
}
|
||||
ProcessPoll poll() {
|
||||
std::string text;
|
||||
char buffer[8192];
|
||||
#ifdef _WIN32
|
||||
if (output) {
|
||||
DWORD available{};
|
||||
while (PeekNamedPipe(output, nullptr, 0, nullptr, &available, nullptr) && available) {
|
||||
DWORD read{};
|
||||
if (!ReadFile(output, buffer, std::min<DWORD>(available, sizeof(buffer)), &read,
|
||||
nullptr) ||
|
||||
!read)
|
||||
break;
|
||||
text.append(buffer, read);
|
||||
}
|
||||
}
|
||||
if (running && WaitForSingleObject(process, 0) == WAIT_OBJECT_0) {
|
||||
DWORD code{};
|
||||
if (!GetExitCodeProcess(process, &code))
|
||||
throw std::runtime_error("Cannot read process exit status");
|
||||
running = false;
|
||||
exit_code = static_cast<int>(code);
|
||||
}
|
||||
#else
|
||||
if (output >= 0) {
|
||||
while (true) {
|
||||
auto count = ::read(output, buffer, sizeof(buffer));
|
||||
if (count > 0) {
|
||||
text.append(buffer, static_cast<std::size_t>(count));
|
||||
continue;
|
||||
}
|
||||
if (count < 0 && errno == EINTR)
|
||||
continue;
|
||||
if (count == 0) {
|
||||
::close(output);
|
||||
output = -1;
|
||||
} else if (errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
throw std::runtime_error("Cannot read child output");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (running) {
|
||||
int status{};
|
||||
pid_t result;
|
||||
do {
|
||||
result = ::waitpid(pid, &status, WNOHANG);
|
||||
} while (result < 0 && errno == EINTR);
|
||||
if (result == pid) {
|
||||
running = false;
|
||||
exit_code = WIFEXITED(status) ? WEXITSTATUS(status)
|
||||
: WIFSIGNALED(status) ? 128 + WTERMSIG(status)
|
||||
: 1;
|
||||
} else if (result < 0)
|
||||
throw std::runtime_error("Cannot collect child process");
|
||||
}
|
||||
#endif
|
||||
if (!running) {
|
||||
#ifdef _WIN32
|
||||
DWORD available{};
|
||||
while (output && PeekNamedPipe(output, nullptr, 0, nullptr, &available, nullptr) &&
|
||||
available) {
|
||||
DWORD read{};
|
||||
if (!ReadFile(output, buffer, std::min<DWORD>(available, sizeof(buffer)), &read,
|
||||
nullptr) ||
|
||||
!read)
|
||||
break;
|
||||
text.append(buffer, read);
|
||||
}
|
||||
#else
|
||||
if (output >= 0)
|
||||
while (true) {
|
||||
auto count = ::read(output, buffer, sizeof(buffer));
|
||||
if (count > 0) {
|
||||
text.append(buffer, static_cast<std::size_t>(count));
|
||||
continue;
|
||||
}
|
||||
if (count < 0 && errno == EINTR)
|
||||
continue;
|
||||
if (count == 0) {
|
||||
::close(output);
|
||||
output = -1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return {running, exit_code, std::move(text)};
|
||||
}
|
||||
void cancel() {
|
||||
if (!running)
|
||||
return;
|
||||
#ifdef _WIN32
|
||||
if (job)
|
||||
TerminateJobObject(job, 130);
|
||||
TerminateProcess(process, 130);
|
||||
WaitForSingleObject(process, INFINITE);
|
||||
DWORD code{};
|
||||
GetExitCodeProcess(process, &code);
|
||||
exit_code = static_cast<int>(code);
|
||||
running = false;
|
||||
#else
|
||||
::kill(-pid, SIGTERM);
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(200);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
int status{};
|
||||
auto result = ::waitpid(pid, &status, WNOHANG);
|
||||
if (result == pid) {
|
||||
running = false;
|
||||
exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status);
|
||||
::kill(-pid, SIGKILL);
|
||||
return;
|
||||
}
|
||||
if (result < 0 && errno != EINTR) {
|
||||
running = false;
|
||||
return;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
::kill(-pid, SIGKILL);
|
||||
int status{};
|
||||
while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
running = false;
|
||||
exit_code = 130;
|
||||
#endif
|
||||
}
|
||||
};
|
||||
Process::Process(const ProcessOptions& options) : impl_(std::make_unique<Impl>()) {
|
||||
if (options.arguments.empty() || options.arguments[0].empty())
|
||||
throw std::invalid_argument("Process requires an executable");
|
||||
for (const auto& argument : options.arguments)
|
||||
if (argument.find('\0') != std::string::npos)
|
||||
throw std::invalid_argument("NUL in process argument");
|
||||
auto cwd = options.working_directory.empty()
|
||||
? std::filesystem::current_path()
|
||||
: std::filesystem::absolute(options.working_directory);
|
||||
if (!std::filesystem::is_directory(cwd))
|
||||
throw std::runtime_error("Process working directory does not exist");
|
||||
#ifdef _WIN32
|
||||
auto program = std::filesystem::path(widen(options.arguments[0]));
|
||||
if (program.has_parent_path() && program.is_relative())
|
||||
program = cwd / program;
|
||||
auto executable = program.has_parent_path() ? program : find_executable(options.arguments[0]);
|
||||
std::wstring command;
|
||||
for (const auto& argument : options.arguments) {
|
||||
if (!command.empty())
|
||||
command += L' ';
|
||||
command += quote(widen(argument));
|
||||
}
|
||||
std::map<std::wstring, std::wstring, CaseInsensitive> environment;
|
||||
auto block = GetEnvironmentStringsW();
|
||||
if (!block)
|
||||
throw std::runtime_error("Cannot read process environment");
|
||||
for (auto entry = block; *entry; entry += wcslen(entry) + 1) {
|
||||
std::wstring text(entry);
|
||||
auto equals = text.find(L'=', text[0] == L'=' ? 1 : 0);
|
||||
if (equals != std::wstring::npos)
|
||||
environment[text.substr(0, equals)] = text.substr(equals + 1);
|
||||
}
|
||||
FreeEnvironmentStringsW(block);
|
||||
for (auto& [key, value] : options.environment) {
|
||||
if (key.empty() || key.find('=') != std::string::npos ||
|
||||
key.find('\0') != std::string::npos || value.find('\0') != std::string::npos)
|
||||
throw std::invalid_argument("Invalid environment entry");
|
||||
environment[widen(key)] = widen(value);
|
||||
}
|
||||
std::vector<wchar_t> env;
|
||||
for (auto& [key, value] : environment) {
|
||||
auto item = key + L"=" + value;
|
||||
env.insert(env.end(), item.begin(), item.end());
|
||||
env.push_back(0);
|
||||
}
|
||||
env.push_back(0);
|
||||
SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
|
||||
HANDLE output_write{}, input{};
|
||||
if (!CreatePipe(&impl_->output, &output_write, &security, 0))
|
||||
throw std::runtime_error("Cannot create process pipe");
|
||||
SetHandleInformation(impl_->output, HANDLE_FLAG_INHERIT, 0);
|
||||
input = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &security,
|
||||
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
SIZE_T bytes{};
|
||||
InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes);
|
||||
std::vector<std::byte> storage(bytes);
|
||||
auto* attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
|
||||
bool initialized = InitializeProcThreadAttributeList(attributes, 1, 0, &bytes) != 0;
|
||||
HANDLE handles[] = {output_write, input};
|
||||
bool updated =
|
||||
initialized && UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
|
||||
handles, sizeof(handles), nullptr, nullptr) != 0;
|
||||
STARTUPINFOEXW startup{};
|
||||
startup.StartupInfo.cb = sizeof(startup);
|
||||
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
||||
startup.StartupInfo.hStdOutput = startup.StartupInfo.hStdError = output_write;
|
||||
startup.StartupInfo.hStdInput = input;
|
||||
startup.lpAttributeList = attributes;
|
||||
PROCESS_INFORMATION process{};
|
||||
bool launched =
|
||||
updated && CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, TRUE,
|
||||
CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED |
|
||||
CREATE_NEW_PROCESS_GROUP | EXTENDED_STARTUPINFO_PRESENT,
|
||||
env.data(), cwd.c_str(), &startup.StartupInfo, &process) != 0;
|
||||
if (initialized)
|
||||
DeleteProcThreadAttributeList(attributes);
|
||||
CloseHandle(output_write);
|
||||
if (input != INVALID_HANDLE_VALUE)
|
||||
CloseHandle(input);
|
||||
if (!launched)
|
||||
throw std::runtime_error("CreateProcess failed for " + options.arguments[0]);
|
||||
impl_->process = process.hProcess;
|
||||
impl_->thread = process.hThread;
|
||||
impl_->running = true;
|
||||
impl_->job = CreateJobObjectW(nullptr, nullptr);
|
||||
if (!impl_->job)
|
||||
throw std::runtime_error("Cannot create compiler job object");
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
if (!SetInformationJobObject(impl_->job, JobObjectExtendedLimitInformation, &limits,
|
||||
sizeof(limits)) ||
|
||||
!AssignProcessToJobObject(impl_->job, impl_->process))
|
||||
throw std::runtime_error("Cannot isolate compiler process group");
|
||||
if (ResumeThread(impl_->thread) == DWORD(-1))
|
||||
throw std::runtime_error("Cannot start compiler process");
|
||||
#else
|
||||
std::map<std::string, std::string> environment;
|
||||
for (char** item = environ; *item; ++item) {
|
||||
std::string text(*item);
|
||||
auto separator = text.find('=');
|
||||
if (separator != std::string::npos)
|
||||
environment[text.substr(0, separator)] = text.substr(separator + 1);
|
||||
}
|
||||
for (const auto& [key, value] : options.environment) {
|
||||
if (key.empty() || key.find('=') != std::string::npos ||
|
||||
key.find('\0') != std::string::npos || value.find('\0') != std::string::npos)
|
||||
throw std::invalid_argument("Invalid environment entry");
|
||||
environment[key] = value;
|
||||
}
|
||||
auto executable = resolve_program(options.arguments[0], environment["PATH"], cwd);
|
||||
std::vector<std::string> env_storage;
|
||||
std::vector<char*> argv, envp;
|
||||
for (const auto& argument : options.arguments)
|
||||
argv.push_back(const_cast<char*>(argument.c_str()));
|
||||
argv.push_back(nullptr);
|
||||
for (const auto& [key, value] : environment)
|
||||
env_storage.push_back(key + "=" + value);
|
||||
for (auto& entry : env_storage)
|
||||
envp.push_back(entry.data());
|
||||
envp.push_back(nullptr);
|
||||
int pipes[2];
|
||||
if (::pipe2(pipes, O_CLOEXEC) < 0)
|
||||
throw std::runtime_error("Cannot create process pipe");
|
||||
int input = ::open("/dev/null", O_RDONLY | O_CLOEXEC);
|
||||
if (input < 0) {
|
||||
::close(pipes[0]);
|
||||
::close(pipes[1]);
|
||||
throw std::runtime_error("Cannot open process input");
|
||||
}
|
||||
auto pid = ::fork();
|
||||
if (pid == 0) {
|
||||
::setpgid(0, 0);
|
||||
::close(pipes[0]);
|
||||
if (::dup2(input, STDIN_FILENO) < 0 || ::dup2(pipes[1], STDOUT_FILENO) < 0 ||
|
||||
::dup2(pipes[1], STDERR_FILENO) < 0 || ::chdir(cwd.c_str()) < 0)
|
||||
::_exit(126);
|
||||
::close(input);
|
||||
::close(pipes[1]);
|
||||
::execve(executable.c_str(), argv.data(), envp.data());
|
||||
constexpr char message[] = "Cannot execute child process\n";
|
||||
::write(STDERR_FILENO, message, sizeof(message) - 1);
|
||||
::_exit(127);
|
||||
}
|
||||
::close(input);
|
||||
::close(pipes[1]);
|
||||
if (pid < 0) {
|
||||
::close(pipes[0]);
|
||||
throw std::runtime_error("Cannot fork process");
|
||||
}
|
||||
::setpgid(pid, pid);
|
||||
impl_->pid = pid;
|
||||
impl_->output = pipes[0];
|
||||
impl_->running = true;
|
||||
int flags = ::fcntl(pipes[0], F_GETFL, 0);
|
||||
if (flags < 0 || ::fcntl(pipes[0], F_SETFL, flags | O_NONBLOCK) < 0)
|
||||
throw std::runtime_error("Cannot configure process output pipe");
|
||||
#endif
|
||||
}
|
||||
Process::~Process() = default;
|
||||
Process::Process(Process&&) noexcept = default;
|
||||
Process& Process::operator=(Process&&) noexcept = default;
|
||||
ProcessPoll Process::poll() {
|
||||
return impl_->poll();
|
||||
}
|
||||
void Process::cancel() {
|
||||
impl_->cancel();
|
||||
}
|
||||
} // namespace faset
|
||||
@@ -0,0 +1,747 @@
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <faset/assets/asset_data.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
namespace faset::editor {
|
||||
namespace fs = std::filesystem;
|
||||
namespace {
|
||||
struct Cancelled {};
|
||||
constexpr std::size_t max_log_bytes = 8 * 1024 * 1024;
|
||||
void validate_scene(const Json& scene) {
|
||||
if (!scene.is_object() || scene.value("format", "") != "faset.scene" ||
|
||||
scene.value("version", 0) != 1 || !scene.contains("entities") ||
|
||||
!scene["entities"].is_array())
|
||||
throw std::runtime_error("Cook requires a version 1 Faset scene");
|
||||
if (!scene.value("instances", Json::array()).empty())
|
||||
throw std::runtime_error("Resolve template instances before cooking");
|
||||
int dimension = scene.value("dimension", 0);
|
||||
if (dimension != 2 && dimension != 3)
|
||||
throw std::runtime_error("Scene dimension must be 2 or 3");
|
||||
}
|
||||
void validate_component_types(const Json& scene, const Json& schema) {
|
||||
std::map<std::string, int> versions;
|
||||
for (const auto* type : {"faset.transform", "faset.sprite", "faset.mesh", "faset.camera",
|
||||
"faset.light", "faset.rigid_body_2d", "faset.rigid_body_3d"})
|
||||
versions[type] = 1;
|
||||
for (const auto& type : schema.value("types", Json::array())) {
|
||||
auto id = type.at("id").get<std::string>();
|
||||
if (versions.contains(id))
|
||||
throw std::runtime_error("Gameplay schema duplicates a component type: " + id);
|
||||
versions[id] = type.value("version", 1);
|
||||
}
|
||||
for (const auto& entity : scene.at("entities"))
|
||||
for (const auto& component : entity.value("components", Json::array())) {
|
||||
const auto id = component.at("type").get<std::string>();
|
||||
auto found = versions.find(id);
|
||||
if (found == versions.end())
|
||||
throw std::runtime_error("Cannot cook unresolved component type: " + id);
|
||||
if (component.value("version", 1) != found->second)
|
||||
throw std::runtime_error("Migrate component '" + id +
|
||||
"' to the current gameplay schema before cooking");
|
||||
}
|
||||
}
|
||||
std::set<std::string> asset_references(const Json& scene) {
|
||||
std::set<std::string> result;
|
||||
for (const auto& entity : scene.at("entities"))
|
||||
for (const auto& component : entity.value("components", Json::array())) {
|
||||
const auto type = component.value("type", "");
|
||||
if (type != "faset.mesh" && type != "faset.sprite")
|
||||
continue;
|
||||
const auto fields = component.value("fields", Json::object());
|
||||
auto asset = fields.value(type == "faset.sprite" ? "texture" : "asset", "");
|
||||
if (asset.empty() || asset == "builtin:cube" || asset == "builtin:plane")
|
||||
continue;
|
||||
if (asset.starts_with("builtin:"))
|
||||
throw std::runtime_error("Unknown builtin asset: " + asset);
|
||||
result.insert(asset.substr(0, asset.find('#')));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
void copy_required_file(const fs::path& source, const fs::path& target) {
|
||||
if (!fs::is_regular_file(source) || fs::is_symlink(source))
|
||||
throw std::runtime_error("Required package file missing or not a regular file: " +
|
||||
source.string());
|
||||
fs::create_directories(target.parent_path());
|
||||
fs::copy_file(source, target, fs::copy_options::overwrite_existing);
|
||||
}
|
||||
std::string executable_suffix() {
|
||||
#ifdef _WIN32
|
||||
return ".exe";
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
fs::path build_executable(const fs::path& build, const std::string& configuration,
|
||||
const std::string& target) {
|
||||
for (const auto& root :
|
||||
{build, build / configuration, build / "bin", build / "bin" / configuration}) {
|
||||
auto file = root / (target + executable_suffix());
|
||||
if (fs::is_regular_file(file))
|
||||
return file;
|
||||
}
|
||||
throw std::runtime_error("Build did not produce " + target);
|
||||
}
|
||||
} // namespace
|
||||
Json JobStatus::json() const {
|
||||
return {{"id", id}, {"kind", kind}, {"state", state},
|
||||
{"stage", stage}, {"progress", progress}, {"log", log},
|
||||
{"error", error}, {"result", result}};
|
||||
}
|
||||
void write_cooked_scene(const fs::path& path, const Json& scene) {
|
||||
validate_scene(scene);
|
||||
const auto payload = Json::to_cbor(scene);
|
||||
std::string bytes = "FASETSCN";
|
||||
bytes.reserve(20 + payload.size());
|
||||
for (unsigned i = 0; i < 4; ++i)
|
||||
bytes.push_back(static_cast<char>((std::uint32_t(1) >> (i * 8)) & 255));
|
||||
for (unsigned i = 0; i < 8; ++i)
|
||||
bytes.push_back(static_cast<char>((std::uint64_t(payload.size()) >> (i * 8)) & 255));
|
||||
bytes.append(reinterpret_cast<const char*>(payload.data()), payload.size());
|
||||
atomic_write(path, bytes);
|
||||
}
|
||||
struct BuildService::Impl {
|
||||
struct Job {
|
||||
mutable std::mutex mutex;
|
||||
JobStatus status;
|
||||
std::atomic<bool> cancelled{};
|
||||
std::condition_variable finished;
|
||||
Json scene;
|
||||
Json asset_manifests = Json::object();
|
||||
fs::path output;
|
||||
};
|
||||
BuildConfig config;
|
||||
mutable std::mutex mutex;
|
||||
std::condition_variable condition;
|
||||
std::map<std::string, std::shared_ptr<Job>> jobs;
|
||||
std::deque<std::shared_ptr<Job>> queue;
|
||||
bool stopping{};
|
||||
std::thread worker;
|
||||
explicit Impl(BuildConfig c) : config(std::move(c)) {
|
||||
if (config.project_root.empty() || config.engine_root.empty())
|
||||
throw std::invalid_argument("Build service requires project and engine directories");
|
||||
config.project_root = fs::absolute(config.project_root);
|
||||
config.engine_root = fs::absolute(config.engine_root);
|
||||
if (config.cache_root.empty())
|
||||
config.cache_root = config.project_root / ".faset" / "cache";
|
||||
else
|
||||
config.cache_root = fs::absolute(config.cache_root);
|
||||
if (config.build_directory.empty())
|
||||
config.build_directory = config.project_root / ".faset" / "build";
|
||||
else
|
||||
config.build_directory = fs::absolute(config.build_directory);
|
||||
if (config.configuration != "Debug" && config.configuration != "Release" &&
|
||||
config.configuration != "RelWithDebInfo")
|
||||
throw std::invalid_argument("Unsupported build configuration");
|
||||
if (config.export_configuration != "Release" &&
|
||||
config.export_configuration != "RelWithDebInfo")
|
||||
throw std::invalid_argument("Export configuration must be Release or RelWithDebInfo");
|
||||
fs::create_directories(config.project_root);
|
||||
fs::create_directories(config.cache_root);
|
||||
worker = std::thread([this] { work(); });
|
||||
}
|
||||
~Impl() {
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
stopping = true;
|
||||
for (auto& [_, job] : jobs)
|
||||
job->cancelled = true;
|
||||
}
|
||||
condition.notify_all();
|
||||
if (worker.joinable())
|
||||
worker.join();
|
||||
}
|
||||
std::shared_ptr<Job> lookup(const std::string& id) const {
|
||||
std::lock_guard lock(mutex);
|
||||
auto it = jobs.find(id);
|
||||
if (it == jobs.end())
|
||||
throw std::out_of_range("Unknown build job: " + id);
|
||||
return it->second;
|
||||
}
|
||||
std::string enqueue(std::string kind, Json scene = {}, fs::path output = {}) {
|
||||
auto job = std::make_shared<Job>();
|
||||
job->status.id = new_id();
|
||||
job->status.kind = std::move(kind);
|
||||
job->scene = std::move(scene);
|
||||
job->output = std::move(output);
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (stopping)
|
||||
throw std::runtime_error("Build service is stopping");
|
||||
jobs.emplace(job->status.id, job);
|
||||
queue.push_back(job);
|
||||
}
|
||||
condition.notify_all();
|
||||
return job->status.id;
|
||||
}
|
||||
void checkpoint(Job& job, std::string stage, double progress) {
|
||||
if (job.cancelled)
|
||||
throw Cancelled{};
|
||||
std::lock_guard lock(job.mutex);
|
||||
job.status.stage = std::move(stage);
|
||||
job.status.progress = progress;
|
||||
}
|
||||
void log(Job& job, std::string_view text) {
|
||||
std::lock_guard lock(job.mutex);
|
||||
job.status.log.append(text);
|
||||
if (job.status.log.size() > max_log_bytes)
|
||||
job.status.log.erase(0, job.status.log.size() - max_log_bytes);
|
||||
}
|
||||
std::string run(Job& job, std::vector<std::string> arguments, const fs::path& cwd) {
|
||||
if (job.cancelled)
|
||||
throw Cancelled{};
|
||||
std::string description = "$";
|
||||
for (const auto& argument : arguments)
|
||||
description += " " + Json(argument).dump();
|
||||
description += '\n';
|
||||
log(job, description);
|
||||
Process process({std::move(arguments), cwd, {}});
|
||||
std::string output;
|
||||
while (true) {
|
||||
if (job.cancelled) {
|
||||
process.cancel();
|
||||
auto final = process.poll();
|
||||
log(job, final.output);
|
||||
throw Cancelled{};
|
||||
}
|
||||
auto poll = process.poll();
|
||||
log(job, poll.output);
|
||||
output += poll.output;
|
||||
if (output.size() > max_log_bytes)
|
||||
output.erase(0, output.size() - max_log_bytes);
|
||||
if (!poll.running) {
|
||||
if (poll.exit_code.value_or(1) != 0)
|
||||
throw std::runtime_error("Process exited with code " +
|
||||
std::to_string(poll.exit_code.value_or(1)) +
|
||||
"; see job log");
|
||||
return output;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(15));
|
||||
}
|
||||
}
|
||||
void validate_assets(const Json& scene) {
|
||||
assets::AssetStore pipeline(config.cache_root);
|
||||
for (const auto& id : asset_references(scene))
|
||||
pipeline.load_asset(id);
|
||||
}
|
||||
Json build(Job& job, bool exporting = false) {
|
||||
const auto& configuration = exporting ? config.export_configuration : config.configuration;
|
||||
const auto native_directory = config.build_directory / configuration;
|
||||
checkpoint(job, "Configuring C++ gameplay", .05);
|
||||
if (!fs::is_regular_file(config.project_root / "Scripts" / "Gameplay.cpp") ||
|
||||
!fs::is_regular_file(config.project_root / "Scripts" / "Gameplay.hpp"))
|
||||
throw std::runtime_error("Project Scripts/Gameplay.cpp and Gameplay.hpp are required; "
|
||||
"create a project scaffold first");
|
||||
fs::create_directories(native_directory);
|
||||
std::vector<std::string> arguments = {config.cmake,
|
||||
"-S",
|
||||
config.engine_root.string(),
|
||||
"-B",
|
||||
native_directory.string(),
|
||||
"-G",
|
||||
config.generator,
|
||||
"-DCMAKE_BUILD_TYPE=" + configuration,
|
||||
"-DBUILD_TESTING=OFF",
|
||||
"-DFASET_BUILD_EDITOR=OFF",
|
||||
"-DFASET_BUILD_RENDERER=ON",
|
||||
"-DFASET_BUILD_RUNTIME=ON",
|
||||
"-DFASET_BUILD_ASSETS=ON",
|
||||
"-DFASET_GAMEPLAY_SOURCE_DIR=" +
|
||||
(config.project_root / "Scripts").string()};
|
||||
bool compiler_overridden = false;
|
||||
for (const auto& arg : config.configure_arguments)
|
||||
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
|
||||
compiler_overridden = true;
|
||||
if (!compiler_overridden) {
|
||||
#ifdef _WIN32
|
||||
auto compiler = find_executable("clang-cl");
|
||||
arguments.push_back("-DCMAKE_C_COMPILER=" + compiler.string());
|
||||
arguments.push_back("-DCMAKE_CXX_COMPILER=" + compiler.string());
|
||||
#else
|
||||
arguments.push_back("-DCMAKE_C_COMPILER=" + find_executable("clang").string());
|
||||
arguments.push_back("-DCMAKE_CXX_COMPILER=" + find_executable("clang++").string());
|
||||
#endif
|
||||
}
|
||||
arguments.insert(arguments.end(), config.configure_arguments.begin(),
|
||||
config.configure_arguments.end());
|
||||
arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration);
|
||||
run(job, std::move(arguments), config.project_root);
|
||||
checkpoint(job, "Compiling and linking Player", .25);
|
||||
run(job,
|
||||
{config.cmake, "--build", native_directory.string(), "--config", configuration,
|
||||
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
|
||||
config.project_root);
|
||||
checkpoint(job, "Exporting gameplay schema", .58);
|
||||
auto player = build_executable(native_directory, configuration, "faset_player");
|
||||
auto exporter = build_executable(native_directory, configuration, "faset_schema_exporter");
|
||||
const auto staging = config.cache_root / "builds" / (".staging-" + job.status.id);
|
||||
const auto generation = config.cache_root / "builds" / job.status.id;
|
||||
fs::create_directories(staging);
|
||||
try {
|
||||
const auto schema_file = staging / "schema.json";
|
||||
run(job, {exporter.string(), "--output", schema_file.string()}, config.project_root);
|
||||
auto schema = read_json(schema_file);
|
||||
if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 ||
|
||||
!schema.contains("types") || !schema.at("types").is_array())
|
||||
throw std::runtime_error("SchemaExporter returned an invalid manifest");
|
||||
std::string fingerprint = sha256_file(player) + sha256_file(exporter) +
|
||||
read_text(native_directory / "CMakeCache.txt");
|
||||
for (const auto& file : {"Gameplay.cpp", "Gameplay.hpp"})
|
||||
fingerprint += read_text(config.project_root / "Scripts" / file);
|
||||
fingerprint = sha256(fingerprint);
|
||||
schema["build_fingerprint"] = fingerprint;
|
||||
atomic_write_json(schema_file, schema);
|
||||
copy_required_file(player, staging / ("faset_player" + executable_suffix()));
|
||||
copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix()));
|
||||
for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"})
|
||||
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
|
||||
copy_runtime_libraries(job, player, staging, native_directory, configuration);
|
||||
Json manifest{{"format", "faset.build"},
|
||||
{"version", 1},
|
||||
{"id", job.status.id},
|
||||
{"fingerprint", fingerprint},
|
||||
{"configuration", configuration},
|
||||
{"player", "faset_player" + executable_suffix()},
|
||||
{"schema", "schema.json"}};
|
||||
atomic_write_json(staging / "manifest.json", manifest);
|
||||
checkpoint(job, "Publishing build generation", .68);
|
||||
fs::rename(staging, generation);
|
||||
atomic_write_json(config.cache_root / "last_build.json",
|
||||
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
|
||||
return {{"generation", job.status.id},
|
||||
{"directory", generation.string()},
|
||||
{"build_directory", native_directory.string()},
|
||||
{"configuration", configuration},
|
||||
{"player", (generation / ("faset_player" + executable_suffix())).string()},
|
||||
{"schema", (generation / "schema.json").string()},
|
||||
{"fingerprint", fingerprint}};
|
||||
} catch (...) {
|
||||
std::error_code error;
|
||||
fs::remove_all(staging, error);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
void copy_runtime_libraries(Job& job, const fs::path& executable, const fs::path& destination,
|
||||
const fs::path& native_directory,
|
||||
const std::string& configuration) {
|
||||
#ifdef _WIN32
|
||||
// Libraries produced by the selected toolchain are copied beside the executable.
|
||||
// System DLLs (including Vulkan) remain platform prerequisites.
|
||||
for (const auto& root :
|
||||
{executable.parent_path(), native_directory, native_directory / configuration})
|
||||
if (fs::is_directory(root))
|
||||
for (const auto& entry : fs::directory_iterator(root)) {
|
||||
auto extension = entry.path().extension().string();
|
||||
for (auto& c : extension)
|
||||
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (entry.is_regular_file() && extension == ".dll")
|
||||
copy_required_file(entry.path(), destination / entry.path().filename());
|
||||
}
|
||||
// The actual packaged executable is launched before publication to reject missing imports.
|
||||
(void)job;
|
||||
#else
|
||||
const auto output =
|
||||
run(job, {find_executable("ldd").string(), executable.string()}, config.project_root);
|
||||
if (output.find("not found") != std::string::npos)
|
||||
throw std::runtime_error("Player has unresolved shared library dependencies");
|
||||
// SDL/physics/gameplay are linked statically. glibc/libstdc++/Vulkan are the host baseline.
|
||||
// Refuse an unexpected private DSO rather than silently publish a non-portable package.
|
||||
std::size_t begin{};
|
||||
while (begin < output.size()) {
|
||||
auto end = output.find('\n', begin);
|
||||
auto line = output.substr(begin, end == std::string::npos ? end : end - begin);
|
||||
auto arrow = line.find("=> ");
|
||||
if (arrow != std::string::npos) {
|
||||
auto name = line.substr(0, arrow);
|
||||
name.erase(0, name.find_first_not_of(" \t"));
|
||||
auto path_start = arrow + 3;
|
||||
auto path_end = line.find(" (", path_start);
|
||||
auto path = line.substr(path_start, path_end - path_start);
|
||||
if (!path.empty() && path[0] == '/' && !path.starts_with("/lib/") &&
|
||||
!path.starts_with("/lib64/") && !path.starts_with("/usr/lib/") &&
|
||||
!path.starts_with("/usr/lib64/"))
|
||||
throw std::runtime_error(
|
||||
"Private shared library needs an explicit package rule: " + path);
|
||||
}
|
||||
if (end == std::string::npos)
|
||||
break;
|
||||
begin = end + 1;
|
||||
}
|
||||
(void)destination;
|
||||
(void)native_directory;
|
||||
(void)configuration;
|
||||
#endif
|
||||
}
|
||||
Json cook(Job& job) {
|
||||
checkpoint(job, "Validating scene and assets", .1);
|
||||
validate_scene(job.scene);
|
||||
validate_assets(job.scene);
|
||||
Json schema = Json::object();
|
||||
if (fs::exists(config.cache_root / "last_build.json")) {
|
||||
auto pointer = read_json(config.cache_root / "last_build.json");
|
||||
auto schema_path = project_path(
|
||||
config.cache_root,
|
||||
fs::path("builds") / pointer.at("generation").get<std::string>() / "schema.json");
|
||||
schema = read_json(schema_path);
|
||||
}
|
||||
validate_component_types(job.scene, schema);
|
||||
auto source_hash = sha256(job.scene.dump());
|
||||
auto schema_fingerprint = schema.value("build_fingerprint", std::string("builtin-v1"));
|
||||
auto digest = sha256(source_hash + schema_fingerprint);
|
||||
auto directory = config.cache_root / "cooked" / digest;
|
||||
auto staging = config.cache_root / "cooked" / (".staging-" + job.status.id);
|
||||
fs::create_directories(staging);
|
||||
try {
|
||||
checkpoint(job, "Writing cooked scene", .6);
|
||||
write_cooked_scene(staging / "scene.fscene", job.scene);
|
||||
Json manifest{{"format", "faset.cooked-scene"},
|
||||
{"version", 1},
|
||||
{"scene", job.scene.value("id", "")},
|
||||
{"source_hash", source_hash},
|
||||
{"schema_fingerprint", schema_fingerprint},
|
||||
{"sha256", sha256_file(staging / "scene.fscene")}};
|
||||
atomic_write_json(staging / "manifest.json", manifest);
|
||||
checkpoint(job, "Publishing cooked scene", .9);
|
||||
if (fs::exists(directory)) {
|
||||
if (read_json(directory / "manifest.json") != manifest ||
|
||||
sha256_file(directory / "scene.fscene") !=
|
||||
manifest.at("sha256").get<std::string>())
|
||||
throw std::runtime_error("Existing cooked generation is corrupt");
|
||||
fs::remove_all(staging);
|
||||
} else
|
||||
fs::rename(staging, directory);
|
||||
atomic_write_json(config.cache_root / "last_cook.json", {{"generation", digest}});
|
||||
return {{"generation", digest},
|
||||
{"scene", (directory / "scene.fscene").string()},
|
||||
{"directory", directory.string()}};
|
||||
} catch (...) {
|
||||
std::error_code error;
|
||||
fs::remove_all(staging, error);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
void package_notices(const fs::path& destination, const fs::path& native_directory) {
|
||||
fs::create_directories(destination);
|
||||
auto lock = read_json(config.engine_root / "dependencies.lock.json");
|
||||
const std::vector<std::string> runtime_dependencies = {"sdl3", "entt", "box2d",
|
||||
"box3d", "json", "stb"};
|
||||
Json used = Json::object();
|
||||
for (const auto& name : runtime_dependencies) {
|
||||
std::vector<fs::path> roots = {native_directory / "_deps" / (name + "-src"),
|
||||
config.engine_root / ".cache" / "deps-src" / name};
|
||||
bool copied{};
|
||||
for (const auto& source : roots) {
|
||||
if (!fs::is_directory(source))
|
||||
continue;
|
||||
for (const auto& entry : fs::directory_iterator(source)) {
|
||||
if (!entry.is_regular_file())
|
||||
continue;
|
||||
auto filename = entry.path().filename().string();
|
||||
std::string upper = filename;
|
||||
for (auto& c : upper)
|
||||
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
if (upper.starts_with("LICENSE") || upper.starts_with("COPYING") ||
|
||||
upper.starts_with("NOTICE")) {
|
||||
copy_required_file(entry.path(), destination / name / filename);
|
||||
copied = true;
|
||||
}
|
||||
}
|
||||
if (copied)
|
||||
break;
|
||||
}
|
||||
if (!copied)
|
||||
throw std::runtime_error("Cannot package required license notices for " + name);
|
||||
used[name] = lock.at("dependencies").at(name);
|
||||
}
|
||||
atomic_write_json(destination / "dependencies.json", used);
|
||||
if (fs::is_regular_file(config.engine_root / "LICENSE"))
|
||||
copy_required_file(config.engine_root / "LICENSE", destination / "Faset-LICENSE");
|
||||
atomic_write(destination / "Faset-NOTICE.txt",
|
||||
"Faset Engine\nhttps://github.com/emil28092005/Faset_Engine\nSee the source "
|
||||
"repository for the current license status of Faset's own code.\nThird-party "
|
||||
"license texts are included in the adjacent directories.\n");
|
||||
}
|
||||
void package_assets(Job& job, const fs::path& destination) {
|
||||
assets::AssetStore packaged(destination);
|
||||
for (const auto& id : asset_references(job.scene)) {
|
||||
if (job.cancelled)
|
||||
throw Cancelled{};
|
||||
auto manifest = job.asset_manifests.at(id);
|
||||
auto generation = manifest.at("generation").get<std::string>();
|
||||
auto source_directory = project_path(config.cache_root, fs::path("assets") / id /
|
||||
"generations" / generation);
|
||||
auto target = destination / "assets" / id / "generations" / generation;
|
||||
for (const auto& file : manifest.at("files")) {
|
||||
auto relative = fs::path(file.at("path").get<std::string>());
|
||||
copy_required_file(project_path(source_directory, relative),
|
||||
project_path(target, relative));
|
||||
}
|
||||
manifest["source"] = "<cooked>";
|
||||
if (manifest.contains("payload_source"))
|
||||
manifest["payload_source"] = "<cooked>";
|
||||
atomic_write_json(target / "manifest.json", manifest);
|
||||
atomic_write_json(
|
||||
destination / "assets" / id / "current.json",
|
||||
{{"schema_version", 1}, {"generation", generation}, {"source", "<cooked>"}});
|
||||
packaged.load_asset(id);
|
||||
}
|
||||
}
|
||||
Json export_game(Job& job) {
|
||||
validate_scene(job.scene);
|
||||
validate_assets(job.scene);
|
||||
assets::AssetStore source(config.cache_root);
|
||||
for (const auto& id : asset_references(job.scene))
|
||||
job.asset_manifests[id] = source.current_manifest(id);
|
||||
const auto built = build(job, true);
|
||||
validate_component_types(job.scene, read_json(built.at("schema").get<std::string>()));
|
||||
auto output = fs::absolute(job.output);
|
||||
if (output.empty())
|
||||
throw std::runtime_error("An export destination is required");
|
||||
fs::create_directories(output / "generations");
|
||||
auto staging = output / (".staging-" + job.status.id);
|
||||
auto generation = output / "generations" / job.status.id;
|
||||
fs::create_directories(staging);
|
||||
try {
|
||||
checkpoint(job, "Cooking export snapshot", .72);
|
||||
write_cooked_scene(staging / "scene.fscene", job.scene);
|
||||
auto build_directory = fs::path(built.at("directory").get<std::string>());
|
||||
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
|
||||
staging / ("faset_player" + executable_suffix()));
|
||||
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"})
|
||||
copy_required_file(build_directory / "shaders" / shader,
|
||||
staging / "shaders" / shader);
|
||||
for (const auto& entry : fs::directory_iterator(build_directory)) {
|
||||
auto extension = entry.path().extension().string();
|
||||
for (auto& c : extension)
|
||||
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (entry.is_regular_file() && extension == ".dll")
|
||||
copy_required_file(entry.path(), staging / entry.path().filename());
|
||||
}
|
||||
checkpoint(job, "Packaging assets and notices", .80);
|
||||
package_assets(job, staging);
|
||||
package_notices(staging / "Notices", built.at("build_directory").get<std::string>());
|
||||
atomic_write(
|
||||
staging / "README.txt",
|
||||
"Run faset_player" + executable_suffix() +
|
||||
" to start this game.\nThe executable loads scene.fscene and assets beside "
|
||||
"it.\nKeep shaders/, assets/, and Notices/ with the executable.\nA compatible "
|
||||
"Vulkan 1.3 driver and the supported OS runtime are required.\n");
|
||||
#ifdef _WIN32
|
||||
atomic_write(staging / "Windows-Runtime.txt",
|
||||
"Install the Microsoft Visual C++ x64 Redistributable if Windows reports "
|
||||
"a missing MSVCP140 or VCRUNTIME140 DLL.\nOfficial installer: "
|
||||
"https://aka.ms/vc14/vc_redist.x64.exe\nThe Vulkan loader and GPU driver "
|
||||
"must also be installed.\n");
|
||||
#endif
|
||||
checkpoint(job, "Validating packaged Player", .90);
|
||||
auto validation_output =
|
||||
run(job,
|
||||
{(staging / ("faset_player" + executable_suffix())).string(), "--validate",
|
||||
"--scene", (staging / "scene.fscene").string(), "--assets", staging.string()},
|
||||
staging);
|
||||
Json files = Json::array();
|
||||
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
|
||||
if (entry.is_symlink())
|
||||
throw std::runtime_error("Export contains a symlink");
|
||||
if (entry.is_regular_file())
|
||||
files.push_back(
|
||||
{{"path", entry.path().lexically_relative(staging).generic_string()},
|
||||
{"sha256", sha256_file(entry.path())},
|
||||
{"size", entry.file_size()}});
|
||||
}
|
||||
Json manifest{{"format", "faset.export"},
|
||||
{"version", 1},
|
||||
{"generation", job.status.id},
|
||||
{"build_fingerprint", built.at("fingerprint")},
|
||||
{"scene_hash", sha256(job.scene.dump())},
|
||||
{"asset_generations", Json::object()},
|
||||
{"configuration", built.at("configuration")},
|
||||
{"executable", "faset_player" + executable_suffix()},
|
||||
{"files", files}};
|
||||
manifest["units"] = {
|
||||
{"distance", "metre"}, {"angle", "radian"}, {"coordinates", "right-handed Y-up"}};
|
||||
manifest["simulation"] = {{"fixed_delta", 1.0 / 60.0},
|
||||
{"max_catch_up_ticks", 4},
|
||||
{"physics_substeps", 4},
|
||||
{"gravity", {0, -9.81, 0}}};
|
||||
if (job.scene.contains("simulation"))
|
||||
manifest["simulation"].update(job.scene.at("simulation"));
|
||||
manifest["renderer_profile"] = {
|
||||
{"api", "Vulkan 1.3"},
|
||||
{"required_features", {"dynamicRendering", "synchronization2"}},
|
||||
{"materials", {"base-color factor and texture", "metallic and roughness factors"}},
|
||||
{"texture_sampling", "linear clamp, one mip level"},
|
||||
{"shadow_map", {{"resolution", 1024}, {"world_extent", 40}}},
|
||||
{"unsupported_material_features",
|
||||
{"normal maps", "metallic-roughness maps", "emissive and occlusion maps",
|
||||
"alpha mode selection", "unlit mode", "per-material face culling"}}};
|
||||
manifest["validation_log"] = validation_output;
|
||||
for (const auto& [id, asset] : job.asset_manifests.items())
|
||||
manifest["asset_generations"][id] = asset.at("generation");
|
||||
#ifdef _WIN32
|
||||
manifest["platform"] = "windows";
|
||||
manifest["prerequisites"] = {
|
||||
"Windows x64", "Vulkan 1.3 driver",
|
||||
"Microsoft Visual C++ x64 Redistributable (Visual Studio 2022 or newer)"};
|
||||
#else
|
||||
manifest["platform"] = "linux";
|
||||
manifest["prerequisites"] = {"Linux x86_64", "Vulkan 1.3 driver",
|
||||
"Compatible glibc and libstdc++ runtime"};
|
||||
#endif
|
||||
atomic_write_json(staging / "manifest.json", manifest);
|
||||
checkpoint(job, "Publishing export generation", .98);
|
||||
fs::rename(staging, generation);
|
||||
atomic_write_json(output / "current.json",
|
||||
{{"format", "faset.export-pointer"},
|
||||
{"version", 1},
|
||||
{"generation", job.status.id},
|
||||
{"directory", "generations/" + job.status.id}});
|
||||
return {{"directory", generation.string()},
|
||||
{"executable", (generation / ("faset_player" + executable_suffix())).string()},
|
||||
{"manifest", (generation / "manifest.json").string()},
|
||||
{"generation", job.status.id},
|
||||
{"build", built},
|
||||
{"schema", built.at("schema")},
|
||||
{"player", built.at("player")},
|
||||
{"build_directory", built.at("build_directory")},
|
||||
{"configuration", built.at("configuration")}};
|
||||
} catch (...) {
|
||||
std::error_code error;
|
||||
fs::remove_all(staging, error);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
void work() {
|
||||
while (true) {
|
||||
std::shared_ptr<Job> job;
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
condition.wait(lock, [&] { return stopping || !queue.empty(); });
|
||||
if (queue.empty()) {
|
||||
if (stopping)
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
job = queue.front();
|
||||
queue.pop_front();
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(job->mutex);
|
||||
job->status.state = "running";
|
||||
}
|
||||
try {
|
||||
if (job->cancelled)
|
||||
throw Cancelled{};
|
||||
Json result;
|
||||
if (job->status.kind == "build")
|
||||
result = build(*job);
|
||||
else if (job->status.kind == "cook")
|
||||
result = cook(*job);
|
||||
else
|
||||
result = export_game(*job);
|
||||
std::lock_guard lock(job->mutex);
|
||||
job->status.state = "succeeded";
|
||||
job->status.stage = "Complete";
|
||||
job->status.progress = 1;
|
||||
job->status.result = std::move(result);
|
||||
} catch (const Cancelled&) {
|
||||
std::lock_guard lock(job->mutex);
|
||||
job->status.state = "cancelled";
|
||||
job->status.stage = "Cancelled";
|
||||
job->status.error =
|
||||
"Job cancelled; previous published generations remain available";
|
||||
} catch (const std::exception& error) {
|
||||
std::lock_guard lock(job->mutex);
|
||||
job->status.state = "failed";
|
||||
job->status.stage = "Failed";
|
||||
job->status.error = error.what();
|
||||
}
|
||||
job->finished.notify_all();
|
||||
condition.notify_all();
|
||||
}
|
||||
}
|
||||
};
|
||||
BuildService::BuildService(BuildConfig config) : impl_(std::make_unique<Impl>(std::move(config))) {}
|
||||
BuildService::~BuildService() = default;
|
||||
const BuildConfig& BuildService::config() const {
|
||||
return impl_->config;
|
||||
}
|
||||
void BuildService::scaffold(const std::string& name, int dimension) {
|
||||
if (name.empty() || (dimension != 2 && dimension != 3))
|
||||
throw std::invalid_argument("Project name and dimension 2 or 3 required");
|
||||
const auto& c = impl_->config;
|
||||
fs::create_directories(c.project_root / "Scripts");
|
||||
fs::create_directories(c.project_root / "Scenes");
|
||||
fs::create_directories(c.project_root / "Assets");
|
||||
for (const auto* file : {"Gameplay.cpp", "Gameplay.hpp"}) {
|
||||
auto target = c.project_root / "Scripts" / file;
|
||||
if (!fs::exists(target)) {
|
||||
auto source = c.engine_root / "tools" / "project_templates" / file;
|
||||
copy_required_file(source, target);
|
||||
}
|
||||
}
|
||||
auto project = c.project_root / "project.faset.json";
|
||||
if (!fs::exists(project))
|
||||
atomic_write_json(project, {{"format", "faset.project"},
|
||||
{"version", 1},
|
||||
{"id", new_id()},
|
||||
{"name", name},
|
||||
{"dimension", dimension},
|
||||
{"start_scene", "Scenes/main.scene.json"}});
|
||||
if (!fs::exists(c.project_root / ".gitignore"))
|
||||
atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n");
|
||||
}
|
||||
std::string BuildService::start_build() {
|
||||
return impl_->enqueue("build");
|
||||
}
|
||||
std::string BuildService::start_cook(Json scene) {
|
||||
return impl_->enqueue("cook", std::move(scene));
|
||||
}
|
||||
std::string BuildService::start_export(Json scene, const fs::path& output) {
|
||||
if (output.empty())
|
||||
throw std::invalid_argument("Export output directory is required");
|
||||
return impl_->enqueue("export", std::move(scene), output);
|
||||
}
|
||||
JobStatus BuildService::job(const std::string& id) const {
|
||||
auto value = impl_->lookup(id);
|
||||
std::lock_guard lock(value->mutex);
|
||||
return value->status;
|
||||
}
|
||||
std::vector<JobStatus> BuildService::jobs() const {
|
||||
std::vector<std::shared_ptr<Impl::Job>> values;
|
||||
{
|
||||
std::lock_guard lock(impl_->mutex);
|
||||
for (auto& [_, value] : impl_->jobs)
|
||||
values.push_back(value);
|
||||
}
|
||||
std::vector<JobStatus> result;
|
||||
for (auto& value : values) {
|
||||
std::lock_guard lock(value->mutex);
|
||||
result.push_back(value->status);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
void BuildService::cancel(const std::string& id) {
|
||||
impl_->lookup(id)->cancelled = true;
|
||||
impl_->condition.notify_all();
|
||||
}
|
||||
JobStatus BuildService::wait(const std::string& id) {
|
||||
auto value = impl_->lookup(id);
|
||||
std::unique_lock lock(value->mutex);
|
||||
value->finished.wait(lock, [&] { return value->status.finished(); });
|
||||
return value->status;
|
||||
}
|
||||
} // namespace faset::editor
|
||||
@@ -0,0 +1,157 @@
|
||||
#include <algorithm>
|
||||
#include <faset/authoring/templates.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/commands.hpp>
|
||||
#include <set>
|
||||
|
||||
namespace faset::editor {
|
||||
Json Commands::object_schema(Json properties, Json required) {
|
||||
return {{"type", "object"},
|
||||
{"properties", std::move(properties)},
|
||||
{"required", std::move(required)},
|
||||
{"additionalProperties", false}};
|
||||
}
|
||||
void Commands::add(std::string name, std::string description, Json schema, Handler handler,
|
||||
bool read_only) {
|
||||
require(!commands_.contains(name), "command.duplicate", "Command already registered: " + name);
|
||||
Json descriptor = {{"name", name},
|
||||
{"description", std::move(description)},
|
||||
{"inputSchema", std::move(schema)},
|
||||
{"annotations", {{"readOnlyHint", read_only}, {"openWorldHint", false}}}};
|
||||
commands_.emplace(std::move(name), Command{std::move(descriptor), std::move(handler)});
|
||||
}
|
||||
void Commands::remove(const std::string& name) {
|
||||
commands_.erase(name);
|
||||
}
|
||||
Json Commands::list() const {
|
||||
Json result = Json::array();
|
||||
for (const auto& [name, command] : commands_)
|
||||
result.push_back(command.descriptor);
|
||||
return result;
|
||||
}
|
||||
Json Commands::call(const std::string& name, const Json& arguments) {
|
||||
const auto found = commands_.find(name);
|
||||
require(found != commands_.end(), "command.unknown", "Unknown editor command: " + name);
|
||||
require(arguments.is_object(), "arguments.object", "Tool arguments must be an object");
|
||||
const auto& schema = found->second.descriptor["inputSchema"];
|
||||
for (const auto& key : schema.value("required", Json::array()))
|
||||
require(arguments.contains(key.get<std::string>()), "arguments.required",
|
||||
"Missing argument: " + key.get<std::string>());
|
||||
for (const auto& [key, value] : arguments.items()) {
|
||||
require(schema["properties"].contains(key), "arguments.unknown",
|
||||
"Unknown argument: " + key);
|
||||
const auto& property = schema["properties"][key];
|
||||
const auto type = property.value("type", std::string());
|
||||
const bool valid =
|
||||
type.empty() || (type == "string" && value.is_string()) ||
|
||||
(type == "boolean" && value.is_boolean()) || (type == "number" && value.is_number()) ||
|
||||
(type == "integer" && value.is_number_integer()) ||
|
||||
(type == "object" && value.is_object()) || (type == "array" && value.is_array());
|
||||
require(valid, "arguments.type", "Invalid type for argument: " + key);
|
||||
if (property.contains("enum"))
|
||||
require(std::find(property["enum"].begin(), property["enum"].end(), value) !=
|
||||
property["enum"].end(),
|
||||
"arguments.enum", "Unsupported value for argument: " + key);
|
||||
if (property.contains("maximum") && value.is_number())
|
||||
require(value.get<double>() <= property["maximum"].get<double>(), "arguments.maximum",
|
||||
"Argument exceeds its maximum: " + key);
|
||||
if (property.contains("minimum") && value.is_number())
|
||||
require(value.get<double>() >= property["minimum"].get<double>(), "arguments.minimum",
|
||||
"Argument is below its minimum: " + key);
|
||||
}
|
||||
try {
|
||||
return found->second.handler(arguments);
|
||||
} catch (const Json::exception& error) {
|
||||
throw Error("arguments.invalid", "Invalid command data", {{"reason", error.what()}});
|
||||
}
|
||||
}
|
||||
Json Commands::resolved_scene(const std::string& id) const {
|
||||
const auto result = authoring::resolve_templates(
|
||||
authoring_.query(id).at("scene"), authoring_.schemas(),
|
||||
[&](const std::string& path) { return read_json(project_path(authoring_.root(), path)); });
|
||||
return {{"scene", result.scene}, {"conflicts", result.conflicts}};
|
||||
}
|
||||
Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authoring) {
|
||||
const Json text = {{"type", "string"}}, integer = {{"type", "integer"}, {"minimum", 0}},
|
||||
boolean = {{"type", "boolean"}};
|
||||
add(
|
||||
"faset_documents",
|
||||
"List open authoring documents and their revisions. This does not inspect a running game.",
|
||||
object_schema(Json::object()),
|
||||
[&](const Json&) { return Json{{"documents", authoring_.documents()}}; }, true);
|
||||
add("faset_document_create",
|
||||
"Create an unsaved 2D or 3D scene. Returns its persistent document ID and revision.",
|
||||
object_schema({{"name", text}, {"dimension", integer}}, {"name", "dimension"}),
|
||||
[&](const Json& args) { return authoring_.create(args.at("name"), args.at("dimension")); });
|
||||
add("faset_document_open",
|
||||
"Open a scene relative to the project. Set recover=true to load its saved recovery "
|
||||
"journal.",
|
||||
object_schema({{"path", text}, {"recover", boolean}}, {"path"}), [&](const Json& args) {
|
||||
return authoring_.open(args.at("path").get<std::string>(),
|
||||
args.value("recover", false));
|
||||
});
|
||||
add(
|
||||
"faset_document_query",
|
||||
"Read an authoring scene, persistent IDs, dirty state and revision. No Player or runtime "
|
||||
"state is exposed.",
|
||||
object_schema({{"document", text}}, {"document"}),
|
||||
[&](const Json& args) { return authoring_.query(args.at("document")); }, true);
|
||||
add("faset_document_save",
|
||||
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
|
||||
object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) {
|
||||
return authoring_.save(args.at("document"), args.value("path", std::string()));
|
||||
});
|
||||
add(
|
||||
"faset_schema",
|
||||
"Inspect registered component TypeIds, stable FieldIds, defaults and constraints.",
|
||||
object_schema(Json::object()), [&](const Json&) { return authoring_.schemas().manifest(); },
|
||||
true);
|
||||
add("faset_scene_edit",
|
||||
"Apply one atomic authoring batch with optimistic revision checking and one Undo step. "
|
||||
"Operations: entity.create/rename/delete/duplicate/reparent; component.add/remove/set; "
|
||||
"scene.rename; template.instance/override/revert/suppress/add/reparent. Use persistent IDs "
|
||||
"from document_query and schema. An idempotency_key retries the same payload in this "
|
||||
"session.",
|
||||
object_schema({{"document", text},
|
||||
{"revision", integer},
|
||||
{"operations", {{"type", "array"}, {"items", {{"type", "object"}}}}},
|
||||
{"idempotency_key", text}},
|
||||
{"document", "revision", "operations"}),
|
||||
[&](const Json& args) {
|
||||
return authoring_.transact(args.at("document"), args.at("revision"),
|
||||
args.at("operations"),
|
||||
args.value("idempotency_key", std::string()));
|
||||
});
|
||||
add("faset_undo", "Undo one authoring transaction. Requires the current document revision.",
|
||||
object_schema({{"document", text}, {"revision", integer}}, {"document", "revision"}),
|
||||
[&](const Json& args) {
|
||||
return authoring_.undo(args.at("document"), args.at("revision"));
|
||||
});
|
||||
add("faset_redo", "Redo one authoring transaction. Requires the current document revision.",
|
||||
object_schema({{"document", text}, {"revision", integer}}, {"document", "revision"}),
|
||||
[&](const Json& args) {
|
||||
return authoring_.redo(args.at("document"), args.at("revision"));
|
||||
});
|
||||
add(
|
||||
"faset_template_preview",
|
||||
"Resolve authoring templates and report conflicts without changing source documents. This "
|
||||
"is not a live game query.",
|
||||
object_schema({{"document", text}}, {"document"}),
|
||||
[&](const Json& args) { return resolved_scene(args.at("document")); }, true);
|
||||
add("faset_recovery_restore",
|
||||
"Restore a recovery journal, including an unsaved new scene. If the document is already "
|
||||
"open, pass its current revision. External file changes are never overwritten.",
|
||||
object_schema({{"document", text}, {"revision", integer}}, {"document"}),
|
||||
[&](const Json& args) {
|
||||
return authoring_.recover(
|
||||
args.at("document"),
|
||||
args.contains("revision")
|
||||
? std::optional<std::uint64_t>(args.at("revision").get<std::uint64_t>())
|
||||
: std::nullopt);
|
||||
});
|
||||
add(
|
||||
"faset_recovery_list", "List document recovery records in this project.",
|
||||
object_schema(Json::object()),
|
||||
[&](const Json&) { return Json{{"recovery", authoring_.recovery_documents()}}; }, true);
|
||||
}
|
||||
} // namespace faset::editor
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
#include <faset/editor/mcp.hpp>
|
||||
#include <iostream>
|
||||
#ifdef _WIN32
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <cerrno>
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace faset::editor {
|
||||
namespace {
|
||||
Json rpc_error(Json id, int code, std::string message, Json data = Json::object()) {
|
||||
return {
|
||||
{"jsonrpc", "2.0"},
|
||||
{"id", id},
|
||||
{"error", {{"code", code}, {"message", std::move(message)}, {"data", std::move(data)}}}};
|
||||
}
|
||||
} // namespace
|
||||
Json McpServer::parse_error() const {
|
||||
return rpc_error(nullptr, -32700, "Parse error");
|
||||
}
|
||||
std::optional<Json> McpServer::handle(const Json& message) {
|
||||
if (!message.is_object() || !message.contains("jsonrpc") || message["jsonrpc"] != "2.0" ||
|
||||
!message.contains("method") || !message["method"].is_string())
|
||||
return rpc_error(nullptr, -32600, "Invalid Request");
|
||||
const auto method = message.at("method").get<std::string>();
|
||||
if (!message.contains("id")) {
|
||||
if (method == "notifications/initialized" && initialized_)
|
||||
ready_ = true;
|
||||
return std::nullopt;
|
||||
}
|
||||
const Json id = message.at("id");
|
||||
if (!(id.is_string() || id.is_number_integer()))
|
||||
return rpc_error(nullptr, -32600, "Request ID must be a string or integer");
|
||||
const auto params = message.value("params", Json::object());
|
||||
if (!params.is_object())
|
||||
return rpc_error(id, -32602, "Invalid params");
|
||||
auto result = [&](Json value) {
|
||||
return Json{{"jsonrpc", "2.0"}, {"id", id}, {"result", std::move(value)}};
|
||||
};
|
||||
if (method == "ping")
|
||||
return result(Json::object());
|
||||
if (method == "initialize") {
|
||||
if (initialized_)
|
||||
return rpc_error(id, -32600, "Session already initialized");
|
||||
if (!params.contains("protocolVersion") || !params["protocolVersion"].is_string())
|
||||
return rpc_error(id, -32602, "protocolVersion is required");
|
||||
initialized_ = true;
|
||||
return result(
|
||||
{{"protocolVersion", "2025-06-18"},
|
||||
{"serverInfo", {{"name", "faset-editor"}, {"version", FASET_VERSION}}},
|
||||
{"capabilities", {{"tools", Json::object()}, {"resources", Json::object()}}},
|
||||
{"instructions",
|
||||
"Faset tools edit project documents and manage editor jobs. Query revisions before "
|
||||
"writes. Player/runtime world access is intentionally unavailable."}});
|
||||
}
|
||||
if (!ready_)
|
||||
return rpc_error(id, -32002, "Initialize the session before using editor tools");
|
||||
if (method == "tools/list")
|
||||
return result({{"tools", commands_.list()}});
|
||||
if (method == "tools/call") {
|
||||
if (!params.contains("name") || !params["name"].is_string())
|
||||
return rpc_error(id, -32602, "Tool name is required");
|
||||
try {
|
||||
auto value =
|
||||
commands_.call(params.at("name"), params.value("arguments", Json::object()));
|
||||
Json content = Json::array();
|
||||
if (value.is_object() && value.contains("image_base64")) {
|
||||
content.push_back(
|
||||
{{"type", "image"},
|
||||
{"data", value.at("image_base64")},
|
||||
{"mimeType", value.value("mimeType", std::string("image/png"))}});
|
||||
value.erase("image_base64");
|
||||
}
|
||||
content.push_back({{"type", "text"}, {"text", value.dump()}});
|
||||
return result({{"content", content}, {"structuredContent", value}, {"isError", false}});
|
||||
} catch (const Error& error) {
|
||||
const auto value = Json{{"error", error.json()}};
|
||||
return result({{"content", Json::array({{{"type", "text"}, {"text", value.dump()}}})},
|
||||
{"structuredContent", value},
|
||||
{"isError", true}});
|
||||
} catch (const std::exception& error) {
|
||||
const auto value =
|
||||
Json{{"error", {{"code", "editor.failure"}, {"message", error.what()}}}};
|
||||
return result({{"content", Json::array({{{"type", "text"}, {"text", value.dump()}}})},
|
||||
{"structuredContent", value},
|
||||
{"isError", true}});
|
||||
}
|
||||
}
|
||||
if (method == "resources/list")
|
||||
return result({{"resources", Json::array({{{"uri", "faset://schema"},
|
||||
{"name", "Component schema"},
|
||||
{"mimeType", "application/json"}},
|
||||
{{"uri", "faset://documents"},
|
||||
{"name", "Open authoring documents"},
|
||||
{"mimeType", "application/json"}}})}});
|
||||
if (method == "resources/read") {
|
||||
if (!params.contains("uri") || !params["uri"].is_string())
|
||||
return rpc_error(id, -32602, "Resource URI must be a string");
|
||||
const auto uri = params.at("uri").get<std::string>();
|
||||
Json value;
|
||||
if (uri == "faset://schema")
|
||||
value = commands_.authoring().schemas().manifest();
|
||||
else if (uri == "faset://documents")
|
||||
value = commands_.authoring().documents();
|
||||
else
|
||||
return rpc_error(id, -32602, "Unknown resource URI");
|
||||
return result({{"contents", Json::array({{{"uri", uri},
|
||||
{"mimeType", "application/json"},
|
||||
{"text", value.dump()}}})}});
|
||||
}
|
||||
return rpc_error(id, -32601, "Method not found");
|
||||
}
|
||||
std::vector<std::string> StdioTransport::poll() {
|
||||
std::vector<std::string> lines;
|
||||
if (closed_)
|
||||
return lines;
|
||||
char bytes[65536];
|
||||
std::size_t count = 0;
|
||||
#ifdef _WIN32
|
||||
const auto input = GetStdHandle(STD_INPUT_HANDLE);
|
||||
const auto type = GetFileType(input);
|
||||
DWORD available = 0;
|
||||
if (type == FILE_TYPE_PIPE) {
|
||||
if (!PeekNamedPipe(input, nullptr, 0, nullptr, &available, nullptr)) {
|
||||
closed_ = true;
|
||||
return lines;
|
||||
}
|
||||
} else if (type == FILE_TYPE_DISK)
|
||||
available = sizeof(bytes);
|
||||
else
|
||||
return lines;
|
||||
if (available) {
|
||||
DWORD read = 0;
|
||||
if (!ReadFile(input, bytes, std::min<DWORD>(available, sizeof(bytes)), &read, nullptr) ||
|
||||
read == 0)
|
||||
closed_ = true;
|
||||
count = read;
|
||||
}
|
||||
#else
|
||||
pollfd input{STDIN_FILENO, POLLIN, 0};
|
||||
if (::poll(&input, 1, 0) > 0 && (input.revents & (POLLIN | POLLHUP))) {
|
||||
const auto read = ::read(STDIN_FILENO, bytes, sizeof(bytes));
|
||||
if (read > 0)
|
||||
count = static_cast<std::size_t>(read);
|
||||
else if (read == 0)
|
||||
closed_ = true;
|
||||
else if (errno != EINTR && errno != EAGAIN)
|
||||
closed_ = true;
|
||||
}
|
||||
#endif
|
||||
buffer_.append(bytes, count);
|
||||
require(buffer_.size() <= 8 * 1024 * 1024, "mcp.message_size", "MCP input exceeds 8 MiB");
|
||||
std::size_t newline = 0;
|
||||
while ((newline = buffer_.find('\n')) != std::string::npos) {
|
||||
auto line = buffer_.substr(0, newline);
|
||||
if (!line.empty() && line.back() == '\r')
|
||||
line.pop_back();
|
||||
if (!line.empty())
|
||||
lines.push_back(std::move(line));
|
||||
buffer_.erase(0, newline + 1);
|
||||
}
|
||||
if (closed_ && !buffer_.empty()) {
|
||||
lines.push_back(std::move(buffer_));
|
||||
buffer_.clear();
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
void StdioTransport::send(const Json& value) {
|
||||
std::cout << value.dump() << '\n';
|
||||
std::cout.flush();
|
||||
}
|
||||
} // namespace faset::editor
|
||||
@@ -0,0 +1,358 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/plugin_api.h>
|
||||
#include <faset/editor/plugins.hpp>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
#ifdef _WIN32
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <dlfcn.h>
|
||||
#endif
|
||||
|
||||
namespace faset::editor {
|
||||
namespace {
|
||||
void append(void* context, const char* bytes, std::uint64_t size) {
|
||||
auto& output = *static_cast<std::string*>(context);
|
||||
require(size <= 8 * 1024 * 1024 && output.size() + size <= 8 * 1024 * 1024,
|
||||
"plugin.output_limit", "Plugin response exceeds 8 MiB");
|
||||
output.append(bytes, static_cast<std::size_t>(size));
|
||||
}
|
||||
Json response(const std::string& bytes) {
|
||||
auto value = Json::parse(bytes);
|
||||
require(value.is_object(), "plugin.response", "Plugin response must be a JSON object");
|
||||
return value;
|
||||
}
|
||||
bool identifier(const std::string& value) {
|
||||
return !value.empty() && value.size() <= 128 &&
|
||||
std::all_of(value.begin(), value.end(), [](unsigned char c) {
|
||||
return c < 128 && (std::isalnum(c) || c == '.' || c == '_' || c == '-');
|
||||
});
|
||||
}
|
||||
std::string prefix(std::string id) {
|
||||
for (auto& c : id)
|
||||
if (c == '.' || c == '-')
|
||||
c = '_';
|
||||
return "plugin_" + id + "_";
|
||||
}
|
||||
} // namespace
|
||||
struct PluginManager::Impl {
|
||||
struct Registration {
|
||||
Json descriptor;
|
||||
FasetCommand callback = nullptr;
|
||||
void* user = nullptr;
|
||||
};
|
||||
struct Module {
|
||||
Impl* owner = nullptr;
|
||||
Json manifest;
|
||||
std::filesystem::path directory;
|
||||
std::vector<Registration> commands;
|
||||
Json panels = Json::array();
|
||||
std::vector<std::string> installed;
|
||||
FasetEditorHost host{};
|
||||
FasetEditorPlugin plugin{};
|
||||
void* library = nullptr;
|
||||
bool ready = false;
|
||||
std::string failure;
|
||||
~Module() {
|
||||
ready = false;
|
||||
for (const auto& name : installed)
|
||||
owner->commands.remove(name);
|
||||
if (plugin.shutdown)
|
||||
try {
|
||||
plugin.shutdown(plugin.user);
|
||||
} catch (...) { /* Plugin contract forbids exceptions. */
|
||||
}
|
||||
#ifdef _WIN32
|
||||
if (library)
|
||||
FreeLibrary(static_cast<HMODULE>(library));
|
||||
#else
|
||||
if (library)
|
||||
dlclose(library);
|
||||
#endif
|
||||
}
|
||||
std::string id() const {
|
||||
return manifest.at("id");
|
||||
}
|
||||
void check_thread() const {
|
||||
require(std::this_thread::get_id() == owner->thread, "plugin.thread",
|
||||
"Editor SDK calls must run on the Editor thread");
|
||||
}
|
||||
};
|
||||
Commands& commands;
|
||||
Logger logger;
|
||||
std::thread::id thread = std::this_thread::get_id();
|
||||
std::vector<std::unique_ptr<Module>> modules;
|
||||
Json records = Json::array();
|
||||
bool attempted = false;
|
||||
unsigned call_depth = 0;
|
||||
Impl(Commands& value, Logger output) : commands(value), logger(std::move(output)) {}
|
||||
~Impl() {
|
||||
while (!modules.empty())
|
||||
modules.pop_back();
|
||||
}
|
||||
static int command(void* context, const char* descriptor, FasetCommand callback, void* user) {
|
||||
auto& module = *static_cast<Module*>(context);
|
||||
try {
|
||||
module.check_thread();
|
||||
require(!module.ready, "plugin.registration_closed", "Registrations are startup-only");
|
||||
require(callback, "plugin.callback", "Command callback is missing");
|
||||
const auto value = Json::parse(descriptor);
|
||||
const auto name = value.at("name").get<std::string>();
|
||||
require(name.starts_with(prefix(module.id())) && identifier(name),
|
||||
"plugin.command_owner", "Plugin command must use its module prefix");
|
||||
require(value.at("description").is_string() &&
|
||||
value.at("inputSchema").at("type") == "object" &&
|
||||
value.at("inputSchema").at("properties").is_object(),
|
||||
"plugin.command_schema", "Command requires an object input schema");
|
||||
for (const auto& prior : module.commands)
|
||||
require(prior.descriptor.at("name") != name, "plugin.command_duplicate",
|
||||
"Duplicate plugin command");
|
||||
module.commands.push_back({value, callback, user});
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
module.failure = error.what();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
static int panel(void* context, const char* descriptor) {
|
||||
auto& module = *static_cast<Module*>(context);
|
||||
try {
|
||||
module.check_thread();
|
||||
require(!module.ready, "plugin.registration_closed", "Registrations are startup-only");
|
||||
auto value = Json::parse(descriptor);
|
||||
require(value.at("id").get<std::string>().starts_with(module.id() + "."),
|
||||
"plugin.panel_owner", "Panel ID must belong to its module");
|
||||
require(value.at("title").is_string() && value.at("command").is_string(),
|
||||
"plugin.panel", "Panel needs a title and command");
|
||||
if (!value.contains("arguments"))
|
||||
value["arguments"] = Json::object();
|
||||
require(value["arguments"].is_object(), "plugin.panel",
|
||||
"Panel arguments must be an object");
|
||||
for (const auto& previous : module.panels)
|
||||
require(previous["id"] != value["id"], "plugin.panel_duplicate",
|
||||
"Duplicate panel ID");
|
||||
value["owner"] = module.id();
|
||||
module.panels.push_back(std::move(value));
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
module.failure = error.what();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
static int invoke(void* context, const char* name, const char* arguments, FasetWrite write,
|
||||
void* receiver) {
|
||||
auto& module = *static_cast<Module*>(context);
|
||||
bool entered = false;
|
||||
try {
|
||||
module.check_thread();
|
||||
require(module.ready, "plugin.not_ready",
|
||||
"Editor commands become available after plugin startup");
|
||||
require(module.owner->call_depth < 32, "plugin.recursion",
|
||||
"Plugin command recursion limit exceeded");
|
||||
++module.owner->call_depth;
|
||||
entered = true;
|
||||
const auto output = module.owner->commands.call(name, Json::parse(arguments)).dump();
|
||||
--module.owner->call_depth;
|
||||
entered = false;
|
||||
write(receiver, output.data(), output.size());
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
if (entered)
|
||||
--module.owner->call_depth;
|
||||
const auto* known = dynamic_cast<const Error*>(&error);
|
||||
const auto output =
|
||||
(known ? known->json()
|
||||
: Json{{"code", "plugin.command"}, {"message", error.what()}})
|
||||
.dump();
|
||||
if (write)
|
||||
try {
|
||||
write(receiver, output.data(), output.size());
|
||||
} catch (...) {
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
static void log(void* context, const char* text) {
|
||||
auto& module = *static_cast<Module*>(context);
|
||||
try {
|
||||
module.check_thread();
|
||||
module.owner->logger(module.id() + ": " + text);
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void activate(const Json& manifest, const std::filesystem::path& directory) {
|
||||
auto module = std::make_unique<Module>();
|
||||
module->owner = this;
|
||||
module->manifest = manifest;
|
||||
module->directory = directory;
|
||||
const auto path = project_path(directory, manifest.at("library").get<std::string>());
|
||||
require(std::filesystem::is_regular_file(path), "plugin.library",
|
||||
"Plugin library is missing");
|
||||
#ifdef _WIN32
|
||||
module->library =
|
||||
LoadLibraryExW(path.c_str(), nullptr,
|
||||
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
|
||||
auto entry = module->library
|
||||
? reinterpret_cast<FasetPluginEntry>(GetProcAddress(
|
||||
static_cast<HMODULE>(module->library), "faset_editor_plugin"))
|
||||
: nullptr;
|
||||
#else
|
||||
module->library = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
|
||||
const auto error = module->library ? nullptr : dlerror();
|
||||
require(module->library, "plugin.load", error ? error : "Cannot load plugin library");
|
||||
auto entry =
|
||||
reinterpret_cast<FasetPluginEntry>(dlsym(module->library, "faset_editor_plugin"));
|
||||
#endif
|
||||
require(module->library && entry, "plugin.entry",
|
||||
"Library does not export faset_editor_plugin");
|
||||
module->host = {FASET_EDITOR_API_VERSION,
|
||||
sizeof(FasetEditorHost),
|
||||
FASET_EDITOR_SDK_FINGERPRINT,
|
||||
module.get(),
|
||||
command,
|
||||
panel,
|
||||
invoke,
|
||||
log};
|
||||
require(entry(&module->host, &module->plugin) == 0, "plugin.startup",
|
||||
"Plugin startup failed");
|
||||
require(module->failure.empty(), "plugin.registration", module->failure);
|
||||
require(module->plugin.api_version == FASET_EDITOR_API_VERSION &&
|
||||
module->plugin.struct_size == sizeof(FasetEditorPlugin),
|
||||
"plugin.api", "Plugin returned an incompatible API");
|
||||
require(module->plugin.build_fingerprint &&
|
||||
std::string(module->plugin.build_fingerprint) == FASET_EDITOR_SDK_FINGERPRINT,
|
||||
"plugin.build", "Plugin binary does not match this Editor SDK build");
|
||||
for (const auto& value : module->panels) {
|
||||
const auto name = value.at("command").get<std::string>();
|
||||
require(std::any_of(module->commands.begin(), module->commands.end(),
|
||||
[&](const auto& reg) { return reg.descriptor.at("name") == name; }),
|
||||
"plugin.panel_command", "Panel command must be registered by its owner");
|
||||
}
|
||||
for (const auto& registration : module->commands) {
|
||||
const auto& descriptor = registration.descriptor;
|
||||
const auto name = descriptor.at("name").get<std::string>();
|
||||
commands.add(
|
||||
name, descriptor.at("description"), descriptor.at("inputSchema"),
|
||||
[registration](const Json& arguments) {
|
||||
std::string output;
|
||||
const auto input = arguments.dump();
|
||||
const int result =
|
||||
registration.callback(registration.user, input.c_str(), append, &output);
|
||||
const auto value = response(output);
|
||||
if (result != 0)
|
||||
throw Error(value.value("code", std::string("plugin.failed")),
|
||||
value.value("message", std::string("Plugin command failed")),
|
||||
value);
|
||||
return value;
|
||||
},
|
||||
descriptor.value("read_only", false));
|
||||
module->installed.push_back(name);
|
||||
}
|
||||
module->ready = true;
|
||||
logger("Loaded editor plugin: " + module->id());
|
||||
modules.push_back(std::move(module));
|
||||
}
|
||||
};
|
||||
PluginManager::PluginManager(Commands& commands, Logger logger)
|
||||
: impl_(std::make_unique<Impl>(commands, std::move(logger))) {}
|
||||
PluginManager::~PluginManager() = default;
|
||||
std::string PluginManager::fingerprint() {
|
||||
return FASET_EDITOR_SDK_FINGERPRINT;
|
||||
}
|
||||
Json PluginManager::status() const {
|
||||
return impl_->records;
|
||||
}
|
||||
Json PluginManager::panels() const {
|
||||
Json result = Json::array();
|
||||
for (const auto& module : impl_->modules)
|
||||
for (const auto& panel : module->panels)
|
||||
result.push_back(panel);
|
||||
return result;
|
||||
}
|
||||
void PluginManager::load(const std::filesystem::path& directory) {
|
||||
require(!impl_->attempted, "plugin.restart_required",
|
||||
"Plugin discovery runs once; restart the Editor after changing packages");
|
||||
impl_->attempted = true;
|
||||
if (!std::filesystem::exists(directory))
|
||||
return;
|
||||
struct Source {
|
||||
Json manifest;
|
||||
std::filesystem::path directory;
|
||||
};
|
||||
std::map<std::string, Source> sources;
|
||||
std::set<std::string> invalid;
|
||||
auto failure = [&](const std::string& id, const std::string& message) {
|
||||
invalid.insert(id);
|
||||
impl_->records.push_back({{"id", id}, {"state", "failed"}, {"message", message}});
|
||||
impl_->logger("Plugin " + id + ": " + message);
|
||||
};
|
||||
for (const auto& entry : std::filesystem::recursive_directory_iterator(directory))
|
||||
if (entry.is_regular_file() &&
|
||||
entry.path().filename().string().ends_with(".faset-plugin.json")) {
|
||||
std::string id = entry.path().filename().string();
|
||||
try {
|
||||
const auto manifest = read_json(entry.path());
|
||||
id = manifest.at("id");
|
||||
require(identifier(id), "plugin.id", "Invalid module ID");
|
||||
require(!sources.contains(id), "plugin.duplicate", "Duplicate module ID");
|
||||
require(manifest.at("format") == "faset.editor_plugin" &&
|
||||
manifest.at("version") == 1 && manifest.at("kind") == "editor",
|
||||
"plugin.manifest", "Unsupported editor plugin manifest");
|
||||
require(manifest.at("module_version").is_string() &&
|
||||
manifest.at("dependencies").is_array(),
|
||||
"plugin.manifest", "Plugin needs version and dependency list");
|
||||
require(manifest.at("api_version") == FASET_EDITOR_API_VERSION &&
|
||||
manifest.at("build_fingerprint") == fingerprint(),
|
||||
"plugin.compatibility",
|
||||
"Plugin manifest does not match this Editor SDK; rebuild it");
|
||||
project_path(entry.path().parent_path(), manifest.at("library").get<std::string>());
|
||||
sources.emplace(id, Source{manifest, entry.path().parent_path()});
|
||||
} catch (const std::exception& error) {
|
||||
failure(id, error.what());
|
||||
}
|
||||
}
|
||||
std::map<std::string, int> colors;
|
||||
std::vector<std::string> order;
|
||||
std::function<void(const std::string&)> visit = [&](const std::string& id) {
|
||||
require(sources.contains(id) && !invalid.contains(id), "plugin.dependency",
|
||||
"Missing or invalid dependency: " + id);
|
||||
require(colors[id] != 1, "plugin.cycle", "Plugin dependency cycle at " + id);
|
||||
if (colors[id] == 2)
|
||||
return;
|
||||
colors[id] = 1;
|
||||
for (const auto& dependency : sources.at(id).manifest.at("dependencies")) {
|
||||
const std::string required = dependency.at("id");
|
||||
visit(required);
|
||||
require(sources.at(required).manifest.at("module_version") == dependency.at("version"),
|
||||
"plugin.dependency_version", "Dependency version mismatch: " + required);
|
||||
}
|
||||
colors[id] = 2;
|
||||
order.push_back(id);
|
||||
};
|
||||
for (const auto& [id, source] : sources)
|
||||
try {
|
||||
visit(id);
|
||||
} catch (const std::exception& error) {
|
||||
failure(id, error.what());
|
||||
}
|
||||
std::set<std::string> loaded;
|
||||
for (const auto& id : order)
|
||||
if (!invalid.contains(id))
|
||||
try {
|
||||
const auto& source = sources.at(id);
|
||||
for (const auto& dependency : source.manifest.at("dependencies"))
|
||||
require(loaded.contains(dependency.at("id").get<std::string>()),
|
||||
"plugin.dependency_failed", "A required plugin failed to load");
|
||||
impl_->activate(source.manifest, source.directory);
|
||||
loaded.insert(id);
|
||||
impl_->records.push_back({{"id", id},
|
||||
{"version", source.manifest.at("module_version")},
|
||||
{"state", "loaded"}});
|
||||
} catch (const std::exception& error) {
|
||||
failure(id, error.what());
|
||||
}
|
||||
}
|
||||
} // namespace faset::editor
|
||||
@@ -0,0 +1,358 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/session.hpp>
|
||||
|
||||
namespace faset::editor {
|
||||
namespace {
|
||||
std::string executable_name(const std::string& name) {
|
||||
#ifdef _WIN32
|
||||
return name + ".exe";
|
||||
#else
|
||||
return name;
|
||||
#endif
|
||||
}
|
||||
BuildConfig build_config(const SessionConfig& config) {
|
||||
BuildConfig result;
|
||||
result.project_root = config.project_root;
|
||||
result.engine_root = config.engine_root;
|
||||
result.build_directory = config.project_root / ".faset/build";
|
||||
result.cache_root = config.project_root / ".faset/cache";
|
||||
return result;
|
||||
}
|
||||
Json resolved_or_throw(Commands& commands, const std::string& id) {
|
||||
const auto resolved = commands.resolved_scene(id);
|
||||
if (!resolved.at("conflicts").empty())
|
||||
throw Error("template.conflicts", "Resolve template conflicts before Play or export",
|
||||
{{"conflicts", resolved.at("conflicts")}});
|
||||
return resolved.at("scene");
|
||||
}
|
||||
} // namespace
|
||||
struct Session::ImportTask {
|
||||
std::string id;
|
||||
std::shared_ptr<assets::ImportJob> job = std::make_shared<assets::ImportJob>();
|
||||
mutable std::mutex mutex;
|
||||
std::string state = "queued", error;
|
||||
Json result = Json::object();
|
||||
Json json() const {
|
||||
std::lock_guard lock(mutex);
|
||||
const auto progress = job->progress();
|
||||
return {{"id", id},
|
||||
{"kind", "import"},
|
||||
{"state", state},
|
||||
{"stage", progress.stage},
|
||||
{"progress", progress.fraction},
|
||||
{"error", error},
|
||||
{"result", result}};
|
||||
}
|
||||
};
|
||||
Session::Session(SessionConfig config)
|
||||
: config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_),
|
||||
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)) {
|
||||
register_commands();
|
||||
plugins_ = std::make_unique<PluginManager>(
|
||||
commands_, [this](std::string message) { log(std::move(message)); });
|
||||
plugins_->load(config_.project_root / "Plugins");
|
||||
const auto schema = config_.project_root / ".faset/schema.json";
|
||||
if (std::filesystem::exists(schema))
|
||||
try {
|
||||
load_schema(schema);
|
||||
} catch (const std::exception& error) {
|
||||
log(std::string("Schema load failed: ") + error.what());
|
||||
}
|
||||
}
|
||||
Session::~Session() {
|
||||
for (const auto& [id, task] : imports_)
|
||||
task->job->cancel();
|
||||
workers_.clear();
|
||||
stop_player();
|
||||
}
|
||||
void Session::log(std::string value) {
|
||||
if (value.empty())
|
||||
return;
|
||||
logs_.push_back(std::move(value));
|
||||
if (logs_.size() > 1000)
|
||||
logs_.erase(logs_.begin(), logs_.begin() + 100);
|
||||
}
|
||||
Json Session::project() const {
|
||||
const auto path = config_.project_root / "project.faset.json";
|
||||
if (std::filesystem::exists(path))
|
||||
return read_json(path);
|
||||
return {{"format", "faset.project"},
|
||||
{"version", 1},
|
||||
{"name", config_.project_root.filename().string()},
|
||||
{"dimension", 3}};
|
||||
}
|
||||
void Session::scaffold(const std::string& name, int dimension) {
|
||||
builds_.scaffold(name, dimension);
|
||||
log("Created project: " + name);
|
||||
}
|
||||
Json Session::assets_list() const {
|
||||
Json list = Json::array();
|
||||
const auto directory = assets_.cache_root() / "assets";
|
||||
if (std::filesystem::exists(directory))
|
||||
for (const auto& entry : std::filesystem::directory_iterator(directory))
|
||||
if (entry.is_directory()) {
|
||||
try {
|
||||
const auto id = entry.path().filename().string();
|
||||
const auto manifest = assets_.current_manifest(id);
|
||||
list.push_back({{"id", id}, {"manifest", manifest}});
|
||||
} catch (const std::exception& error) {
|
||||
list.push_back(
|
||||
{{"id", entry.path().filename().string()}, {"error", error.what()}});
|
||||
}
|
||||
}
|
||||
return {{"assets", list}};
|
||||
}
|
||||
Json Session::jobs() const {
|
||||
Json list = Json::array();
|
||||
for (const auto& item : builds_.jobs())
|
||||
list.push_back(item.json());
|
||||
for (const auto& [id, task] : imports_)
|
||||
list.push_back(task->json());
|
||||
return {{"jobs", list}};
|
||||
}
|
||||
Json Session::job(const std::string& id) const {
|
||||
if (imports_.contains(id))
|
||||
return imports_.at(id)->json();
|
||||
return builds_.job(id).json();
|
||||
}
|
||||
void Session::load_schema(const std::filesystem::path& path) {
|
||||
const auto value = read_json(path);
|
||||
authoring_.replace_external_schemas(value);
|
||||
const auto output = config_.project_root / ".faset/schema.json";
|
||||
if (std::filesystem::weakly_canonical(path) != std::filesystem::weakly_canonical(output))
|
||||
atomic_write_json(output, value);
|
||||
log("Gameplay schema loaded");
|
||||
}
|
||||
void Session::launch_player(Json scene, const std::filesystem::path& executable) {
|
||||
require(std::filesystem::is_regular_file(executable), "play.missing_player",
|
||||
"Build the Player before starting Play");
|
||||
const auto directory = config_.project_root / ".faset/play" / new_id();
|
||||
std::filesystem::create_directories(directory);
|
||||
const auto snapshot = directory / "scene.fscene";
|
||||
write_cooked_scene(snapshot, scene);
|
||||
control_path_ = directory / "control.json";
|
||||
control_sequence_ = 0;
|
||||
ProcessOptions options;
|
||||
options.arguments = {
|
||||
executable.string(), "--scene", snapshot.string(), "--assets",
|
||||
assets_.cache_root().string(), "--control", control_path_.string()};
|
||||
options.working_directory = config_.project_root;
|
||||
player_ = std::make_unique<Process>(options);
|
||||
log("Play started in a separate Player process");
|
||||
}
|
||||
void Session::stop_player() {
|
||||
if (!pending_play_job_.empty()) {
|
||||
builds_.cancel(pending_play_job_);
|
||||
pending_play_job_.clear();
|
||||
pending_play_scene_ = nullptr;
|
||||
}
|
||||
if (player_) {
|
||||
player_->cancel();
|
||||
const auto result = player_->poll();
|
||||
log(result.output);
|
||||
player_.reset();
|
||||
log("Play stopped; authoring scene unchanged");
|
||||
}
|
||||
}
|
||||
void Session::poll() {
|
||||
if (player_) {
|
||||
const auto result = player_->poll();
|
||||
log(result.output);
|
||||
if (!result.running) {
|
||||
log("Player exited with code " + std::to_string(result.exit_code.value_or(-1)));
|
||||
player_.reset();
|
||||
}
|
||||
}
|
||||
for (const auto& value : builds_.jobs()) {
|
||||
if (observed_jobs_[value.id] == value.state)
|
||||
continue;
|
||||
observed_jobs_[value.id] = value.state;
|
||||
if (value.state == "failed")
|
||||
log(value.kind + " failed: " + value.error);
|
||||
if (value.state == "succeeded") {
|
||||
log(value.kind + " completed");
|
||||
if (value.result.contains("schema"))
|
||||
try {
|
||||
load_schema(value.result.at("schema").get<std::string>());
|
||||
} catch (const std::exception& error) {
|
||||
log(std::string("Schema update failed: ") + error.what());
|
||||
}
|
||||
}
|
||||
if (value.id == pending_play_job_ && value.finished()) {
|
||||
pending_play_job_.clear();
|
||||
if (value.state == "succeeded")
|
||||
try {
|
||||
const auto executable =
|
||||
value.result.value("player", (builds_.config().build_directory /
|
||||
executable_name("faset_player"))
|
||||
.string());
|
||||
launch_player(pending_play_scene_, executable);
|
||||
} catch (const std::exception& error) {
|
||||
log(std::string("Play failed: ") + error.what());
|
||||
}
|
||||
pending_play_scene_ = nullptr;
|
||||
}
|
||||
}
|
||||
for (const auto& [id, task] : imports_) {
|
||||
const auto value = task->json();
|
||||
const auto status = value.at("state").get<std::string>();
|
||||
if (observed_jobs_[id] == status)
|
||||
continue;
|
||||
observed_jobs_[id] = status;
|
||||
if (status == "succeeded")
|
||||
log("Asset import completed: " + value["result"].value("asset_id", std::string()));
|
||||
if (status == "failed" || status == "conflict")
|
||||
log("Asset import " + status + ": " + value.value("error", std::string()));
|
||||
}
|
||||
}
|
||||
void Session::register_commands() {
|
||||
const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}};
|
||||
auto schema = [](Json properties, Json required = Json::array()) {
|
||||
return Commands::object_schema(std::move(properties), std::move(required));
|
||||
};
|
||||
commands_.add(
|
||||
"faset_capabilities", "Inspect available Editor services and rendering capabilities.",
|
||||
schema(Json::object()),
|
||||
[&](const Json&) {
|
||||
bool screenshot = false;
|
||||
for (const auto& command : commands_.list())
|
||||
if (command.at("name") == "faset_editor_capture")
|
||||
screenshot = true;
|
||||
return Json{{"authoring", true},
|
||||
{"build", true},
|
||||
{"import", true},
|
||||
{"plugins", true},
|
||||
{"viewport_capture", screenshot},
|
||||
{"runtime_entity_access", false},
|
||||
{"protocol", "2025-06-18"}};
|
||||
},
|
||||
true);
|
||||
commands_.add(
|
||||
"faset_plugins", "Inspect startup-loaded Editor plugins and exact SDK compatibility.",
|
||||
schema(Json::object()),
|
||||
[&](const Json&) {
|
||||
return Json{{"sdk_fingerprint", PluginManager::fingerprint()},
|
||||
{"plugins", plugins_->status()},
|
||||
{"panels", plugins_->panels()}};
|
||||
},
|
||||
true);
|
||||
commands_.add(
|
||||
"faset_project", "Read the authoring project's settings.", schema(Json::object()),
|
||||
[&](const Json&) { return project(); }, true);
|
||||
commands_.add(
|
||||
"faset_assets", "List imported asset manifests and resource identities.",
|
||||
schema(Json::object()), [&](const Json&) { return assets_list(); }, true);
|
||||
commands_.add("faset_import",
|
||||
"Import GLB/glTF or a Blender export manifest relative to this project. Returns "
|
||||
"a cancellable job ID; failure retains the last successful generation.",
|
||||
schema({{"path", text},
|
||||
{"settings", {{"type", "object"}}},
|
||||
{"allow_removed_outputs", boolean}},
|
||||
{"path"}),
|
||||
[&](const Json& args) {
|
||||
assets::ImportRequest request;
|
||||
request.source =
|
||||
project_path(config_.project_root, args.at("path").get<std::string>());
|
||||
request.settings = args.value("settings", Json(nullptr));
|
||||
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
|
||||
auto task = std::make_shared<ImportTask>();
|
||||
task->id = "import-" + new_id();
|
||||
imports_[task->id] = task;
|
||||
workers_.emplace_back([this, task, request] {
|
||||
{
|
||||
std::lock_guard lock(task->mutex);
|
||||
task->state = "running";
|
||||
}
|
||||
try {
|
||||
const auto result = assets_.import_asset(request, *task->job);
|
||||
std::lock_guard lock(task->mutex);
|
||||
task->state =
|
||||
result.status == assets::ImportStatus::succeeded ? "succeeded"
|
||||
: result.status == assets::ImportStatus::cancelled ? "cancelled"
|
||||
: result.status == assets::ImportStatus::conflict ? "conflict"
|
||||
: "failed";
|
||||
task->result = {{"asset_id", result.asset_id},
|
||||
{"generation", result.generation},
|
||||
{"diagnostics", result.diagnostics},
|
||||
{"cache_hit", result.cache_hit},
|
||||
{"manifest", result.manifest}};
|
||||
for (const auto& message : result.diagnostics)
|
||||
task->error += message + "\n";
|
||||
} catch (const std::exception& error) {
|
||||
std::lock_guard lock(task->mutex);
|
||||
task->state = "failed";
|
||||
task->error = error.what();
|
||||
}
|
||||
});
|
||||
return Json{{"job", task->id}};
|
||||
});
|
||||
commands_.add("faset_build",
|
||||
"Incrementally compile C++ gameplay and export its metadata in separate native "
|
||||
"processes. Returns a job ID.",
|
||||
schema(Json::object()),
|
||||
[&](const Json&) { return Json{{"job", builds_.start_build()}}; });
|
||||
commands_.add("faset_export",
|
||||
"Build, validate and export a resolved authoring snapshot to a project-relative "
|
||||
"output directory. Returns a job ID.",
|
||||
schema({{"document", text}, {"output", text}}, {"document", "output"}),
|
||||
[&](const Json& args) {
|
||||
return Json{{"job", builds_.start_export(
|
||||
resolved_or_throw(commands_, args.at("document")),
|
||||
project_path(config_.project_root,
|
||||
args.at("output").get<std::string>()))}};
|
||||
});
|
||||
commands_.add(
|
||||
"faset_jobs", "List editor import/build/export jobs and their progress.",
|
||||
schema(Json::object()), [&](const Json&) { return jobs(); }, true);
|
||||
commands_.add(
|
||||
"faset_job", "Read an editor job's progress, result and diagnostics.",
|
||||
schema({{"id", text}}, {"id"}), [&](const Json& args) { return job(args.at("id")); }, true);
|
||||
commands_.add("faset_job_cancel",
|
||||
"Cancel an editor job. Cancellation is separate from authoring Undo.",
|
||||
schema({{"id", text}}, {"id"}), [&](const Json& args) {
|
||||
const auto id = args.at("id").get<std::string>();
|
||||
if (imports_.contains(id))
|
||||
imports_.at(id)->job->cancel();
|
||||
else
|
||||
builds_.cancel(id);
|
||||
return Json{{"cancel_requested", true}};
|
||||
});
|
||||
commands_.add("faset_play",
|
||||
"Build gameplay, then start a separate Player from an immutable snapshot of the "
|
||||
"current authoring document. Returns the build job ID.",
|
||||
schema({{"document", text}}, {"document"}), [&](const Json& args) {
|
||||
stop_player();
|
||||
pending_play_scene_ = resolved_or_throw(commands_, args.at("document"));
|
||||
pending_play_job_ = builds_.start_build();
|
||||
return Json{{"job", pending_play_job_}, {"play_pending", true}};
|
||||
});
|
||||
commands_.add(
|
||||
"faset_stop",
|
||||
"Stop editor Play or cancel its pending build. Does not modify the authoring scene.",
|
||||
schema(Json::object()), [&](const Json&) {
|
||||
stop_player();
|
||||
return Json{{"stopped", true}};
|
||||
});
|
||||
commands_.add("faset_play_control",
|
||||
"Pause, resume, or single-step the Editor's Player session. No game entities or "
|
||||
"state are exposed.",
|
||||
schema({{"command", {{"type", "string"}, {"enum", {"pause", "resume", "step"}}}}},
|
||||
{"command"}),
|
||||
[&](const Json& args) {
|
||||
require(bool(player_), "play.not_running", "Player is not running");
|
||||
const auto command = args.at("command").get<std::string>();
|
||||
require(command == "pause" || command == "resume" || command == "step",
|
||||
"play.command", "Unknown Play control");
|
||||
atomic_write_json(control_path_,
|
||||
{{"sequence", ++control_sequence_}, {"command", command}});
|
||||
return Json{{"queued", true}};
|
||||
});
|
||||
commands_.add(
|
||||
"faset_editor_logs",
|
||||
"Read compiler, importer and process diagnostics collected by this Editor session.",
|
||||
schema(Json::object()), [&](const Json&) { return Json{{"logs", logs_}}; }, true);
|
||||
}
|
||||
} // namespace faset::editor
|
||||
@@ -0,0 +1,378 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <faset/assets/asset_data.hpp>
|
||||
#include <faset/player/SceneView.hpp>
|
||||
#include <limits>
|
||||
#include <numbers>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <unordered_map>
|
||||
#if defined(FASET_HAS_STB)
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#define STBI_NO_STDIO
|
||||
#include <stb_image.h>
|
||||
#endif
|
||||
|
||||
namespace faset::player {
|
||||
namespace {
|
||||
using Json = nlohmann::json;
|
||||
Json properties(const Json& entity, const std::string& name) {
|
||||
if (entity.contains(name))
|
||||
return entity.at(name);
|
||||
if (entity.contains("components"))
|
||||
for (const auto& c : entity["components"])
|
||||
if (c.at("type") == "faset." + name)
|
||||
return c.at("fields");
|
||||
return Json{};
|
||||
}
|
||||
template <std::size_t N>
|
||||
std::array<float, N> vec(const Json& value, const char* name, std::array<float, N> fallback) {
|
||||
if (value.is_null() || !value.contains(name))
|
||||
return fallback;
|
||||
auto result = value.at(name).get<std::array<float, N>>();
|
||||
for (float v : result)
|
||||
if (!std::isfinite(v))
|
||||
throw std::runtime_error("nonfinite scene vector");
|
||||
return result;
|
||||
}
|
||||
render::Vec3 point(const render::Mat4& m, render::Vec3 p) {
|
||||
return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
|
||||
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
|
||||
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]};
|
||||
}
|
||||
render::Vec3 direction(const render::Mat4& m, render::Vec3 p) {
|
||||
return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2],
|
||||
m[2] * p[0] + m[6] * p[1] + m[10] * p[2]};
|
||||
}
|
||||
std::pair<std::string, std::string> reference(const std::string& ref) {
|
||||
const auto hash = ref.find('#');
|
||||
return {ref.substr(0, hash), hash == std::string::npos ? std::string{} : ref.substr(hash + 1)};
|
||||
}
|
||||
std::shared_ptr<const render::Mesh> plane() {
|
||||
static const auto mesh = []() {
|
||||
auto out = std::make_shared<render::Mesh>();
|
||||
out->vertices = {{{-.5f, 0, -.5f}, {0, 1, 0}, {1, 1, 1, 1}, {0, 0}},
|
||||
{{.5f, 0, -.5f}, {0, 1, 0}, {1, 1, 1, 1}, {1, 0}},
|
||||
{{.5f, 0, .5f}, {0, 1, 0}, {1, 1, 1, 1}, {1, 1}},
|
||||
{{-.5f, 0, .5f}, {0, 1, 0}, {1, 1, 1, 1}, {0, 1}}};
|
||||
out->indices = {0, 2, 1, 0, 3, 2};
|
||||
return out;
|
||||
}();
|
||||
return mesh;
|
||||
}
|
||||
} // namespace
|
||||
struct SceneView::Impl {
|
||||
struct Bundle {
|
||||
assets::CookedAsset data;
|
||||
std::vector<std::vector<std::shared_ptr<const render::Mesh>>> meshes;
|
||||
std::vector<std::shared_ptr<const render::Texture>> textures;
|
||||
};
|
||||
assets::AssetStore pipeline;
|
||||
std::unordered_map<std::string, Bundle> bundles;
|
||||
std::vector<std::string> messages;
|
||||
explicit Impl(std::filesystem::path path) : pipeline(std::move(path)) {}
|
||||
Bundle& bundle(const std::string& id) {
|
||||
if (auto it = bundles.find(id); it != bundles.end())
|
||||
return it->second;
|
||||
Bundle result;
|
||||
result.data = pipeline.load_asset(id);
|
||||
for (const auto& mesh : result.data.meshes) {
|
||||
auto& primitives = result.meshes.emplace_back();
|
||||
for (const auto& primitive : mesh.primitives) {
|
||||
auto converted = std::make_shared<render::Mesh>();
|
||||
converted->indices = primitive.indices;
|
||||
converted->vertices.reserve(primitive.vertices.size());
|
||||
for (const auto& vertex : primitive.vertices)
|
||||
converted->vertices.push_back(
|
||||
{vertex.position, vertex.normal, {1, 1, 1, 1}, vertex.uv});
|
||||
primitives.push_back(std::move(converted));
|
||||
}
|
||||
}
|
||||
for (const auto& texture : result.data.textures) {
|
||||
std::shared_ptr<render::Texture> converted;
|
||||
#if defined(FASET_HAS_STB)
|
||||
if (texture.bytes.size() > std::size_t(std::numeric_limits<int>::max()))
|
||||
throw std::runtime_error("Encoded texture exceeds decoder limit");
|
||||
int width = 0, height = 0, channels = 0;
|
||||
if (!stbi_info_from_memory(reinterpret_cast<const unsigned char*>(texture.bytes.data()),
|
||||
static_cast<int>(texture.bytes.size()), &width, &height,
|
||||
&channels))
|
||||
throw std::runtime_error("Cannot read texture dimensions: " + texture.id);
|
||||
if (width <= 0 || height <= 0 || width > 16384 || height > 16384 ||
|
||||
std::uint64_t(width) * std::uint64_t(height) > 64 * 1024 * 1024)
|
||||
throw std::runtime_error("Texture exceeds decoder image limits");
|
||||
auto pixels = stbi_load_from_memory(
|
||||
reinterpret_cast<const unsigned char*>(texture.bytes.data()),
|
||||
static_cast<int>(texture.bytes.size()), &width, &height, &channels, 4);
|
||||
if (!pixels)
|
||||
throw std::runtime_error("Cannot decode texture " + texture.id + ": " +
|
||||
std::string(stbi_failure_reason() ? stbi_failure_reason()
|
||||
: "unsupported image"));
|
||||
std::unique_ptr<unsigned char, decltype(&stbi_image_free)> guard(pixels,
|
||||
&stbi_image_free);
|
||||
if (width <= 0 || height <= 0 || width > 16384 || height > 16384)
|
||||
throw std::runtime_error("Texture exceeds 16384 dimension limit");
|
||||
converted = std::make_shared<render::Texture>();
|
||||
converted->width = width;
|
||||
converted->height = height;
|
||||
converted->srgb = true;
|
||||
converted->rgba.assign(pixels, pixels + std::size_t(width) * std::size_t(height) * 4);
|
||||
#else
|
||||
throw std::runtime_error("Image decoding was not enabled for this Player build");
|
||||
#endif
|
||||
result.textures.push_back(std::move(converted));
|
||||
}
|
||||
return bundles.emplace(id, std::move(result)).first->second;
|
||||
}
|
||||
std::shared_ptr<const render::Texture> texture(const std::string& ref) {
|
||||
auto [id, selector] = reference(ref);
|
||||
auto& asset = bundle(id);
|
||||
if (asset.textures.empty())
|
||||
throw std::runtime_error("Asset has no texture: " + ref);
|
||||
if (selector.empty())
|
||||
return asset.textures.front();
|
||||
for (std::size_t i = 0; i < asset.data.textures.size(); ++i)
|
||||
if (asset.data.textures[i].id == selector)
|
||||
return asset.textures[i];
|
||||
throw std::runtime_error("Texture subasset does not exist: " + ref);
|
||||
}
|
||||
void imported(render::Snapshot& out, const std::string& ref, const render::Mat4& model,
|
||||
render::Color tint) {
|
||||
const auto [id, selector] = reference(ref);
|
||||
auto& asset = bundle(id);
|
||||
auto emit = [&](std::size_t meshIndex, const render::Mat4& local) {
|
||||
if (meshIndex >= asset.meshes.size())
|
||||
throw std::runtime_error("Invalid cooked mesh index");
|
||||
for (std::size_t p = 0; p < asset.meshes[meshIndex].size(); ++p) {
|
||||
render::DrawItem draw;
|
||||
draw.mesh = asset.meshes[meshIndex][p];
|
||||
draw.model = render::multiply(model, local);
|
||||
draw.color = tint;
|
||||
const auto material = asset.data.meshes[meshIndex].primitives[p].material;
|
||||
if (material >= 0) {
|
||||
if (std::size_t(material) >= asset.data.materials.size())
|
||||
throw std::runtime_error("Invalid cooked material index");
|
||||
const auto& m = asset.data.materials[material];
|
||||
for (int i = 0; i < 4; ++i)
|
||||
draw.color[i] *= m.base_color[i];
|
||||
draw.roughness = m.roughness;
|
||||
draw.metallic = m.metallic;
|
||||
if (m.base_color_texture >= 0) {
|
||||
if (std::size_t(m.base_color_texture) >= asset.textures.size())
|
||||
throw std::runtime_error("Invalid base-color texture index");
|
||||
draw.texture = asset.textures[m.base_color_texture];
|
||||
}
|
||||
if (m.normal_texture >= 0 || m.metallic_roughness_texture >= 0 ||
|
||||
m.alpha_mode != "OPAQUE" || m.unlit || m.double_sided)
|
||||
messages.push_back(
|
||||
"warning: material " + m.id +
|
||||
" has features beyond the initial base-color/PBR renderer");
|
||||
}
|
||||
out.draws.push_back(std::move(draw));
|
||||
}
|
||||
};
|
||||
if (!selector.empty())
|
||||
for (std::size_t i = 0; i < asset.data.meshes.size(); ++i)
|
||||
if (asset.data.meshes[i].id == selector) {
|
||||
emit(i, render::identity);
|
||||
return;
|
||||
}
|
||||
std::unordered_map<std::string, const assets::Node*> nodes;
|
||||
for (const auto& node : asset.data.nodes)
|
||||
nodes.emplace(node.id, &node);
|
||||
if (!selector.empty() && !nodes.contains(selector))
|
||||
throw std::runtime_error("Node/mesh subasset does not exist: " + ref);
|
||||
std::unordered_map<std::string, render::Mat4> matrices;
|
||||
std::set<std::string> active;
|
||||
auto world = [&](auto&& self, const assets::Node& node) -> render::Mat4 {
|
||||
if (auto it = matrices.find(node.id); it != matrices.end())
|
||||
return it->second;
|
||||
if (!active.insert(node.id).second)
|
||||
throw std::runtime_error("Cyclic cooked node hierarchy");
|
||||
auto matrix = node.local_transform;
|
||||
if (!node.parent_id.empty()) {
|
||||
auto parent = nodes.find(node.parent_id);
|
||||
if (parent == nodes.end())
|
||||
throw std::runtime_error("Missing cooked parent node");
|
||||
matrix = render::multiply(self(self, *parent->second), matrix);
|
||||
}
|
||||
active.erase(node.id);
|
||||
return matrices.emplace(node.id, matrix).first->second;
|
||||
};
|
||||
if (asset.data.nodes.empty())
|
||||
for (std::size_t i = 0; i < asset.meshes.size(); ++i)
|
||||
emit(i, render::identity);
|
||||
for (const auto& node : asset.data.nodes)
|
||||
if (node.mesh >= 0) {
|
||||
bool selected = selector.empty();
|
||||
auto current = &node;
|
||||
std::set<std::string> seen;
|
||||
while (!selected && current && seen.insert(current->id).second) {
|
||||
selected = current->id == selector;
|
||||
auto parent = nodes.find(current->parent_id);
|
||||
current = parent == nodes.end() ? nullptr : parent->second;
|
||||
}
|
||||
if (selected)
|
||||
emit(static_cast<std::size_t>(node.mesh), world(world, node));
|
||||
}
|
||||
}
|
||||
};
|
||||
SceneView::SceneView(std::filesystem::path cacheRoot)
|
||||
: impl_(std::make_unique<Impl>(std::move(cacheRoot))) {}
|
||||
SceneView::~SceneView() = default;
|
||||
void SceneView::clearCache() {
|
||||
impl_->bundles.clear();
|
||||
}
|
||||
const std::vector<std::string>& SceneView::diagnostics() const {
|
||||
return impl_->messages;
|
||||
}
|
||||
render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSettings camera) {
|
||||
if (!std::isfinite(aspect) || aspect <= 0)
|
||||
throw std::invalid_argument("Viewport aspect must be positive");
|
||||
impl_->messages.clear();
|
||||
render::Snapshot out;
|
||||
const auto& entities = scene.at("entities");
|
||||
if (!entities.is_array())
|
||||
throw std::invalid_argument("Scene entities must be an array");
|
||||
std::unordered_map<std::string, const Json*> byId;
|
||||
for (const auto& entity : entities)
|
||||
if (!byId.emplace(entity.at("id").get<std::string>(), &entity).second)
|
||||
throw std::invalid_argument("Duplicate scene ID");
|
||||
std::unordered_map<std::string, render::Mat4> matrices;
|
||||
std::set<std::string> active;
|
||||
auto world = [&](auto&& self, const Json& entity) -> render::Mat4 {
|
||||
auto id = entity.at("id").get<std::string>();
|
||||
if (auto it = matrices.find(id); it != matrices.end())
|
||||
return it->second;
|
||||
if (!active.insert(id).second)
|
||||
throw std::invalid_argument("Cyclic scene hierarchy");
|
||||
const auto fields = properties(entity, "transform");
|
||||
auto matrix = render::transform(vec<3>(fields, "position", {0, 0, 0}),
|
||||
vec<3>(fields, "rotation", {0, 0, 0}),
|
||||
vec<3>(fields, "scale", {1, 1, 1}));
|
||||
if (entity.contains("parent") && !entity["parent"].is_null()) {
|
||||
auto parent = byId.find(entity["parent"].get<std::string>());
|
||||
if (parent == byId.end())
|
||||
throw std::invalid_argument("Missing scene parent");
|
||||
matrix = render::multiply(self(self, *parent->second), matrix);
|
||||
}
|
||||
active.erase(id);
|
||||
return matrices.emplace(id, matrix).first->second;
|
||||
};
|
||||
const int dimension = scene.value("dimension", 3);
|
||||
if (dimension != 2 && dimension != 3)
|
||||
throw std::invalid_argument("Scene dimension must be 2 or 3");
|
||||
render::Vec3 cameraUp{0, 1, 0};
|
||||
bool foundCamera = false;
|
||||
std::vector<std::pair<int, render::Sprite>> sprites;
|
||||
for (const auto& entity : entities) {
|
||||
const auto model = world(world, entity);
|
||||
if (auto fields = properties(entity, "camera");
|
||||
!fields.is_null() && !camera.overrideSceneCamera && !foundCamera) {
|
||||
camera.eye = point(model, {0, 0, 0});
|
||||
camera.target = point(model, {0, 0, -1});
|
||||
cameraUp = direction(model, {0, 1, 0});
|
||||
camera.verticalFovDegrees = fields.value("fov", 60.0f);
|
||||
camera.nearPlane = fields.value("near", 0.1f);
|
||||
camera.farPlane = fields.value("far", 1000.0f);
|
||||
foundCamera = true;
|
||||
}
|
||||
if (auto fields = properties(entity, "light"); !fields.is_null())
|
||||
out.light_direction = direction(model, {-0.5f, -1, -0.3f});
|
||||
if (auto fields = properties(entity, "sprite"); !fields.is_null()) {
|
||||
render::Sprite sprite;
|
||||
sprite.position = point(model, {0, 0, 0});
|
||||
auto size = vec<2>(fields, "size", {1, 1});
|
||||
float sx = std::hypot(model[0], model[1]), sy = std::hypot(model[4], model[5]);
|
||||
sprite.size = {size[0] * sx, size[1] * sy};
|
||||
sprite.rotation = std::atan2(model[1], model[0]);
|
||||
sprite.color = vec<4>(fields, "color", {1, 1, 1, 1});
|
||||
if (model[0] * model[5] - model[1] * model[4] < 0)
|
||||
sprite.size[1] = -sprite.size[1];
|
||||
if (sx > 0 && sy > 0 &&
|
||||
std::abs((model[0] * model[4] + model[1] * model[5]) / (sx * sy)) > 0.0001f)
|
||||
impl_->messages.push_back("warning: sprite hierarchy shear is approximated");
|
||||
auto texture = fields.value("texture", std::string{});
|
||||
if (!texture.empty())
|
||||
try {
|
||||
sprite.texture = impl_->texture(texture);
|
||||
} catch (const std::exception& e) {
|
||||
impl_->messages.push_back("error: " + std::string(e.what()));
|
||||
sprite.color = {1, 0, 1, 1};
|
||||
}
|
||||
sprites.emplace_back(fields.value("layer", 0), std::move(sprite));
|
||||
}
|
||||
if (auto fields = properties(entity, "mesh"); !fields.is_null()) {
|
||||
const auto tint = vec<4>(fields, "color", {1, 1, 1, 1});
|
||||
const auto asset = fields.value("asset", std::string{});
|
||||
if (asset.empty() || asset.starts_with("builtin:")) {
|
||||
const auto primitive = asset.empty()
|
||||
? fields.value("primitive", std::string("cube"))
|
||||
: asset.substr(8);
|
||||
if (primitive != "plane" && primitive != "cube")
|
||||
throw std::invalid_argument("Unsupported builtin mesh: " + primitive);
|
||||
out.draws.push_back({primitive == "plane" ? plane() : render::cube_mesh(),
|
||||
model,
|
||||
tint,
|
||||
0.65f,
|
||||
0.0f,
|
||||
true,
|
||||
{}});
|
||||
} else
|
||||
try {
|
||||
impl_->imported(out, asset, model, tint);
|
||||
} catch (const std::exception& e) {
|
||||
impl_->messages.push_back("error: " + std::string(e.what()));
|
||||
out.draws.push_back(
|
||||
{render::cube_mesh(), model, {1, 0, 1, 1}, 0.65f, 0.0f, true, {}});
|
||||
}
|
||||
}
|
||||
}
|
||||
std::stable_sort(sprites.begin(), sprites.end(),
|
||||
[](const auto& a, const auto& b) { return a.first < b.first; });
|
||||
for (auto& pair : sprites)
|
||||
out.sprites.push_back(std::move(pair.second));
|
||||
if (dimension == 2) {
|
||||
const float height = camera.orthographicHeight;
|
||||
if (!std::isfinite(height) || height <= 0)
|
||||
throw std::invalid_argument("Orthographic height must be positive");
|
||||
const auto center = foundCamera ? camera.eye : camera.target;
|
||||
out.view_projection =
|
||||
render::multiply(render::orthographic(-height * aspect / 2, height * aspect / 2,
|
||||
-height / 2, height / 2, -100, 100),
|
||||
render::transform({-center[0], -center[1], 0}));
|
||||
out.eye = {center[0], center[1], 10};
|
||||
} else {
|
||||
if (!std::isfinite(camera.verticalFovDegrees) || camera.verticalFovDegrees <= 0 ||
|
||||
camera.verticalFovDegrees >= 179)
|
||||
throw std::invalid_argument("Camera FOV out of range");
|
||||
if (!std::isfinite(camera.nearPlane) || !std::isfinite(camera.farPlane) ||
|
||||
camera.nearPlane <= 0 || camera.farPlane <= camera.nearPlane)
|
||||
throw std::invalid_argument("Camera depth range is invalid");
|
||||
float distance = 0;
|
||||
render::Vec3 delta{};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if (!std::isfinite(camera.eye[i]) || !std::isfinite(camera.target[i]) ||
|
||||
!std::isfinite(cameraUp[i]))
|
||||
throw std::invalid_argument("Camera basis must be finite");
|
||||
delta[i] = camera.target[i] - camera.eye[i];
|
||||
distance += delta[i] * delta[i];
|
||||
}
|
||||
const render::Vec3 cross{delta[1] * cameraUp[2] - delta[2] * cameraUp[1],
|
||||
delta[2] * cameraUp[0] - delta[0] * cameraUp[2],
|
||||
delta[0] * cameraUp[1] - delta[1] * cameraUp[0]};
|
||||
if (distance < 1e-10f ||
|
||||
cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2] < 1e-10f)
|
||||
throw std::invalid_argument("Camera basis is degenerate");
|
||||
out.eye = camera.eye;
|
||||
out.view_projection = render::multiply(
|
||||
render::perspective(camera.verticalFovDegrees * std::numbers::pi_v<float> / 180, aspect,
|
||||
camera.nearPlane, camera.farPlane),
|
||||
render::look_at(camera.eye, camera.target, cameraUp));
|
||||
}
|
||||
std::sort(impl_->messages.begin(), impl_->messages.end());
|
||||
impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()),
|
||||
impl_->messages.end());
|
||||
return out;
|
||||
}
|
||||
} // namespace faset::player
|
||||
@@ -0,0 +1,32 @@
|
||||
#include <cstring>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/player/SceneView.hpp>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace faset::player {
|
||||
nlohmann::json readScene(const std::filesystem::path& path) {
|
||||
if (!std::filesystem::is_regular_file(path))
|
||||
throw std::runtime_error("Scene file does not exist: " + path.string());
|
||||
if (std::filesystem::file_size(path) > 256 * 1024 * 1024)
|
||||
throw std::runtime_error("Scene exceeds the 256 MiB reader limit");
|
||||
const auto bytes = faset::read_text(path);
|
||||
if (bytes.size() >= 8 && std::memcmp(bytes.data(), "FASETSCN", 8) == 0) {
|
||||
if (bytes.size() < 20)
|
||||
throw std::runtime_error("Truncated cooked scene header");
|
||||
std::uint32_t version = 0;
|
||||
std::uint64_t size = 0;
|
||||
for (int i = 0; i < 4; ++i)
|
||||
version |= std::uint32_t(static_cast<unsigned char>(bytes[8 + i])) << (8 * i);
|
||||
for (int i = 0; i < 8; ++i)
|
||||
size |= std::uint64_t(static_cast<unsigned char>(bytes[12 + i])) << (8 * i);
|
||||
if (version != 1)
|
||||
throw std::runtime_error("Unsupported cooked scene version");
|
||||
if (size != bytes.size() - 20)
|
||||
throw std::runtime_error("Cooked scene payload size mismatch");
|
||||
return nlohmann::json::from_cbor(bytes.begin() + 20, bytes.end(), true, true);
|
||||
}
|
||||
if (path.extension() == ".fscene")
|
||||
throw std::runtime_error("Cooked scene magic is invalid");
|
||||
return nlohmann::json::parse(bytes);
|
||||
}
|
||||
} // namespace faset::player
|
||||
+80
-19
@@ -1,26 +1,87 @@
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <cmath>
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <stdexcept>
|
||||
namespace faset::render {
|
||||
namespace {
|
||||
Vec3 sub(Vec3 a, Vec3 b) { return {a[0]-b[0],a[1]-b[1],a[2]-b[2]}; }
|
||||
float dot(Vec3 a,Vec3 b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];}
|
||||
Vec3 cross(Vec3 a,Vec3 b){return {a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]};}
|
||||
Vec3 unit(Vec3 a){float l=std::sqrt(dot(a,a)); if(l<1e-6f) throw std::invalid_argument("Degenerate camera axis"); return {a[0]/l,a[1]/l,a[2]/l};}
|
||||
Vec3 sub(Vec3 a, Vec3 b) {
|
||||
return {a[0] - b[0], a[1] - b[1], a[2] - b[2]};
|
||||
}
|
||||
Mat4 multiply(const Mat4& a,const Mat4& b){Mat4 r{};for(int c=0;c<4;++c)for(int y=0;y<4;++y)for(int k=0;k<4;++k)r[c*4+y]+=a[k*4+y]*b[c*4+k];return r;}
|
||||
Mat4 transform(Vec3 p,Vec3 r,Vec3 s){
|
||||
const float 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]);
|
||||
Mat4 x{1,0,0,0,0,cx,sx,0,0,-sx,cx,0,0,0,0,1};
|
||||
Mat4 y{cy,0,-sy,0,0,1,0,0,sy,0,cy,0,0,0,0,1};
|
||||
Mat4 z{cz,sz,0,0,-sz,cz,0,0,0,0,1,0,0,0,0,1};
|
||||
auto m=multiply(z,multiply(y,x));for(int c=0;c<3;++c)for(int i=0;i<3;++i)m[c*4+i]*=s[c];m[12]=p[0];m[13]=p[1];m[14]=p[2];return m;
|
||||
float dot(Vec3 a, Vec3 b) {
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
}
|
||||
Mat4 perspective(float fov,float aspect,float n,float f){if(aspect<=0||n<=0||f<=n)throw std::invalid_argument("Invalid perspective volume");float q=1/std::tan(fov*.5f);return {q/aspect,0,0,0,0,-q,0,0,0,0,f/(n-f),-1,0,0,n*f/(n-f),0};}
|
||||
Mat4 orthographic(float l,float r,float b,float t,float n,float f){if(r==l||t==b||f==n)throw std::invalid_argument("Invalid orthographic volume");return {2/(r-l),0,0,0,0,-2/(t-b),0,0,0,0,1/(n-f),0,-(r+l)/(r-l),(t+b)/(t-b),n/(n-f),1};}
|
||||
Mat4 look_at(Vec3 e,Vec3 t,Vec3 up){auto f=unit(sub(t,e));auto s=unit(cross(f,up));auto u=cross(s,f);return {s[0],u[0],-f[0],0,s[1],u[1],-f[1],0,s[2],u[2],-f[2],0,-dot(s,e),-dot(u,e),dot(f,e),1};}
|
||||
std::shared_ptr<const Mesh> cube_mesh(){static auto mesh=[](){auto m=std::make_shared<Mesh>();
|
||||
const Vec3 normals[]={{0,0,1},{0,0,-1},{1,0,0},{-1,0,0},{0,1,0},{0,-1,0}};
|
||||
const Vec3 points[][4]={{{-.5f,-.5f,.5f},{.5f,-.5f,.5f},{.5f,.5f,.5f},{-.5f,.5f,.5f}},{{.5f,-.5f,-.5f},{-.5f,-.5f,-.5f},{-.5f,.5f,-.5f},{.5f,.5f,-.5f}},{{.5f,-.5f,.5f},{.5f,-.5f,-.5f},{.5f,.5f,-.5f},{.5f,.5f,.5f}},{{-.5f,-.5f,-.5f},{-.5f,-.5f,.5f},{-.5f,.5f,.5f},{-.5f,.5f,-.5f}},{{-.5f,.5f,.5f},{.5f,.5f,.5f},{.5f,.5f,-.5f},{-.5f,.5f,-.5f}},{{-.5f,-.5f,-.5f},{.5f,-.5f,-.5f},{.5f,-.5f,.5f},{-.5f,-.5f,.5f}}};
|
||||
for(int face=0;face<6;++face){for(auto p:points[face])m->vertices.push_back({p,normals[face],{1,1,1,1}});for(auto i:{0u,1u,2u,0u,2u,3u})m->indices.push_back(face*4+i);}return m;}();return mesh;}
|
||||
Vec3 cross(Vec3 a, Vec3 b) {
|
||||
return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]};
|
||||
}
|
||||
Vec3 unit(Vec3 a) {
|
||||
float l = std::sqrt(dot(a, a));
|
||||
if (l < 1e-6f)
|
||||
throw std::invalid_argument("Degenerate camera axis");
|
||||
return {a[0] / l, a[1] / l, a[2] / l};
|
||||
}
|
||||
} // namespace
|
||||
Mat4 multiply(const Mat4& a, const Mat4& b) {
|
||||
Mat4 r{};
|
||||
for (int c = 0; c < 4; ++c)
|
||||
for (int y = 0; y < 4; ++y)
|
||||
for (int k = 0; k < 4; ++k)
|
||||
r[c * 4 + y] += a[k * 4 + y] * b[c * 4 + k];
|
||||
return r;
|
||||
}
|
||||
Mat4 transform(Vec3 p, Vec3 r, Vec3 s) {
|
||||
const float 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]);
|
||||
Mat4 x{1, 0, 0, 0, 0, cx, sx, 0, 0, -sx, cx, 0, 0, 0, 0, 1};
|
||||
Mat4 y{cy, 0, -sy, 0, 0, 1, 0, 0, sy, 0, cy, 0, 0, 0, 0, 1};
|
||||
Mat4 z{cz, sz, 0, 0, -sz, cz, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
|
||||
auto m = multiply(z, multiply(y, x));
|
||||
for (int c = 0; c < 3; ++c)
|
||||
for (int i = 0; i < 3; ++i)
|
||||
m[c * 4 + i] *= s[c];
|
||||
m[12] = p[0];
|
||||
m[13] = p[1];
|
||||
m[14] = p[2];
|
||||
return m;
|
||||
}
|
||||
Mat4 perspective(float fov, float aspect, float n, float f) {
|
||||
if (aspect <= 0 || n <= 0 || f <= n)
|
||||
throw std::invalid_argument("Invalid perspective volume");
|
||||
float q = 1 / std::tan(fov * .5f);
|
||||
return {q / aspect, 0, 0, 0, 0, -q, 0, 0, 0, 0, f / (n - f), -1, 0, 0, n * f / (n - f), 0};
|
||||
}
|
||||
Mat4 orthographic(float l, float r, float b, float t, float n, float f) {
|
||||
if (r == l || t == b || f == n)
|
||||
throw std::invalid_argument("Invalid orthographic volume");
|
||||
return {2 / (r - l), 0, 0, 0, 0, -2 / (t - b), 0,
|
||||
0, 0, 0, 1 / (n - f), 0, -(r + l) / (r - l), (t + b) / (t - b),
|
||||
n / (n - f), 1};
|
||||
}
|
||||
Mat4 look_at(Vec3 e, Vec3 t, Vec3 up) {
|
||||
auto f = unit(sub(t, e));
|
||||
auto s = unit(cross(f, up));
|
||||
auto u = cross(s, f);
|
||||
return {s[0], u[0], -f[0], 0, s[1], u[1], -f[1], 0,
|
||||
s[2], u[2], -f[2], 0, -dot(s, e), -dot(u, e), dot(f, e), 1};
|
||||
}
|
||||
std::shared_ptr<const Mesh> cube_mesh() {
|
||||
static auto mesh = []() {
|
||||
auto m = std::make_shared<Mesh>();
|
||||
const Vec3 normals[] = {{0, 0, 1}, {0, 0, -1}, {1, 0, 0},
|
||||
{-1, 0, 0}, {0, 1, 0}, {0, -1, 0}};
|
||||
const Vec3 points[][4] = {
|
||||
{{-.5f, -.5f, .5f}, {.5f, -.5f, .5f}, {.5f, .5f, .5f}, {-.5f, .5f, .5f}},
|
||||
{{.5f, -.5f, -.5f}, {-.5f, -.5f, -.5f}, {-.5f, .5f, -.5f}, {.5f, .5f, -.5f}},
|
||||
{{.5f, -.5f, .5f}, {.5f, -.5f, -.5f}, {.5f, .5f, -.5f}, {.5f, .5f, .5f}},
|
||||
{{-.5f, -.5f, -.5f}, {-.5f, -.5f, .5f}, {-.5f, .5f, .5f}, {-.5f, .5f, -.5f}},
|
||||
{{-.5f, .5f, .5f}, {.5f, .5f, .5f}, {.5f, .5f, -.5f}, {-.5f, .5f, -.5f}},
|
||||
{{-.5f, -.5f, -.5f}, {.5f, -.5f, -.5f}, {.5f, -.5f, .5f}, {-.5f, -.5f, .5f}}};
|
||||
for (int face = 0; face < 6; ++face) {
|
||||
for (auto p : points[face])
|
||||
m->vertices.push_back({p, normals[face], {1, 1, 1, 1}});
|
||||
for (auto i : {0u, 1u, 2u, 0u, 2u, 3u})
|
||||
m->indices.push_back(face * 4 + i);
|
||||
}
|
||||
return m;
|
||||
}();
|
||||
return mesh;
|
||||
}
|
||||
} // namespace faset::render
|
||||
|
||||
+21
-10
@@ -3,25 +3,36 @@
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
namespace faset::render {
|
||||
void RenderGraph::import(std::string resource) { imports_.push_back(std::move(resource)); }
|
||||
void RenderGraph::add(std::string name, std::vector<std::string> reads, std::vector<std::string> writes, Callback execute) {
|
||||
if (name.empty() || !execute) throw std::invalid_argument("RenderGraph pass requires a name and callback");
|
||||
for (const auto& pass : passes_) if (pass.name == name) throw std::invalid_argument("Duplicate RenderGraph pass: " + name);
|
||||
passes_.push_back({std::move(name),std::move(reads),std::move(writes),std::move(execute)});
|
||||
void RenderGraph::import(std::string resource) {
|
||||
imports_.push_back(std::move(resource));
|
||||
}
|
||||
void RenderGraph::add(std::string name, std::vector<std::string> reads,
|
||||
std::vector<std::string> writes, Callback execute) {
|
||||
if (name.empty() || !execute)
|
||||
throw std::invalid_argument("RenderGraph pass requires a name and callback");
|
||||
for (const auto& pass : passes_)
|
||||
if (pass.name == name)
|
||||
throw std::invalid_argument("Duplicate RenderGraph pass: " + name);
|
||||
passes_.push_back({std::move(name), std::move(reads), std::move(writes), std::move(execute)});
|
||||
}
|
||||
void RenderGraph::execute() const {
|
||||
std::unordered_set<std::string> available(imports_.begin(), imports_.end());
|
||||
// Validate the whole graph before recording any GPU work.
|
||||
for (const auto& pass : passes_) {
|
||||
for (const auto& resource : pass.reads)
|
||||
if (!available.contains(resource)) throw std::runtime_error("RenderGraph pass '" + pass.name + "' reads uninitialized resource '" + resource + "'");
|
||||
for (const auto& resource : pass.writes) available.insert(resource);
|
||||
if (!available.contains(resource))
|
||||
throw std::runtime_error("RenderGraph pass '" + pass.name +
|
||||
"' reads uninitialized resource '" + resource + "'");
|
||||
for (const auto& resource : pass.writes)
|
||||
available.insert(resource);
|
||||
}
|
||||
for (const auto& pass : passes_) pass.callback();
|
||||
for (const auto& pass : passes_)
|
||||
pass.callback();
|
||||
}
|
||||
std::vector<std::string> RenderGraph::pass_names() const {
|
||||
std::vector<std::string> result;
|
||||
for (const auto& pass : passes_) result.push_back(pass.name);
|
||||
for (const auto& pass : passes_)
|
||||
result.push_back(pass.name);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} // namespace faset::render
|
||||
|
||||
+1396
-222
File diff suppressed because it is too large
Load Diff
+207
-65
@@ -1,7 +1,7 @@
|
||||
#include "Physics.hpp"
|
||||
#include <algorithm>
|
||||
#include <box2d/box2d.h>
|
||||
#include <box3d/box3d.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#include <unordered_map>
|
||||
@@ -15,103 +15,245 @@ b3Quat quaternion(Vec3 e) {
|
||||
return b3MulQuat(z, b3MulQuat(y, x));
|
||||
}
|
||||
Vec3 euler(b3Quat q) {
|
||||
const float x=q.v.x, y=q.v.y, z=q.v.z, w=q.s;
|
||||
return {std::atan2(2*(w*x+y*z), 1-2*(x*x+y*y)),
|
||||
std::asin(std::clamp(2*(w*y-z*x), -1.0f, 1.0f)),
|
||||
std::atan2(2*(w*z+x*y), 1-2*(y*y+z*z))};
|
||||
}
|
||||
const float x = q.v.x, y = q.v.y, z = q.v.z, w = q.s;
|
||||
return {std::atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)),
|
||||
std::asin(std::clamp(2 * (w * y - z * x), -1.0f, 1.0f)),
|
||||
std::atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))};
|
||||
}
|
||||
} // namespace
|
||||
struct Physics::Impl {
|
||||
struct Body { b2BodyId two{}; b3BodyId three{}; std::uint64_t shape{}; bool dynamic{}; };
|
||||
struct Body {
|
||||
b2BodyId two{};
|
||||
b3BodyId three{};
|
||||
std::uint64_t shape{};
|
||||
bool dynamic{};
|
||||
bool contactQueryValid{};
|
||||
};
|
||||
int dimension;
|
||||
int substeps;
|
||||
Vec3 up{0, 1, 0};
|
||||
b2WorldId world2{};
|
||||
b3WorldId world3{};
|
||||
std::unordered_map<std::uint32_t, Body> bodies;
|
||||
std::unordered_map<std::uint64_t, std::uint32_t> shapes;
|
||||
Impl(int dim, Vec3 gravity, int count):dimension(dim),substeps(count) {
|
||||
if(dim==2) { auto def=b2DefaultWorldDef(); def.gravity={gravity[0],gravity[1]}; world2=b2CreateWorld(&def); }
|
||||
else { auto def=b3DefaultWorldDef(); def.gravity={gravity[0],gravity[1],gravity[2]}; world3=b3CreateWorld(&def); }
|
||||
Impl(int dim, Vec3 gravity, int count) : dimension(dim), substeps(count) {
|
||||
const float length = std::sqrt(gravity[0] * gravity[0] + gravity[1] * gravity[1] +
|
||||
(dim == 3 ? gravity[2] * gravity[2] : 0));
|
||||
if (length > 0.00001f)
|
||||
up = {-gravity[0] / length, -gravity[1] / length, dim == 3 ? -gravity[2] / length : 0};
|
||||
if (dim == 2) {
|
||||
auto def = b2DefaultWorldDef();
|
||||
def.gravity = {gravity[0], gravity[1]};
|
||||
world2 = b2CreateWorld(&def);
|
||||
} else {
|
||||
auto def = b3DefaultWorldDef();
|
||||
def.gravity = {gravity[0], gravity[1], gravity[2]};
|
||||
world3 = b3CreateWorld(&def);
|
||||
}
|
||||
}
|
||||
~Impl() {
|
||||
if (dimension == 2)
|
||||
b2DestroyWorld(world2);
|
||||
else
|
||||
b3DestroyWorld(world3);
|
||||
}
|
||||
~Impl() { if(dimension==2) b2DestroyWorld(world2); else b3DestroyWorld(world3); }
|
||||
};
|
||||
Physics::Physics(int dimension, Vec3 gravity, int substeps):impl_(std::make_unique<Impl>(dimension,gravity,substeps)){}
|
||||
Physics::~Physics()=default;
|
||||
Physics::Physics(int dimension, Vec3 gravity, int substeps)
|
||||
: impl_(std::make_unique<Impl>(dimension, gravity, substeps)) {}
|
||||
Physics::~Physics() = default;
|
||||
void Physics::add(std::uint32_t id, const Transform& t, const BodySettings& settings) {
|
||||
if(contains(id)) throw std::logic_error("physics body already exists");
|
||||
Impl::Body body{}; body.dynamic=settings.type=="dynamic";
|
||||
if(impl_->dimension==2) {
|
||||
auto def=b2DefaultBodyDef();
|
||||
def.type=settings.type=="static"?b2_staticBody:settings.type=="kinematic"?b2_kinematicBody:b2_dynamicBody;
|
||||
def.position={t.position[0],t.position[1]}; def.rotation=b2MakeRot(t.rotation[2]);
|
||||
def.linearVelocity={settings.linearVelocity[0],settings.linearVelocity[1]}; def.gravityScale=settings.gravityScale;
|
||||
body.two=b2CreateBody(impl_->world2,&def);
|
||||
auto shape=b2DefaultShapeDef(); shape.density=settings.density; shape.material.friction=settings.friction;
|
||||
shape.material.restitution=settings.restitution; shape.enableContactEvents=true;
|
||||
shape.filter.categoryBits=settings.categoryBits; shape.filter.maskBits=settings.maskBits;
|
||||
const auto box=b2MakeBox(settings.halfExtents[0]*std::abs(t.scale[0]),settings.halfExtents[1]*std::abs(t.scale[1]));
|
||||
body.shape=b2StoreShapeId(b2CreatePolygonShape(body.two,&shape,&box));
|
||||
if (contains(id))
|
||||
throw std::logic_error("physics body already exists");
|
||||
Impl::Body body{};
|
||||
body.dynamic = settings.type == "dynamic";
|
||||
if (impl_->dimension == 2) {
|
||||
auto def = b2DefaultBodyDef();
|
||||
def.type = settings.type == "static" ? b2_staticBody
|
||||
: settings.type == "kinematic" ? b2_kinematicBody
|
||||
: b2_dynamicBody;
|
||||
def.position = {t.position[0], t.position[1]};
|
||||
def.rotation = b2MakeRot(t.rotation[2]);
|
||||
def.linearVelocity = {settings.linearVelocity[0], settings.linearVelocity[1]};
|
||||
def.gravityScale = settings.gravityScale;
|
||||
body.two = b2CreateBody(impl_->world2, &def);
|
||||
auto shape = b2DefaultShapeDef();
|
||||
shape.density = settings.density;
|
||||
shape.material.friction = settings.friction;
|
||||
shape.material.restitution = settings.restitution;
|
||||
shape.enableContactEvents = true;
|
||||
shape.filter.categoryBits = settings.categoryBits;
|
||||
shape.filter.maskBits = settings.maskBits;
|
||||
const auto box = b2MakeBox(settings.halfExtents[0] * std::abs(t.scale[0]),
|
||||
settings.halfExtents[1] * std::abs(t.scale[1]));
|
||||
body.shape = b2StoreShapeId(b2CreatePolygonShape(body.two, &shape, &box));
|
||||
} else {
|
||||
auto def=b3DefaultBodyDef();
|
||||
def.type=settings.type=="static"?b3_staticBody:settings.type=="kinematic"?b3_kinematicBody:b3_dynamicBody;
|
||||
def.position={t.position[0],t.position[1],t.position[2]}; def.rotation=quaternion(t.rotation);
|
||||
def.linearVelocity={settings.linearVelocity[0],settings.linearVelocity[1],settings.linearVelocity[2]}; def.gravityScale=settings.gravityScale;
|
||||
body.three=b3CreateBody(impl_->world3,&def);
|
||||
auto shape=b3DefaultShapeDef(); shape.density=settings.density; shape.baseMaterial.friction=settings.friction;
|
||||
shape.baseMaterial.restitution=settings.restitution; shape.enableContactEvents=true;
|
||||
shape.filter.categoryBits=settings.categoryBits; shape.filter.maskBits=settings.maskBits;
|
||||
auto box=b3MakeBoxHull(settings.halfExtents[0]*std::abs(t.scale[0]),settings.halfExtents[1]*std::abs(t.scale[1]),settings.halfExtents[2]*std::abs(t.scale[2]));
|
||||
body.shape=b3StoreShapeId(b3CreateHullShape(body.three,&shape,&box.base));
|
||||
auto def = b3DefaultBodyDef();
|
||||
def.type = settings.type == "static" ? b3_staticBody
|
||||
: settings.type == "kinematic" ? b3_kinematicBody
|
||||
: b3_dynamicBody;
|
||||
def.position = {t.position[0], t.position[1], t.position[2]};
|
||||
def.rotation = quaternion(t.rotation);
|
||||
def.linearVelocity = {settings.linearVelocity[0], settings.linearVelocity[1],
|
||||
settings.linearVelocity[2]};
|
||||
def.gravityScale = settings.gravityScale;
|
||||
body.three = b3CreateBody(impl_->world3, &def);
|
||||
auto shape = b3DefaultShapeDef();
|
||||
shape.density = settings.density;
|
||||
shape.baseMaterial.friction = settings.friction;
|
||||
shape.baseMaterial.restitution = settings.restitution;
|
||||
shape.enableContactEvents = true;
|
||||
shape.filter.categoryBits = settings.categoryBits;
|
||||
shape.filter.maskBits = settings.maskBits;
|
||||
auto box = b3MakeBoxHull(settings.halfExtents[0] * std::abs(t.scale[0]),
|
||||
settings.halfExtents[1] * std::abs(t.scale[1]),
|
||||
settings.halfExtents[2] * std::abs(t.scale[2]));
|
||||
body.shape = b3StoreShapeId(b3CreateHullShape(body.three, &shape, &box.base));
|
||||
}
|
||||
impl_->shapes.emplace(body.shape,id); impl_->bodies.emplace(id,body);
|
||||
impl_->shapes.emplace(body.shape, id);
|
||||
impl_->bodies.emplace(id, body);
|
||||
}
|
||||
void Physics::remove(std::uint32_t id) {
|
||||
const auto it=impl_->bodies.find(id); if(it==impl_->bodies.end()) return;
|
||||
const auto it = impl_->bodies.find(id);
|
||||
if (it == impl_->bodies.end())
|
||||
return;
|
||||
impl_->shapes.erase(it->second.shape);
|
||||
if(impl_->dimension==2) b2DestroyBody(it->second.two); else b3DestroyBody(it->second.three);
|
||||
if (impl_->dimension == 2)
|
||||
b2DestroyBody(it->second.two);
|
||||
else
|
||||
b3DestroyBody(it->second.three);
|
||||
impl_->bodies.erase(it);
|
||||
}
|
||||
bool Physics::contains(std::uint32_t id) const { return impl_->bodies.contains(id); }
|
||||
bool Physics::dynamic(std::uint32_t id) const { return impl_->bodies.at(id).dynamic; }
|
||||
bool Physics::contains(std::uint32_t id) const {
|
||||
return impl_->bodies.contains(id);
|
||||
}
|
||||
bool Physics::dynamic(std::uint32_t id) const {
|
||||
return impl_->bodies.at(id).dynamic;
|
||||
}
|
||||
Transform Physics::transform(std::uint32_t id, Transform t) const {
|
||||
const auto& body=impl_->bodies.at(id);
|
||||
if(impl_->dimension==2) { auto p=b2Body_GetPosition(body.two); t.position[0]=p.x;t.position[1]=p.y;t.rotation[2]=b2Rot_GetAngle(b2Body_GetRotation(body.two)); }
|
||||
else { auto p=b3Body_GetPosition(body.three);t.position={float(p.x),float(p.y),float(p.z)};t.rotation=euler(b3Body_GetRotation(body.three)); }
|
||||
const auto& body = impl_->bodies.at(id);
|
||||
if (impl_->dimension == 2) {
|
||||
auto p = b2Body_GetPosition(body.two);
|
||||
t.position[0] = p.x;
|
||||
t.position[1] = p.y;
|
||||
t.rotation[2] = b2Rot_GetAngle(b2Body_GetRotation(body.two));
|
||||
} else {
|
||||
auto p = b3Body_GetPosition(body.three);
|
||||
t.position = {float(p.x), float(p.y), float(p.z)};
|
||||
t.rotation = euler(b3Body_GetRotation(body.three));
|
||||
}
|
||||
return t;
|
||||
}
|
||||
Vec3 Physics::velocity(std::uint32_t id) const {
|
||||
const auto& body=impl_->bodies.at(id);
|
||||
if(impl_->dimension==2) { auto v=b2Body_GetLinearVelocity(body.two);return {v.x,v.y,0}; }
|
||||
auto v=b3Body_GetLinearVelocity(body.three);return {v.x,v.y,v.z};
|
||||
const auto& body = impl_->bodies.at(id);
|
||||
if (impl_->dimension == 2) {
|
||||
auto v = b2Body_GetLinearVelocity(body.two);
|
||||
return {v.x, v.y, 0};
|
||||
}
|
||||
auto v = b3Body_GetLinearVelocity(body.three);
|
||||
return {v.x, v.y, v.z};
|
||||
}
|
||||
bool Physics::grounded(std::uint32_t id) const {
|
||||
const auto& body = impl_->bodies.at(id);
|
||||
if (!body.contactQueryValid)
|
||||
return false;
|
||||
auto supports = [&](Vec3 normal, float sign) {
|
||||
return sign * (normal[0] * impl_->up[0] + normal[1] * impl_->up[1] +
|
||||
normal[2] * impl_->up[2]) >
|
||||
0.6f;
|
||||
};
|
||||
if (impl_->dimension == 2) {
|
||||
const int capacity = b2Body_GetContactCapacity(body.two);
|
||||
if (capacity <= 0)
|
||||
return false;
|
||||
std::vector<b2ContactData> contacts(static_cast<std::size_t>(capacity));
|
||||
const int count = b2Body_GetContactData(body.two, contacts.data(), capacity);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const auto& c = contacts[i];
|
||||
const float sign = b2StoreShapeId(c.shapeIdA) == body.shape ? -1.0f : 1.0f;
|
||||
if (!supports({c.manifold.normal.x, c.manifold.normal.y, 0}, sign))
|
||||
continue;
|
||||
for (int p = 0; p < c.manifold.pointCount; ++p)
|
||||
if (c.manifold.points[p].separation <= 0.02f)
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
const int capacity = b3Body_GetContactCapacity(body.three);
|
||||
if (capacity <= 0)
|
||||
return false;
|
||||
std::vector<b3ContactData> contacts(static_cast<std::size_t>(capacity));
|
||||
const int count = b3Body_GetContactData(body.three, contacts.data(), capacity);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const auto& c = contacts[i];
|
||||
const float sign = b3StoreShapeId(c.shapeIdA) == body.shape ? -1.0f : 1.0f;
|
||||
// Box3D's manifold pointer is consumed now and never retained.
|
||||
for (int m = 0; m < c.manifoldCount; ++m) {
|
||||
const auto& manifold = c.manifolds[m];
|
||||
if (!supports({manifold.normal.x, manifold.normal.y, manifold.normal.z}, sign))
|
||||
continue;
|
||||
for (int p = 0; p < manifold.pointCount; ++p)
|
||||
if (manifold.points[p].separation <= 0.02f)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void Physics::teleport(std::uint32_t id, const Transform& t) {
|
||||
const auto& body=impl_->bodies.at(id);
|
||||
if(impl_->dimension==2) b2Body_SetTransform(body.two,{t.position[0],t.position[1]},b2MakeRot(t.rotation[2]));
|
||||
else b3Body_SetTransform(body.three,{t.position[0],t.position[1],t.position[2]},quaternion(t.rotation));
|
||||
auto& body = impl_->bodies.at(id);
|
||||
body.contactQueryValid = false;
|
||||
if (impl_->dimension == 2) {
|
||||
b2Body_SetTransform(body.two, {t.position[0], t.position[1]}, b2MakeRot(t.rotation[2]));
|
||||
b2Body_SetAwake(body.two, true);
|
||||
} else {
|
||||
b3Body_SetTransform(body.three, {t.position[0], t.position[1], t.position[2]},
|
||||
quaternion(t.rotation));
|
||||
b3Body_SetAwake(body.three, true);
|
||||
}
|
||||
}
|
||||
void Physics::setVelocity(std::uint32_t id, Vec3 v) {
|
||||
const auto& body=impl_->bodies.at(id);
|
||||
if(impl_->dimension==2) b2Body_SetLinearVelocity(body.two,{v[0],v[1]}); else b3Body_SetLinearVelocity(body.three,{v[0],v[1],v[2]});
|
||||
const auto& body = impl_->bodies.at(id);
|
||||
if (impl_->dimension == 2)
|
||||
b2Body_SetLinearVelocity(body.two, {v[0], v[1]});
|
||||
else
|
||||
b3Body_SetLinearVelocity(body.three, {v[0], v[1], v[2]});
|
||||
}
|
||||
void Physics::impulse(std::uint32_t id, Vec3 v) {
|
||||
const auto& body=impl_->bodies.at(id);
|
||||
if(impl_->dimension==2) b2Body_ApplyLinearImpulseToCenter(body.two,{v[0],v[1]},true); else b3Body_ApplyLinearImpulseToCenter(body.three,{v[0],v[1],v[2]},true);
|
||||
const auto& body = impl_->bodies.at(id);
|
||||
if (impl_->dimension == 2)
|
||||
b2Body_ApplyLinearImpulseToCenter(body.two, {v[0], v[1]}, true);
|
||||
else
|
||||
b3Body_ApplyLinearImpulseToCenter(body.three, {v[0], v[1], v[2]}, true);
|
||||
}
|
||||
std::vector<Contact> Physics::step(float delta) {
|
||||
std::vector<Contact> contacts;
|
||||
auto append=[&](std::uint64_t a,std::uint64_t b,bool began) {
|
||||
auto first=impl_->shapes.find(a),second=impl_->shapes.find(b);
|
||||
if(first!=impl_->shapes.end() && second!=impl_->shapes.end()) contacts.push_back({first->second,second->second,began});
|
||||
auto append = [&](std::uint64_t a, std::uint64_t b, bool began) {
|
||||
auto first = impl_->shapes.find(a), second = impl_->shapes.find(b);
|
||||
if (first != impl_->shapes.end() && second != impl_->shapes.end())
|
||||
contacts.push_back({first->second, second->second, began});
|
||||
};
|
||||
if(impl_->dimension==2) {
|
||||
b2World_Step(impl_->world2,delta,impl_->substeps);auto events=b2World_GetContactEvents(impl_->world2);
|
||||
for(int i=0;i<events.beginCount;++i) append(b2StoreShapeId(events.beginEvents[i].shapeIdA),b2StoreShapeId(events.beginEvents[i].shapeIdB),true);
|
||||
for(int i=0;i<events.endCount;++i) append(b2StoreShapeId(events.endEvents[i].shapeIdA),b2StoreShapeId(events.endEvents[i].shapeIdB),false);
|
||||
if (impl_->dimension == 2) {
|
||||
b2World_Step(impl_->world2, delta, impl_->substeps);
|
||||
auto events = b2World_GetContactEvents(impl_->world2);
|
||||
for (int i = 0; i < events.beginCount; ++i)
|
||||
append(b2StoreShapeId(events.beginEvents[i].shapeIdA),
|
||||
b2StoreShapeId(events.beginEvents[i].shapeIdB), true);
|
||||
for (int i = 0; i < events.endCount; ++i)
|
||||
append(b2StoreShapeId(events.endEvents[i].shapeIdA),
|
||||
b2StoreShapeId(events.endEvents[i].shapeIdB), false);
|
||||
} else {
|
||||
b3World_Step(impl_->world3,delta,impl_->substeps);auto events=b3World_GetContactEvents(impl_->world3);
|
||||
for(int i=0;i<events.beginCount;++i) append(b3StoreShapeId(events.beginEvents[i].shapeIdA),b3StoreShapeId(events.beginEvents[i].shapeIdB),true);
|
||||
for(int i=0;i<events.endCount;++i) append(b3StoreShapeId(events.endEvents[i].shapeIdA),b3StoreShapeId(events.endEvents[i].shapeIdB),false);
|
||||
b3World_Step(impl_->world3, delta, impl_->substeps);
|
||||
auto events = b3World_GetContactEvents(impl_->world3);
|
||||
for (int i = 0; i < events.beginCount; ++i)
|
||||
append(b3StoreShapeId(events.beginEvents[i].shapeIdA),
|
||||
b3StoreShapeId(events.beginEvents[i].shapeIdB), true);
|
||||
for (int i = 0; i < events.endCount; ++i)
|
||||
append(b3StoreShapeId(events.endEvents[i].shapeIdA),
|
||||
b3StoreShapeId(events.endEvents[i].shapeIdB), false);
|
||||
}
|
||||
for (auto& [id, body] : impl_->bodies) {
|
||||
(void)id;
|
||||
body.contactQueryValid = true;
|
||||
}
|
||||
return contacts;
|
||||
}
|
||||
}
|
||||
} // namespace faset::runtime::detail
|
||||
|
||||
+10
-4
@@ -14,9 +14,13 @@ struct BodySettings {
|
||||
std::uint64_t categoryBits{1};
|
||||
std::uint64_t maskBits{~std::uint64_t{0}};
|
||||
};
|
||||
struct Contact { std::uint32_t first; std::uint32_t second; bool began; };
|
||||
struct Contact {
|
||||
std::uint32_t first;
|
||||
std::uint32_t second;
|
||||
bool began;
|
||||
};
|
||||
class Physics {
|
||||
public:
|
||||
public:
|
||||
Physics(int dimension, Vec3 gravity, int substeps);
|
||||
~Physics();
|
||||
void add(std::uint32_t id, const Transform&, const BodySettings&);
|
||||
@@ -25,12 +29,14 @@ public:
|
||||
bool dynamic(std::uint32_t id) const;
|
||||
Transform transform(std::uint32_t id, Transform previous) const;
|
||||
Vec3 velocity(std::uint32_t id) const;
|
||||
bool grounded(std::uint32_t id) const;
|
||||
void teleport(std::uint32_t id, const Transform&);
|
||||
void setVelocity(std::uint32_t id, Vec3);
|
||||
void impulse(std::uint32_t id, Vec3);
|
||||
std::vector<Contact> step(float delta);
|
||||
private:
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
}
|
||||
} // namespace faset::runtime::detail
|
||||
|
||||
+702
-223
@@ -1,10 +1,10 @@
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include "Physics.hpp"
|
||||
#include <entt/entt.hpp>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <deque>
|
||||
#include <entt/entt.hpp>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <numbers>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
@@ -12,282 +12,761 @@
|
||||
|
||||
namespace faset::runtime {
|
||||
namespace {
|
||||
using Json=nlohmann::json;
|
||||
using Json = nlohmann::json;
|
||||
std::atomic<std::uint64_t> nextSession{1};
|
||||
constexpr const char* body2="faset.rigid_body_2d";
|
||||
constexpr const char* body3="faset.rigid_body_3d";
|
||||
void require(bool condition,const std::string& message) { if(!condition) throw std::invalid_argument(message); }
|
||||
template<std::size_t N> std::array<float,N> vectorValue(const Json& object,const char* key,std::array<float,N> fallback) {
|
||||
if(!object.contains(key)) return fallback;
|
||||
const auto& value=object.at(key); require(value.is_array()&&value.size()==N,std::string(key)+": wrong vector size");
|
||||
for(std::size_t i=0;i<N;++i) { require(value[i].is_number(),std::string(key)+": expected number"); fallback[i]=value[i].get<float>(); require(std::isfinite(fallback[i]),std::string(key)+": nonfinite value"); }
|
||||
constexpr const char* body2 = "faset.rigid_body_2d";
|
||||
constexpr const char* body3 = "faset.rigid_body_3d";
|
||||
void require(bool condition, const std::string& message) {
|
||||
if (!condition)
|
||||
throw std::invalid_argument(message);
|
||||
}
|
||||
template <std::size_t N>
|
||||
std::array<float, N> vectorValue(const Json& object, const char* key,
|
||||
std::array<float, N> fallback) {
|
||||
if (!object.contains(key))
|
||||
return fallback;
|
||||
const auto& value = object.at(key);
|
||||
require(value.is_array() && value.size() == N, std::string(key) + ": wrong vector size");
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
require(value[i].is_number(), std::string(key) + ": expected number");
|
||||
fallback[i] = value[i].get<float>();
|
||||
require(std::isfinite(fallback[i]), std::string(key) + ": nonfinite value");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
float number(const Json& fields,const char* key,float fallback) {
|
||||
if(!fields.contains(key)) return fallback;
|
||||
require(fields.at(key).is_number(),std::string(key)+": expected number");
|
||||
float v=fields.at(key).get<float>();require(std::isfinite(v),std::string(key)+": nonfinite value");return v;
|
||||
float number(const Json& fields, const char* key, float fallback) {
|
||||
if (!fields.contains(key))
|
||||
return fallback;
|
||||
require(fields.at(key).is_number(), std::string(key) + ": expected number");
|
||||
float v = fields.at(key).get<float>();
|
||||
require(std::isfinite(v), std::string(key) + ": nonfinite value");
|
||||
return v;
|
||||
}
|
||||
Transform readTransform(const Json& fields) {
|
||||
return {vectorValue<3>(fields,"position",{0,0,0}),vectorValue<3>(fields,"rotation",{0,0,0}),vectorValue<3>(fields,"scale",{1,1,1})};
|
||||
return {vectorValue<3>(fields, "position", {0, 0, 0}),
|
||||
vectorValue<3>(fields, "rotation", {0, 0, 0}),
|
||||
vectorValue<3>(fields, "scale", {1, 1, 1})};
|
||||
}
|
||||
void validateTransform(const Transform& t) {
|
||||
for(const auto& values:{t.position,t.rotation,t.scale}) for(float value:values) require(std::isfinite(value),"nonfinite transform");
|
||||
for (const auto& values : {t.position, t.rotation, t.scale})
|
||||
for (float value : values)
|
||||
require(std::isfinite(value), "nonfinite transform");
|
||||
}
|
||||
Json transformJson(const Transform& t) { return {{"position",t.position},{"rotation",t.rotation},{"scale",t.scale}}; }
|
||||
detail::BodySettings settings(const Json& fields,int dimension) {
|
||||
Json transformJson(const Transform& t) {
|
||||
return {{"position", t.position}, {"rotation", t.rotation}, {"scale", t.scale}};
|
||||
}
|
||||
detail::BodySettings settings(const Json& fields, int dimension) {
|
||||
detail::BodySettings b;
|
||||
b.type=fields.value("body_type",std::string("dynamic"));require(b.type=="dynamic"||b.type=="static"||b.type=="kinematic","invalid body_type");
|
||||
if(dimension==2) {
|
||||
auto half=vectorValue<2>(fields,"half_extents",{0.5f,0.5f});b.halfExtents={half[0],half[1],0.5f};
|
||||
auto vel=vectorValue<2>(fields,"linear_velocity",{0,0});b.linearVelocity={vel[0],vel[1],0};
|
||||
} else { b.halfExtents=vectorValue<3>(fields,"half_extents",{0.5f,0.5f,0.5f});b.linearVelocity=vectorValue<3>(fields,"linear_velocity",{0,0,0}); }
|
||||
for(float extent:b.halfExtents) require(extent>0&&extent<100000,"half_extents must be positive and finite");
|
||||
b.density=number(fields,"density",1); b.friction=number(fields,"friction",0.3f);
|
||||
b.restitution=number(fields,"restitution",0);b.gravityScale=number(fields,"gravity_scale",1);
|
||||
require(b.density>0&&b.friction>=0&&b.restitution>=0&&b.restitution<=1,"invalid physics material");
|
||||
auto bits=[&](const char* name,std::uint64_t fallback) { if(!fields.contains(name))return fallback; const auto& value=fields.at(name);require(value.is_number_unsigned()||(value.is_number_integer()&&value.get<std::int64_t>()>=0),std::string(name)+": expected nonnegative bits");return value.get<std::uint64_t>(); };
|
||||
b.categoryBits=bits("category_bits",1);b.maskBits=bits("mask_bits",~std::uint64_t{0});return b;
|
||||
}
|
||||
void validateEntity(const Json& entity,int dimension) {
|
||||
require(entity.is_object(),"entity must be an object");
|
||||
require(entity.contains("id")&&entity["id"].is_string()&&!entity["id"].get<std::string>().empty(),"entity requires id");
|
||||
require(!entity.contains("name")||entity["name"].is_string(),"entity name must be a string");
|
||||
require(entity.contains("components")&&entity["components"].is_array(),"entity requires components array");
|
||||
if(entity.contains("parent"))require(entity["parent"].is_null()||entity["parent"].is_string(),"parent must be an id or null");
|
||||
std::set<std::string> types, ids;Transform transform{};bool physical=false;
|
||||
for(const auto& component:entity["components"]) {
|
||||
require(component.is_object()&&component.contains("id")&&component["id"].is_string()&&!component["id"].get<std::string>().empty(),"component requires id");
|
||||
require(component.contains("type")&&component["type"].is_string()&&!component["type"].get<std::string>().empty(),"component requires type");
|
||||
require(component.value("version",1)==1,"unsupported component version");
|
||||
require(component.contains("fields")&&component["fields"].is_object(),"component requires fields");
|
||||
require(ids.insert(component["id"].get<std::string>()).second,"duplicate component id");
|
||||
const auto type=component["type"].get<std::string>();require(types.insert(type).second,"duplicate component type");
|
||||
const auto& f=component["fields"];
|
||||
if(type=="faset.transform")transform=readTransform(f);
|
||||
if(type==body2||type==body3) { require(type==(dimension==2?body2:body3),"physics dimension does not match scene");settings(f,dimension);physical=true; }
|
||||
if(type=="faset.sprite") {vectorValue<4>(f,"color",{1,1,1,1});auto size=vectorValue<2>(f,"size",{1,1});require(size[0]>0&&size[1]>0,"sprite size must be positive");require(!f.contains("texture")||f["texture"].is_string(),"sprite texture must be a string");require(!f.contains("layer")||f["layer"].is_number_integer(),"sprite layer must be an integer");}
|
||||
if(type=="faset.mesh") {vectorValue<4>(f,"color",{1,1,1,1});require(!f.contains("asset")||f["asset"].is_string(),"mesh asset must be a string");require(!f.contains("primitive")||f["primitive"].is_string(),"mesh primitive must be a string");}
|
||||
b.type = fields.value("body_type", std::string("dynamic"));
|
||||
require(b.type == "dynamic" || b.type == "static" || b.type == "kinematic",
|
||||
"invalid body_type");
|
||||
if (dimension == 2) {
|
||||
auto half = vectorValue<2>(fields, "half_extents", {0.5f, 0.5f});
|
||||
b.halfExtents = {half[0], half[1], 0.5f};
|
||||
auto vel = vectorValue<2>(fields, "linear_velocity", {0, 0});
|
||||
b.linearVelocity = {vel[0], vel[1], 0};
|
||||
} else {
|
||||
b.halfExtents = vectorValue<3>(fields, "half_extents", {0.5f, 0.5f, 0.5f});
|
||||
b.linearVelocity = vectorValue<3>(fields, "linear_velocity", {0, 0, 0});
|
||||
}
|
||||
if(physical) {
|
||||
require(!entity.contains("parent")||entity["parent"].is_null(),"physics bodies must be root entities in the initial runtime");
|
||||
for(int i=0;i<dimension;++i)require(std::abs(transform.scale[i])>0.00001f,"physics scale must be nonzero");
|
||||
if(dimension==2) require(transform.rotation[0]==0&&transform.rotation[1]==0,"2D physics rotates only around Z");
|
||||
}
|
||||
}
|
||||
Transform interpolate(const Transform& a,const Transform& b,float alpha) {
|
||||
Transform out;
|
||||
for(int i=0;i<3;++i) {
|
||||
out.position[i]=std::lerp(a.position[i],b.position[i],alpha);out.scale[i]=std::lerp(a.scale[i],b.scale[i],alpha);
|
||||
}
|
||||
auto quaternion=[](Vec3 r) {
|
||||
const float cx=std::cos(r[0]*0.5f),sx=std::sin(r[0]*0.5f),cy=std::cos(r[1]*0.5f),sy=std::sin(r[1]*0.5f),cz=std::cos(r[2]*0.5f),sz=std::sin(r[2]*0.5f);
|
||||
return Vec4{sx*cy*cz-cx*sy*sz,cx*sy*cz+sx*cy*sz,cx*cy*sz-sx*sy*cz,cx*cy*cz+sx*sy*sz};
|
||||
for (float extent : b.halfExtents)
|
||||
require(extent > 0 && extent < 100000, "half_extents must be positive and finite");
|
||||
b.density = number(fields, "density", 1);
|
||||
b.friction = number(fields, "friction", 0.3f);
|
||||
b.restitution = number(fields, "restitution", 0);
|
||||
b.gravityScale = number(fields, "gravity_scale", 1);
|
||||
require(b.density > 0 && b.friction >= 0 && b.restitution >= 0 && b.restitution <= 1,
|
||||
"invalid physics material");
|
||||
auto bits = [&](const char* name, std::uint64_t fallback) {
|
||||
if (!fields.contains(name))
|
||||
return fallback;
|
||||
const auto& value = fields.at(name);
|
||||
require(value.is_number_unsigned() ||
|
||||
(value.is_number_integer() && value.get<std::int64_t>() >= 0),
|
||||
std::string(name) + ": expected nonnegative bits");
|
||||
return value.get<std::uint64_t>();
|
||||
};
|
||||
auto qa=quaternion(a.rotation),qb=quaternion(b.rotation);float dot=0;
|
||||
for(int i=0;i<4;++i)dot+=qa[i]*qb[i];
|
||||
if(dot<0){for(auto& q:qb)q=-q;dot=-dot;}
|
||||
float wa=1-alpha,wb=alpha;
|
||||
if(dot<0.9995f){const float angle=std::acos(std::clamp(dot,-1.0f,1.0f)),denom=std::sin(angle);wa=std::sin((1-alpha)*angle)/denom;wb=std::sin(alpha*angle)/denom;}
|
||||
Vec4 q{};float length=0;for(int i=0;i<4;++i){q[i]=wa*qa[i]+wb*qb[i];length+=q[i]*q[i];}for(auto& v:q)v/=std::sqrt(length);
|
||||
const auto [x,y,z,w]=q;
|
||||
out.rotation={std::atan2(2*(w*x+y*z),1-2*(x*x+y*y)),std::asin(std::clamp(2*(w*y-z*x),-1.0f,1.0f)),std::atan2(2*(w*z+x*y),1-2*(y*y+z*z))};
|
||||
b.categoryBits = bits("category_bits", 1);
|
||||
b.maskBits = bits("mask_bits", ~std::uint64_t{0});
|
||||
return b;
|
||||
}
|
||||
void validateEntity(const Json& entity, int dimension) {
|
||||
require(entity.is_object(), "entity must be an object");
|
||||
require(entity.contains("id") && entity["id"].is_string() &&
|
||||
!entity["id"].get<std::string>().empty(),
|
||||
"entity requires id");
|
||||
require(!entity.contains("name") || entity["name"].is_string(), "entity name must be a string");
|
||||
require(entity.contains("components") && entity["components"].is_array(),
|
||||
"entity requires components array");
|
||||
if (entity.contains("parent"))
|
||||
require(entity["parent"].is_null() || entity["parent"].is_string(),
|
||||
"parent must be an id or null");
|
||||
std::set<std::string> types, ids;
|
||||
Transform transform{};
|
||||
bool physical = false;
|
||||
for (const auto& component : entity["components"]) {
|
||||
require(component.is_object() && component.contains("id") && component["id"].is_string() &&
|
||||
!component["id"].get<std::string>().empty(),
|
||||
"component requires id");
|
||||
require(component.contains("type") && component["type"].is_string() &&
|
||||
!component["type"].get<std::string>().empty(),
|
||||
"component requires type");
|
||||
require(component.value("version", 1) == 1, "unsupported component version");
|
||||
require(component.contains("fields") && component["fields"].is_object(),
|
||||
"component requires fields");
|
||||
require(ids.insert(component["id"].get<std::string>()).second, "duplicate component id");
|
||||
const auto type = component["type"].get<std::string>();
|
||||
require(types.insert(type).second, "duplicate component type");
|
||||
const auto& f = component["fields"];
|
||||
if (type == "faset.transform")
|
||||
transform = readTransform(f);
|
||||
if (type == body2 || type == body3) {
|
||||
require(type == (dimension == 2 ? body2 : body3),
|
||||
"physics dimension does not match scene");
|
||||
settings(f, dimension);
|
||||
physical = true;
|
||||
}
|
||||
if (type == "faset.sprite") {
|
||||
vectorValue<4>(f, "color", {1, 1, 1, 1});
|
||||
auto size = vectorValue<2>(f, "size", {1, 1});
|
||||
require(size[0] > 0 && size[1] > 0, "sprite size must be positive");
|
||||
require(!f.contains("texture") || f["texture"].is_string(),
|
||||
"sprite texture must be a string");
|
||||
require(!f.contains("layer") || f["layer"].is_number_integer(),
|
||||
"sprite layer must be an integer");
|
||||
}
|
||||
if (type == "faset.mesh") {
|
||||
vectorValue<4>(f, "color", {1, 1, 1, 1});
|
||||
require(!f.contains("asset") || f["asset"].is_string(), "mesh asset must be a string");
|
||||
require(!f.contains("primitive") || f["primitive"].is_string(),
|
||||
"mesh primitive must be a string");
|
||||
}
|
||||
}
|
||||
if (physical) {
|
||||
require(!entity.contains("parent") || entity["parent"].is_null(),
|
||||
"physics bodies must be root entities in the initial runtime");
|
||||
for (int i = 0; i < dimension; ++i)
|
||||
require(std::abs(transform.scale[i]) > 0.00001f, "physics scale must be nonzero");
|
||||
if (dimension == 2)
|
||||
require(transform.rotation[0] == 0 && transform.rotation[1] == 0,
|
||||
"2D physics rotates only around Z");
|
||||
}
|
||||
}
|
||||
Transform interpolate(const Transform& a, const Transform& b, float alpha) {
|
||||
Transform out;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
out.position[i] = std::lerp(a.position[i], b.position[i], alpha);
|
||||
out.scale[i] = std::lerp(a.scale[i], b.scale[i], alpha);
|
||||
}
|
||||
auto quaternion = [](Vec3 r) {
|
||||
const float cx = std::cos(r[0] * 0.5f), sx = std::sin(r[0] * 0.5f),
|
||||
cy = std::cos(r[1] * 0.5f), sy = std::sin(r[1] * 0.5f),
|
||||
cz = std::cos(r[2] * 0.5f), sz = std::sin(r[2] * 0.5f);
|
||||
return Vec4{sx * cy * cz - cx * sy * sz, cx * sy * cz + sx * cy * sz,
|
||||
cx * cy * sz - sx * sy * cz, cx * cy * cz + sx * sy * sz};
|
||||
};
|
||||
auto qa = quaternion(a.rotation), qb = quaternion(b.rotation);
|
||||
float dot = 0;
|
||||
for (int i = 0; i < 4; ++i)
|
||||
dot += qa[i] * qb[i];
|
||||
if (dot < 0) {
|
||||
for (auto& q : qb)
|
||||
q = -q;
|
||||
dot = -dot;
|
||||
}
|
||||
float wa = 1 - alpha, wb = alpha;
|
||||
if (dot < 0.9995f) {
|
||||
const float angle = std::acos(std::clamp(dot, -1.0f, 1.0f)), denom = std::sin(angle);
|
||||
wa = std::sin((1 - alpha) * angle) / denom;
|
||||
wb = std::sin(alpha * angle) / denom;
|
||||
}
|
||||
Vec4 q{};
|
||||
float length = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
q[i] = wa * qa[i] + wb * qb[i];
|
||||
length += q[i] * q[i];
|
||||
}
|
||||
for (auto& v : q)
|
||||
v /= std::sqrt(length);
|
||||
const auto [x, y, z, w] = q;
|
||||
out.rotation = {std::atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)),
|
||||
std::asin(std::clamp(2 * (w * y - z * x), -1.0f, 1.0f)),
|
||||
std::atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))};
|
||||
return out;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
struct Runtime::Impl {
|
||||
struct Data { Json document; std::uint64_t generation; };
|
||||
struct Pose { Transform previous,current,presented; bool changedInUpdate{}; };
|
||||
struct Data {
|
||||
Json document;
|
||||
std::uint64_t generation;
|
||||
};
|
||||
struct Pose {
|
||||
Transform previous, current, presented;
|
||||
bool changedInUpdate{};
|
||||
};
|
||||
enum class Phase { Idle, Initialize, Fixed, Update, Late, Destroy };
|
||||
enum class Kind { Spawn, Destroy, Add, Remove };
|
||||
struct Command { Kind kind; EntityHandle handle; Json payload; std::string type; };
|
||||
struct Command {
|
||||
Kind kind;
|
||||
EntityHandle handle;
|
||||
Json payload;
|
||||
std::string type;
|
||||
};
|
||||
Runtime* owner;
|
||||
RuntimeConfig config;
|
||||
entt::registry registry;
|
||||
std::unordered_map<std::string,entt::entity> ids;
|
||||
std::unordered_map<std::string,Behavior> behaviors;
|
||||
std::unordered_map<std::string, entt::entity> ids;
|
||||
std::unordered_map<std::string, Behavior> behaviors;
|
||||
std::vector<entt::entity> order;
|
||||
std::deque<Command> pending;
|
||||
std::unique_ptr<detail::Physics> physics;
|
||||
std::vector<CollisionEvent> contacts;
|
||||
std::vector<std::string> diagnostics;
|
||||
std::uint64_t session{nextSession.fetch_add(1)};
|
||||
std::uint64_t generation{},tick{};
|
||||
std::uint64_t generation{}, tick{};
|
||||
int dimension{3};
|
||||
double accumulator{},alpha{};
|
||||
bool paused{},busy{};
|
||||
InputState currentInput{},queuedInput{};
|
||||
double accumulator{}, alpha{};
|
||||
bool paused{}, busy{};
|
||||
InputState currentInput{}, queuedInput{};
|
||||
Phase phase{Phase::Idle};
|
||||
Impl(Runtime* runtime,RuntimeConfig cfg):owner(runtime),config(cfg){}
|
||||
EntityHandle handle(entt::entity e) const {return {session,entt::to_integral(e),registry.get<Data>(e).generation};}
|
||||
bool valid(EntityHandle h)const noexcept { auto e=static_cast<entt::entity>(h.slot);return h.session==session&®istry.valid(e)&®istry.all_of<Data>(e)&®istry.get<Data>(e).generation==h.generation; }
|
||||
entt::entity entity(EntityHandle h)const {if(!valid(h))throw std::invalid_argument("stale or foreign runtime handle");return static_cast<entt::entity>(h.slot);}
|
||||
const Json* component(entt::entity e,const std::string& type)const {for(const auto& c:registry.get<Data>(e).document["components"])if(c["type"]==type)return &c;return nullptr;}
|
||||
void callback(const Behavior::Callback& fn,entt::entity e,double dt) {
|
||||
if(!fn)return;
|
||||
try {fn(*owner,handle(e),dt);}catch(const std::exception& ex){diagnostics.push_back("gameplay "+registry.get<Data>(e).document["id"].get<std::string>()+": "+ex.what());}catch(...){diagnostics.push_back("unknown gameplay exception");}
|
||||
Impl(Runtime* runtime, RuntimeConfig cfg) : owner(runtime), config(cfg) {}
|
||||
EntityHandle handle(entt::entity e) const {
|
||||
return {session, entt::to_integral(e), registry.get<Data>(e).generation};
|
||||
}
|
||||
void lifecycle(entt::entity e,Behavior::Callback Behavior::* member,double dt) {
|
||||
const auto components=registry.get<Data>(e).document["components"];
|
||||
for(const auto& c:components) {auto it=behaviors.find(c["type"].get<std::string>());if(it!=behaviors.end())callback(it->second.*member,e,dt);}
|
||||
bool valid(EntityHandle h) const noexcept {
|
||||
auto e = static_cast<entt::entity>(h.slot);
|
||||
return h.session == session && registry.valid(e) && registry.all_of<Data>(e) &&
|
||||
registry.get<Data>(e).generation == h.generation;
|
||||
}
|
||||
entt::entity entity(EntityHandle h) const {
|
||||
if (!valid(h))
|
||||
throw std::invalid_argument("stale or foreign runtime handle");
|
||||
return static_cast<entt::entity>(h.slot);
|
||||
}
|
||||
const Json* component(entt::entity e, const std::string& type) const {
|
||||
for (const auto& c : registry.get<Data>(e).document["components"])
|
||||
if (c["type"] == type)
|
||||
return &c;
|
||||
return nullptr;
|
||||
}
|
||||
void callback(const Behavior::Callback& fn, entt::entity e, double dt) {
|
||||
if (!fn)
|
||||
return;
|
||||
try {
|
||||
fn(*owner, handle(e), dt);
|
||||
} catch (const std::exception& ex) {
|
||||
diagnostics.push_back("gameplay " +
|
||||
registry.get<Data>(e).document["id"].get<std::string>() + ": " +
|
||||
ex.what());
|
||||
} catch (...) {
|
||||
diagnostics.push_back("unknown gameplay exception");
|
||||
}
|
||||
}
|
||||
void lifecycle(entt::entity e, Behavior::Callback Behavior::* member, double dt) {
|
||||
const auto components = registry.get<Data>(e).document["components"];
|
||||
for (const auto& c : components) {
|
||||
auto it = behaviors.find(c["type"].get<std::string>());
|
||||
if (it != behaviors.end())
|
||||
callback(it->second.*member, e, dt);
|
||||
}
|
||||
}
|
||||
void all(Behavior::Callback Behavior::* member, double dt) {
|
||||
for (auto e : order)
|
||||
if (registry.valid(e))
|
||||
lifecycle(e, member, dt);
|
||||
}
|
||||
void all(Behavior::Callback Behavior::* member,double dt) {for(auto e:order)if(registry.valid(e))lifecycle(e,member,dt);}
|
||||
void syncVisual(entt::entity e) {
|
||||
if(auto c=component(e,"faset.sprite")){const auto& f=(*c)["fields"];registry.emplace_or_replace<Sprite>(e,vectorValue<4>(f,"color",{1,1,1,1}),vectorValue<2>(f,"size",{1,1}),f.value("texture",std::string{}),f.value("layer",0));}else registry.remove<Sprite>(e);
|
||||
if(auto c=component(e,"faset.mesh")){const auto& f=(*c)["fields"];registry.emplace_or_replace<Mesh>(e,f.value("asset",std::string{}),vectorValue<4>(f,"color",{1,1,1,1}),f.value("primitive",std::string("cube")));}else registry.remove<Mesh>(e);
|
||||
if (auto c = component(e, "faset.sprite")) {
|
||||
const auto& f = (*c)["fields"];
|
||||
registry.emplace_or_replace<Sprite>(
|
||||
e, vectorValue<4>(f, "color", {1, 1, 1, 1}), vectorValue<2>(f, "size", {1, 1}),
|
||||
f.value("texture", std::string{}), f.value("layer", 0));
|
||||
} else
|
||||
registry.remove<Sprite>(e);
|
||||
if (auto c = component(e, "faset.mesh")) {
|
||||
const auto& f = (*c)["fields"];
|
||||
registry.emplace_or_replace<Mesh>(e, f.value("asset", std::string{}),
|
||||
vectorValue<4>(f, "color", {1, 1, 1, 1}),
|
||||
f.value("primitive", std::string("cube")));
|
||||
} else
|
||||
registry.remove<Mesh>(e);
|
||||
}
|
||||
void addPhysics(entt::entity e) {
|
||||
require(bool(physics),"load a scene before creating physics");
|
||||
auto c=component(e,dimension==2?body2:body3);if(c)physics->add(entt::to_integral(e),registry.get<Pose>(e).current,settings((*c)["fields"],dimension));
|
||||
require(bool(physics), "load a scene before creating physics");
|
||||
auto c = component(e, dimension == 2 ? body2 : body3);
|
||||
if (c)
|
||||
physics->add(entt::to_integral(e), registry.get<Pose>(e).current,
|
||||
settings((*c)["fields"], dimension));
|
||||
}
|
||||
entt::entity create(Json document) {
|
||||
const auto id=document["id"].get<std::string>();require(!ids.contains(id),"duplicate entity id: "+id);
|
||||
auto e=registry.create();Transform t{};
|
||||
for(const auto& c:document["components"])if(c["type"]=="faset.transform")t=readTransform(c["fields"]);
|
||||
registry.emplace<Data>(e,std::move(document),++generation);registry.emplace<Pose>(e,t,t,t,false);ids.emplace(id,e);order.push_back(e);addPhysics(e);syncVisual(e);return e;
|
||||
const auto id = document["id"].get<std::string>();
|
||||
require(!ids.contains(id), "duplicate entity id: " + id);
|
||||
auto e = registry.create();
|
||||
Transform t{};
|
||||
for (const auto& c : document["components"])
|
||||
if (c["type"] == "faset.transform")
|
||||
t = readTransform(c["fields"]);
|
||||
registry.emplace<Data>(e, std::move(document), ++generation);
|
||||
registry.emplace<Pose>(e, t, t, t, false);
|
||||
ids.emplace(id, e);
|
||||
order.push_back(e);
|
||||
addPhysics(e);
|
||||
syncVisual(e);
|
||||
return e;
|
||||
}
|
||||
void erase(entt::entity e) {
|
||||
// Authoring hierarchy destruction has the same subtree semantics in runtime.
|
||||
auto id=registry.get<Data>(e).document["id"].get<std::string>();
|
||||
auto id = registry.get<Data>(e).document["id"].get<std::string>();
|
||||
std::vector<entt::entity> children;
|
||||
for(auto child:order)if(registry.valid(child)&®istry.get<Data>(child).document.value("parent",Json{})==id)children.push_back(child);
|
||||
for(auto child:children)erase(child);
|
||||
phase=Phase::Destroy;lifecycle(e,&Behavior::onDestroy,0);
|
||||
physics->remove(entt::to_integral(e));ids.erase(id);registry.destroy(e);
|
||||
std::erase(order,e);
|
||||
for (auto child : order)
|
||||
if (registry.valid(child) &&
|
||||
registry.get<Data>(child).document.value("parent", Json{}) == id)
|
||||
children.push_back(child);
|
||||
for (auto child : children)
|
||||
erase(child);
|
||||
phase = Phase::Destroy;
|
||||
lifecycle(e, &Behavior::onDestroy, 0);
|
||||
physics->remove(entt::to_integral(e));
|
||||
ids.erase(id);
|
||||
registry.destroy(e);
|
||||
std::erase(order, e);
|
||||
}
|
||||
void commands() {
|
||||
auto commands=std::move(pending);pending.clear();
|
||||
for(auto& command:commands)try {
|
||||
if(command.kind==Kind::Spawn) {
|
||||
validateEntity(command.payload,dimension);
|
||||
auto parent=command.payload.value("parent",Json{});require(parent.is_null()||ids.contains(parent.get<std::string>()),"spawn parent is absent");
|
||||
auto e=create(std::move(command.payload));phase=Phase::Initialize;lifecycle(e,&Behavior::onStart,0);continue;
|
||||
auto commands = std::move(pending);
|
||||
pending.clear();
|
||||
for (auto& command : commands)
|
||||
try {
|
||||
if (command.kind == Kind::Spawn) {
|
||||
validateEntity(command.payload, dimension);
|
||||
auto parent = command.payload.value("parent", Json{});
|
||||
require(parent.is_null() || ids.contains(parent.get<std::string>()),
|
||||
"spawn parent is absent");
|
||||
auto e = create(std::move(command.payload));
|
||||
phase = Phase::Initialize;
|
||||
lifecycle(e, &Behavior::onStart, 0);
|
||||
continue;
|
||||
}
|
||||
if (!valid(command.handle)) {
|
||||
diagnostics.push_back("ignored structural command for stale handle");
|
||||
continue;
|
||||
}
|
||||
auto e = entity(command.handle);
|
||||
if (command.kind == Kind::Destroy) {
|
||||
erase(e);
|
||||
continue;
|
||||
}
|
||||
auto candidate = registry.get<Data>(e).document;
|
||||
auto& components = candidate["components"];
|
||||
if (command.kind == Kind::Add) {
|
||||
components.push_back(command.payload);
|
||||
validateEntity(candidate, dimension);
|
||||
} else {
|
||||
auto it =
|
||||
std::find_if(components.begin(), components.end(),
|
||||
[&](const Json& c) { return c["type"] == command.type; });
|
||||
if (it == components.end())
|
||||
continue;
|
||||
if (command.type == "faset.transform" && physics->contains(command.handle.slot))
|
||||
throw std::invalid_argument("remove physics before removing transform");
|
||||
auto behavior = behaviors.find(command.type);
|
||||
if (behavior != behaviors.end()) {
|
||||
phase = Phase::Destroy;
|
||||
callback(behavior->second.onDestroy, e, 0);
|
||||
}
|
||||
components.erase(it);
|
||||
}
|
||||
const std::string changed = command.kind == Kind::Add
|
||||
? command.payload["type"].get<std::string>()
|
||||
: command.type;
|
||||
if ((changed == body2 || changed == body3) && command.kind == Kind::Add) {
|
||||
const auto& pose = registry.get<Pose>(e).current;
|
||||
for (int i = 0; i < dimension; ++i)
|
||||
require(std::abs(pose.scale[i]) > 0.00001f,
|
||||
"runtime physics scale must be nonzero");
|
||||
if (dimension == 2)
|
||||
require(pose.rotation[0] == 0 && pose.rotation[1] == 0,
|
||||
"2D physics rotates only around Z");
|
||||
}
|
||||
registry.get<Data>(e).document = std::move(candidate);
|
||||
syncVisual(e);
|
||||
if (changed == body2 || changed == body3) {
|
||||
if (command.kind == Kind::Add)
|
||||
addPhysics(e);
|
||||
else
|
||||
physics->remove(command.handle.slot);
|
||||
}
|
||||
if (changed == "faset.transform") {
|
||||
auto& d = registry.get<Pose>(e);
|
||||
d.current = command.kind == Kind::Add ? readTransform(command.payload["fields"])
|
||||
: Transform{};
|
||||
d.previous = d.presented = d.current;
|
||||
}
|
||||
if (command.kind == Kind::Add) {
|
||||
auto it = behaviors.find(changed);
|
||||
if (it != behaviors.end()) {
|
||||
phase = Phase::Initialize;
|
||||
callback(it->second.onStart, e, 0);
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
diagnostics.push_back(std::string("structural command rejected: ") + ex.what());
|
||||
}
|
||||
if(!valid(command.handle)){diagnostics.push_back("ignored structural command for stale handle");continue;}
|
||||
auto e=entity(command.handle);
|
||||
if(command.kind==Kind::Destroy){erase(e);continue;}
|
||||
auto candidate=registry.get<Data>(e).document;
|
||||
auto& components=candidate["components"];
|
||||
if(command.kind==Kind::Add) {components.push_back(command.payload);validateEntity(candidate,dimension);}
|
||||
else { auto it=std::find_if(components.begin(),components.end(),[&](const Json& c){return c["type"]==command.type;});if(it==components.end())continue;
|
||||
if(command.type=="faset.transform"&&physics->contains(command.handle.slot))throw std::invalid_argument("remove physics before removing transform");
|
||||
auto behavior=behaviors.find(command.type);if(behavior!=behaviors.end()){phase=Phase::Destroy;callback(behavior->second.onDestroy,e,0);}components.erase(it);
|
||||
}
|
||||
const std::string changed=command.kind==Kind::Add?command.payload["type"].get<std::string>():command.type;
|
||||
if((changed==body2||changed==body3)&&command.kind==Kind::Add) {
|
||||
const auto& pose=registry.get<Pose>(e).current;
|
||||
for(int i=0;i<dimension;++i)require(std::abs(pose.scale[i])>0.00001f,"runtime physics scale must be nonzero");
|
||||
if(dimension==2)require(pose.rotation[0]==0&&pose.rotation[1]==0,"2D physics rotates only around Z");
|
||||
}
|
||||
registry.get<Data>(e).document=std::move(candidate);syncVisual(e);
|
||||
if(changed==body2||changed==body3) {if(command.kind==Kind::Add)addPhysics(e);else physics->remove(command.handle.slot);}
|
||||
if(changed=="faset.transform") {auto& d=registry.get<Pose>(e);d.current=command.kind==Kind::Add?readTransform(command.payload["fields"]):Transform{};d.previous=d.presented=d.current;}
|
||||
if(command.kind==Kind::Add){auto it=behaviors.find(changed);if(it!=behaviors.end()){phase=Phase::Initialize;callback(it->second.onStart,e,0);}}
|
||||
}catch(const std::exception& ex){diagnostics.push_back(std::string("structural command rejected: ")+ex.what());}
|
||||
}
|
||||
void fixed() {
|
||||
commands();phase=Phase::Fixed;
|
||||
for(auto [e,d]:registry.view<Pose>().each()){(void)e;d.previous=d.current;}
|
||||
currentInput=queuedInput;queuedInput.jumpPressed=false;queuedInput.interactPressed=false;
|
||||
all(&Behavior::fixedUpdate,config.fixedDelta);
|
||||
contacts.clear();
|
||||
if(physics) {
|
||||
auto events=physics->step(static_cast<float>(config.fixedDelta));
|
||||
for(auto e:order)if(physics->contains(entt::to_integral(e))) {auto& d=registry.get<Pose>(e);d.current=physics->transform(entt::to_integral(e),d.current);}
|
||||
for(const auto& event:events) {
|
||||
auto a=static_cast<entt::entity>(event.first),b=static_cast<entt::entity>(event.second);
|
||||
if(!registry.valid(a)||!registry.valid(b))continue;
|
||||
contacts.push_back({handle(a),handle(b),event.began});
|
||||
}
|
||||
for(const auto& event:contacts)for(auto h:{event.first,event.second}) {
|
||||
auto e=entity(h);for(const auto& c:registry.get<Data>(e).document["components"]) {
|
||||
auto it=behaviors.find(c["type"].get<std::string>());if(it!=behaviors.end()&&it->second.onCollision)
|
||||
try{it->second.onCollision(*owner,h,event);}catch(const std::exception& ex){diagnostics.push_back(std::string("collision callback: ")+ex.what());}catch(...){diagnostics.push_back("unknown collision callback exception");}
|
||||
}
|
||||
}
|
||||
commands();
|
||||
phase = Phase::Fixed;
|
||||
for (auto [e, d] : registry.view<Pose>().each()) {
|
||||
(void)e;
|
||||
d.previous = d.current;
|
||||
}
|
||||
++tick;phase=Phase::Idle;
|
||||
currentInput = queuedInput;
|
||||
queuedInput.jumpPressed = false;
|
||||
queuedInput.interactPressed = false;
|
||||
all(&Behavior::fixedUpdate, config.fixedDelta);
|
||||
contacts.clear();
|
||||
if (physics) {
|
||||
auto events = physics->step(static_cast<float>(config.fixedDelta));
|
||||
for (auto e : order)
|
||||
if (physics->contains(entt::to_integral(e))) {
|
||||
auto& d = registry.get<Pose>(e);
|
||||
d.current = physics->transform(entt::to_integral(e), d.current);
|
||||
}
|
||||
for (const auto& event : events) {
|
||||
auto a = static_cast<entt::entity>(event.first),
|
||||
b = static_cast<entt::entity>(event.second);
|
||||
if (!registry.valid(a) || !registry.valid(b))
|
||||
continue;
|
||||
contacts.push_back({handle(a), handle(b), event.began});
|
||||
}
|
||||
for (const auto& event : contacts)
|
||||
for (auto h : {event.first, event.second}) {
|
||||
auto e = entity(h);
|
||||
for (const auto& c : registry.get<Data>(e).document["components"]) {
|
||||
auto it = behaviors.find(c["type"].get<std::string>());
|
||||
if (it != behaviors.end() && it->second.onCollision)
|
||||
try {
|
||||
it->second.onCollision(*owner, h, event);
|
||||
} catch (const std::exception& ex) {
|
||||
diagnostics.push_back(std::string("collision callback: ") +
|
||||
ex.what());
|
||||
} catch (...) {
|
||||
diagnostics.push_back("unknown collision callback exception");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
++tick;
|
||||
phase = Phase::Idle;
|
||||
}
|
||||
FrameStats frame(double elapsed,InputState input,bool step) {
|
||||
require(std::isfinite(elapsed)&&elapsed>=0,"elapsed time must be finite and nonnegative");require(!busy,"recursive runtime advance");
|
||||
require(std::isfinite(input.horizontal)&&std::isfinite(input.vertical),"input axes must be finite");
|
||||
struct Guard {bool& busy;~Guard(){busy=false;}}guard{busy};busy=true;
|
||||
FrameStats frame(double elapsed, InputState input, bool step) {
|
||||
require(std::isfinite(elapsed) && elapsed >= 0,
|
||||
"elapsed time must be finite and nonnegative");
|
||||
require(!busy, "recursive runtime advance");
|
||||
require(std::isfinite(input.horizontal) && std::isfinite(input.vertical),
|
||||
"input axes must be finite");
|
||||
struct Guard {
|
||||
bool& busy;
|
||||
~Guard() {
|
||||
busy = false;
|
||||
}
|
||||
} guard{busy};
|
||||
busy = true;
|
||||
FrameStats stats{};
|
||||
if(paused&&!step){accumulator=0;currentInput={};queuedInput={};return {0,0,alpha,tick};}
|
||||
queuedInput.horizontal=input.horizontal;queuedInput.vertical=input.vertical;
|
||||
queuedInput.jumpPressed=queuedInput.jumpPressed||input.jumpPressed;queuedInput.interactPressed=queuedInput.interactPressed||input.interactPressed;
|
||||
for(auto [e,d]:registry.view<Pose>().each()){(void)e;d.changedInUpdate=false;}
|
||||
accumulator+=step?config.fixedDelta:elapsed;
|
||||
while(accumulator+1e-12>=config.fixedDelta&&stats.fixedTicks<(step?1u:config.maxCatchUpTicks)) {fixed();accumulator=std::max(0.0,accumulator-config.fixedDelta);++stats.fixedTicks;}
|
||||
if(accumulator>=config.fixedDelta){auto remaining=std::fmod(accumulator,config.fixedDelta);stats.droppedTime=accumulator-remaining;accumulator=remaining;diagnostics.push_back("dropped_time="+std::to_string(stats.droppedTime));}
|
||||
currentInput=input;phase=Phase::Update;all(&Behavior::update,step?config.fixedDelta:elapsed);
|
||||
alpha=step?1.0:std::clamp(accumulator/config.fixedDelta,0.0,1.0);
|
||||
for(auto [e,d]:registry.view<Pose>().each()){(void)e;d.presented=d.changedInUpdate?d.current:interpolate(d.previous,d.current,static_cast<float>(alpha));}
|
||||
phase=Phase::Late;all(&Behavior::lateUpdate,step?config.fixedDelta:elapsed);phase=Phase::Idle;
|
||||
stats.interpolationAlpha=alpha;stats.tick=tick;return stats;
|
||||
if (paused && !step) {
|
||||
accumulator = 0;
|
||||
currentInput = {};
|
||||
queuedInput = {};
|
||||
return {0, 0, alpha, tick};
|
||||
}
|
||||
queuedInput.horizontal = input.horizontal;
|
||||
queuedInput.vertical = input.vertical;
|
||||
queuedInput.jumpPressed = queuedInput.jumpPressed || input.jumpPressed;
|
||||
queuedInput.interactPressed = queuedInput.interactPressed || input.interactPressed;
|
||||
for (auto [e, d] : registry.view<Pose>().each()) {
|
||||
(void)e;
|
||||
d.changedInUpdate = false;
|
||||
}
|
||||
accumulator += step ? config.fixedDelta : elapsed;
|
||||
while (accumulator + 1e-12 >= config.fixedDelta &&
|
||||
stats.fixedTicks < (step ? 1u : config.maxCatchUpTicks)) {
|
||||
fixed();
|
||||
accumulator = std::max(0.0, accumulator - config.fixedDelta);
|
||||
++stats.fixedTicks;
|
||||
}
|
||||
if (accumulator >= config.fixedDelta) {
|
||||
auto remaining = std::fmod(accumulator, config.fixedDelta);
|
||||
stats.droppedTime = accumulator - remaining;
|
||||
accumulator = remaining;
|
||||
diagnostics.push_back("dropped_time=" + std::to_string(stats.droppedTime));
|
||||
}
|
||||
currentInput = input;
|
||||
phase = Phase::Update;
|
||||
all(&Behavior::update, step ? config.fixedDelta : elapsed);
|
||||
alpha = step ? 1.0 : std::clamp(accumulator / config.fixedDelta, 0.0, 1.0);
|
||||
for (auto [e, d] : registry.view<Pose>().each()) {
|
||||
(void)e;
|
||||
d.presented = d.changedInUpdate
|
||||
? d.current
|
||||
: interpolate(d.previous, d.current, static_cast<float>(alpha));
|
||||
}
|
||||
phase = Phase::Late;
|
||||
all(&Behavior::lateUpdate, step ? config.fixedDelta : elapsed);
|
||||
phase = Phase::Idle;
|
||||
stats.interpolationAlpha = alpha;
|
||||
stats.tick = tick;
|
||||
return stats;
|
||||
}
|
||||
};
|
||||
|
||||
Runtime::Runtime(RuntimeConfig cfg):impl_(std::make_unique<Impl>(this,cfg)) {
|
||||
require(std::isfinite(cfg.fixedDelta)&&cfg.fixedDelta>0&&cfg.fixedDelta<=1,"invalid fixed delta");
|
||||
require(cfg.maxCatchUpTicks>0&&cfg.maxCatchUpTicks<=1024,"invalid catchup limit");require(cfg.physicsSubsteps>0&&cfg.physicsSubsteps<=128,"invalid physics substeps");
|
||||
for(float value:cfg.gravity)require(std::isfinite(value),"invalid gravity");
|
||||
Runtime::Runtime(RuntimeConfig cfg) : impl_(std::make_unique<Impl>(this, cfg)) {
|
||||
require(std::isfinite(cfg.fixedDelta) && cfg.fixedDelta > 0 && cfg.fixedDelta <= 1,
|
||||
"invalid fixed delta");
|
||||
require(cfg.maxCatchUpTicks > 0 && cfg.maxCatchUpTicks <= 1024, "invalid catchup limit");
|
||||
require(cfg.physicsSubsteps > 0 && cfg.physicsSubsteps <= 128, "invalid physics substeps");
|
||||
for (float value : cfg.gravity)
|
||||
require(std::isfinite(value), "invalid gravity");
|
||||
}
|
||||
Runtime::~Runtime(){try{clear();}catch(...){}}
|
||||
void Runtime::registerBehavior(std::string type,Behavior behavior) {
|
||||
require(!impl_->busy&&impl_->ids.empty(),"register gameplay before loading scene");require(!type.empty()&&!impl_->behaviors.contains(type),"duplicate or empty behavior type");impl_->behaviors.emplace(std::move(type),std::move(behavior));
|
||||
Runtime::~Runtime() {
|
||||
try {
|
||||
clear();
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
void Runtime::registerBehavior(std::string type, Behavior behavior) {
|
||||
require(!impl_->busy && impl_->ids.empty(), "register gameplay before loading scene");
|
||||
require(!type.empty() && !impl_->behaviors.contains(type), "duplicate or empty behavior type");
|
||||
impl_->behaviors.emplace(std::move(type), std::move(behavior));
|
||||
}
|
||||
void Runtime::load(const Json& scene) {
|
||||
require(!impl_->busy,"cannot load scene from gameplay callback");
|
||||
require(scene.is_object()&&scene.value("format",std::string{})=="faset.scene"&&scene.value("version",0)==1,"unsupported scene format/version");
|
||||
const int dimension=scene.value("dimension",3);require(dimension==2||dimension==3,"scene dimension must be 2 or 3");
|
||||
require(scene.contains("entities")&&scene["entities"].is_array(),"scene entities must be an array");
|
||||
require(!scene.contains("instances")||(scene["instances"].is_array()&&scene["instances"].empty()),"resolve template instances before runtime loading");
|
||||
std::unordered_map<std::string,Json> entities;
|
||||
for(const auto& entity:scene["entities"]) {validateEntity(entity,dimension);require(entities.emplace(entity["id"].get<std::string>(),entity).second,"duplicate scene entity id");}
|
||||
for(const auto& [id,entity]:entities) {
|
||||
std::set<std::string> visited{id};auto parent=entity.value("parent",Json{});
|
||||
while(!parent.is_null()){auto key=parent.get<std::string>();require(entities.contains(key),"unknown parent entity");require(visited.insert(key).second,"cyclic parent hierarchy");parent=entities.at(key).value("parent",Json{});}
|
||||
require(!impl_->busy, "cannot load scene from gameplay callback");
|
||||
require(scene.is_object() && scene.value("format", std::string{}) == "faset.scene" &&
|
||||
scene.value("version", 0) == 1,
|
||||
"unsupported scene format/version");
|
||||
const int dimension = scene.value("dimension", 3);
|
||||
require(dimension == 2 || dimension == 3, "scene dimension must be 2 or 3");
|
||||
require(scene.contains("entities") && scene["entities"].is_array(),
|
||||
"scene entities must be an array");
|
||||
require(!scene.contains("instances") ||
|
||||
(scene["instances"].is_array() && scene["instances"].empty()),
|
||||
"resolve template instances before runtime loading");
|
||||
std::unordered_map<std::string, Json> entities;
|
||||
for (const auto& entity : scene["entities"]) {
|
||||
validateEntity(entity, dimension);
|
||||
require(entities.emplace(entity["id"].get<std::string>(), entity).second,
|
||||
"duplicate scene entity id");
|
||||
}
|
||||
auto next=std::make_unique<Impl>(this,impl_->config);next->dimension=dimension;next->behaviors=impl_->behaviors;
|
||||
next->physics=std::make_unique<detail::Physics>(dimension,next->config.gravity,next->config.physicsSubsteps);
|
||||
for(const auto& entity:scene["entities"])next->create(entity);
|
||||
clear();impl_=std::move(next);impl_->busy=true;impl_->phase=Impl::Phase::Initialize;impl_->all(&Behavior::onStart,0);impl_->phase=Impl::Phase::Idle;impl_->busy=false;
|
||||
for (const auto& [id, entity] : entities) {
|
||||
std::set<std::string> visited{id};
|
||||
auto parent = entity.value("parent", Json{});
|
||||
while (!parent.is_null()) {
|
||||
auto key = parent.get<std::string>();
|
||||
require(entities.contains(key), "unknown parent entity");
|
||||
require(visited.insert(key).second, "cyclic parent hierarchy");
|
||||
parent = entities.at(key).value("parent", Json{});
|
||||
}
|
||||
}
|
||||
auto next = std::make_unique<Impl>(this, impl_->config);
|
||||
next->dimension = dimension;
|
||||
next->behaviors = impl_->behaviors;
|
||||
next->physics = std::make_unique<detail::Physics>(dimension, next->config.gravity,
|
||||
next->config.physicsSubsteps);
|
||||
for (const auto& entity : scene["entities"])
|
||||
next->create(entity);
|
||||
clear();
|
||||
impl_ = std::move(next);
|
||||
impl_->busy = true;
|
||||
impl_->phase = Impl::Phase::Initialize;
|
||||
impl_->all(&Behavior::onStart, 0);
|
||||
impl_->phase = Impl::Phase::Idle;
|
||||
impl_->busy = false;
|
||||
}
|
||||
void Runtime::clear(){require(!impl_->busy,"cannot clear runtime from gameplay callback");impl_->busy=true;while(!impl_->order.empty())impl_->erase(impl_->order.back());impl_->pending.clear();impl_->contacts.clear();impl_->physics.reset();impl_->accumulator=0;impl_->tick=0;impl_->session=nextSession.fetch_add(1);impl_->busy=false;}
|
||||
FrameStats Runtime::advance(double dt,InputState input){return impl_->frame(dt,input,false);}
|
||||
FrameStats Runtime::singleStep(InputState input){return impl_->frame(0,input,true);}
|
||||
void Runtime::setPaused(bool value){require(!impl_->busy,"pause control belongs outside gameplay callbacks");impl_->paused=value;impl_->accumulator=0;impl_->queuedInput={};impl_->alpha=0;for(auto [e,pose]:impl_->registry.view<Impl::Pose>().each()){(void)e;pose.previous=pose.presented=pose.current;}}
|
||||
bool Runtime::paused()const noexcept{return impl_->paused;}
|
||||
EntityHandle Runtime::find(const std::string& id)const{auto it=impl_->ids.find(id);return it==impl_->ids.end()?EntityHandle{}:impl_->handle(it->second);}
|
||||
bool Runtime::valid(EntityHandle handle)const noexcept{return impl_->valid(handle);}
|
||||
Transform Runtime::transform(EntityHandle h)const{return impl_->registry.get<Impl::Pose>(impl_->entity(h)).current;}
|
||||
Transform Runtime::presentation(EntityHandle h)const{return impl_->registry.get<Impl::Pose>(impl_->entity(h)).presented;}
|
||||
Json Runtime::fields(EntityHandle h,const std::string& type)const{auto c=impl_->component(impl_->entity(h),type);if(!c)throw std::invalid_argument("entity has no component: "+type);return (*c)["fields"];}
|
||||
Vec3 Runtime::velocity(EntityHandle h)const{impl_->entity(h);if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");return impl_->physics->velocity(h.slot);}
|
||||
InputState Runtime::input()const noexcept{return impl_->currentInput;}
|
||||
const std::vector<CollisionEvent>& Runtime::collisions()const noexcept{return impl_->contacts;}
|
||||
void Runtime::setTransform(EntityHandle h,const Transform& value){validateTransform(value);auto e=impl_->entity(h);if(impl_->physics&&impl_->physics->contains(h.slot))throw std::invalid_argument("physics transform requires teleport");auto& d=impl_->registry.get<Impl::Pose>(e);d.current=value;if(impl_->phase!=Impl::Phase::Fixed){d.previous=d.presented=value;d.changedInUpdate=true;}}
|
||||
void Runtime::setPresentation(EntityHandle h,const Transform& value){validateTransform(value);require(impl_->phase==Impl::Phase::Late,"presentation may only be changed during LateUpdate");impl_->registry.get<Impl::Pose>(impl_->entity(h)).presented=value;}
|
||||
void Runtime::teleport(EntityHandle h,const Transform& value){validateTransform(value);auto e=impl_->entity(h);auto& d=impl_->registry.get<Impl::Pose>(e);if(impl_->physics&&impl_->physics->contains(h.slot)){require(d.current.scale==value.scale,"changing collider scale requires remove/add body");if(impl_->dimension==2)require(value.rotation[0]==0&&value.rotation[1]==0,"2D physics rotates only around Z");impl_->physics->teleport(h.slot,value);}d.previous=d.current=d.presented=value;d.changedInUpdate=true;}
|
||||
void Runtime::setVelocity(EntityHandle h,Vec3 value){impl_->entity(h);for(float v:value)require(std::isfinite(v),"nonfinite velocity");if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");impl_->physics->setVelocity(h.slot,value);}
|
||||
void Runtime::applyImpulse(EntityHandle h,Vec3 value){impl_->entity(h);for(float v:value)require(std::isfinite(v),"nonfinite impulse");if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");impl_->physics->impulse(h.slot,value);}
|
||||
void Runtime::spawn(Json entity){require(bool(impl_->physics),"load a scene before spawning");validateEntity(entity,impl_->dimension);impl_->pending.push_back({Impl::Kind::Spawn,{},std::move(entity),{}});}
|
||||
void Runtime::destroy(EntityHandle h){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Destroy,h,{},{}});}
|
||||
void Runtime::addComponent(EntityHandle h,Json component){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Add,h,std::move(component),{}});}
|
||||
void Runtime::removeComponent(EntityHandle h,const std::string& type){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Remove,h,{},type});}
|
||||
RuntimeSnapshot Runtime::snapshot()const {
|
||||
RuntimeSnapshot out{impl_->dimension,impl_->tick,impl_->alpha,{}};out.entities.reserve(impl_->order.size());
|
||||
for(auto e:impl_->order) {const auto& d=impl_->registry.get<Impl::Data>(e);RenderEntity item;item.id=d.document["id"].get<std::string>();item.name=d.document.value("name",item.id);item.transform=impl_->registry.get<Impl::Pose>(e).presented;
|
||||
if(d.document.contains("parent")&&!d.document["parent"].is_null())item.parent=d.document["parent"].get<std::string>();
|
||||
if(auto sprite=impl_->registry.try_get<Sprite>(e))item.sprite=*sprite;
|
||||
if(auto mesh=impl_->registry.try_get<Mesh>(e))item.mesh=*mesh;
|
||||
void Runtime::clear() {
|
||||
require(!impl_->busy, "cannot clear runtime from gameplay callback");
|
||||
impl_->busy = true;
|
||||
while (!impl_->order.empty())
|
||||
impl_->erase(impl_->order.back());
|
||||
impl_->pending.clear();
|
||||
impl_->contacts.clear();
|
||||
impl_->physics.reset();
|
||||
impl_->accumulator = 0;
|
||||
impl_->tick = 0;
|
||||
impl_->session = nextSession.fetch_add(1);
|
||||
impl_->busy = false;
|
||||
}
|
||||
FrameStats Runtime::advance(double dt, InputState input) {
|
||||
return impl_->frame(dt, input, false);
|
||||
}
|
||||
FrameStats Runtime::singleStep(InputState input) {
|
||||
return impl_->frame(0, input, true);
|
||||
}
|
||||
void Runtime::setPaused(bool value) {
|
||||
require(!impl_->busy, "pause control belongs outside gameplay callbacks");
|
||||
impl_->paused = value;
|
||||
impl_->accumulator = 0;
|
||||
impl_->queuedInput = {};
|
||||
impl_->alpha = 0;
|
||||
for (auto [e, pose] : impl_->registry.view<Impl::Pose>().each()) {
|
||||
(void)e;
|
||||
pose.previous = pose.presented = pose.current;
|
||||
}
|
||||
}
|
||||
bool Runtime::paused() const noexcept {
|
||||
return impl_->paused;
|
||||
}
|
||||
EntityHandle Runtime::find(const std::string& id) const {
|
||||
auto it = impl_->ids.find(id);
|
||||
return it == impl_->ids.end() ? EntityHandle{} : impl_->handle(it->second);
|
||||
}
|
||||
bool Runtime::valid(EntityHandle handle) const noexcept {
|
||||
return impl_->valid(handle);
|
||||
}
|
||||
Transform Runtime::transform(EntityHandle h) const {
|
||||
return impl_->registry.get<Impl::Pose>(impl_->entity(h)).current;
|
||||
}
|
||||
Transform Runtime::presentation(EntityHandle h) const {
|
||||
return impl_->registry.get<Impl::Pose>(impl_->entity(h)).presented;
|
||||
}
|
||||
Json Runtime::fields(EntityHandle h, const std::string& type) const {
|
||||
auto c = impl_->component(impl_->entity(h), type);
|
||||
if (!c)
|
||||
throw std::invalid_argument("entity has no component: " + type);
|
||||
return (*c)["fields"];
|
||||
}
|
||||
Vec3 Runtime::velocity(EntityHandle h) const {
|
||||
impl_->entity(h);
|
||||
if (!impl_->physics || !impl_->physics->contains(h.slot))
|
||||
throw std::invalid_argument("entity has no physics body");
|
||||
return impl_->physics->velocity(h.slot);
|
||||
}
|
||||
bool Runtime::grounded(EntityHandle h) const {
|
||||
impl_->entity(h);
|
||||
if (!impl_->physics || !impl_->physics->contains(h.slot))
|
||||
throw std::invalid_argument("entity has no physics body");
|
||||
return impl_->physics->grounded(h.slot);
|
||||
}
|
||||
InputState Runtime::input() const noexcept {
|
||||
return impl_->currentInput;
|
||||
}
|
||||
const std::vector<CollisionEvent>& Runtime::collisions() const noexcept {
|
||||
return impl_->contacts;
|
||||
}
|
||||
void Runtime::setTransform(EntityHandle h, const Transform& value) {
|
||||
validateTransform(value);
|
||||
auto e = impl_->entity(h);
|
||||
if (impl_->physics && impl_->physics->contains(h.slot))
|
||||
throw std::invalid_argument("physics transform requires teleport");
|
||||
auto& d = impl_->registry.get<Impl::Pose>(e);
|
||||
d.current = value;
|
||||
if (impl_->phase != Impl::Phase::Fixed) {
|
||||
d.previous = d.presented = value;
|
||||
d.changedInUpdate = true;
|
||||
}
|
||||
}
|
||||
void Runtime::setPresentation(EntityHandle h, const Transform& value) {
|
||||
validateTransform(value);
|
||||
require(impl_->phase == Impl::Phase::Late,
|
||||
"presentation may only be changed during LateUpdate");
|
||||
impl_->registry.get<Impl::Pose>(impl_->entity(h)).presented = value;
|
||||
}
|
||||
void Runtime::teleport(EntityHandle h, const Transform& value) {
|
||||
validateTransform(value);
|
||||
auto e = impl_->entity(h);
|
||||
auto& d = impl_->registry.get<Impl::Pose>(e);
|
||||
if (impl_->physics && impl_->physics->contains(h.slot)) {
|
||||
require(d.current.scale == value.scale, "changing collider scale requires remove/add body");
|
||||
if (impl_->dimension == 2)
|
||||
require(value.rotation[0] == 0 && value.rotation[1] == 0,
|
||||
"2D physics rotates only around Z");
|
||||
impl_->physics->teleport(h.slot, value);
|
||||
}
|
||||
d.previous = d.current = d.presented = value;
|
||||
d.changedInUpdate = true;
|
||||
}
|
||||
void Runtime::setVelocity(EntityHandle h, Vec3 value) {
|
||||
impl_->entity(h);
|
||||
for (float v : value)
|
||||
require(std::isfinite(v), "nonfinite velocity");
|
||||
if (!impl_->physics || !impl_->physics->contains(h.slot))
|
||||
throw std::invalid_argument("entity has no physics body");
|
||||
impl_->physics->setVelocity(h.slot, value);
|
||||
}
|
||||
void Runtime::applyImpulse(EntityHandle h, Vec3 value) {
|
||||
impl_->entity(h);
|
||||
for (float v : value)
|
||||
require(std::isfinite(v), "nonfinite impulse");
|
||||
if (!impl_->physics || !impl_->physics->contains(h.slot))
|
||||
throw std::invalid_argument("entity has no physics body");
|
||||
impl_->physics->impulse(h.slot, value);
|
||||
}
|
||||
void Runtime::spawn(Json entity) {
|
||||
require(bool(impl_->physics), "load a scene before spawning");
|
||||
validateEntity(entity, impl_->dimension);
|
||||
impl_->pending.push_back({Impl::Kind::Spawn, {}, std::move(entity), {}});
|
||||
}
|
||||
void Runtime::destroy(EntityHandle h) {
|
||||
impl_->entity(h);
|
||||
impl_->pending.push_back({Impl::Kind::Destroy, h, {}, {}});
|
||||
}
|
||||
void Runtime::addComponent(EntityHandle h, Json component) {
|
||||
impl_->entity(h);
|
||||
impl_->pending.push_back({Impl::Kind::Add, h, std::move(component), {}});
|
||||
}
|
||||
void Runtime::removeComponent(EntityHandle h, const std::string& type) {
|
||||
impl_->entity(h);
|
||||
impl_->pending.push_back({Impl::Kind::Remove, h, {}, type});
|
||||
}
|
||||
RuntimeSnapshot Runtime::snapshot() const {
|
||||
RuntimeSnapshot out{impl_->dimension, impl_->tick, impl_->alpha, {}};
|
||||
out.entities.reserve(impl_->order.size());
|
||||
for (auto e : impl_->order) {
|
||||
const auto& d = impl_->registry.get<Impl::Data>(e);
|
||||
RenderEntity item;
|
||||
item.id = d.document["id"].get<std::string>();
|
||||
item.name = d.document.value("name", item.id);
|
||||
item.transform = impl_->registry.get<Impl::Pose>(e).presented;
|
||||
if (d.document.contains("parent") && !d.document["parent"].is_null())
|
||||
item.parent = d.document["parent"].get<std::string>();
|
||||
if (auto sprite = impl_->registry.try_get<Sprite>(e))
|
||||
item.sprite = *sprite;
|
||||
if (auto mesh = impl_->registry.try_get<Mesh>(e))
|
||||
item.mesh = *mesh;
|
||||
out.entities.push_back(std::move(item));
|
||||
}return out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Json Runtime::snapshotJson()const{auto value=snapshot();Json entities=Json::array();for(const auto& e:value.entities){Json item{{"id",e.id},{"name",e.name},{"parent",e.parent?Json(*e.parent):Json{}},{"transform",transformJson(e.transform)}};if(e.sprite)item["sprite"]={{"color",e.sprite->color},{"size",e.sprite->size},{"texture",e.sprite->texture},{"layer",e.sprite->layer}};if(e.mesh)item["mesh"]={{"asset",e.mesh->asset},{"color",e.mesh->color},{"primitive",e.mesh->primitive}};const auto entity=impl_->ids.at(e.id);for(const auto& type:{"faset.camera","faset.light"})if(auto c=impl_->component(entity,type))item[type==std::string("faset.camera")?"camera":"light"]=(*c)["fields"];entities.push_back(std::move(item));}return {{"dimension",value.dimension},{"tick",value.tick},{"alpha",value.alpha},{"entities",entities}};}
|
||||
std::uint64_t Runtime::session()const noexcept{return impl_->session;}
|
||||
const std::vector<std::string>& Runtime::diagnostics()const noexcept{return impl_->diagnostics;}
|
||||
Json Runtime::snapshotJson() const {
|
||||
auto value = snapshot();
|
||||
Json entities = Json::array();
|
||||
for (const auto& e : value.entities) {
|
||||
Json item{{"id", e.id},
|
||||
{"name", e.name},
|
||||
{"parent", e.parent ? Json(*e.parent) : Json{}},
|
||||
{"transform", transformJson(e.transform)}};
|
||||
if (e.sprite)
|
||||
item["sprite"] = {{"color", e.sprite->color},
|
||||
{"size", e.sprite->size},
|
||||
{"texture", e.sprite->texture},
|
||||
{"layer", e.sprite->layer}};
|
||||
if (e.mesh)
|
||||
item["mesh"] = {{"asset", e.mesh->asset},
|
||||
{"color", e.mesh->color},
|
||||
{"primitive", e.mesh->primitive}};
|
||||
const auto entity = impl_->ids.at(e.id);
|
||||
for (const auto& type : {"faset.camera", "faset.light"})
|
||||
if (auto c = impl_->component(entity, type))
|
||||
item[type == std::string("faset.camera") ? "camera" : "light"] = (*c)["fields"];
|
||||
entities.push_back(std::move(item));
|
||||
}
|
||||
return {{"dimension", value.dimension},
|
||||
{"tick", value.tick},
|
||||
{"alpha", value.alpha},
|
||||
{"entities", entities}};
|
||||
}
|
||||
std::uint64_t Runtime::session() const noexcept {
|
||||
return impl_->session;
|
||||
}
|
||||
const std::vector<std::string>& Runtime::diagnostics() const noexcept {
|
||||
return impl_->diagnostics;
|
||||
}
|
||||
} // namespace faset::runtime
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
#include <faset/ui/ui.hpp>
|
||||
#include <ft2build.h>
|
||||
#include FT_FREETYPE_H
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <hb-ft.h>
|
||||
#include <hb.h>
|
||||
#include <map>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace faset::ui {
|
||||
namespace {
|
||||
void quad(render::Snapshot& snapshot, render::Quad q, const Rect& clip) {
|
||||
const Rect area{q.x, q.y, q.width, q.height};
|
||||
const auto visible = area.intersection(clip);
|
||||
if (visible.width <= 0 || visible.height <= 0 || q.width <= 0 || q.height <= 0)
|
||||
return;
|
||||
const float u0 = q.uv_rect[0], v0 = q.uv_rect[1], du = q.uv_rect[2] - u0,
|
||||
dv = q.uv_rect[3] - v0;
|
||||
q.uv_rect = {u0 + (visible.x - q.x) / q.width * du, v0 + (visible.y - q.y) / q.height * dv,
|
||||
u0 + (visible.x + visible.width - q.x) / q.width * du,
|
||||
v0 + (visible.y + visible.height - q.y) / q.height * dv};
|
||||
q.x = visible.x;
|
||||
q.y = visible.y;
|
||||
q.width = visible.width;
|
||||
q.height = visible.height;
|
||||
snapshot.ui_quads.push_back(std::move(q));
|
||||
}
|
||||
} // namespace
|
||||
struct FontAtlas::Impl {
|
||||
FT_Library library{};
|
||||
FT_Face face{};
|
||||
hb_font_t* font{};
|
||||
std::vector<unsigned char> font_bytes;
|
||||
std::shared_ptr<render::Texture> atlas = std::make_shared<render::Texture>();
|
||||
struct Glyph {
|
||||
unsigned x, y, width, height;
|
||||
int left, top;
|
||||
};
|
||||
std::map<std::pair<unsigned, unsigned>, Glyph> glyphs;
|
||||
unsigned x = 2, y = 2, row_height = 0, size = 0;
|
||||
explicit Impl(const std::filesystem::path& path) {
|
||||
std::ifstream in(path, std::ios::binary | std::ios::ate);
|
||||
if (!in)
|
||||
throw std::runtime_error("Cannot open UI font: " + path.string());
|
||||
auto length = in.tellg();
|
||||
if (length <= 0)
|
||||
throw std::runtime_error("Empty UI font");
|
||||
font_bytes.resize(static_cast<std::size_t>(length));
|
||||
in.seekg(0);
|
||||
in.read(reinterpret_cast<char*>(font_bytes.data()), length);
|
||||
if (!in)
|
||||
throw std::runtime_error("Cannot read UI font");
|
||||
if (FT_Init_FreeType(&library))
|
||||
throw std::runtime_error("FreeType initialization failed");
|
||||
if (FT_New_Memory_Face(library, font_bytes.data(), static_cast<FT_Long>(font_bytes.size()),
|
||||
0, &face)) {
|
||||
FT_Done_FreeType(library);
|
||||
library = nullptr;
|
||||
throw std::runtime_error("Cannot load UI font face");
|
||||
}
|
||||
font = hb_ft_font_create_referenced(face);
|
||||
hb_ft_font_set_load_flags(font, FT_LOAD_DEFAULT);
|
||||
atlas->width = atlas->height = 2048;
|
||||
atlas->rgba.resize(2048 * 2048 * 4, 0);
|
||||
atlas->revision = 1;
|
||||
atlas->srgb = false;
|
||||
}
|
||||
~Impl() {
|
||||
if (font)
|
||||
hb_font_destroy(font);
|
||||
if (face)
|
||||
FT_Done_Face(face);
|
||||
if (library)
|
||||
FT_Done_FreeType(library);
|
||||
}
|
||||
void set_size(float pixels) {
|
||||
const auto wanted = static_cast<unsigned>(std::clamp(std::round(pixels), 6.f, 128.f));
|
||||
if (wanted != size) {
|
||||
size = wanted;
|
||||
if (FT_Set_Pixel_Sizes(face, 0, size))
|
||||
throw std::runtime_error("Invalid font size");
|
||||
hb_ft_font_changed(font);
|
||||
}
|
||||
}
|
||||
Glyph glyph(unsigned index) {
|
||||
const auto key = std::make_pair(size, index);
|
||||
if (auto found = glyphs.find(key); found != glyphs.end())
|
||||
return found->second;
|
||||
if (FT_Load_Glyph(face, index, FT_LOAD_DEFAULT) ||
|
||||
FT_Render_Glyph(face->glyph, FT_RENDER_MODE_NORMAL))
|
||||
throw std::runtime_error("Cannot rasterize UI glyph");
|
||||
const auto& bitmap = face->glyph->bitmap;
|
||||
if (x + bitmap.width + 2 > atlas->width) {
|
||||
x = 2;
|
||||
y += row_height + 2;
|
||||
row_height = 0;
|
||||
}
|
||||
if (y + bitmap.rows + 2 > atlas->height)
|
||||
throw std::runtime_error("UI font atlas exhausted");
|
||||
Glyph result{
|
||||
x, y, bitmap.width, bitmap.rows, face->glyph->bitmap_left, face->glyph->bitmap_top};
|
||||
for (unsigned py = 0; py < bitmap.rows; ++py) {
|
||||
const auto* row = bitmap.buffer + (bitmap.pitch >= 0 ? py : bitmap.rows - 1 - py) *
|
||||
std::abs(bitmap.pitch);
|
||||
for (unsigned px = 0; px < bitmap.width; ++px) {
|
||||
const auto offset = ((y + py) * atlas->width + x + px) * 4;
|
||||
atlas->rgba[offset] = atlas->rgba[offset + 1] = atlas->rgba[offset + 2] = 255;
|
||||
atlas->rgba[offset + 3] = row[px];
|
||||
}
|
||||
}
|
||||
x += bitmap.width + 2;
|
||||
row_height = std::max(row_height, bitmap.rows);
|
||||
++atlas->revision;
|
||||
glyphs.emplace(key, result);
|
||||
return result;
|
||||
}
|
||||
hb_buffer_t* shape(std::string_view text, float pixels) {
|
||||
set_size(pixels);
|
||||
auto* buffer = hb_buffer_create();
|
||||
hb_buffer_add_utf8(buffer, text.data(), static_cast<int>(text.size()), 0,
|
||||
static_cast<int>(text.size()));
|
||||
hb_buffer_guess_segment_properties(buffer);
|
||||
hb_shape(font, buffer, nullptr, 0);
|
||||
return buffer;
|
||||
}
|
||||
};
|
||||
FontAtlas::FontAtlas(const std::filesystem::path& path) : impl_(std::make_unique<Impl>(path)) {}
|
||||
FontAtlas::~FontAtlas() = default;
|
||||
float FontAtlas::measure(std::string_view text, float pixels) {
|
||||
auto* buffer = impl_->shape(text, pixels);
|
||||
std::unique_ptr<hb_buffer_t, decltype(&hb_buffer_destroy)> guard(buffer, &hb_buffer_destroy);
|
||||
unsigned count = 0;
|
||||
const auto* positions = hb_buffer_get_glyph_positions(buffer, &count);
|
||||
float advance = 0;
|
||||
for (unsigned i = 0; i < count; ++i)
|
||||
advance += positions[i].x_advance / 64.f;
|
||||
return advance;
|
||||
}
|
||||
void FontAtlas::draw(render::Snapshot& snapshot, std::string_view text, float x, float y,
|
||||
float pixels, Color color, const Rect& clip) {
|
||||
auto* buffer = impl_->shape(text, pixels);
|
||||
std::unique_ptr<hb_buffer_t, decltype(&hb_buffer_destroy)> guard(buffer, &hb_buffer_destroy);
|
||||
unsigned count = 0;
|
||||
const auto* infos = hb_buffer_get_glyph_infos(buffer, &count);
|
||||
const auto* positions = hb_buffer_get_glyph_positions(buffer, &count);
|
||||
const float baseline = y + impl_->face->size->metrics.ascender / 64.f;
|
||||
for (unsigned i = 0; i < count; ++i) {
|
||||
const auto glyph = impl_->glyph(infos[i].codepoint);
|
||||
if (glyph.width && glyph.height) {
|
||||
render::Quad q;
|
||||
q.x = x + positions[i].x_offset / 64.f + glyph.left;
|
||||
q.y = baseline - positions[i].y_offset / 64.f - glyph.top;
|
||||
q.width = static_cast<float>(glyph.width);
|
||||
q.height = static_cast<float>(glyph.height);
|
||||
q.color = color;
|
||||
q.texture = impl_->atlas;
|
||||
q.uv_rect = {float(glyph.x) / impl_->atlas->width,
|
||||
float(glyph.y) / impl_->atlas->height,
|
||||
float(glyph.x + glyph.width) / impl_->atlas->width,
|
||||
float(glyph.y + glyph.height) / impl_->atlas->height};
|
||||
quad(snapshot, std::move(q), clip);
|
||||
}
|
||||
x += positions[i].x_advance / 64.f;
|
||||
}
|
||||
}
|
||||
std::shared_ptr<const render::Texture> FontAtlas::texture() const {
|
||||
return impl_->atlas;
|
||||
}
|
||||
} // namespace faset::ui
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <faset/ui/ui.hpp>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace faset::ui {
|
||||
namespace {
|
||||
bool continuation(unsigned char c) {
|
||||
return (c & 0xc0) == 0x80;
|
||||
}
|
||||
std::size_t previous(std::string_view text, std::size_t at) {
|
||||
if (!at)
|
||||
return 0;
|
||||
--at;
|
||||
while (at && continuation(static_cast<unsigned char>(text[at])))
|
||||
--at;
|
||||
return at;
|
||||
}
|
||||
std::size_t next(std::string_view text, std::size_t at) {
|
||||
if (at >= text.size())
|
||||
return text.size();
|
||||
++at;
|
||||
while (at < text.size() && continuation(static_cast<unsigned char>(text[at])))
|
||||
++at;
|
||||
return at;
|
||||
}
|
||||
bool word(unsigned char c) {
|
||||
return c >= 128 || std::isalnum(c) || c == '_';
|
||||
}
|
||||
} // namespace
|
||||
bool TextBuffer::valid_utf8(std::string_view text) {
|
||||
for (std::size_t i = 0; i < text.size();) {
|
||||
const auto lead = static_cast<unsigned char>(text[i++]);
|
||||
if (lead < 128)
|
||||
continue;
|
||||
unsigned cp = 0;
|
||||
int n = 0;
|
||||
unsigned minimum = 0;
|
||||
if ((lead & 0xe0) == 0xc0) {
|
||||
cp = lead & 31;
|
||||
n = 1;
|
||||
minimum = 128;
|
||||
} else if ((lead & 0xf0) == 0xe0) {
|
||||
cp = lead & 15;
|
||||
n = 2;
|
||||
minimum = 2048;
|
||||
} else if ((lead & 0xf8) == 0xf0) {
|
||||
cp = lead & 7;
|
||||
n = 3;
|
||||
minimum = 65536;
|
||||
} else
|
||||
return false;
|
||||
if (i + static_cast<std::size_t>(n) > text.size())
|
||||
return false;
|
||||
while (n--) {
|
||||
auto c = static_cast<unsigned char>(text[i++]);
|
||||
if (!continuation(c))
|
||||
return false;
|
||||
cp = (cp << 6) | (c & 63);
|
||||
}
|
||||
if (cp < minimum || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
TextBuffer::TextBuffer(std::string text) {
|
||||
reset(std::move(text));
|
||||
}
|
||||
void TextBuffer::reset(std::string text) {
|
||||
if (!valid_utf8(text))
|
||||
throw std::invalid_argument("Invalid UTF-8 text");
|
||||
text_ = std::move(text);
|
||||
cursor_ = anchor_ = text_.size();
|
||||
undo_.clear();
|
||||
redo_.clear();
|
||||
}
|
||||
void TextBuffer::set_cursor(std::size_t byte, bool select) {
|
||||
cursor_ = std::min(byte, text_.size());
|
||||
while (cursor_ && cursor_ < text_.size() &&
|
||||
continuation(static_cast<unsigned char>(text_[cursor_])))
|
||||
--cursor_;
|
||||
if (!select)
|
||||
anchor_ = cursor_;
|
||||
}
|
||||
void TextBuffer::select_all() {
|
||||
anchor_ = 0;
|
||||
cursor_ = text_.size();
|
||||
}
|
||||
std::string TextBuffer::selected_text() const {
|
||||
return text_.substr(std::min(cursor_, anchor_),
|
||||
std::max(cursor_, anchor_) - std::min(cursor_, anchor_));
|
||||
}
|
||||
void TextBuffer::left(bool select, bool by_word) {
|
||||
if (!select && has_selection()) {
|
||||
set_cursor(std::min(cursor_, anchor_));
|
||||
return;
|
||||
}
|
||||
auto position = previous(text_, cursor_);
|
||||
if (by_word) {
|
||||
while (position && !word(static_cast<unsigned char>(text_[position])))
|
||||
position = previous(text_, position);
|
||||
while (position && word(static_cast<unsigned char>(text_[previous(text_, position)])))
|
||||
position = previous(text_, position);
|
||||
}
|
||||
set_cursor(position, select);
|
||||
}
|
||||
void TextBuffer::right(bool select, bool by_word) {
|
||||
if (!select && has_selection()) {
|
||||
set_cursor(std::max(cursor_, anchor_));
|
||||
return;
|
||||
}
|
||||
auto position = next(text_, cursor_);
|
||||
if (by_word) {
|
||||
while (position < text_.size() && word(static_cast<unsigned char>(text_[position])))
|
||||
position = next(text_, position);
|
||||
while (position < text_.size() && !word(static_cast<unsigned char>(text_[position])))
|
||||
position = next(text_, position);
|
||||
}
|
||||
set_cursor(position, select);
|
||||
}
|
||||
void TextBuffer::home(bool select) {
|
||||
set_cursor(0, select);
|
||||
}
|
||||
void TextBuffer::end(bool select) {
|
||||
set_cursor(text_.size(), select);
|
||||
}
|
||||
void TextBuffer::remember() {
|
||||
undo_.push_back({text_, cursor_, anchor_});
|
||||
if (undo_.size() > 256)
|
||||
undo_.erase(undo_.begin());
|
||||
redo_.clear();
|
||||
}
|
||||
void TextBuffer::erase_selection() {
|
||||
const auto begin = std::min(cursor_, anchor_), end = std::max(cursor_, anchor_);
|
||||
text_.erase(begin, end - begin);
|
||||
cursor_ = anchor_ = begin;
|
||||
}
|
||||
bool TextBuffer::insert(std::string_view utf8) {
|
||||
if (!valid_utf8(utf8) || text_.size() + utf8.size() > 1024 * 1024)
|
||||
return false;
|
||||
if (utf8.empty() && !has_selection())
|
||||
return false;
|
||||
remember();
|
||||
erase_selection();
|
||||
text_.insert(cursor_, utf8);
|
||||
cursor_ += utf8.size();
|
||||
anchor_ = cursor_;
|
||||
return true;
|
||||
}
|
||||
bool TextBuffer::backspace() {
|
||||
if (!has_selection() && !cursor_)
|
||||
return false;
|
||||
remember();
|
||||
if (has_selection())
|
||||
erase_selection();
|
||||
else {
|
||||
auto begin = previous(text_, cursor_);
|
||||
text_.erase(begin, cursor_ - begin);
|
||||
cursor_ = anchor_ = begin;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool TextBuffer::delete_forward() {
|
||||
if (!has_selection() && cursor_ == text_.size())
|
||||
return false;
|
||||
remember();
|
||||
if (has_selection())
|
||||
erase_selection();
|
||||
else
|
||||
text_.erase(cursor_, next(text_, cursor_) - cursor_);
|
||||
anchor_ = cursor_;
|
||||
return true;
|
||||
}
|
||||
bool TextBuffer::undo() {
|
||||
if (undo_.empty())
|
||||
return false;
|
||||
redo_.push_back({text_, cursor_, anchor_});
|
||||
auto old = std::move(undo_.back());
|
||||
undo_.pop_back();
|
||||
text_ = std::move(old.text);
|
||||
cursor_ = old.cursor;
|
||||
anchor_ = old.anchor;
|
||||
return true;
|
||||
}
|
||||
bool TextBuffer::redo() {
|
||||
if (redo_.empty())
|
||||
return false;
|
||||
undo_.push_back({text_, cursor_, anchor_});
|
||||
auto old = std::move(redo_.back());
|
||||
redo_.pop_back();
|
||||
text_ = std::move(old.text);
|
||||
cursor_ = old.cursor;
|
||||
anchor_ = old.anchor;
|
||||
return true;
|
||||
}
|
||||
} // namespace faset::ui
|
||||
+1149
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user