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

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