Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export
This commit is contained in:
+259
-55
@@ -1,73 +1,277 @@
|
||||
#include <faset/assets/asset_pipeline.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <bit>
|
||||
#include <chrono>
|
||||
#include <faset/assets/asset_pipeline.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace faset::assets;
|
||||
namespace fs=std::filesystem;
|
||||
namespace fs = std::filesystem;
|
||||
namespace {
|
||||
void require(bool value,const std::string& message){if(!value)throw std::runtime_error(message);}
|
||||
void u32(std::vector<unsigned char>& data,std::uint32_t value){for(int i=0;i<4;++i)data.push_back(static_cast<unsigned char>(value>>(8*i)));}
|
||||
void save(const fs::path& file,const std::vector<unsigned char>& data){std::ofstream out(file,std::ios::binary);out.write(reinterpret_cast<const char*>(data.data()),static_cast<std::streamsize>(data.size()));}
|
||||
void save(const fs::path& file,const std::string& text){std::ofstream(file,std::ios::binary)<<text;}
|
||||
std::vector<unsigned char> geometry(float x){std::vector<unsigned char> bin;for(float f:{x,0.f,0.f,1.f,0.f,0.f,0.f,1.f,0.f})u32(bin,std::bit_cast<std::uint32_t>(f));for(unsigned char c:{0,0,1,0,2,0})bin.push_back(c);return bin;}
|
||||
Json document(const std::string& name,bool stable,bool second,float x=0) {
|
||||
Json node{{"name",name},{"mesh",0}};if(stable)node["extras"]={{"faset_id","node-door"}};
|
||||
Json j={{"asset",{{"version","2.0"}}},{"scene",0},{"scenes",Json::array({{{"nodes",Json::array({0})}}})},{"nodes",Json::array({node})},{"buffers",Json::array({{{"byteLength",42}}})},{"bufferViews",Json::array({{{"buffer",0},{"byteOffset",0},{"byteLength",36},{"target",34962}},{{"buffer",0},{"byteOffset",36},{"byteLength",6},{"target",34963}}})},{"accessors",Json::array({{{"bufferView",0},{"componentType",5126},{"count",3},{"type","VEC3"},{"min",{std::min(0.f,x),0,0}},{"max",{std::max(1.f,x),1,0}}},{{"bufferView",1},{"componentType",5123},{"count",3},{"type","SCALAR"}}})},{"meshes",Json::array({{{"name","Triangle"},{"extras",{{"faset_id","mesh-triangle"}}},{"primitives",Json::array({{{"attributes",{{"POSITION",0}}},{"indices",1},{"material",0}}})}}})},{"materials",Json::array({{{"name","Red"},{"pbrMetallicRoughness",{{"baseColorFactor",{1.0,0.2,0.1,1.0}},{"metallicFactor",0.2},{"roughnessFactor",0.6}}}}})}};
|
||||
if(second){j["nodes"].push_back({{"name","Handle"},{"mesh",0},{"extras",{{"faset_id","node-handle"}}}});j["scenes"][0]["nodes"].push_back(1);}
|
||||
void require(bool value, const std::string& message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
void u32(std::vector<unsigned char>& data, std::uint32_t value) {
|
||||
for (int i = 0; i < 4; ++i)
|
||||
data.push_back(static_cast<unsigned char>(value >> (8 * i)));
|
||||
}
|
||||
void save(const fs::path& file, const std::vector<unsigned char>& data) {
|
||||
std::ofstream out(file, std::ios::binary);
|
||||
out.write(reinterpret_cast<const char*>(data.data()),
|
||||
static_cast<std::streamsize>(data.size()));
|
||||
}
|
||||
void save(const fs::path& file, const std::string& text) {
|
||||
std::ofstream(file, std::ios::binary) << text;
|
||||
}
|
||||
std::vector<unsigned char> geometry(float x) {
|
||||
std::vector<unsigned char> bin;
|
||||
for (float f : {x, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f, 0.f})
|
||||
u32(bin, std::bit_cast<std::uint32_t>(f));
|
||||
for (unsigned char c : {0, 0, 1, 0, 2, 0})
|
||||
bin.push_back(c);
|
||||
return bin;
|
||||
}
|
||||
Json document(const std::string& name, bool stable, bool second, float x = 0) {
|
||||
Json node{{"name", name}, {"mesh", 0}};
|
||||
if (stable)
|
||||
node["extras"] = {{"faset_id", "node-door"}};
|
||||
Json j = {
|
||||
{"asset", {{"version", "2.0"}}},
|
||||
{"scene", 0},
|
||||
{"scenes", Json::array({{{"nodes", Json::array({0})}}})},
|
||||
{"nodes", Json::array({node})},
|
||||
{"buffers", Json::array({{{"byteLength", 42}}})},
|
||||
{"bufferViews",
|
||||
Json::array({{{"buffer", 0}, {"byteOffset", 0}, {"byteLength", 36}, {"target", 34962}},
|
||||
{{"buffer", 0}, {"byteOffset", 36}, {"byteLength", 6}, {"target", 34963}}})},
|
||||
{"accessors",
|
||||
Json::array(
|
||||
{{{"bufferView", 0},
|
||||
{"componentType", 5126},
|
||||
{"count", 3},
|
||||
{"type", "VEC3"},
|
||||
{"min", {std::min(0.f, x), 0, 0}},
|
||||
{"max", {std::max(1.f, x), 1, 0}}},
|
||||
{{"bufferView", 1}, {"componentType", 5123}, {"count", 3}, {"type", "SCALAR"}}})},
|
||||
{"meshes", Json::array({{{"name", "Triangle"},
|
||||
{"extras", {{"faset_id", "mesh-triangle"}}},
|
||||
{"primitives", Json::array({{{"attributes", {{"POSITION", 0}}},
|
||||
{"indices", 1},
|
||||
{"material", 0}}})}}})},
|
||||
{"materials", Json::array({{{"name", "Red"},
|
||||
{"pbrMetallicRoughness",
|
||||
{{"baseColorFactor", {1.0, 0.2, 0.1, 1.0}},
|
||||
{"metallicFactor", 0.2},
|
||||
{"roughnessFactor", 0.6}}}}})}};
|
||||
if (second) {
|
||||
j["nodes"].push_back(
|
||||
{{"name", "Handle"}, {"mesh", 0}, {"extras", {{"faset_id", "node-handle"}}}});
|
||||
j["scenes"][0]["nodes"].push_back(1);
|
||||
}
|
||||
return j;
|
||||
}
|
||||
void glb(const fs::path& path,const std::string& name="Door",bool stable=true,bool second=false,float x=0) {
|
||||
auto json=document(name,stable,second,x).dump();while(json.size()%4)json+=' ';
|
||||
auto binary=geometry(x);while(binary.size()%4)binary.push_back(0);
|
||||
std::vector<unsigned char> result;u32(result,0x46546c67);u32(result,2);u32(result,static_cast<std::uint32_t>(12+8+json.size()+8+binary.size()));u32(result,static_cast<std::uint32_t>(json.size()));u32(result,0x4e4f534a);result.insert(result.end(),json.begin(),json.end());u32(result,static_cast<std::uint32_t>(binary.size()));u32(result,0x004e4942);result.insert(result.end(),binary.begin(),binary.end());save(path,result);
|
||||
void glb(const fs::path& path, const std::string& name = "Door", bool stable = true,
|
||||
bool second = false, float x = 0) {
|
||||
auto json = document(name, stable, second, x).dump();
|
||||
while (json.size() % 4)
|
||||
json += ' ';
|
||||
auto binary = geometry(x);
|
||||
while (binary.size() % 4)
|
||||
binary.push_back(0);
|
||||
std::vector<unsigned char> result;
|
||||
u32(result, 0x46546c67);
|
||||
u32(result, 2);
|
||||
u32(result, static_cast<std::uint32_t>(12 + 8 + json.size() + 8 + binary.size()));
|
||||
u32(result, static_cast<std::uint32_t>(json.size()));
|
||||
u32(result, 0x4e4f534a);
|
||||
result.insert(result.end(), json.begin(), json.end());
|
||||
u32(result, static_cast<std::uint32_t>(binary.size()));
|
||||
u32(result, 0x004e4942);
|
||||
result.insert(result.end(), binary.begin(), binary.end());
|
||||
save(path, result);
|
||||
}
|
||||
void success(const ImportResult& result){if(!result.ok()){std::string text="Import failed: ";for(const auto& d:result.diagnostics)text+=d+"; ";throw std::runtime_error(text);}}
|
||||
void success(const ImportResult& result) {
|
||||
if (!result.ok()) {
|
||||
std::string text = "Import failed: ";
|
||||
for (const auto& d : result.diagnostics)
|
||||
text += d + "; ";
|
||||
throw std::runtime_error(text);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
const auto root=fs::temp_directory_path()/("faset-assets-test-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
const auto root = fs::temp_directory_path() /
|
||||
("faset-assets-test-" +
|
||||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
fs::create_directories(root);
|
||||
try {
|
||||
AssetPipeline pipeline(root/"cache");const auto source=root/"door.glb";glb(source);
|
||||
auto first=pipeline.import_asset({source});success(first);require(!first.asset_id.empty(),"persistent identity missing");
|
||||
auto asset=pipeline.load_asset(first.asset_id);require(asset.meshes.size()==1&&asset.nodes.size()==1,"mesh/node extraction");require(asset.meshes[0].primitives[0].indices==std::vector<std::uint32_t>({0,1,2}),"index extraction");require(asset.meshes[0].primitives[0].vertices[0].normal[2]==1,"generated normal");require(asset.materials[0].base_color[1]>.19f&&asset.materials[0].metallic==.2f,"PBR extraction");
|
||||
const auto node_id=asset.nodes[0].id;
|
||||
const Json custom{{node_id,{{"gameplay",{{"locked",true}}},{"physics",{{"mass",12}}},{"material","custom-brass"}}}};
|
||||
pipeline.set_overrides(first.asset_id,custom);
|
||||
auto unchanged=pipeline.import_asset({source});success(unchanged);require(unchanged.cache_hit&&unchanged.generation==first.generation,"content cache hit");
|
||||
glb(source,"Renamed panel",true,false,.25f);auto modified=pipeline.import_asset({source});success(modified);require(modified.asset_id==first.asset_id&&modified.generation!=first.generation,"stable asset identity and changed generation");
|
||||
asset=pipeline.load_asset(first.asset_id);require(asset.nodes[0].id==node_id&&asset.nodes[0].name=="Renamed panel","faset_id survives rename");require(asset.meshes[0].primitives[0].vertices[0].position[0]==.25f,"new binary geometry loaded");require(pipeline.overrides(first.asset_id)==custom,"reimport erased authoring overrides");
|
||||
glb(source,"Renamed panel",true,true,.25f);auto added=pipeline.import_asset({source});success(added);
|
||||
glb(source,"Renamed panel",true,false,.25f);auto removed=pipeline.import_asset({source});require(removed.status==ImportStatus::conflict&&!removed.removed_output_ids.empty(),"deletion must conflict");require(pipeline.load_asset(first.asset_id).generation==added.generation,"conflict replaced active generation");require(pipeline.overrides(first.asset_id)==custom,"conflict erased overrides");
|
||||
ImportRequest resolve{source};resolve.allow_removed_outputs=true;auto resolved=pipeline.import_asset(resolve);success(resolved);require(pipeline.load_asset(first.asset_id).nodes.size()==1,"explicit deletion resolution");
|
||||
const auto active=resolved.generation;
|
||||
save(source,std::string("not a GLB"));auto failed=pipeline.import_asset({source});require(failed.status==ImportStatus::failed,"invalid source accepted");require(pipeline.load_asset(first.asset_id).generation==active,"failed import replaced active");
|
||||
glb(source,"Renamed panel",true,false,.5f);ImportJob* job_ptr=nullptr;ImportJob job([&](const ImportProgress& p){if(p.fraction>=.9f)job_ptr->cancel();});job_ptr=&job;
|
||||
auto cancelled=pipeline.import_asset({source},job);require(cancelled.status==ImportStatus::cancelled,"cancel before commit failed");require(pipeline.load_asset(first.asset_id).generation==active,"cancel replaced active");
|
||||
ImportRequest changed_settings{source};changed_settings.settings={{"target","test-profile"}};auto settings=pipeline.import_asset(changed_settings);success(settings);require(settings.generation!=active,"recipe omitted from cache key");
|
||||
const auto plain=root/"ordinary.glb";glb(plain,"Ordinary",false);auto standard=pipeline.import_asset({plain});success(standard);require(!pipeline.load_asset(standard.asset_id).nodes[0].stable_source_id,"ordinary GLB wrongly marked stable source");
|
||||
glb(plain,"Renamed without ID",false);require(pipeline.import_asset({plain}).status==ImportStatus::conflict,"ambiguous rename silently matched");
|
||||
auto duplicate=document("Duplicate",true,true);duplicate["nodes"][1]["extras"]["faset_id"]="node-door";duplicate["buffers"][0]["uri"]="mesh.bin";save(root/"mesh.bin",geometry(0));save(root/"duplicate.gltf",duplicate.dump());require(pipeline.import_asset({root/"duplicate.gltf"}).status==ImportStatus::failed,"duplicate source ID accepted");
|
||||
auto external=document("External",true,false);external["buffers"][0]["uri"]="mesh.bin";external["images"]=Json::array({{{"uri","pixel.png"},{"mimeType","image/png"}}});external["textures"]=Json::array({{{"source",0}}});external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"]={{"index",0}};
|
||||
AssetPipeline pipeline(root / "cache");
|
||||
const auto source = root / "door.glb";
|
||||
glb(source);
|
||||
auto first = pipeline.import_asset({source});
|
||||
success(first);
|
||||
require(!first.asset_id.empty(), "persistent identity missing");
|
||||
auto asset = pipeline.load_asset(first.asset_id);
|
||||
require(asset.meshes.size() == 1 && asset.nodes.size() == 1, "mesh/node extraction");
|
||||
require(asset.meshes[0].primitives[0].indices == std::vector<std::uint32_t>({0, 1, 2}),
|
||||
"index extraction");
|
||||
require(asset.meshes[0].primitives[0].vertices[0].normal[2] == 1, "generated normal");
|
||||
require(asset.materials[0].base_color[1] > .19f && asset.materials[0].metallic == .2f,
|
||||
"PBR extraction");
|
||||
const auto node_id = asset.nodes[0].id;
|
||||
const Json custom{{node_id,
|
||||
{{"gameplay", {{"locked", true}}},
|
||||
{"physics", {{"mass", 12}}},
|
||||
{"material", "custom-brass"}}}};
|
||||
pipeline.set_overrides(first.asset_id, custom);
|
||||
auto unchanged = pipeline.import_asset({source});
|
||||
success(unchanged);
|
||||
require(unchanged.cache_hit && unchanged.generation == first.generation,
|
||||
"content cache hit");
|
||||
glb(source, "Renamed panel", true, false, .25f);
|
||||
auto modified = pipeline.import_asset({source});
|
||||
success(modified);
|
||||
require(modified.asset_id == first.asset_id && modified.generation != first.generation,
|
||||
"stable asset identity and changed generation");
|
||||
asset = pipeline.load_asset(first.asset_id);
|
||||
require(asset.nodes[0].id == node_id && asset.nodes[0].name == "Renamed panel",
|
||||
"faset_id survives rename");
|
||||
require(asset.meshes[0].primitives[0].vertices[0].position[0] == .25f,
|
||||
"new binary geometry loaded");
|
||||
require(pipeline.overrides(first.asset_id) == custom,
|
||||
"reimport erased authoring overrides");
|
||||
glb(source, "Renamed panel", true, true, .25f);
|
||||
auto added = pipeline.import_asset({source});
|
||||
success(added);
|
||||
glb(source, "Renamed panel", true, false, .25f);
|
||||
auto removed = pipeline.import_asset({source});
|
||||
require(removed.status == ImportStatus::conflict && !removed.removed_output_ids.empty(),
|
||||
"deletion must conflict");
|
||||
require(pipeline.load_asset(first.asset_id).generation == added.generation,
|
||||
"conflict replaced active generation");
|
||||
require(pipeline.overrides(first.asset_id) == custom, "conflict erased overrides");
|
||||
ImportRequest resolve{source};
|
||||
resolve.allow_removed_outputs = true;
|
||||
auto resolved = pipeline.import_asset(resolve);
|
||||
success(resolved);
|
||||
require(pipeline.load_asset(first.asset_id).nodes.size() == 1,
|
||||
"explicit deletion resolution");
|
||||
const auto active = resolved.generation;
|
||||
save(source, std::string("not a GLB"));
|
||||
auto failed = pipeline.import_asset({source});
|
||||
require(failed.status == ImportStatus::failed, "invalid source accepted");
|
||||
require(pipeline.load_asset(first.asset_id).generation == active,
|
||||
"failed import replaced active");
|
||||
glb(source, "Renamed panel", true, false, .5f);
|
||||
ImportJob* job_ptr = nullptr;
|
||||
ImportJob job([&](const ImportProgress& p) {
|
||||
if (p.fraction >= .9f)
|
||||
job_ptr->cancel();
|
||||
});
|
||||
job_ptr = &job;
|
||||
auto cancelled = pipeline.import_asset({source}, job);
|
||||
require(cancelled.status == ImportStatus::cancelled, "cancel before commit failed");
|
||||
require(pipeline.load_asset(first.asset_id).generation == active, "cancel replaced active");
|
||||
ImportRequest changed_settings{source};
|
||||
changed_settings.settings = {{"target", "test-profile"}};
|
||||
auto settings = pipeline.import_asset(changed_settings);
|
||||
success(settings);
|
||||
require(settings.generation != active, "recipe omitted from cache key");
|
||||
const auto plain = root / "ordinary.glb";
|
||||
glb(plain, "Ordinary", false);
|
||||
auto standard = pipeline.import_asset({plain});
|
||||
success(standard);
|
||||
require(!pipeline.load_asset(standard.asset_id).nodes[0].stable_source_id,
|
||||
"ordinary GLB wrongly marked stable source");
|
||||
glb(plain, "Renamed without ID", false);
|
||||
require(pipeline.import_asset({plain}).status == ImportStatus::conflict,
|
||||
"ambiguous rename silently matched");
|
||||
auto duplicate = document("Duplicate", true, true);
|
||||
duplicate["nodes"][1]["extras"]["faset_id"] = "node-door";
|
||||
duplicate["buffers"][0]["uri"] = "mesh.bin";
|
||||
save(root / "mesh.bin", geometry(0));
|
||||
save(root / "duplicate.gltf", duplicate.dump());
|
||||
require(pipeline.import_asset({root / "duplicate.gltf"}).status == ImportStatus::failed,
|
||||
"duplicate source ID accepted");
|
||||
auto external = document("External", true, false);
|
||||
external["buffers"][0]["uri"] = "mesh.bin";
|
||||
external["images"] = Json::array({{{"uri", "pixel.png"}, {"mimeType", "image/png"}}});
|
||||
external["textures"] = Json::array({{{"source", 0}}});
|
||||
external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"] = {{"index", 0}};
|
||||
// Real 1x1 PNG payload, transparent pixel; importer owns encoded bytes.
|
||||
const std::vector<unsigned char> png={137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,11,73,68,65,84,120,156,99,96,0,2,0,0,5,0,1,165,246,69,64,0,0,0,0,73,69,78,68,174,66,96,130};
|
||||
save(root/"pixel.png",png);save(root/"external.gltf",external.dump());auto ext=pipeline.import_asset({root/"external.gltf"});success(ext);auto ext_asset=pipeline.load_asset(ext.asset_id);require(ext_asset.textures.size()==1&&ext_asset.textures[0].bytes.size()==png.size(),"external image not extracted");require(ext_asset.materials[0].base_color_texture==0,"material texture reference lost");
|
||||
save(root/"mesh.bin",geometry(.3f));auto dependent=pipeline.import_asset({root/"external.gltf"});success(dependent);require(dependent.generation!=ext.generation,"buffer dependency not invalidated");
|
||||
const std::vector<unsigned char> png = {
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0,
|
||||
0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0,
|
||||
0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0,
|
||||
1, 165, 246, 69, 64, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130};
|
||||
save(root / "pixel.png", png);
|
||||
save(root / "external.gltf", external.dump());
|
||||
auto ext = pipeline.import_asset({root / "external.gltf"});
|
||||
success(ext);
|
||||
auto ext_asset = pipeline.load_asset(ext.asset_id);
|
||||
require(ext_asset.textures.size() == 1 && ext_asset.textures[0].bytes.size() == png.size(),
|
||||
"external image not extracted");
|
||||
require(ext_asset.materials[0].base_color_texture == 0, "material texture reference lost");
|
||||
save(root / "mesh.bin", geometry(.3f));
|
||||
auto dependent = pipeline.import_asset({root / "external.gltf"});
|
||||
success(dependent);
|
||||
require(dependent.generation != ext.generation, "buffer dependency not invalidated");
|
||||
// A Blender bundle keeps its logical source stable while immutable payload paths change.
|
||||
const auto bundle_dir=root/"bundle";fs::create_directories(bundle_dir/"payload");
|
||||
glb(bundle_dir/"payload"/"first.glb","Bundle",true,false);
|
||||
Json bundle{{"schema_version",1},{"asset_id","bundle-asset"},{"files",Json::array({{{"path","payload/first.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"first.glb")}}})}};
|
||||
save(bundle_dir/"manifest.json",bundle.dump());auto bundle_first=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_first);require(bundle_first.asset_id=="bundle-asset","bundle identity lost");
|
||||
glb(bundle_dir/"payload"/"second.glb","Bundle renamed",true,false,.4f);bundle["files"][0]={{"path","payload/second.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"second.glb")}};
|
||||
save(bundle_dir/"manifest.json",bundle.dump());auto bundle_second=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_second);require(bundle_second.asset_id==bundle_first.asset_id&&bundle_second.generation!=bundle_first.generation,"bundle reimport identity/generation");
|
||||
bundle["files"][0]["sha256"]=std::string(64,'0');save(bundle_dir/"manifest.json",bundle.dump());require(pipeline.import_asset({bundle_dir/"manifest.json"}).status==ImportStatus::failed,"bundle checksum ignored");require(pipeline.load_asset("bundle-asset").generation==bundle_second.generation,"bad bundle replaced active");
|
||||
const auto bundle_dir = root / "bundle";
|
||||
fs::create_directories(bundle_dir / "payload");
|
||||
glb(bundle_dir / "payload" / "first.glb", "Bundle", true, false);
|
||||
Json bundle{{"schema_version", 1},
|
||||
{"asset_id", "bundle-asset"},
|
||||
{"files", Json::array({{{"path", "payload/first.glb"},
|
||||
{"sha256", faset::sha256_file(bundle_dir / "payload" /
|
||||
"first.glb")}}})}};
|
||||
save(bundle_dir / "manifest.json", bundle.dump());
|
||||
auto bundle_first = pipeline.import_asset({bundle_dir / "manifest.json"});
|
||||
success(bundle_first);
|
||||
require(bundle_first.asset_id == "bundle-asset", "bundle identity lost");
|
||||
glb(bundle_dir / "payload" / "second.glb", "Bundle renamed", true, false, .4f);
|
||||
bundle["files"][0] = {
|
||||
{"path", "payload/second.glb"},
|
||||
{"sha256", faset::sha256_file(bundle_dir / "payload" / "second.glb")}};
|
||||
save(bundle_dir / "manifest.json", bundle.dump());
|
||||
auto bundle_second = pipeline.import_asset({bundle_dir / "manifest.json"});
|
||||
success(bundle_second);
|
||||
require(bundle_second.asset_id == bundle_first.asset_id &&
|
||||
bundle_second.generation != bundle_first.generation,
|
||||
"bundle reimport identity/generation");
|
||||
bundle["files"][0]["sha256"] = std::string(64, '0');
|
||||
save(bundle_dir / "manifest.json", bundle.dump());
|
||||
require(pipeline.import_asset({bundle_dir / "manifest.json"}).status ==
|
||||
ImportStatus::failed,
|
||||
"bundle checksum ignored");
|
||||
require(pipeline.load_asset("bundle-asset").generation == bundle_second.generation,
|
||||
"bad bundle replaced active");
|
||||
// Changing a dependency after it was snapshotted cannot publish mixed content.
|
||||
const auto dependency_active=pipeline.load_asset(ext.asset_id).generation;
|
||||
ImportJob mutate([&](const ImportProgress& progress){if(progress.fraction==.5f)save(root/"mesh.bin",geometry(.7f));});
|
||||
require(pipeline.import_asset({root/"external.gltf"},mutate).status==ImportStatus::failed,"concurrent dependency edit accepted");require(pipeline.load_asset(ext.asset_id).generation==dependency_active,"concurrent edit changed active");
|
||||
fs::remove_all(root/"cache");auto restored=pipeline.import_asset({source});success(restored);require(restored.asset_id==first.asset_id,"cleared cache changed AssetId");require(restored.manifest["settings"]==changed_settings.settings,"persisted recipe lost");require(pipeline.overrides(first.asset_id)==custom,"cleared cache lost authoring overrides");
|
||||
fs::remove_all(root);std::cout<<"assets: geometry/PBR/texture, GLB/glTF, cache, rename, deletion, overrides, failure, cancellation OK\n";return 0;
|
||||
} catch(const std::exception& e){std::cerr<<e.what()<<"\nFixtures retained: "<<root<<'\n';return 1;}
|
||||
const auto dependency_active = pipeline.load_asset(ext.asset_id).generation;
|
||||
ImportJob mutate([&](const ImportProgress& progress) {
|
||||
if (progress.fraction == .5f)
|
||||
save(root / "mesh.bin", geometry(.7f));
|
||||
});
|
||||
require(pipeline.import_asset({root / "external.gltf"}, mutate).status ==
|
||||
ImportStatus::failed,
|
||||
"concurrent dependency edit accepted");
|
||||
require(pipeline.load_asset(ext.asset_id).generation == dependency_active,
|
||||
"concurrent edit changed active");
|
||||
fs::remove_all(root / "cache");
|
||||
auto restored = pipeline.import_asset({source});
|
||||
success(restored);
|
||||
require(restored.asset_id == first.asset_id, "cleared cache changed AssetId");
|
||||
require(restored.manifest["settings"] == changed_settings.settings,
|
||||
"persisted recipe lost");
|
||||
require(pipeline.overrides(first.asset_id) == custom,
|
||||
"cleared cache lost authoring overrides");
|
||||
fs::remove_all(root);
|
||||
std::cout << "assets: geometry/PBR/texture, GLB/glTF, cache, rename, deletion, overrides, "
|
||||
"failure, cancellation OK\n";
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << "\nFixtures retained: " << root << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
+166
-46
@@ -1,56 +1,176 @@
|
||||
#include <faset/authoring/service.hpp>
|
||||
#include <faset/authoring/templates.hpp>
|
||||
#include <faset/authoring/transforms.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <iostream>
|
||||
|
||||
#define CHECK(x) do {if(!(x))throw std::runtime_error("Check failed at line "+std::to_string(__LINE__)+": " #x);}while(false)
|
||||
template<class Fn> void fails(Fn fn,const std::string& code) {try{fn();}catch(const faset::Error& error){CHECK(error.code()==code);return;}throw std::runtime_error("Expected error "+code);}
|
||||
int main() {
|
||||
using namespace faset;using namespace faset::authoring;
|
||||
const auto root=std::filesystem::temp_directory_path()/("faset-authoring-"+new_id());
|
||||
#define CHECK(x) \
|
||||
do { \
|
||||
if (!(x)) \
|
||||
throw std::runtime_error("Check failed at line " + std::to_string(__LINE__) + \
|
||||
": " #x); \
|
||||
} while (false)
|
||||
template <class Fn> void fails(Fn fn, const std::string& code) {
|
||||
try {
|
||||
auto schemas=builtin_schemas();AuthoringService service(root,schemas);
|
||||
auto created=service.create("Courtyard",3);const std::string id=created["id"];
|
||||
auto first=make_entity(schemas,"Door");const std::string entity_id=first["id"],transform_id=first["components"][0]["id"];
|
||||
Json commands=Json::array({{{"op","entity.create"},{"entity",first}}});
|
||||
const auto after=service.transact(id,0,commands,"request-1");CHECK(after["revision"]==1);CHECK(after["scene"]["entities"].size()==1);
|
||||
CHECK(service.transact(id,0,commands,"request-1")==after);
|
||||
fails([&]{service.transact(id,0,commands);},"revision.conflict");
|
||||
fails([&]{service.transact(id,1,commands,"request-1");},"idempotency.conflict");
|
||||
auto bad=Json::array({{{"op","entity.rename"},{"entity",entity_id},{"name","Should not survive"}},{{"op","component.set"},{"entity",entity_id},{"component",transform_id},{"field","position"},{"value","not a vector"}}});
|
||||
fails([&]{service.transact(id,1,bad);},"validation.field_type");CHECK(service.query(id)==after);
|
||||
auto edited=service.transact(id,1,Json::array({{{"op","component.set"},{"entity",entity_id},{"component",transform_id},{"field","position"},{"value",{4,0,2}}}}));
|
||||
CHECK(edited["revision"]==2);CHECK(service.undo(id,2)["scene"]==after["scene"]);CHECK(service.redo(id,3)["scene"]==edited["scene"]);
|
||||
CHECK(service.save(id,"Scenes/courtyard.scene.json")["dirty"]==false);
|
||||
service.transact(id,4,Json::array({{{"op","entity.rename"},{"entity",entity_id},{"name","Дверь 世界"}}}));
|
||||
AuthoringService restarted(root,schemas);const auto recovered=restarted.open("Scenes/courtyard.scene.json",true);CHECK(recovered["scene"]["entities"][0]["name"]=="Дверь 世界");CHECK(recovered["dirty"]==true);
|
||||
atomic_write(root/"Scenes/courtyard.scene.json",read_text(root/"Scenes/courtyard.scene.json")+"\n");
|
||||
fails([&]{restarted.save(id);},"save.disk_conflict");
|
||||
fn();
|
||||
} catch (const faset::Error& error) {
|
||||
CHECK(error.code() == code);
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error("Expected error " + code);
|
||||
}
|
||||
int main() {
|
||||
using namespace faset;
|
||||
using namespace faset::authoring;
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-authoring-" + new_id());
|
||||
try {
|
||||
auto schemas = builtin_schemas();
|
||||
AuthoringService service(root, schemas);
|
||||
auto created = service.create("Courtyard", 3);
|
||||
const std::string id = created["id"];
|
||||
auto first = make_entity(schemas, "Door");
|
||||
const std::string entity_id = first["id"], transform_id = first["components"][0]["id"];
|
||||
Json commands = Json::array({{{"op", "entity.create"}, {"entity", first}}});
|
||||
const auto after = service.transact(id, 0, commands, "request-1");
|
||||
CHECK(after["revision"] == 1);
|
||||
CHECK(after["scene"]["entities"].size() == 1);
|
||||
CHECK(service.transact(id, 0, commands, "request-1") == after);
|
||||
fails([&] { service.transact(id, 0, commands); }, "revision.conflict");
|
||||
fails([&] { service.transact(id, 1, commands, "request-1"); }, "idempotency.conflict");
|
||||
auto bad = Json::array(
|
||||
{{{"op", "entity.rename"}, {"entity", entity_id}, {"name", "Should not survive"}},
|
||||
{{"op", "component.set"},
|
||||
{"entity", entity_id},
|
||||
{"component", transform_id},
|
||||
{"field", "position"},
|
||||
{"value", "not a vector"}}});
|
||||
fails([&] { service.transact(id, 1, bad); }, "validation.field_type");
|
||||
CHECK(service.query(id) == after);
|
||||
auto edited = service.transact(id, 1,
|
||||
Json::array({{{"op", "component.set"},
|
||||
{"entity", entity_id},
|
||||
{"component", transform_id},
|
||||
{"field", "position"},
|
||||
{"value", {4, 0, 2}}}}));
|
||||
CHECK(edited["revision"] == 2);
|
||||
CHECK(service.undo(id, 2)["scene"] == after["scene"]);
|
||||
CHECK(service.redo(id, 3)["scene"] == edited["scene"]);
|
||||
CHECK(service.save(id, "Scenes/courtyard.scene.json")["dirty"] == false);
|
||||
service.transact(
|
||||
id, 4,
|
||||
Json::array(
|
||||
{{{"op", "entity.rename"}, {"entity", entity_id}, {"name", "Дверь 世界"}}}));
|
||||
AuthoringService restarted(root, schemas);
|
||||
const auto recovered = restarted.open("Scenes/courtyard.scene.json", true);
|
||||
CHECK(recovered["scene"]["entities"][0]["name"] == "Дверь 世界");
|
||||
CHECK(recovered["dirty"] == true);
|
||||
AuthoringService opened_then_recovered(root, schemas);
|
||||
opened_then_recovered.open("Scenes/courtyard.scene.json");
|
||||
fails([&] { opened_then_recovered.recover(id); }, "recovery.revision_required");
|
||||
CHECK(opened_then_recovered.recover(id, 0)["scene"]["entities"][0]["name"] == "Дверь 世界");
|
||||
auto fresh = service.create("Never saved", 2);
|
||||
const std::string fresh_id = fresh.at("id");
|
||||
service.transact(fresh_id, 0,
|
||||
Json::array({{{"op", "entity.create"}, {"name", "Recovered sprite"}}}));
|
||||
AuthoringService recover_new(root, schemas);
|
||||
const auto restored_new = recover_new.recover(fresh_id);
|
||||
CHECK(restored_new["dirty"] == true && restored_new["path"] == "");
|
||||
CHECK(restored_new["scene"]["entities"].size() == 1);
|
||||
fails([&] { recover_new.recover("../../outside"); }, "id.invalid");
|
||||
atomic_write(root / "Scenes/courtyard.scene.json",
|
||||
read_text(root / "Scenes/courtyard.scene.json") + "\n");
|
||||
fails([&] { restarted.save(id); }, "save.disk_conflict");
|
||||
// Parent cycles are rejected atomically; names never provide identity.
|
||||
fails([&]{service.transact(id,5,Json::array({{{"op","entity.reparent"},{"entity",entity_id},{"parent",entity_id}}}));},"entity.cycle");
|
||||
CHECK(service.query(id)["revision"]==5);
|
||||
fails(
|
||||
[&] {
|
||||
service.transact(id, 5,
|
||||
Json::array({{{"op", "entity.reparent"},
|
||||
{"entity", entity_id},
|
||||
{"parent", entity_id}}}));
|
||||
},
|
||||
"entity.cycle");
|
||||
CHECK(service.query(id)["revision"] == 5);
|
||||
// Unavailable extension data is retained, including fields unknown to this SDK.
|
||||
auto unknown=make_entity(schemas,"Plugin object");unknown["components"].push_back({{"id",new_id()},{"type","plugin.future"},{"version",5},{"fields",{{"unknown",Json::array({1,2,3})}}}});
|
||||
service.transact(id,5,Json::array({{{"op","entity.create"},{"entity",unknown}}}));CHECK(service.query(id)["scene"]["entities"][1]==unknown);
|
||||
auto unknown = make_entity(schemas, "Plugin object");
|
||||
unknown["components"].push_back({{"id", new_id()},
|
||||
{"type", "plugin.future"},
|
||||
{"version", 5},
|
||||
{"fields", {{"unknown", Json::array({1, 2, 3})}}}});
|
||||
service.transact(id, 5, Json::array({{{"op", "entity.create"}, {"entity", unknown}}}));
|
||||
CHECK(service.query(id)["scene"]["entities"][1] == unknown);
|
||||
// Nested template addresses remain valid after source rename and source reparent.
|
||||
auto source=make_scene("Door template");source["entities"].push_back(first);
|
||||
auto middle=make_scene("Nested");middle["instances"].push_back({{"id","nested"},{"source","door"}});
|
||||
auto outer=make_scene("Level");
|
||||
Json address={{"path",Json::array({"nested"})},{"object",entity_id},{"component",transform_id},{"field","position"}};
|
||||
outer["instances"].push_back({{"id","one"},{"source","middle"},{"overrides",Json::array({{{"address",address},{"value",{8,0,0}}}})}});
|
||||
outer["instances"].push_back({{"id","two"},{"source","middle"}});
|
||||
auto loader=[&](const std::string& name){return name=="door"?source:middle;};
|
||||
auto resolved=resolve_templates(outer,schemas,loader);CHECK(resolved.conflicts.empty());CHECK(resolved.scene["entities"].size()==2);
|
||||
CHECK(resolved.scene["entities"][0]["components"][0]["fields"]["position"]==Json::array({8,0,0}));
|
||||
CHECK(resolved.scene["entities"][1]["components"][0]["fields"]["position"]==Json::array({0,0,0}));
|
||||
const auto stable=resolved.scene["entities"][0]["id"];source["entities"][0]["name"]="Renamed";
|
||||
CHECK(resolve_templates(outer,schemas,loader).scene["entities"][0]["id"]==stable);
|
||||
source["entities"]=Json::array();CHECK(resolve_templates(outer,schemas,loader).conflicts.size()==1);CHECK(outer["instances"][0]["overrides"].size()==1);
|
||||
// Stable FieldId survives a label rename; incompatible migrations require an explicit decision.
|
||||
SchemaRegistry newer;newer.register_schema({{"id","sample.type"},{"name","Sample"},{"version",2},{"fields",{{"speed",{{"id","speed"},{"name","Movement speed"},{"type","number"},{"default",2}}},{"enabled",{{"type","boolean"},{"default",true}}}}}});
|
||||
newer.add_migration("sample.type",1,{{"speed",{{"scale",0.01}}}});
|
||||
const auto migrated=newer.migrate_component({{"id",new_id()},{"type","sample.type"},{"version",1},{"fields",{{"speed",300},{"unrecognized","preserve"}}}});
|
||||
CHECK(migrated["fields"]["speed"]==3.0);CHECK(migrated["fields"]["enabled"]==true);CHECK(migrated["fields"]["unrecognized"]=="preserve");
|
||||
std::filesystem::remove_all(root);std::cout<<"Authoring transactions, conflict/retry, Undo, recovery, unknown fields, nested IDs and migrations passed\n";return 0;
|
||||
} catch(const std::exception& error) {std::filesystem::remove_all(root);std::cerr<<error.what()<<'\n';return 1;}
|
||||
auto source = make_scene("Door template");
|
||||
source["entities"].push_back(first);
|
||||
auto middle = make_scene("Nested");
|
||||
middle["instances"].push_back({{"id", "nested"}, {"source", "door"}});
|
||||
auto outer = make_scene("Level");
|
||||
Json address = {{"path", Json::array({"nested"})},
|
||||
{"object", entity_id},
|
||||
{"component", transform_id},
|
||||
{"field", "position"}};
|
||||
outer["instances"].push_back(
|
||||
{{"id", "one"},
|
||||
{"source", "middle"},
|
||||
{"overrides", Json::array({{{"address", address}, {"value", {8, 0, 0}}}})}});
|
||||
outer["instances"].push_back({{"id", "two"}, {"source", "middle"}});
|
||||
auto loader = [&](const std::string& name) { return name == "door" ? source : middle; };
|
||||
auto resolved = resolve_templates(outer, schemas, loader);
|
||||
CHECK(resolved.conflicts.empty());
|
||||
CHECK(resolved.scene["entities"].size() == 2);
|
||||
CHECK(resolved.scene["entities"][0]["components"][0]["fields"]["position"] ==
|
||||
Json::array({8, 0, 0}));
|
||||
CHECK(resolved.scene["entities"][1]["components"][0]["fields"]["position"] ==
|
||||
Json::array({0, 0, 0}));
|
||||
const auto stable = resolved.scene["entities"][0]["id"];
|
||||
source["entities"][0]["name"] = "Renamed";
|
||||
CHECK(resolve_templates(outer, schemas, loader).scene["entities"][0]["id"] == stable);
|
||||
source["entities"] = Json::array();
|
||||
CHECK(resolve_templates(outer, schemas, loader).conflicts.size() == 1);
|
||||
CHECK(outer["instances"][0]["overrides"].size() == 1);
|
||||
// Stable FieldId survives a label rename; incompatible migrations require an explicit
|
||||
// decision.
|
||||
SchemaRegistry newer;
|
||||
newer.register_schema(
|
||||
{{"id", "sample.type"},
|
||||
{"name", "Sample"},
|
||||
{"version", 2},
|
||||
{"fields",
|
||||
{{"speed",
|
||||
{{"id", "speed"}, {"name", "Movement speed"}, {"type", "number"}, {"default", 2}}},
|
||||
{"enabled", {{"type", "boolean"}, {"default", true}}}}}});
|
||||
newer.add_migration("sample.type", 1, {{"speed", {{"scale", 0.01}}}});
|
||||
const auto migrated =
|
||||
newer.migrate_component({{"id", new_id()},
|
||||
{"type", "sample.type"},
|
||||
{"version", 1},
|
||||
{"fields", {{"speed", 300}, {"unrecognized", "preserve"}}}});
|
||||
CHECK(migrated["fields"]["speed"] == 3.0);
|
||||
CHECK(migrated["fields"]["enabled"] == true);
|
||||
CHECK(migrated["fields"]["unrecognized"] == "preserve");
|
||||
// Full TRS reparent, including parent rotation/scale, preserves world placement.
|
||||
auto hierarchy = make_scene("Transforms");
|
||||
auto parent = make_entity(schemas, "Parent");
|
||||
auto child = make_entity(schemas, "Child");
|
||||
parent["components"][0]["fields"]["position"] = Json::array({10, 0, 0});
|
||||
parent["components"][0]["fields"]["rotation"] = Json::array({0, 0, 1.5707963267948966});
|
||||
parent["components"][0]["fields"]["scale"] = Json::array({2, 2, 2});
|
||||
child["components"][0]["fields"]["position"] = Json::array({10, 4, 0});
|
||||
hierarchy["entities"] = Json::array({parent, child});
|
||||
reparent_entity(hierarchy, child["id"], parent["id"], true);
|
||||
auto position = hierarchy["entities"][1]["components"][0]["fields"]["position"];
|
||||
CHECK(std::abs(position[0].get<double>() - 2.0) < 1e-6);
|
||||
CHECK(std::abs(position[1].get<double>()) < 1e-6);
|
||||
reparent_entity(hierarchy, child["id"], nullptr, true);
|
||||
position = hierarchy["entities"][1]["components"][0]["fields"]["position"];
|
||||
CHECK(std::abs(position[0].get<double>() - 10) < 1e-6);
|
||||
CHECK(std::abs(position[1].get<double>() - 4) < 1e-6);
|
||||
std::filesystem::remove_all(root);
|
||||
std::cout << "Authoring transactions, conflict/retry, Undo, recovery, unknown fields, "
|
||||
"nested IDs and migrations passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::filesystem::remove_all(root);
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import bpy, sys, pathlib, shutil, json
|
||||
root=pathlib.Path(sys.argv[sys.argv.index('--')+1])
|
||||
output=pathlib.Path(sys.argv[sys.argv.index('--')+2])
|
||||
sys.path.insert(0,str(root/'tools'))
|
||||
import blender_addon
|
||||
blender_addon.register()
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
bpy.ops.mesh.primitive_cube_add(location=(0,0,1))
|
||||
obj=bpy.context.object;obj.name='Door panel'
|
||||
obj['faset_id']='1c4b69fb-1219-4430-b046-8185eb265a0f'
|
||||
obj.data['faset_id']='2072eb21-2558-41ee-a843-82e676e6a44e'
|
||||
mat=bpy.data.materials.new('Brass');mat.diffuse_color=(0.8,0.5,0.12,1);mat['faset_id']='31dc2a5b-3d1c-41b8-b4ef-ddc626087458';obj.data.materials.append(mat)
|
||||
bpy.context.scene['faset_asset_id']='54c039e4-c19f-4365-a9f8-a38416ec6e3f'
|
||||
bpy.context.scene.frame_set(17,subframe=.25)
|
||||
for stage in ('initial','renamed','removed'):
|
||||
directory=output/stage;directory.mkdir(parents=True,exist_ok=True)
|
||||
if stage=='renamed':
|
||||
obj.name='Renamed Door';obj.data.vertices[0].co.x-=.2
|
||||
if stage=='removed':
|
||||
bpy.data.objects.remove(obj,do_unlink=True)
|
||||
result=bpy.ops.export_scene.faset_bundle(filepath=str(directory/'manifest.json'))
|
||||
assert 'FINISHED' in result,result
|
||||
assert bpy.context.scene.frame_current==17 and bpy.context.scene.frame_subframe==.25
|
||||
if stage!='removed':
|
||||
assert bpy.context.view_layer.objects.active==obj and obj.select_get()
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(directory/'source.blend'))
|
||||
print('FASET_BLENDER_ROUNDTRIP_EXPORT_OK')
|
||||
@@ -0,0 +1,289 @@
|
||||
#include <bit>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <faset/assets/asset_pipeline.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#ifndef _WIN32
|
||||
#include <csignal>
|
||||
#endif
|
||||
using namespace faset;
|
||||
namespace fs = std::filesystem;
|
||||
void require(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
std::string collect(Process& process) {
|
||||
std::string text;
|
||||
while (true) {
|
||||
auto poll = process.poll();
|
||||
text += poll.output;
|
||||
if (!poll.running) {
|
||||
require(poll.exit_code == 0, "Child process failed");
|
||||
return text;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
}
|
||||
Json scene(int dimension) {
|
||||
return {{"format", "faset.scene"}, {"version", 1}, {"id", "scene-test"},
|
||||
{"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()},
|
||||
{"instances", Json::array()}};
|
||||
}
|
||||
int integration(const fs::path& root) {
|
||||
fs::create_directories(root);
|
||||
editor::BuildConfig config;
|
||||
config.project_root = root / "project";
|
||||
config.engine_root = FASET_ENGINE_SOURCE;
|
||||
config.build_directory = root / "native-build";
|
||||
config.cache_root = root / "project" / ".faset" / "cache";
|
||||
editor::BuildService service(config);
|
||||
service.scaffold("Export integration", 3);
|
||||
// This dedicated integration fixture is reset before testing incremental user edits.
|
||||
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp",
|
||||
read_text(config.engine_root / "tools" / "project_templates" / "Gameplay.cpp"));
|
||||
auto wait = [&](const std::string& id) {
|
||||
std::string stage;
|
||||
while (true) {
|
||||
auto job = service.job(id);
|
||||
if (job.stage != stage) {
|
||||
stage = job.stage;
|
||||
std::cout << job.stage << std::endl;
|
||||
}
|
||||
if (job.finished()) {
|
||||
if (job.state != "succeeded") {
|
||||
std::cerr << job.error << '\n' << job.log;
|
||||
throw std::runtime_error("Integration job failed");
|
||||
}
|
||||
return job;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
};
|
||||
const auto assets = config.project_root / "Assets";
|
||||
std::string binary;
|
||||
for (float value : {-1.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 2.f, 0.f}) {
|
||||
auto bits = std::bit_cast<std::uint32_t>(value);
|
||||
for (int i = 0; i < 4; ++i)
|
||||
binary.push_back(static_cast<char>(bits >> (8 * i)));
|
||||
}
|
||||
atomic_write(assets / "triangle.bin", binary);
|
||||
Json gltf = {
|
||||
{"asset", {{"version", "2.0"}}},
|
||||
{"scene", 0},
|
||||
{"scenes", Json::array({{{"nodes", {0}}}})},
|
||||
{"nodes", Json::array({{{"mesh", 0}, {"extras", {{"faset_id", "triangle-node"}}}}})},
|
||||
{"buffers", Json::array({{{"uri", "triangle.bin"}, {"byteLength", 36}}})},
|
||||
{"bufferViews", Json::array({{{"buffer", 0}, {"byteLength", 36}}})},
|
||||
{"accessors", Json::array({{{"bufferView", 0},
|
||||
{"componentType", 5126},
|
||||
{"count", 3},
|
||||
{"type", "VEC3"},
|
||||
{"min", {-1, 0, 0}},
|
||||
{"max", {1, 2, 0}}}})},
|
||||
{"meshes",
|
||||
Json::array({{{"primitives", Json::array({{{"attributes", {{"POSITION", 0}}}}})}}})}};
|
||||
atomic_write_json(assets / "triangle.gltf", gltf);
|
||||
assets::AssetPipeline importer(config.cache_root);
|
||||
auto imported = importer.import_asset({assets / "triangle.gltf"});
|
||||
require(imported.ok(), "Integration asset import");
|
||||
for (int dimension : {2, 3}) {
|
||||
auto document = scene(dimension);
|
||||
auto component = [](std::string type, Json fields) {
|
||||
return Json{
|
||||
{"id", type}, {"type", type}, {"version", 1}, {"fields", std::move(fields)}};
|
||||
};
|
||||
Json components = Json::array(
|
||||
{component("faset.transform",
|
||||
{{"position", {0, 0, 0}}, {"rotation", {0, 0, 0}}, {"scale", {1, 1, 1}}})});
|
||||
if (dimension == 2)
|
||||
components.push_back(
|
||||
component("faset.sprite", {{"size", {2, 2}}, {"color", {.2, .7, .9, 1}}}));
|
||||
else
|
||||
components.push_back(component(
|
||||
"faset.mesh", {{"asset", imported.asset_id + "#" +
|
||||
importer.load_asset(imported.asset_id).nodes.at(0).id},
|
||||
{"color", {.3, .8, .5, 1}}}));
|
||||
document["entities"].push_back({{"id", "object"},
|
||||
{"name", "Object"},
|
||||
{"parent", nullptr},
|
||||
{"components", components}});
|
||||
auto result =
|
||||
wait(service.start_export(document, root / ("export-" + std::to_string(dimension))));
|
||||
const auto directory = fs::path(result.result.at("directory").get<std::string>());
|
||||
require(result.result.at("configuration") == "Release",
|
||||
"Exports default to the Release profile");
|
||||
require(fs::path(result.result.at("build_directory").get<std::string>()) ==
|
||||
config.build_directory / "Release",
|
||||
"Export has a separate CMake directory");
|
||||
require(read_json(directory / "manifest.json").at("configuration") == "Release",
|
||||
"Export manifest records the actual profile");
|
||||
Process player({{result.result.at("executable").get<std::string>(), "--headless",
|
||||
"--frames", "3", "--capture", (directory / "verification.ppm").string()},
|
||||
directory,
|
||||
{}});
|
||||
std::cout << collect(player);
|
||||
require(fs::file_size(directory / "verification.ppm") > 1000,
|
||||
"Exported game rendered a frame");
|
||||
atomic_write_json(root / ("result-" + std::to_string(dimension) + ".json"), result.result);
|
||||
}
|
||||
auto source = read_text(config.project_root / "Scripts" / "Gameplay.cpp");
|
||||
auto position = source.find("Character");
|
||||
require(position != std::string::npos, "Template schema fixture");
|
||||
source.replace(position, 9, "Custom Character");
|
||||
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", source);
|
||||
auto release_cache = read_text(config.build_directory / "Release" / "CMakeCache.txt");
|
||||
auto rebuilt = wait(service.start_build());
|
||||
require(rebuilt.result.at("configuration") == "Debug", "Development builds remain Debug");
|
||||
require(fs::path(rebuilt.result.at("build_directory").get<std::string>()) ==
|
||||
config.build_directory / "Debug",
|
||||
"Development CMake directory is isolated");
|
||||
require(read_text(config.build_directory / "Release" / "CMakeCache.txt") == release_cache,
|
||||
"Development build preserves the Release cache");
|
||||
auto schema = read_json(rebuilt.result.at("schema").get<std::string>());
|
||||
bool updated{};
|
||||
for (const auto& type : schema.at("types"))
|
||||
if (type.value("name", "") == "Custom Character")
|
||||
updated = true;
|
||||
require(updated, "Incremental C++ build produced new schema");
|
||||
auto last = read_text(config.cache_root / "last_build.json");
|
||||
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp",
|
||||
source + "\n#error intentional_build_failure\n");
|
||||
auto failed = service.wait(service.start_build());
|
||||
require(failed.state == "failed", "Invalid user C++ must fail build");
|
||||
require(read_text(config.cache_root / "last_build.json") == last,
|
||||
"Failed compile preserved last good build");
|
||||
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", source);
|
||||
std::cout << "Real 2D/3D exports, imported mesh packaging, native launches, incremental C++ "
|
||||
"schema and failed-build recovery passed\n";
|
||||
return 0;
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
if (argc > 1 && std::string(argv[1]) == "--child") {
|
||||
Json args = Json::array();
|
||||
for (int i = 2; i < argc; ++i)
|
||||
args.push_back(argv[i]);
|
||||
std::cout << Json{{"args", args},
|
||||
{"cwd", fs::current_path().string()},
|
||||
{"env", std::getenv("FASET_PROCESS_TEST")
|
||||
? std::getenv("FASET_PROCESS_TEST")
|
||||
: ""}}
|
||||
.dump()
|
||||
<< std::endl;
|
||||
std::cerr << "stderr-sentinel\n";
|
||||
std::cout << std::string(100000, 'x') << std::endl;
|
||||
return 0;
|
||||
}
|
||||
if (argc > 1 && std::string(argv[1]) == "--sleep") {
|
||||
#ifndef _WIN32
|
||||
std::signal(SIGTERM, SIG_IGN);
|
||||
#endif
|
||||
std::cout << "ready\n" << std::flush;
|
||||
std::this_thread::sleep_for(std::chrono::seconds(30));
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && std::string(argv[1]) == "--integration") {
|
||||
try {
|
||||
return integration(fs::absolute(argv[2]));
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
fs::path temporary = fs::temp_directory_path() / ("Faset build test " + new_id());
|
||||
try {
|
||||
fs::create_directories(temporary);
|
||||
auto executable = fs::absolute(argv[0]);
|
||||
Process child({{executable.string(), "--child", "space argument", "quote\"backslash\\",
|
||||
"$(touch not-executed); & |", ""},
|
||||
temporary,
|
||||
{{"FASET_PROCESS_TEST", "value with spaces"}}});
|
||||
auto text = collect(child);
|
||||
auto result = Json::parse(text.substr(0, text.find('\n')));
|
||||
require(result["args"] == Json::array({"space argument", "quote\"backslash\\",
|
||||
"$(touch not-executed); & |", ""}),
|
||||
"Arguments must remain literal");
|
||||
require(result["env"] == "value with spaces", "Child environment override");
|
||||
require(fs::equivalent(result["cwd"].get<std::string>(), temporary),
|
||||
"Child working directory");
|
||||
require(text.find("stderr-sentinel") != std::string::npos && text.size() > 100000,
|
||||
"Combined pipe output drained fully");
|
||||
require(!fs::exists(temporary / "not-executed"), "No shell execution");
|
||||
Process sleeper({{executable.string(), "--sleep"}, temporary, {}});
|
||||
bool ready{};
|
||||
while (!ready) {
|
||||
auto p = sleeper.poll();
|
||||
ready = p.output.find("ready") != std::string::npos;
|
||||
require(p.running, "Sleeper exited early");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
sleeper.cancel();
|
||||
require(!sleeper.poll().running, "Cancellation must reap the process");
|
||||
require(std::chrono::steady_clock::now() - start < std::chrono::seconds(3),
|
||||
"Cancellation must finish promptly");
|
||||
editor::BuildConfig config;
|
||||
config.project_root = temporary / "project";
|
||||
config.engine_root = FASET_ENGINE_SOURCE;
|
||||
editor::BuildService service(config);
|
||||
service.scaffold("Test project", 2);
|
||||
atomic_write(config.project_root / "Scripts" / "Gameplay.cpp", "// User code\n");
|
||||
service.scaffold("Another name", 3);
|
||||
require(read_text(config.project_root / "Scripts" / "Gameplay.cpp") == "// User code\n",
|
||||
"Scaffold preserves existing source");
|
||||
auto id = service.start_cook(scene(2));
|
||||
auto cooked = service.wait(id);
|
||||
require(cooked.state == "succeeded", "Cook job succeeds");
|
||||
auto bytes = read_text(cooked.result.at("scene").get<std::string>());
|
||||
require(bytes.substr(0, 8) == "FASETSCN" && bytes.size() > 20, "Cooked envelope magic");
|
||||
std::uint32_t version{};
|
||||
std::uint64_t size{};
|
||||
for (unsigned i = 0; i < 4; ++i)
|
||||
version |= std::uint32_t(static_cast<unsigned char>(bytes[8 + i])) << (8 * i);
|
||||
for (unsigned i = 0; i < 8; ++i)
|
||||
size |= std::uint64_t(static_cast<unsigned char>(bytes[12 + i])) << (8 * i);
|
||||
require(version == 1 && size == bytes.size() - 20, "Cooked envelope version and size");
|
||||
require(Json::from_cbor(bytes.begin() + 20, bytes.end()) == scene(2),
|
||||
"Cooked scene preserves all values");
|
||||
auto previous = read_text(service.config().cache_root / "last_cook.json");
|
||||
auto broken = scene(3);
|
||||
broken["version"] = 999;
|
||||
auto bad = service.wait(service.start_cook(broken));
|
||||
require(bad.state == "failed", "Unsupported scene version rejected");
|
||||
require(read_text(service.config().cache_root / "last_cook.json") == previous,
|
||||
"Failed cook preserves last good generation");
|
||||
broken = scene(3);
|
||||
broken["entities"].push_back(
|
||||
{{"id", "entity"},
|
||||
{"components",
|
||||
Json::array({{{"type", "faset.mesh"}, {"fields", {{"asset", "missing-asset"}}}}})}});
|
||||
auto missing = service.wait(service.start_cook(broken));
|
||||
require(missing.state == "failed", "Missing asset blocks publication");
|
||||
require(read_text(service.config().cache_root / "last_cook.json") == previous,
|
||||
"Missing asset preserves last good generation");
|
||||
broken = scene(3);
|
||||
broken["entities"].push_back({{"id", "entity"},
|
||||
{"components", Json::array({{{"type", "faset.unknown"},
|
||||
{"version", 1},
|
||||
{"fields", Json::object()}}})}});
|
||||
auto unknown = service.wait(service.start_cook(broken));
|
||||
require(unknown.state == "failed" &&
|
||||
unknown.error.find("unresolved component") != std::string::npos,
|
||||
"Unknown component type blocks cooking");
|
||||
require(read_text(service.config().cache_root / "last_cook.json") == previous,
|
||||
"Unknown schema preserves last good generation");
|
||||
std::cout << "Literal process arguments, pipes, cancellation, scaffold and atomic cook "
|
||||
"contracts passed\n";
|
||||
fs::remove_all(temporary);
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
std::error_code ignored;
|
||||
fs::remove_all(temporary, ignored);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+48
-16
@@ -1,27 +1,59 @@
|
||||
#include <faset/core/error.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/error.hpp>
|
||||
#include <iostream>
|
||||
#include <set>
|
||||
|
||||
#define CHECK(x) do { if(!(x)) throw std::runtime_error("Check failed: " #x); } while(false)
|
||||
#define CHECK(x) \
|
||||
do { \
|
||||
if (!(x)) \
|
||||
throw std::runtime_error("Check failed: " #x); \
|
||||
} while (false)
|
||||
int main() {
|
||||
const auto directory=std::filesystem::temp_directory_path()/("faset-core-"+faset::new_id());
|
||||
const auto directory =
|
||||
std::filesystem::temp_directory_path() / ("faset-core-" + faset::new_id());
|
||||
try {
|
||||
CHECK(faset::sha256("")=="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
CHECK(faset::sha256("abc")=="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
CHECK(faset::sha256(std::string(1000000,'a'))=="cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
|
||||
CHECK(faset::sha256("") ==
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
CHECK(faset::sha256("abc") ==
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
CHECK(faset::sha256(std::string(1000000, 'a')) ==
|
||||
"cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
|
||||
std::set<std::string> ids;
|
||||
for(int i=0;i<1000;++i) { const auto id=faset::new_id();CHECK(id.size()==36);CHECK(id[14]=='4');CHECK(ids.insert(id).second); }
|
||||
faset::atomic_write(directory/"state.json","{\"value\":1}");
|
||||
faset::atomic_write_json(directory/"state.json",{{"value",2},{"text","Привет 世界"}});
|
||||
CHECK(faset::read_json(directory/"state.json").at("value")==2);
|
||||
CHECK(faset::sha256_file(directory/"state.json")==faset::sha256(faset::read_text(directory/"state.json")));
|
||||
CHECK(std::filesystem::equivalent(faset::project_path(directory,"assets/../state.json"),directory/"state.json"));
|
||||
bool rejected=false;try { faset::project_path(directory,"../escape"); } catch(const faset::Error&) {rejected=true;} CHECK(rejected);
|
||||
rejected=false;try { faset::project_path(directory,directory/"state.json"); } catch(const faset::Error&) {rejected=true;} CHECK(rejected);
|
||||
for (int i = 0; i < 1000; ++i) {
|
||||
const auto id = faset::new_id();
|
||||
CHECK(id.size() == 36);
|
||||
CHECK(id[14] == '4');
|
||||
CHECK(ids.insert(id).second);
|
||||
}
|
||||
faset::atomic_write(directory / "state.json", "{\"value\":1}");
|
||||
faset::atomic_write_json(directory / "state.json", {{"value", 2}, {"text", "Привет 世界"}});
|
||||
CHECK(faset::read_json(directory / "state.json").at("value") == 2);
|
||||
CHECK(faset::sha256_file(directory / "state.json") ==
|
||||
faset::sha256(faset::read_text(directory / "state.json")));
|
||||
CHECK(std::filesystem::equivalent(faset::project_path(directory, "assets/../state.json"),
|
||||
directory / "state.json"));
|
||||
bool rejected = false;
|
||||
try {
|
||||
faset::project_path(directory, "../escape");
|
||||
} catch (const faset::Error&) {
|
||||
rejected = true;
|
||||
}
|
||||
CHECK(rejected);
|
||||
rejected = false;
|
||||
try {
|
||||
faset::project_path(directory, directory / "state.json");
|
||||
} catch (const faset::Error&) {
|
||||
rejected = true;
|
||||
}
|
||||
CHECK(rejected);
|
||||
std::filesystem::remove_all(directory);
|
||||
std::cout<<"Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path boundaries passed\n";
|
||||
std::cout << "Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path "
|
||||
"boundaries passed\n";
|
||||
return 0;
|
||||
} catch(const std::exception& error) {std::filesystem::remove_all(directory);std::cerr<<error.what()<<'\n';return 1;}
|
||||
} catch (const std::exception& error) {
|
||||
std::filesystem::remove_all(directory);
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
#include <cmath>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/editor_ui.hpp>
|
||||
#include <iostream>
|
||||
using namespace faset;
|
||||
void check(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
render::Event key(std::string value, bool control = false, bool shift = false) {
|
||||
render::Event e;
|
||||
e.type = render::Event::Type::KeyDown;
|
||||
e.key = std::move(value);
|
||||
e.control = control;
|
||||
e.shift = shift;
|
||||
return e;
|
||||
}
|
||||
void click(editor::EditorUI& ui, const std::string& id) {
|
||||
const auto* widget = ui.widgets().find(id);
|
||||
check(widget != nullptr, "Missing widget");
|
||||
const auto rect = widget->rect.intersection(widget->clip);
|
||||
check(rect.width > 0 && rect.height > 0, "Widget clipped");
|
||||
render::Event down;
|
||||
down.type = render::Event::Type::MouseDown;
|
||||
down.button = 1;
|
||||
down.x = rect.x + rect.width * .5f;
|
||||
down.y = rect.y + rect.height * .5f;
|
||||
auto up = down;
|
||||
up.type = render::Event::Type::MouseUp;
|
||||
ui.frame({down, up});
|
||||
}
|
||||
void text(editor::EditorUI& ui, const std::string& id, const std::string& value,
|
||||
bool commit = true) {
|
||||
click(ui, id);
|
||||
render::Event e;
|
||||
e.type = render::Event::Type::TextInput;
|
||||
e.text = value;
|
||||
std::vector<render::Event> events{key("A", true), e};
|
||||
if (commit)
|
||||
events.push_back(key("Return"));
|
||||
ui.frame(events);
|
||||
}
|
||||
int main() {
|
||||
auto root = std::filesystem::temp_directory_path() / ("faset-ui-authoring-" + new_id());
|
||||
try {
|
||||
std::filesystem::create_directories(root);
|
||||
atomic_write_json(root / "project.faset.json", {{"format", "faset.project"},
|
||||
{"version", 1},
|
||||
{"name", "UI integration test"},
|
||||
{"dimension", 3}});
|
||||
#if defined(FASET_TEST_PLUGIN_DIRECTORY)
|
||||
std::filesystem::create_directories(root / "Plugins");
|
||||
for (const auto& file : std::filesystem::directory_iterator(FASET_TEST_PLUGIN_DIRECTORY))
|
||||
if (file.is_regular_file())
|
||||
std::filesystem::copy_file(file.path(), root / "Plugins" / file.path().filename());
|
||||
#endif
|
||||
editor::Session session({root, FASET_TEST_ENGINE, root});
|
||||
render::Renderer renderer({1280, 800, "Faset editor test", true, true});
|
||||
editor::EditorUI ui(session, renderer,
|
||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/fonts/NotoSans.ttf",
|
||||
std::filesystem::path(FASET_TEST_ENGINE) / "assets/ui/dark.json");
|
||||
ui.frame({});
|
||||
click(ui, "add-cube");
|
||||
auto state = session.authoring().query(ui.current_document());
|
||||
check(state["scene"]["entities"].size() == 1, "Add cube button must author entity");
|
||||
const auto eid = state["scene"]["entities"][0]["id"].get<std::string>();
|
||||
check(ui.selected_entity() == eid, "New entity selected");
|
||||
text(ui, "object-name", "Дверь");
|
||||
state = session.authoring().query(ui.current_document());
|
||||
check(state["scene"]["entities"][0]["name"] == "Дверь", "Inspector Unicode rename");
|
||||
const auto cid = state["scene"]["entities"][0]["components"][0]["id"].get<std::string>();
|
||||
const auto position = "field-" + cid + "-position-0";
|
||||
text(ui, position, "3.5");
|
||||
state = session.authoring().query(ui.current_document());
|
||||
check(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0] == 3.5,
|
||||
"Inspector typed field transaction");
|
||||
click(ui, "undo");
|
||||
state = session.authoring().query(ui.current_document());
|
||||
check(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0] == 0,
|
||||
"Toolbar Undo uses authoring history");
|
||||
click(ui, "redo");
|
||||
state = session.authoring().query(ui.current_document());
|
||||
check(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0] == 3.5,
|
||||
"Toolbar Redo");
|
||||
// A concurrent MCP edit cannot be overwritten by an unfinished inspector
|
||||
// edit.
|
||||
text(ui, "object-name", "Unsaved local name", false);
|
||||
state = session.authoring().query(ui.current_document());
|
||||
session.authoring().transact(
|
||||
ui.current_document(), state.at("revision"),
|
||||
Json::array({{{"op", "entity.rename"}, {"entity", eid}, {"name", "External rename"}}}));
|
||||
ui.frame({key("Return")});
|
||||
state = session.authoring().query(ui.current_document());
|
||||
check(state["scene"]["entities"][0]["name"] == "External rename",
|
||||
"Revision conflict preserves external edit");
|
||||
click(ui, "scene-root");
|
||||
check(ui.selected_entity().empty(), "Scene root deselects object");
|
||||
click(ui, "entity-" + eid);
|
||||
check(ui.selected_entity() == eid, "Scene tree selection");
|
||||
const auto initial_revision = state.at("revision").get<std::uint64_t>();
|
||||
auto* field = ui.widgets().find(position);
|
||||
const auto r = field->rect;
|
||||
render::Event down;
|
||||
down.type = render::Event::Type::MouseDown;
|
||||
down.button = 1;
|
||||
down.x = r.x + 20;
|
||||
down.y = r.y + 12;
|
||||
auto move = down;
|
||||
move.type = render::Event::Type::MouseMove;
|
||||
move.x += 20;
|
||||
auto move2 = move;
|
||||
move2.x += 20;
|
||||
auto up = move2;
|
||||
up.type = render::Event::Type::MouseUp;
|
||||
ui.frame({down, move, move2, up});
|
||||
state = session.authoring().query(ui.current_document());
|
||||
check(state["revision"] == initial_revision + 1,
|
||||
"Numeric drag commits exactly one transaction");
|
||||
// Selecting and manipulating the actual rendered geometry uses viewport
|
||||
// events.
|
||||
auto screen = [&](render::Vec3 position) {
|
||||
const auto& snap = ui.snapshot();
|
||||
const auto& m = snap.view_projection;
|
||||
const auto w = m[3] * position[0] + m[7] * position[1] + m[11] * position[2] + m[15];
|
||||
const auto nx =
|
||||
(m[0] * position[0] + m[4] * position[1] + m[8] * position[2] + m[12]) / w;
|
||||
const auto ny =
|
||||
(m[1] * position[0] + m[5] * position[1] + m[9] * position[2] + m[13]) / w;
|
||||
return render::Vec2{snap.scene_rect[0] + (nx + 1) * snap.scene_rect[2] * .5f,
|
||||
snap.scene_rect[1] + (ny + 1) * snap.scene_rect[3] * .5f};
|
||||
};
|
||||
const auto x =
|
||||
state["scene"]["entities"][0]["components"][0]["fields"]["position"][0].get<float>();
|
||||
ui.select_entity("");
|
||||
ui.frame({});
|
||||
auto center = screen({x, 0, 0});
|
||||
down.x = center[0];
|
||||
down.y = center[1];
|
||||
up = down;
|
||||
up.type = render::Event::Type::MouseUp;
|
||||
ui.frame({down, up});
|
||||
check(ui.selected_entity() == eid, "Viewport ray must select visible cooked geometry");
|
||||
auto tip = screen({x + 1.44f, 0, 0});
|
||||
down.x = (center[0] + tip[0]) * .5f;
|
||||
down.y = (center[1] + tip[1]) * .5f;
|
||||
move = down;
|
||||
move.type = render::Event::Type::MouseMove;
|
||||
move.x += 20;
|
||||
up = move;
|
||||
up.type = render::Event::Type::MouseUp;
|
||||
const auto before_gizmo = state.at("revision").get<std::uint64_t>();
|
||||
ui.frame({down});
|
||||
ui.frame({move});
|
||||
check(session.authoring().query(ui.current_document())["revision"] == before_gizmo,
|
||||
"Gizmo preview must not mutate authoring");
|
||||
ui.frame({up});
|
||||
state = session.authoring().query(ui.current_document());
|
||||
check(state["revision"] == before_gizmo + 1, "Gizmo release commits one transaction");
|
||||
click(ui, "undo");
|
||||
state = session.authoring().query(ui.current_document());
|
||||
check(std::abs(state["scene"]["entities"][0]["components"][0]["fields"]["position"][0]
|
||||
.get<float>() -
|
||||
x) < .001f,
|
||||
"Gizmo undo restores transform");
|
||||
#if defined(FASET_TEST_PLUGIN_DIRECTORY)
|
||||
session.authoring().register_schemas(Json::parse(
|
||||
R"([{"id":"example.beacon","name":"Beacon","version":1,"fields":{"speed":{"id":"speed","name":"Rotation speed","type":"number","default":1.0}}}])"));
|
||||
ui.frame({});
|
||||
check(!session.plugin_panels().empty(), "Actual native plugin panel must load");
|
||||
const auto before_plugin = state["scene"]["entities"].size();
|
||||
click(ui, "tab-plugin-example.beacon.tools");
|
||||
click(ui, "plugin-action-example.beacon.tools");
|
||||
state = session.authoring().query(ui.current_document());
|
||||
check(state["scene"]["entities"].size() == before_plugin + 1,
|
||||
"Plugin panel action must author Beacon through Commands");
|
||||
click(ui, "tab-assets");
|
||||
#endif
|
||||
renderer.render(ui.snapshot());
|
||||
renderer.capture(root / "editor-ui.ppm");
|
||||
check(renderer.stats().validation_errors == 0, "Vulkan validation errors");
|
||||
std::cout << "Editor UI: actual events create/select/rename/typed "
|
||||
"fields/Undo/Redo/conflict/one drag transaction passed. "
|
||||
"Screenshot: "
|
||||
<< (root / "editor-ui.ppm") << '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Exercise the real headless Editor over newline-delimited MCP, without a GPU."""
|
||||
import json
|
||||
import pathlib
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
|
||||
def run(executable, project):
|
||||
process = subprocess.Popen([executable, "--project", str(project), "--mcp"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, encoding="utf-8")
|
||||
output = queue.Queue()
|
||||
def reader():
|
||||
for line in process.stdout:
|
||||
output.put(json.loads(line)) # Any non-JSON stdout fails the test.
|
||||
output.put(None)
|
||||
threading.Thread(target=reader, daemon=True).start()
|
||||
sequence = 0
|
||||
def request(method, params=None):
|
||||
nonlocal sequence
|
||||
sequence += 1
|
||||
message = {"jsonrpc": "2.0", "id": sequence, "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
process.stdin.write(json.dumps(message) + "\n")
|
||||
process.stdin.flush()
|
||||
result = output.get(timeout=20)
|
||||
assert result and result["id"] == sequence, result
|
||||
return result
|
||||
def call(name, arguments=None, error=False):
|
||||
response = request("tools/call", {"name": name, "arguments": arguments or {}})["result"]
|
||||
assert response["isError"] == error, response
|
||||
return response["structuredContent"]
|
||||
try:
|
||||
initialized = request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "faset-integration-test", "version": "1"}})
|
||||
assert initialized["result"]["serverInfo"]["name"] == "faset-editor"
|
||||
process.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n')
|
||||
process.stdin.flush()
|
||||
names = {tool["name"] for tool in request("tools/list")["result"]["tools"]}
|
||||
assert {"faset_scene_edit", "faset_export", "faset_job_cancel", "faset_plugins"} <= names
|
||||
assert "faset_runtime_query" not in names and "faset_editor_capture" not in names
|
||||
if (project / "Scenes/main.scene.json").exists():
|
||||
opened = call("faset_document_open", {"path": "Scenes/main.scene.json"})
|
||||
assert opened["scene"]["entities"][0]["name"] == "Door 世界"
|
||||
return
|
||||
scene = call("faset_document_create", {"name": "MCP integration", "dimension": 3})
|
||||
identity = scene["id"]
|
||||
edit = {"document": identity, "revision": 0,
|
||||
"operations": [{"op": "entity.create", "name": "Door 世界"}],
|
||||
"idempotency_key": "create-door"}
|
||||
changed = call("faset_scene_edit", edit)
|
||||
assert call("faset_scene_edit", edit) == changed
|
||||
conflict = dict(edit)
|
||||
del conflict["idempotency_key"]
|
||||
assert call("faset_scene_edit", conflict, True)["error"]["code"] == "revision.conflict"
|
||||
undone = call("faset_undo", {"document": identity, "revision": 1})
|
||||
assert undone["scene"]["entities"] == []
|
||||
redone = call("faset_redo", {"document": identity, "revision": 2})
|
||||
assert redone["scene"] == changed["scene"]
|
||||
saved = call("faset_document_save", {"document": identity, "path": "Scenes/main.scene.json"})
|
||||
assert not saved["dirty"]
|
||||
call("faset_document_open", {"path": "../outside.json"}, True)
|
||||
assert request("resources/read", {"uri": "faset://documents"})["result"]["contents"]
|
||||
assert request("faset_runtime_query")["error"]["code"] == -32601
|
||||
finally:
|
||||
process.stdin.close()
|
||||
try:
|
||||
code = process.wait(timeout=20)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise
|
||||
errors = process.stderr.read()
|
||||
assert code == 0, errors
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="faset-mcp-stdio-") as temporary:
|
||||
run(sys.argv[1], pathlib.Path(temporary))
|
||||
run(sys.argv[1], pathlib.Path(temporary))
|
||||
print("Real MCP stdio lifecycle, clean stdout, revision conflict, retry, Undo/Redo, disk reopen and process shutdown passed")
|
||||
@@ -0,0 +1,76 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/mcp.hpp>
|
||||
#include <iostream>
|
||||
|
||||
#define CHECK(x) \
|
||||
do { \
|
||||
if (!(x)) \
|
||||
throw std::runtime_error("Check failed at " + std::to_string(__LINE__) + ": " #x); \
|
||||
} while (false)
|
||||
int main() {
|
||||
using namespace faset;
|
||||
using namespace faset::editor;
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-mcp-" + new_id());
|
||||
try {
|
||||
authoring::AuthoringService service(root);
|
||||
Commands commands(service);
|
||||
McpServer server(commands);
|
||||
auto request = [&](std::string method, Json params = Json::object()) {
|
||||
auto result = server.handle(
|
||||
{{"jsonrpc", "2.0"}, {"id", 1}, {"method", method}, {"params", params}});
|
||||
CHECK(result.has_value());
|
||||
return *result;
|
||||
};
|
||||
CHECK(request("tools/list")["error"]["code"] == -32002);
|
||||
CHECK(request("initialize",
|
||||
{{"protocolVersion", "2025-06-18"},
|
||||
{"capabilities", Json::object()},
|
||||
{"clientInfo",
|
||||
{{"name", "test"}, {"version", "1"}}}})["result"]["protocolVersion"] ==
|
||||
"2025-06-18");
|
||||
CHECK(!server.handle({{"jsonrpc", "2.0"}, {"method", "notifications/initialized"}}));
|
||||
const auto listed = request("tools/list")["result"]["tools"];
|
||||
CHECK(listed.size() >= 10);
|
||||
for (const auto& tool : listed) {
|
||||
const std::string name = tool["name"];
|
||||
CHECK(name.find("runtime") == std::string::npos);
|
||||
CHECK(tool["inputSchema"]["additionalProperties"] == false);
|
||||
}
|
||||
auto call = [&](std::string name, Json arguments = Json::object()) {
|
||||
return request("tools/call", {{"name", name}, {"arguments", arguments}})["result"];
|
||||
};
|
||||
const auto created =
|
||||
call("faset_document_create", {{"name", "MCP scene"}, {"dimension", 2}});
|
||||
CHECK(created["isError"] == false);
|
||||
const std::string id = created["structuredContent"]["id"];
|
||||
Json args = {{"document", id},
|
||||
{"revision", 0},
|
||||
{"operations", Json::array({{{"op", "entity.create"}, {"name", "Player"}}})},
|
||||
{"idempotency_key", "first"}};
|
||||
const auto first = call("faset_scene_edit", args);
|
||||
CHECK(first["isError"] == false);
|
||||
CHECK(call("faset_scene_edit", args) == first);
|
||||
args.erase("idempotency_key");
|
||||
CHECK(call("faset_scene_edit", args)["structuredContent"]["error"]["code"] ==
|
||||
"revision.conflict");
|
||||
CHECK(service.query(id)["scene"] == first["structuredContent"]["scene"]);
|
||||
CHECK(call("faset_scene_edit", {{"document", id},
|
||||
{"revision", -1},
|
||||
{"operations", args["operations"]}})["isError"] == true);
|
||||
CHECK(call("faset_document_open",
|
||||
{{"path", "../outside.json"}})["structuredContent"]["error"]["code"] ==
|
||||
"path.outside_project");
|
||||
CHECK(call("faset_runtime_query")["isError"] == true);
|
||||
CHECK(request("resources/read", {{"uri", "faset://schema"}}).contains("result"));
|
||||
CHECK(request("not/a/method")["error"]["code"] == -32601);
|
||||
CHECK(server.handle(Json::array())->at("error").at("code") == -32600);
|
||||
std::filesystem::remove_all(root);
|
||||
std::cout << "MCP lifecycle, shared authoring, retries/conflicts, tool schemas and "
|
||||
"editor-only boundary passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::filesystem::remove_all(root);
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#include "../examples/extensions/beacon/Beacon.hpp"
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/plugins.hpp>
|
||||
#include <iostream>
|
||||
#define CHECK(x) \
|
||||
do { \
|
||||
if (!(x)) \
|
||||
throw std::runtime_error("Check failed: " #x); \
|
||||
} while (false)
|
||||
int main() {
|
||||
using namespace faset;
|
||||
using namespace faset::editor;
|
||||
const auto root = std::filesystem::temp_directory_path() / ("faset-plugins-" + new_id());
|
||||
try {
|
||||
std::filesystem::create_directories(root);
|
||||
const auto folder = root / "Plugins";
|
||||
std::filesystem::copy(FASET_TEST_PLUGIN_DIRECTORY, folder,
|
||||
std::filesystem::copy_options::recursive);
|
||||
authoring::AuthoringService authoring(root);
|
||||
authoring.register_schemas(Json::array({beacon::schema()}));
|
||||
Commands commands(authoring);
|
||||
const auto document = authoring.create("Extension test");
|
||||
const auto id = document.at("id");
|
||||
{
|
||||
PluginManager plugins(commands, [](auto) {});
|
||||
plugins.load(folder);
|
||||
CHECK(plugins.status().size() == 1);
|
||||
CHECK(plugins.status()[0]["state"] == "loaded");
|
||||
CHECK(plugins.panels().size() == 1);
|
||||
const auto edited = commands.call("plugin_example_beacon_create", {{"document", id}});
|
||||
CHECK(edited["revision"] == 1);
|
||||
CHECK(edited["scene"]["entities"][0]["components"][2]["type"] == "example.beacon");
|
||||
authoring.undo(id, 1);
|
||||
CHECK(authoring.query(id)["scene"]["entities"].empty());
|
||||
authoring.redo(id, 2);
|
||||
authoring.save(id, "Scenes/plugin.scene.json");
|
||||
// The extension's runtime component runs without loading its Editor DLL/SO.
|
||||
runtime::Runtime world;
|
||||
beacon::register_behavior(world);
|
||||
world.load(edited.at("scene"));
|
||||
world.advance(1.0 / 60);
|
||||
CHECK(world.transform(world.find(edited["scene"]["entities"][0]["id"])).rotation[1] >
|
||||
0);
|
||||
}
|
||||
for (const auto& command : commands.list())
|
||||
CHECK(command["name"] != "plugin_example_beacon_create");
|
||||
authoring::AuthoringService absent(root);
|
||||
CHECK(absent.open("Scenes/plugin.scene.json")["scene"]["entities"][0]["components"][2]
|
||||
["fields"]["speed"] == 1.0);
|
||||
const auto file = folder / "beacon.faset-plugin.json";
|
||||
const auto original = read_json(file);
|
||||
auto wrong = original;
|
||||
wrong["build_fingerprint"] = "wrong-build";
|
||||
atomic_write_json(file, wrong);
|
||||
{
|
||||
PluginManager plugins(commands, [](auto) {});
|
||||
plugins.load(folder);
|
||||
CHECK(plugins.status()[0]["state"] == "failed");
|
||||
CHECK(plugins.panels().empty());
|
||||
}
|
||||
auto cycle = original;
|
||||
cycle["dependencies"] = Json::array({{{"id", "example.beacon"}, {"version", "1.0.0"}}});
|
||||
atomic_write_json(file, cycle);
|
||||
{
|
||||
PluginManager plugins(commands, [](auto) {});
|
||||
plugins.load(folder);
|
||||
CHECK(plugins.status()[0]["state"] == "failed");
|
||||
CHECK(plugins.panels().empty());
|
||||
}
|
||||
auto missing = original;
|
||||
missing["dependencies"] = Json::array({{{"id", "missing"}, {"version", "1.0.0"}}});
|
||||
atomic_write_json(file, missing);
|
||||
{
|
||||
PluginManager plugins(commands, [](auto) {});
|
||||
plugins.load(folder);
|
||||
CHECK(plugins.status()[0]["state"] == "failed");
|
||||
}
|
||||
std::filesystem::remove_all(root);
|
||||
std::cout << "Native plugin ABI, ownership, commands, runtime component, missing package "
|
||||
"preservation and dependency validation passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::filesystem::remove_all(root);
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+127
-21
@@ -1,26 +1,132 @@
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <faset/render/render_graph.hpp>
|
||||
#include <cmath>
|
||||
#include <faset/render/render_graph.hpp>
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace faset::render;
|
||||
void require(bool test,const char* message){if(!test)throw std::runtime_error(message);}
|
||||
int main(int argc,char** argv){try{
|
||||
if(argc>1&&std::string(argv[1])=="--unit"){
|
||||
int count{};RenderGraph invalid;invalid.add("consumer",{"missing"},{},[&]{++count;});bool caught{};try{invalid.execute();}catch(const std::runtime_error&){caught=true;}require(caught&&count==0,"Graph must validate before side effects");RenderGraph graph;graph.import("external");graph.add("first",{"external"},{"color"},[&]{require(count==0,"Pass order");++count;});graph.add("second",{"color"},{},[&]{++count;});graph.execute();require(count==2,"Pass execution count");auto t=transform({2,3,4},{},{2,3,4});require(t[12]==2&&t[13]==3&&t[14]==4,"Transform translation");auto m=multiply(identity,t);require(m==t,"Matrix multiplication identity");require(cube_mesh()->indices.size()==36,"Cube triangle topology");std::cout<<"Render graph and math contracts passed\n";return 0;
|
||||
void require(bool test, const char* message) {
|
||||
if (!test)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
try {
|
||||
if (argc > 1 && std::string(argv[1]) == "--unit") {
|
||||
int count{};
|
||||
RenderGraph invalid;
|
||||
invalid.add("consumer", {"missing"}, {}, [&] { ++count; });
|
||||
bool caught{};
|
||||
try {
|
||||
invalid.execute();
|
||||
} catch (const std::runtime_error&) {
|
||||
caught = true;
|
||||
}
|
||||
require(caught && count == 0, "Graph must validate before side effects");
|
||||
RenderGraph graph;
|
||||
graph.import("external");
|
||||
graph.add("first", {"external"}, {"color"}, [&] {
|
||||
require(count == 0, "Pass order");
|
||||
++count;
|
||||
});
|
||||
graph.add("second", {"color"}, {}, [&] { ++count; });
|
||||
graph.execute();
|
||||
require(count == 2, "Pass execution count");
|
||||
auto t = transform({2, 3, 4}, {}, {2, 3, 4});
|
||||
require(t[12] == 2 && t[13] == 3 && t[14] == 4, "Transform translation");
|
||||
auto m = multiply(identity, t);
|
||||
require(m == t, "Matrix multiplication identity");
|
||||
require(cube_mesh()->indices.size() == 36, "Cube triangle topology");
|
||||
std::cout << "Render graph and math contracts passed\n";
|
||||
return 0;
|
||||
}
|
||||
bool visible = argc > 1 && std::string(argv[1]) == "--visible";
|
||||
Renderer renderer({320, 240, "Faset render validation", !visible, true});
|
||||
Snapshot scene;
|
||||
scene.eye = {4, 3, 5};
|
||||
scene.view_projection =
|
||||
multiply(perspective(.85f, 320.f / 240.f, .1f, 100), look_at(scene.eye, {0, 0, 0}));
|
||||
scene.draws.push_back(
|
||||
{cube_mesh(), transform({0, 0, 0}), {.2f, .65f, .95f, 1}, .4f, .15f, true});
|
||||
scene.draws.push_back({cube_mesh(),
|
||||
transform({0, -1, 0}, {}, {10, 1, 10}),
|
||||
{.45f, .48f, .5f, 1},
|
||||
.8f,
|
||||
0,
|
||||
true});
|
||||
scene.ui_quads.push_back({8, 8, 70, 16, {.8f, .1f, .15f, 1}});
|
||||
auto texture = std::make_shared<Texture>();
|
||||
texture->width = texture->height = 1;
|
||||
texture->rgba = {20, 220, 40, 255};
|
||||
scene.ui_quads.push_back({260, 8, 40, 20, {1, 1, 1, 1}, texture});
|
||||
renderer.render(scene);
|
||||
require(renderer.stats().validation_errors == 0, "Vulkan validation reported an error");
|
||||
auto pixels = renderer.pixels();
|
||||
require(pixels.size() == 320 * 240 * 4, "Readback dimensions");
|
||||
auto index = (10 * 320 + 10) * 4;
|
||||
require(pixels[index] > 190 && pixels[index + 1] < 50, "Colored UI pixel");
|
||||
index = (10 * 320 + 270) * 4;
|
||||
require(pixels[index] < 30 && pixels[index + 1] > 200, "Textured UI pixel");
|
||||
texture->srgb = true;
|
||||
++texture->revision;
|
||||
renderer.render(scene);
|
||||
auto srgb_pixels = renderer.pixels();
|
||||
require(std::abs(int(srgb_pixels[index + 1]) - 220) <= 1,
|
||||
"Unlit sRGB texture retains its display-space color");
|
||||
texture->srgb = false;
|
||||
++texture->revision;
|
||||
auto baked_scene = scene;
|
||||
auto baked_mesh = std::make_shared<Mesh>(*cube_mesh());
|
||||
for (auto& vertex : baked_mesh->vertices) {
|
||||
vertex.position[0] *= 10;
|
||||
vertex.position[2] *= 10;
|
||||
}
|
||||
baked_scene.draws[1].mesh = baked_mesh;
|
||||
baked_scene.draws[1].model = transform({0, -1, 0});
|
||||
renderer.render(baked_scene);
|
||||
auto baked_pixels = renderer.pixels();
|
||||
std::size_t normal_difference{};
|
||||
for (std::size_t i = 0; i < pixels.size(); ++i)
|
||||
if (std::abs(int(pixels[i]) - int(baked_pixels[i])) > 2)
|
||||
++normal_difference;
|
||||
require(normal_difference < 10,
|
||||
"Nonuniformly scaled normals must match baked geometry lighting");
|
||||
auto shadowed = pixels;
|
||||
if (argc > 2)
|
||||
renderer.capture(std::string(argv[2]) + ".shadowed.ppm");
|
||||
for (auto& draw : scene.draws)
|
||||
draw.cast_shadow = false;
|
||||
renderer.render(scene);
|
||||
pixels = renderer.pixels();
|
||||
std::size_t shadow_difference{};
|
||||
for (std::size_t i = 0; i < pixels.size(); i += 4)
|
||||
if (pixels[i] > shadowed[i] + 8)
|
||||
++shadow_difference;
|
||||
if (shadow_difference <= 20) {
|
||||
std::cerr << "Shadow difference pixels: " << shadow_difference << "\n";
|
||||
if (argc > 2)
|
||||
renderer.capture(std::string(argv[2]) + ".unshadowed.ppm");
|
||||
}
|
||||
require(shadow_difference > 20, "Directional shadow must darken rendered surface pixels");
|
||||
for (auto& draw : scene.draws)
|
||||
draw.cast_shadow = true;
|
||||
std::string reload_error;
|
||||
require(renderer.reload_shaders(reload_error), "Compatible shader pipeline reload");
|
||||
texture->rgba = {40, 30, 230, 255};
|
||||
++texture->revision;
|
||||
renderer.render(scene);
|
||||
pixels = renderer.pixels();
|
||||
require(pixels[index + 2] > 220, "Texture revision upload");
|
||||
if (argc > 2)
|
||||
renderer.capture(argv[2]);
|
||||
renderer.resize(400, 300);
|
||||
renderer.poll_events();
|
||||
renderer.render(scene);
|
||||
require(renderer.width() == 400 && renderer.height() == 300, "Render target resize");
|
||||
require(renderer.stats().validation_errors == 0, "Resize validation error");
|
||||
std::cout << "Vulkan frame, shadow/PBR, atlas upload, readback and resize passed on "
|
||||
<< renderer.stats().device << '\n';
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
bool visible=argc>1&&std::string(argv[1])=="--visible";
|
||||
Renderer renderer({320,240,"Faset render validation",!visible,true});
|
||||
Snapshot scene;scene.eye={4,3,5};scene.view_projection=multiply(perspective(.85f,320.f/240.f,.1f,100),look_at(scene.eye,{0,0,0}));scene.draws.push_back({cube_mesh(),transform({0,0,0}),{.2f,.65f,.95f,1},.4f,.15f,true});scene.draws.push_back({cube_mesh(),transform({0,-1,0},{},{8,.2f,8}),{.45f,.48f,.5f,1},.8f,0,true});scene.ui_quads.push_back({8,8,70,16,{.8f,.1f,.15f,1}});
|
||||
auto texture=std::make_shared<Texture>();texture->width=texture->height=1;texture->rgba={20,220,40,255};scene.ui_quads.push_back({260,8,40,20,{1,1,1,1},texture});
|
||||
renderer.render(scene);require(renderer.stats().validation_errors==0,"Vulkan validation reported an error");auto pixels=renderer.pixels();require(pixels.size()==320*240*4,"Readback dimensions");auto index=(10*320+10)*4;require(pixels[index]>190&&pixels[index+1]<50,"Colored UI pixel");index=(10*320+270)*4;require(pixels[index]<30&&pixels[index+1]>200,"Textured UI pixel");
|
||||
auto shadowed=pixels;
|
||||
if(argc>2)renderer.capture(std::string(argv[2])+".shadowed.ppm");
|
||||
for(auto& draw:scene.draws)draw.cast_shadow=false;
|
||||
renderer.render(scene);pixels=renderer.pixels();std::size_t shadow_difference{};for(std::size_t i=0;i<pixels.size();i+=4)if(pixels[i]>shadowed[i]+8)++shadow_difference;if(shadow_difference<=20){std::cerr<<"Shadow difference pixels: "<<shadow_difference<<"\n";if(argc>2)renderer.capture(std::string(argv[2])+".unshadowed.ppm");}require(shadow_difference>20,"Directional shadow must darken rendered surface pixels");
|
||||
for(auto& draw:scene.draws)draw.cast_shadow=true;
|
||||
std::string reload_error;require(renderer.reload_shaders(reload_error),"Compatible shader pipeline reload");
|
||||
texture->rgba={40,30,230,255};++texture->revision;renderer.render(scene);pixels=renderer.pixels();require(pixels[index+2]>220,"Texture revision upload");
|
||||
if(argc>2)renderer.capture(argv[2]);renderer.resize(400,300);renderer.poll_events();renderer.render(scene);require(renderer.width()==400&&renderer.height()==300,"Render target resize");require(renderer.stats().validation_errors==0,"Resize validation error");
|
||||
std::cout<<"Vulkan frame, shadow/PBR, atlas upload, readback and resize passed on "<<renderer.stats().device<<'\n';
|
||||
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}return 0;}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#include <bit>
|
||||
#include <cmath>
|
||||
#include <faset/assets/asset_pipeline.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/player/SceneView.hpp>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
using Json = nlohmann::json;
|
||||
namespace {
|
||||
void check(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
template <class F> void rejects(F&& function, const char* message) {
|
||||
bool caught = false;
|
||||
try {
|
||||
function();
|
||||
} catch (const std::exception&) {
|
||||
caught = true;
|
||||
}
|
||||
check(caught, message);
|
||||
}
|
||||
Json component(std::string type, Json fields) {
|
||||
return {{"id", type}, {"type", type}, {"version", 1}, {"fields", fields}};
|
||||
}
|
||||
Json entity(std::string id, Json parent, Json components) {
|
||||
return {{"id", id}, {"name", id}, {"parent", parent}, {"components", components}};
|
||||
}
|
||||
void run() {
|
||||
const auto folder =
|
||||
std::filesystem::temp_directory_path() / ("faset-player-test-" + faset::new_id());
|
||||
std::filesystem::create_directories(folder);
|
||||
struct Cleanup {
|
||||
std::filesystem::path path;
|
||||
~Cleanup() {
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(path, error);
|
||||
}
|
||||
} cleanup{folder};
|
||||
Json scene{
|
||||
{"format", "faset.scene"},
|
||||
{"version", 1},
|
||||
{"id", "test"},
|
||||
{"name", "Test"},
|
||||
{"dimension", 3},
|
||||
{"instances", Json::array()},
|
||||
{"entities",
|
||||
Json::array(
|
||||
{entity("parent", nullptr,
|
||||
Json::array({component("faset.transform", {{"position", {1, 2, 3}}})})),
|
||||
entity("child", "parent",
|
||||
Json::array({component("faset.transform", {{"position", {2, 0, 0}}}),
|
||||
component("faset.mesh", {{"asset", "builtin:cube"}})}))})}};
|
||||
faset::atomic_write_json(folder / "scene.json", scene);
|
||||
check(faset::player::readScene(folder / "scene.json") == scene, "JSON scene roundtrip");
|
||||
const auto cbor = Json::to_cbor(scene);
|
||||
std::string cooked = "FASETSCN";
|
||||
for (int i = 0; i < 4; ++i)
|
||||
cooked.push_back(static_cast<char>(std::uint32_t{1} >> (8 * i)));
|
||||
for (int i = 0; i < 8; ++i)
|
||||
cooked.push_back(static_cast<char>(std::uint64_t(cbor.size()) >> (8 * i)));
|
||||
cooked.append(reinterpret_cast<const char*>(cbor.data()), cbor.size());
|
||||
faset::atomic_write(folder / "scene.fscene", cooked);
|
||||
check(faset::player::readScene(folder / "scene.fscene") == scene,
|
||||
"CBOR cooked scene roundtrip");
|
||||
auto truncated = cooked.substr(0, cooked.size() - 1);
|
||||
faset::atomic_write(folder / "truncated.fscene", truncated);
|
||||
rejects([&] { faset::player::readScene(folder / "truncated.fscene"); },
|
||||
"reject cooked size mismatch");
|
||||
auto version = cooked;
|
||||
version[8] = 2;
|
||||
faset::atomic_write(folder / "version.fscene", version);
|
||||
rejects([&] { faset::player::readScene(folder / "version.fscene"); }, "reject cooked version");
|
||||
faset::player::SceneView view(folder);
|
||||
auto snapshot = view.build(scene, 16.f / 9.f);
|
||||
check(snapshot.draws.size() == 1, "SceneView builtin mesh");
|
||||
check(snapshot.draws[0].model[12] == 3 && snapshot.draws[0].model[13] == 2 &&
|
||||
snapshot.draws[0].model[14] == 3,
|
||||
"hierarchy local transforms composed");
|
||||
faset::runtime::Runtime world;
|
||||
world.load(scene);
|
||||
auto pose = world.transform(world.find("child"));
|
||||
pose.position[0] = 5;
|
||||
world.setTransform(world.find("child"), pose);
|
||||
snapshot = view.build(world.snapshotJson(), 1);
|
||||
check(snapshot.draws[0].model[12] == 6,
|
||||
"runtime presentation overrides original authoring pose");
|
||||
auto bad = scene;
|
||||
bad["entities"][0]["parent"] = "child";
|
||||
rejects([&] { view.build(bad, 1); }, "view rejects hierarchy cycle");
|
||||
bad = scene;
|
||||
bad["entities"][1]["components"][1]["fields"]["asset"] = "missing-asset";
|
||||
view.build(bad, 1);
|
||||
check(!view.diagnostics().empty() && view.diagnostics()[0].starts_with("error:"),
|
||||
"missing asset is diagnostic, not silent success");
|
||||
auto camera = faset::player::CameraSettings{};
|
||||
camera.eye = camera.target;
|
||||
rejects([&] { view.build(scene, 1, camera); }, "reject degenerate camera");
|
||||
auto two = scene;
|
||||
two["dimension"] = 2;
|
||||
two["entities"] = Json::array(
|
||||
{entity("high", nullptr,
|
||||
Json::array({component("faset.sprite", {{"layer", 10}, {"color", {1, 0, 0, 1}}})})),
|
||||
entity(
|
||||
"low", nullptr,
|
||||
Json::array({component("faset.sprite", {{"layer", 0}, {"color", {0, 1, 0, 1}}})}))});
|
||||
snapshot = view.build(two, 1);
|
||||
check(snapshot.sprites.size() == 2 && snapshot.sprites[0].color[1] == 1,
|
||||
"2D sprites sorted by layer");
|
||||
|
||||
// Import a real textured glTF fixture, then consume only its cooked generation.
|
||||
std::string geometry;
|
||||
auto word = [&](std::uint32_t v) {
|
||||
for (int i = 0; i < 4; ++i)
|
||||
geometry.push_back(static_cast<char>(v >> (8 * i)));
|
||||
};
|
||||
for (float value : {0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f, 0.f})
|
||||
word(std::bit_cast<std::uint32_t>(value));
|
||||
for (char value : {0, 0, 1, 0, 2, 0})
|
||||
geometry.push_back(value);
|
||||
faset::atomic_write(folder / "geometry.bin", geometry);
|
||||
const std::vector<unsigned char> png{
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
|
||||
0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0,
|
||||
13, 73, 68, 65, 84, 120, 156, 99, 248, 16, 32, 242, 31, 0, 5, 220, 2, 84,
|
||||
184, 210, 98, 74, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130};
|
||||
faset::atomic_write(folder / "pixel.png",
|
||||
std::string_view(reinterpret_cast<const char*>(png.data()), png.size()));
|
||||
Json gltf = {
|
||||
{"asset", {{"version", "2.0"}}},
|
||||
{"scene", 0},
|
||||
{"scenes", Json::array({{{"nodes", Json::array({0})}}})},
|
||||
{"nodes", Json::array({{{"mesh", 0}, {"translation", {2, 0, 0}}}})},
|
||||
{"buffers", Json::array({{{"uri", "geometry.bin"}, {"byteLength", 42}}})},
|
||||
{"bufferViews", Json::array({{{"buffer", 0}, {"byteOffset", 0}, {"byteLength", 36}},
|
||||
{{"buffer", 0}, {"byteOffset", 36}, {"byteLength", 6}}})},
|
||||
{"accessors",
|
||||
Json::array(
|
||||
{{{"bufferView", 0},
|
||||
{"componentType", 5126},
|
||||
{"count", 3},
|
||||
{"type", "VEC3"},
|
||||
{"min", {0, 0, 0}},
|
||||
{"max", {1, 1, 0}}},
|
||||
{{"bufferView", 1}, {"componentType", 5123}, {"count", 3}, {"type", "SCALAR"}}})},
|
||||
{"meshes", Json::array({{{"primitives", Json::array({{{"attributes", {{"POSITION", 0}}},
|
||||
{"indices", 1},
|
||||
{"material", 0}}})}}})},
|
||||
{"materials", Json::array({{{"pbrMetallicRoughness",
|
||||
{{"baseColorFactor", {1, 1, 1, 1}},
|
||||
{"baseColorTexture", {{"index", 0}}},
|
||||
{"metallicFactor", 0},
|
||||
{"roughnessFactor", 0.5}}}}})},
|
||||
{"images", Json::array({{{"uri", "pixel.png"}}})},
|
||||
{"textures", Json::array({{{"source", 0}}})}};
|
||||
faset::atomic_write_json(folder / "triangle.gltf", gltf);
|
||||
faset::assets::AssetPipeline pipeline(folder);
|
||||
auto imported = pipeline.import_asset({folder / "triangle.gltf"});
|
||||
check(imported.ok(), "real textured glTF fixture import");
|
||||
auto importedScene = scene;
|
||||
importedScene["entities"][1]["components"][1]["fields"]["asset"] = imported.asset_id;
|
||||
snapshot = view.build(importedScene, 1);
|
||||
check(view.diagnostics().empty(), "valid cooked texture/material produces no error");
|
||||
check(snapshot.draws.size() == 1 && snapshot.draws[0].mesh->vertices.size() == 3,
|
||||
"cooked mesh reaches render snapshot");
|
||||
check(snapshot.draws[0].model[12] == 5, "asset node transform composed with scene hierarchy");
|
||||
check(snapshot.draws[0].texture && snapshot.draws[0].texture->srgb &&
|
||||
snapshot.draws[0].texture->rgba == std::vector<std::uint8_t>({240, 80, 20, 255}),
|
||||
"PNG decoded into sRGB base-color texture");
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
try {
|
||||
run();
|
||||
std::cout << "Player JSON/CBOR, hierarchy, runtime snapshot, camera, sprite ordering and "
|
||||
"asset error contracts passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+309
-70
@@ -1,6 +1,6 @@
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include "Gameplay.hpp"
|
||||
#include <cmath>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
@@ -8,82 +8,321 @@
|
||||
#include <vector>
|
||||
|
||||
using namespace faset::runtime;
|
||||
using Json=nlohmann::json;
|
||||
using Json = nlohmann::json;
|
||||
namespace {
|
||||
void check(bool result,const char* text){if(!result)throw std::runtime_error(text);}
|
||||
void near(float actual,float expected,float tolerance,const char* text){check(std::abs(actual-expected)<tolerance,text);}
|
||||
template<class F>void rejects(F&& fn,const char* message){bool caught=false;try{fn();}catch(const std::exception&){caught=true;}check(caught,message);}
|
||||
Json component(std::string type,Json fields=Json::object()){return {{"id",type+"-id"},{"type",type},{"version",1},{"fields",fields}};}
|
||||
Json entity(std::string id,float y=0){return {{"id",id},{"name",id},{"parent",nullptr},{"components",Json::array({component("faset.transform",{{"position",{0,y,0}}})})}};}
|
||||
Json scene(int dim=2){return {{"format","faset.scene"},{"version",1},{"id","test-scene"},{"name","Test"},{"dimension",dim},{"entities",Json::array()},{"instances",Json::array()}};}
|
||||
void physics(int dimension){
|
||||
Runtime world;auto doc=scene(dimension);auto floor=entity("ground",-0.5f);auto falling=entity("falling",4);
|
||||
auto type=dimension==2?"faset.rigid_body_2d":"faset.rigid_body_3d";
|
||||
Json extents=dimension==2?Json{10,0.5}:Json{10,0.5,10};
|
||||
floor["components"].push_back(component(type,{{"body_type","static"},{"half_extents",extents}}));
|
||||
falling["components"].push_back(component(type));doc["entities"]=Json::array({floor,falling});world.load(doc);auto h=world.find("falling");
|
||||
bool contact=false;
|
||||
for(int i=0;i<240;++i){world.advance(1.0/60);for(const auto& event:world.collisions())contact=contact||event.began;}
|
||||
near(world.transform(h).position[1],0.5f,0.09f,"body must fall and settle on actual solver floor");check(contact,"native contact event must be delivered");
|
||||
auto pose=world.transform(h);pose.position[1]=6;world.teleport(h,pose);
|
||||
near(world.presentation(h).position[1],6,0.0001f,"teleport resets interpolation");
|
||||
rejects([&]{world.setTransform(h,pose);},"physics transform cannot be casually overwritten");
|
||||
world.applyImpulse(h,{0,2,0});check(world.velocity(h)[1]>0,"impulse changes solver velocity");
|
||||
void check(bool result, const char* text) {
|
||||
if (!result)
|
||||
throw std::runtime_error(text);
|
||||
}
|
||||
void lifecycle(){
|
||||
Runtime world;std::vector<std::string> events;bool spawned=false;float presented=-1;int pressedTicks=0;
|
||||
void near(float actual, float expected, float tolerance, const char* text) {
|
||||
check(std::abs(actual - expected) < tolerance, text);
|
||||
}
|
||||
template <class F> void rejects(F&& fn, const char* message) {
|
||||
bool caught = false;
|
||||
try {
|
||||
fn();
|
||||
} catch (const std::exception&) {
|
||||
caught = true;
|
||||
}
|
||||
check(caught, message);
|
||||
}
|
||||
Json component(std::string type, Json fields = Json::object()) {
|
||||
return {{"id", type + "-id"}, {"type", type}, {"version", 1}, {"fields", fields}};
|
||||
}
|
||||
Json entity(std::string id, float y = 0) {
|
||||
return {{"id", id},
|
||||
{"name", id},
|
||||
{"parent", nullptr},
|
||||
{"components", Json::array({component("faset.transform", {{"position", {0, y, 0}}})})}};
|
||||
}
|
||||
Json scene(int dim = 2) {
|
||||
return {{"format", "faset.scene"}, {"version", 1},
|
||||
{"id", "test-scene"}, {"name", "Test"},
|
||||
{"dimension", dim}, {"entities", Json::array()},
|
||||
{"instances", Json::array()}};
|
||||
}
|
||||
void physics(int dimension) {
|
||||
Runtime world;
|
||||
auto doc = scene(dimension);
|
||||
auto floor = entity("ground", -0.5f);
|
||||
auto falling = entity("falling", 4);
|
||||
auto type = dimension == 2 ? "faset.rigid_body_2d" : "faset.rigid_body_3d";
|
||||
Json extents = dimension == 2 ? Json{10, 0.5} : Json{10, 0.5, 10};
|
||||
floor["components"].push_back(
|
||||
component(type, {{"body_type", "static"}, {"half_extents", extents}}));
|
||||
falling["components"].push_back(component(type));
|
||||
doc["entities"] = Json::array({floor, falling});
|
||||
world.load(doc);
|
||||
auto h = world.find("falling");
|
||||
bool contact = false;
|
||||
for (int i = 0; i < 240; ++i) {
|
||||
world.advance(1.0 / 60);
|
||||
for (const auto& event : world.collisions())
|
||||
contact = contact || event.began;
|
||||
}
|
||||
near(world.transform(h).position[1], 0.5f, 0.09f,
|
||||
"body must fall and settle on actual solver floor");
|
||||
check(contact, "native contact event must be delivered");
|
||||
check(world.grounded(h), "settled body must have a supporting native contact");
|
||||
auto pose = world.transform(h);
|
||||
pose.position[1] = 6;
|
||||
world.teleport(h, pose);
|
||||
near(world.presentation(h).position[1], 6, 0.0001f, "teleport resets interpolation");
|
||||
check(!world.grounded(h), "teleport off the floor removes grounded state");
|
||||
rejects([&] { world.setTransform(h, pose); },
|
||||
"physics transform cannot be casually overwritten");
|
||||
world.applyImpulse(h, {0, 2, 0});
|
||||
check(world.velocity(h)[1] > 0, "impulse changes solver velocity");
|
||||
}
|
||||
void lifecycle() {
|
||||
Runtime world;
|
||||
std::vector<std::string> events;
|
||||
bool spawned = false;
|
||||
float presented = -1;
|
||||
int pressedTicks = 0;
|
||||
Behavior behavior;
|
||||
behavior.onStart=[&](Runtime&,EntityHandle,double){events.push_back("start");};
|
||||
behavior.fixedUpdate=[&](Runtime& r,EntityHandle h,double){events.push_back("fixed");if(r.input().jumpPressed)++pressedTicks;auto t=r.transform(h);t.position[0]+=1;r.setTransform(h,t);if(!spawned){r.spawn(entity("spawned"));spawned=true;}};
|
||||
behavior.update=[&](Runtime&,EntityHandle,double){events.push_back("update");};
|
||||
behavior.lateUpdate=[&](Runtime& r,EntityHandle h,double){events.push_back("late");presented=r.presentation(h).position[0];};
|
||||
behavior.onDestroy=[&](Runtime& r,EntityHandle h,double){check(r.valid(h),"OnDestroy still sees a valid handle");events.push_back("destroy");};
|
||||
world.registerBehavior("test.behavior",behavior);auto doc=scene();auto object=entity("main");object["components"].push_back(component("test.behavior"));doc["entities"].push_back(object);world.load(doc);
|
||||
check(events==std::vector<std::string>{"start"},"load runs OnStart once");
|
||||
world.advance(1.0/120,{0,0,true,false});check(!world.find("spawned"),"zero-tick frame applies no structural commands");
|
||||
world.advance(1.0/60);check(!world.find("spawned"),"FixedUpdate spawn must wait until next tick");near(presented,0.5f,0.001f,"LateUpdate receives interpolated transform");check(pressedTicks==1,"input edge preserved across zero-tick frame");
|
||||
world.advance(3.0/60);check(bool(world.find("spawned")),"spawn appears next tick");check(pressedTicks==1,"edge not repeated in catchup ticks");
|
||||
auto old=world.find("main");world.destroy(old);check(world.valid(old),"destroy deferred");world.singleStep();check(!world.valid(old),"handle invalid after removal");
|
||||
check(events.back()=="destroy","destroy lifecycle runs exactly at barrier");
|
||||
world.spawn(entity("replacement"));world.singleStep();check(!world.valid(old),"reused slot never revives a stale handle");
|
||||
auto replacement=world.find("replacement");world.load(doc);check(!world.valid(replacement),"load creates a new session");
|
||||
behavior.onStart = [&](Runtime&, EntityHandle, double) { events.push_back("start"); };
|
||||
behavior.fixedUpdate = [&](Runtime& r, EntityHandle h, double) {
|
||||
events.push_back("fixed");
|
||||
if (r.input().jumpPressed)
|
||||
++pressedTicks;
|
||||
auto t = r.transform(h);
|
||||
t.position[0] += 1;
|
||||
r.setTransform(h, t);
|
||||
if (!spawned) {
|
||||
r.spawn(entity("spawned"));
|
||||
spawned = true;
|
||||
}
|
||||
};
|
||||
behavior.update = [&](Runtime&, EntityHandle, double) { events.push_back("update"); };
|
||||
behavior.lateUpdate = [&](Runtime& r, EntityHandle h, double) {
|
||||
events.push_back("late");
|
||||
presented = r.presentation(h).position[0];
|
||||
};
|
||||
behavior.onDestroy = [&](Runtime& r, EntityHandle h, double) {
|
||||
check(r.valid(h), "OnDestroy still sees a valid handle");
|
||||
events.push_back("destroy");
|
||||
};
|
||||
world.registerBehavior("test.behavior", behavior);
|
||||
auto doc = scene();
|
||||
auto object = entity("main");
|
||||
object["components"].push_back(component("test.behavior"));
|
||||
doc["entities"].push_back(object);
|
||||
world.load(doc);
|
||||
check(events == std::vector<std::string>{"start"}, "load runs OnStart once");
|
||||
world.advance(1.0 / 120, {0, 0, true, false});
|
||||
check(!world.find("spawned"), "zero-tick frame applies no structural commands");
|
||||
world.advance(1.0 / 60);
|
||||
check(!world.find("spawned"), "FixedUpdate spawn must wait until next tick");
|
||||
near(presented, 0.5f, 0.001f, "LateUpdate receives interpolated transform");
|
||||
check(pressedTicks == 1, "input edge preserved across zero-tick frame");
|
||||
world.advance(3.0 / 60);
|
||||
check(bool(world.find("spawned")), "spawn appears next tick");
|
||||
check(pressedTicks == 1, "edge not repeated in catchup ticks");
|
||||
auto old = world.find("main");
|
||||
world.destroy(old);
|
||||
check(world.valid(old), "destroy deferred");
|
||||
world.singleStep();
|
||||
check(!world.valid(old), "handle invalid after removal");
|
||||
check(events.back() == "destroy", "destroy lifecycle runs exactly at barrier");
|
||||
world.spawn(entity("replacement"));
|
||||
world.singleStep();
|
||||
check(!world.valid(old), "reused slot never revives a stale handle");
|
||||
auto replacement = world.find("replacement");
|
||||
world.load(doc);
|
||||
check(!world.valid(replacement), "load creates a new session");
|
||||
// Ensure captured state remains alive while Runtime's destructor calls OnDestroy.
|
||||
world.clear();
|
||||
}
|
||||
void clockAndValidation(){
|
||||
Runtime world;auto doc=scene();doc["entities"].push_back(entity("object"));world.load(doc);
|
||||
auto stats=world.advance(1.0);check(stats.fixedTicks==4,"catchup bounded to four ticks");check(stats.droppedTime>0.9,"excess time reported");check(stats.interpolationAlpha>=0&&stats.interpolationAlpha<1,"interpolation fraction bounded");
|
||||
auto tick=stats.tick;world.setPaused(true);world.advance(100);check(world.snapshot().tick==tick,"pause does not accumulate");world.singleStep();check(world.snapshot().tick==tick+1,"single-step advances exactly once");world.setPaused(false);check(world.advance(0).fixedTicks==0,"resume does not catch up pause");
|
||||
auto old=world.find("object");auto invalid=doc;invalid["entities"][0]["parent"]="object";rejects([&]{world.load(invalid);},"reject hierarchy cycle");check(world.valid(old),"invalid load preserves old world");
|
||||
invalid=doc;invalid["entities"][0]["components"].push_back(component("faset.rigid_body_3d"));rejects([&]{world.load(invalid);},"reject physics dimension mismatch");
|
||||
rejects([&]{world.advance(-1);},"reject negative time");
|
||||
world.addComponent(old,component("faset.sprite"));check(!world.snapshot().entities[0].sprite,"component addition deferred");world.singleStep();check(world.snapshot().entities[0].sprite.has_value(),"component added at barrier");world.removeComponent(old,"faset.sprite");world.singleStep();check(!world.snapshot().entities[0].sprite,"component removed at barrier");
|
||||
Runtime other;other.load(doc);check(!other.valid(old),"handle cannot cross worlds");
|
||||
auto schema=faset::gameplay::schema();check(schema.size()==2,"sample has explicit metadata without world");
|
||||
void clockAndValidation() {
|
||||
Runtime world;
|
||||
auto doc = scene();
|
||||
doc["entities"].push_back(entity("object"));
|
||||
world.load(doc);
|
||||
auto stats = world.advance(1.0);
|
||||
check(stats.fixedTicks == 4, "catchup bounded to four ticks");
|
||||
check(stats.droppedTime > 0.9, "excess time reported");
|
||||
check(stats.interpolationAlpha >= 0 && stats.interpolationAlpha < 1,
|
||||
"interpolation fraction bounded");
|
||||
auto tick = stats.tick;
|
||||
world.setPaused(true);
|
||||
world.advance(100);
|
||||
check(world.snapshot().tick == tick, "pause does not accumulate");
|
||||
world.singleStep();
|
||||
check(world.snapshot().tick == tick + 1, "single-step advances exactly once");
|
||||
world.setPaused(false);
|
||||
check(world.advance(0).fixedTicks == 0, "resume does not catch up pause");
|
||||
auto old = world.find("object");
|
||||
auto invalid = doc;
|
||||
invalid["entities"][0]["parent"] = "object";
|
||||
rejects([&] { world.load(invalid); }, "reject hierarchy cycle");
|
||||
check(world.valid(old), "invalid load preserves old world");
|
||||
invalid = doc;
|
||||
invalid["entities"][0]["components"].push_back(component("faset.rigid_body_3d"));
|
||||
rejects([&] { world.load(invalid); }, "reject physics dimension mismatch");
|
||||
rejects([&] { world.advance(-1); }, "reject negative time");
|
||||
world.addComponent(old, component("faset.sprite"));
|
||||
check(!world.snapshot().entities[0].sprite, "component addition deferred");
|
||||
world.singleStep();
|
||||
check(world.snapshot().entities[0].sprite.has_value(), "component added at barrier");
|
||||
world.removeComponent(old, "faset.sprite");
|
||||
world.singleStep();
|
||||
check(!world.snapshot().entities[0].sprite, "component removed at barrier");
|
||||
Runtime other;
|
||||
other.load(doc);
|
||||
check(!other.valid(old), "handle cannot cross worlds");
|
||||
auto schema = faset::gameplay::schema();
|
||||
check(schema.size() == 2, "sample has explicit metadata without world");
|
||||
}
|
||||
void structuralFailuresAndCallbacks(){
|
||||
Runtime world;auto doc=scene();doc["entities"].push_back(entity("object"));world.load(doc);auto h=world.find("object");
|
||||
world.addComponent(h,component("faset.sprite",{{"size",{-1,2}}}));world.singleStep();check(!world.snapshot().entities[0].sprite,"invalid deferred component leaves entity unchanged");check(!world.diagnostics().empty(),"invalid deferred command reports diagnostic");
|
||||
auto zero=world.transform(h);zero.scale[0]=0;world.setTransform(h,zero);world.addComponent(h,component("faset.rigid_body_2d"));world.singleStep();rejects([&]{world.fields(h,"faset.rigid_body_2d");},"invalid runtime collider scale must not half-add component");
|
||||
zero.scale[0]=1;world.setTransform(h,zero);world.addComponent(h,component("faset.rigid_body_2d"));world.singleStep();check(world.velocity(h)[1]<0,"deferred body runs actual physics");
|
||||
world.removeComponent(h,"faset.rigid_body_2d");world.singleStep();rejects([&]{world.velocity(h);},"removed physics adapter no longer accessible");
|
||||
world.destroy(h);world.destroy(h);world.singleStep();check(!world.valid(h),"repeated deferred destroy is safe");
|
||||
rejects([&]{world.advance(std::numeric_limits<double>::quiet_NaN());},"nonfinite time rejected");
|
||||
rejects([&]{world.advance(0,{std::numeric_limits<float>::infinity(),0,false,false});},"nonfinite input rejected");
|
||||
Runtime callbacks;std::vector<std::string> order;
|
||||
Behavior b;b.onStart=[&](Runtime& r,EntityHandle,double){order.push_back("start");rejects([&]{r.singleStep();},"OnStart cannot recursively advance");};
|
||||
b.fixedUpdate=[&](Runtime&,EntityHandle,double){order.push_back("fixed");throw std::runtime_error("intentional callback failure");};
|
||||
b.update=[&](Runtime&,EntityHandle,double){order.push_back("update");};
|
||||
b.lateUpdate=[&](Runtime& r,EntityHandle h,double){order.push_back("late");auto p=r.presentation(h);p.position[2]=9;r.setPresentation(h,p);};
|
||||
callbacks.registerBehavior("test",b);auto object=entity("callbacks");object["components"].push_back(component("test"));doc["entities"]=Json::array({object});callbacks.load(doc);callbacks.singleStep();
|
||||
check(order==std::vector<std::string>{"start","fixed","update","late"},"callback failure does not skip remaining phases");check(callbacks.diagnostics().size()==1,"callback exception diagnostic");near(callbacks.snapshot().entities[0].transform.position[2],9,0.001f,"LateUpdate changes final presentation only");near(callbacks.transform(callbacks.find("callbacks")).position[2],0,0.001f,"presentation does not overwrite simulation");callbacks.clear();
|
||||
void structuralFailuresAndCallbacks() {
|
||||
Runtime world;
|
||||
auto doc = scene();
|
||||
doc["entities"].push_back(entity("object"));
|
||||
world.load(doc);
|
||||
auto h = world.find("object");
|
||||
world.addComponent(h, component("faset.sprite", {{"size", {-1, 2}}}));
|
||||
world.singleStep();
|
||||
check(!world.snapshot().entities[0].sprite,
|
||||
"invalid deferred component leaves entity unchanged");
|
||||
check(!world.diagnostics().empty(), "invalid deferred command reports diagnostic");
|
||||
auto zero = world.transform(h);
|
||||
zero.scale[0] = 0;
|
||||
world.setTransform(h, zero);
|
||||
world.addComponent(h, component("faset.rigid_body_2d"));
|
||||
world.singleStep();
|
||||
rejects([&] { world.fields(h, "faset.rigid_body_2d"); },
|
||||
"invalid runtime collider scale must not half-add component");
|
||||
zero.scale[0] = 1;
|
||||
world.setTransform(h, zero);
|
||||
world.addComponent(h, component("faset.rigid_body_2d"));
|
||||
world.singleStep();
|
||||
check(world.velocity(h)[1] < 0, "deferred body runs actual physics");
|
||||
world.removeComponent(h, "faset.rigid_body_2d");
|
||||
world.singleStep();
|
||||
rejects([&] { world.velocity(h); }, "removed physics adapter no longer accessible");
|
||||
world.destroy(h);
|
||||
world.destroy(h);
|
||||
world.singleStep();
|
||||
check(!world.valid(h), "repeated deferred destroy is safe");
|
||||
rejects([&] { world.advance(std::numeric_limits<double>::quiet_NaN()); },
|
||||
"nonfinite time rejected");
|
||||
rejects([&] { world.advance(0, {std::numeric_limits<float>::infinity(), 0, false, false}); },
|
||||
"nonfinite input rejected");
|
||||
Runtime callbacks;
|
||||
std::vector<std::string> order;
|
||||
Behavior b;
|
||||
b.onStart = [&](Runtime& r, EntityHandle, double) {
|
||||
order.push_back("start");
|
||||
rejects([&] { r.singleStep(); }, "OnStart cannot recursively advance");
|
||||
};
|
||||
b.fixedUpdate = [&](Runtime&, EntityHandle, double) {
|
||||
order.push_back("fixed");
|
||||
throw std::runtime_error("intentional callback failure");
|
||||
};
|
||||
b.update = [&](Runtime&, EntityHandle, double) { order.push_back("update"); };
|
||||
b.lateUpdate = [&](Runtime& r, EntityHandle h, double) {
|
||||
order.push_back("late");
|
||||
auto p = r.presentation(h);
|
||||
p.position[2] = 9;
|
||||
r.setPresentation(h, p);
|
||||
};
|
||||
callbacks.registerBehavior("test", b);
|
||||
auto object = entity("callbacks");
|
||||
object["components"].push_back(component("test"));
|
||||
doc["entities"] = Json::array({object});
|
||||
callbacks.load(doc);
|
||||
callbacks.singleStep();
|
||||
check(order == std::vector<std::string>{"start", "fixed", "update", "late"},
|
||||
"callback failure does not skip remaining phases");
|
||||
check(callbacks.diagnostics().size() == 1, "callback exception diagnostic");
|
||||
near(callbacks.snapshot().entities[0].transform.position[2], 9, 0.001f,
|
||||
"LateUpdate changes final presentation only");
|
||||
near(callbacks.transform(callbacks.find("callbacks")).position[2], 0, 0.001f,
|
||||
"presentation does not overwrite simulation");
|
||||
callbacks.clear();
|
||||
}
|
||||
void sampleGameplay(){
|
||||
Runtime world;faset::gameplay::registerGameplay(world);auto doc=scene(3);auto door=entity("door");door["components"].push_back(component("gameplay.door",{{"speed",2.0}}));doc["entities"]=Json::array({door});world.load(doc);
|
||||
world.advance(1.0/60,{0,0,false,true});for(int i=0;i<59;++i)world.advance(1.0/60);
|
||||
near(world.transform(world.find("door")).rotation[1],1.5707963f,0.001f,"sample door opens through real static gameplay callback");
|
||||
world.advance(1.0/60,{0,0,false,true});for(int i=0;i<59;++i)world.advance(1.0/60);
|
||||
near(world.transform(world.find("door")).rotation[1],0,0.001f,"sample door toggles closed");
|
||||
void sampleGameplay() {
|
||||
Runtime world;
|
||||
faset::gameplay::registerGameplay(world);
|
||||
auto doc = scene(3);
|
||||
auto door = entity("door");
|
||||
door["components"].push_back(component("gameplay.door", {{"speed", 2.0}}));
|
||||
doc["entities"] = Json::array({door});
|
||||
world.load(doc);
|
||||
world.advance(1.0 / 60, {0, 0, false, true});
|
||||
for (int i = 0; i < 59; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
near(world.transform(world.find("door")).rotation[1], 1.5707963f, 0.001f,
|
||||
"sample door opens through real static gameplay callback");
|
||||
world.advance(1.0 / 60, {0, 0, false, true});
|
||||
for (int i = 0; i < 59; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
near(world.transform(world.find("door")).rotation[1], 0, 0.001f, "sample door toggles closed");
|
||||
}
|
||||
void groundedJump() {
|
||||
Runtime world;
|
||||
faset::gameplay::registerGameplay(world);
|
||||
auto doc = scene(2);
|
||||
auto ground = entity("ground", -0.5f);
|
||||
ground["components"].push_back(
|
||||
component("faset.rigid_body_2d", {{"body_type", "static"}, {"half_extents", {10, .5}}}));
|
||||
auto character = entity("character", 0.5f);
|
||||
character["components"].push_back(component("faset.rigid_body_2d"));
|
||||
character["components"].push_back(component("gameplay.character"));
|
||||
doc["entities"] = Json::array({ground, character});
|
||||
world.load(doc);
|
||||
auto h = world.find("character");
|
||||
for (int i = 0; i < 10; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
check(world.grounded(h), "character starts supported");
|
||||
world.advance(1.0 / 60, {0, 0, true, false});
|
||||
check(world.velocity(h)[1] > 4, "grounded jump sets upward velocity");
|
||||
for (int i = 0; i < 90 && world.velocity(h)[1] > 0.1f; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
check(!world.grounded(h), "apex is not grounded");
|
||||
auto before = world.velocity(h)[1];
|
||||
world.advance(1.0 / 60, {0, 0, true, false});
|
||||
check(world.velocity(h)[1] < before, "jump at apex must not create a second impulse");
|
||||
for (int i = 0; i < 180; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
check(world.grounded(h), "character regains ground after landing");
|
||||
Runtime wall;
|
||||
auto wallScene = scene(2);
|
||||
auto obstacle = entity("wall");
|
||||
obstacle["components"].push_back(
|
||||
component("faset.rigid_body_2d", {{"body_type", "static"}, {"half_extents", {.5, 5}}}));
|
||||
auto body = entity("side");
|
||||
body["components"][0]["fields"]["position"] = {1, 0, 0};
|
||||
body["components"].push_back(component("faset.rigid_body_2d", {{"gravity_scale", 0}}));
|
||||
wallScene["entities"] = Json::array({obstacle, body});
|
||||
wall.load(wallScene);
|
||||
for (int i = 0; i < 5; ++i)
|
||||
wall.advance(1.0 / 60);
|
||||
check(!wall.grounded(wall.find("side")), "wall contact is not a supporting floor contact");
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
try {
|
||||
auto run = [](const char* name, auto fn) {
|
||||
try {
|
||||
fn();
|
||||
std::cout << name << " passed\n";
|
||||
} catch (const std::exception& error) {
|
||||
throw std::runtime_error(std::string(name) + ": " + error.what());
|
||||
}
|
||||
};
|
||||
run("Box2D", [] { physics(2); });
|
||||
run("Box3D", [] { physics(3); });
|
||||
run("Lifecycle", lifecycle);
|
||||
run("Clock/validation", clockAndValidation);
|
||||
run("Structural failures", structuralFailuresAndCallbacks);
|
||||
run("Sample gameplay", sampleGameplay);
|
||||
run("Grounded jump", groundedJump);
|
||||
std::cout << "runtime contracts passed: actual Box2D/Box3D collisions, lifecycle, handles, "
|
||||
"interpolation, deferred mutation, catchup, pause, validation, gameplay\n";
|
||||
return 0;
|
||||
} catch (const std::exception& ex) {
|
||||
std::cerr << ex.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
int main(){try{auto run=[](const char* name,auto fn){try{fn();std::cout<<name<<" passed\n";}catch(const std::exception& error){throw std::runtime_error(std::string(name)+": "+error.what());}};run("Box2D",[]{physics(2);});run("Box3D",[]{physics(3);});run("Lifecycle",lifecycle);run("Clock/validation",clockAndValidation);run("Structural failures",structuralFailuresAndCallbacks);run("Sample gameplay",sampleGameplay);std::cout<<"runtime contracts passed: actual Box2D/Box3D collisions, lifecycle, handles, interpolation, deferred mutation, catchup, pause, validation, gameplay\n";return 0;}catch(const std::exception& ex){std::cerr<<ex.what()<<'\n';return 1;}}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "Gameplay.hpp"
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
void check(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
void near(float a, float b, const char* message) {
|
||||
check(std::abs(a - b) < 0.002f, message);
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
try {
|
||||
nlohmann::json scene;
|
||||
std::ifstream input(FASET_TUTORIAL_SCENE);
|
||||
input >> scene;
|
||||
std::set<std::string> stableIds;
|
||||
for (const auto& entity : scene.at("entities")) {
|
||||
check(stableIds.insert(entity.at("id").get<std::string>()).second,
|
||||
"entity stable ID must be globally unique");
|
||||
for (const auto& component : entity.at("components"))
|
||||
check(stableIds.insert(component.at("id").get<std::string>()).second,
|
||||
"component stable ID must be globally unique for Editor authoring");
|
||||
}
|
||||
const auto types = faset::gameplay::schema();
|
||||
check(types.is_array() && !types.empty(), "tutorial must export real component schemas");
|
||||
for (const auto& type : types)
|
||||
for (const auto& [id, field] : type.at("fields").items())
|
||||
check(field.at("id") == id && field.contains("default"),
|
||||
"schema FieldId/default contract");
|
||||
faset::runtime::Runtime world;
|
||||
faset::gameplay::registerGameplay(world);
|
||||
world.load(scene);
|
||||
const std::string tutorial = FASET_TUTORIAL_NAME;
|
||||
if (tutorial == "moving") {
|
||||
for (int i = 0; i < 60; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
near(world.transform(world.find("actor")).position[0], 2,
|
||||
"60 Hz motion covers two metres per second");
|
||||
world.load(scene);
|
||||
for (int i = 0; i < 30; ++i)
|
||||
world.advance(1.0 / 30);
|
||||
near(world.transform(world.find("actor")).position[0], 2,
|
||||
"30 Hz motion covers the same distance");
|
||||
} else if (tutorial == "following") {
|
||||
world.advance(1.5 / 60);
|
||||
near(world.presentation(world.find("actor")).position[0], 1.0f / 60,
|
||||
"presentation halfway between completed fixed poses");
|
||||
near(world.presentation(world.find("camera")).position[0],
|
||||
world.presentation(world.find("actor")).position[0],
|
||||
"LateUpdate follows interpolated target");
|
||||
near(world.transform(world.find("camera")).position[0], 0,
|
||||
"following does not change simulation transform");
|
||||
const auto target = world.find("actor");
|
||||
world.destroy(target);
|
||||
world.singleStep();
|
||||
check(!world.valid(target), "target handle invalid after destruction");
|
||||
} else if (tutorial == "spawning") {
|
||||
check(!world.find("temporary-box"), "OnStart spawn deferred until first tick");
|
||||
world.singleStep();
|
||||
auto spawned = world.find("temporary-box");
|
||||
check(world.valid(spawned), "child created at first barrier");
|
||||
for (int i = 0; i < 90; ++i)
|
||||
world.singleStep();
|
||||
check(!world.valid(spawned) && !world.find("temporary-box"),
|
||||
"lifetime removes temporary entity");
|
||||
world.load(scene);
|
||||
world.singleStep();
|
||||
check(world.valid(world.find("temporary-box")) && !world.valid(spawned),
|
||||
"module state and handles work across scene restart");
|
||||
} else if (tutorial == "physics") {
|
||||
auto self = world.find("actor");
|
||||
for (int i = 0; i < 10; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
check(world.grounded(self), "controller starts on floor");
|
||||
world.advance(1.0 / 60, {1, 0, true, false});
|
||||
check(world.velocity(self)[0] > 3.5f && world.velocity(self)[1] > 4,
|
||||
"input applies velocity and grounded jump");
|
||||
for (int i = 0; i < 90 && world.velocity(self)[1] > 0.1f; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
check(!world.grounded(self), "apex has no ground contact");
|
||||
const float before = world.velocity(self)[1];
|
||||
world.advance(1.0 / 60, {0, 0, true, false});
|
||||
check(world.velocity(self)[1] < before, "controller refuses air jump");
|
||||
for (int i = 0; i < 180; ++i)
|
||||
world.advance(1.0 / 60);
|
||||
check(world.grounded(self), "controller lands again");
|
||||
} else
|
||||
throw std::runtime_error("Unknown compiled tutorial");
|
||||
check(world.diagnostics().empty(), "tutorial callbacks must not silently report errors");
|
||||
std::cout << tutorial << " tutorial compiled and passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/ui/ui.hpp>
|
||||
#include <iostream>
|
||||
using namespace faset;
|
||||
int main(int argc, char** argv) {
|
||||
try {
|
||||
render::Renderer renderer({1280, 800, "Faset UI reference implementation", true, true});
|
||||
ui::Context ui(FASET_TEST_FONT);
|
||||
ui.set_theme(ui::Theme::load(FASET_UI_THEME));
|
||||
ui.apply_layout(
|
||||
read_json(std::filesystem::path(FASET_UI_THEME).parent_path() / "editor-layout.json"));
|
||||
auto& menu = ui.find("menubar")->add(ui::Kind::Row, "menuitems");
|
||||
for (const auto& name : {"Faset", "File", "Edit", "Scene", "View", "Help"})
|
||||
menu.add(ui::Kind::Button, "menu-" + std::string(name), name).layout.width = 65;
|
||||
auto& tools = ui.find("toolbar")->add(ui::Kind::Row, "tools");
|
||||
for (const auto& name :
|
||||
{"Workshop", "Courtyard", "Save", "Undo", "Redo", "Play", "Stop", "Build"})
|
||||
tools.add(ui::Kind::Button, "tool-" + std::string(name), name).layout.width = 80;
|
||||
auto& scene = *ui.find("scene_panel");
|
||||
scene.add(ui::Kind::Tab, "scene-tab", "Scene").selected = true;
|
||||
auto& tree = scene.add(ui::Kind::Column, "scene-tree");
|
||||
tree.layout.flex = 1;
|
||||
tree.layout.scroll = true;
|
||||
tree.layout.gap = 0;
|
||||
for (const auto& name :
|
||||
{"Courtyard", "Camera", "Sun", "Ground", "Player", "Door", "Crates"}) {
|
||||
auto& row = tree.add(ui::Kind::TreeRow, "object-" + std::string(name), name);
|
||||
row.indent = std::string(name) == "Courtyard" ? 0 : 1;
|
||||
row.selected = std::string(name) == "Door";
|
||||
}
|
||||
auto& inspector = *ui.find("inspector_panel");
|
||||
inspector.add(ui::Kind::Tab, "inspector-tab", "Inspector").selected = true;
|
||||
auto& body = inspector.add(ui::Kind::Column, "properties");
|
||||
body.layout.padding = 10;
|
||||
body.layout.scroll = true;
|
||||
body.layout.flex = 1;
|
||||
body.layout.gap = 8;
|
||||
body.add(ui::Kind::TextField, "object-name", "Door");
|
||||
body.add(ui::Kind::Label, "transform-title", "Transform");
|
||||
for (const auto& name : {"Position", "Rotation", "Scale"}) {
|
||||
auto& row = body.add(ui::Kind::Row, "property-" + std::string(name));
|
||||
row.layout.height = 30;
|
||||
row.add(ui::Kind::Label, "label-" + std::string(name), name).layout.width = 64;
|
||||
for (int axis = 0; axis < 3; ++axis) {
|
||||
auto& field =
|
||||
row.add(ui::Kind::NumberField, std::string(name) + std::to_string(axis));
|
||||
field.layout.flex = 1;
|
||||
field.layout.min_width = 40;
|
||||
field.value = std::string(name) == "Scale" ? 1 : 0;
|
||||
}
|
||||
}
|
||||
body.add(ui::Kind::Label, "mesh-title", "Mesh");
|
||||
body.add(ui::Kind::Button, "mesh-asset", "Door.glb");
|
||||
body.add(ui::Kind::Label, "body-title", "Rigid Body");
|
||||
auto& check = body.add(ui::Kind::Checkbox, "static", "Static");
|
||||
check.checked = true;
|
||||
body.add(ui::Kind::Label, "controller-title", "Door Controller (C++)");
|
||||
auto& speed = body.add(ui::Kind::NumberField, "speed");
|
||||
speed.value = 2;
|
||||
body.add(ui::Kind::Button, "add-component", "Add Component");
|
||||
body.add(ui::Kind::Label, "unicode-check", "Cyrillic: Дверь, сцена");
|
||||
auto& bottom = *ui.find("bottom_panel");
|
||||
auto& tabs = bottom.add(ui::Kind::Row, "asset-tabs");
|
||||
tabs.layout.height = 30;
|
||||
tabs.add(ui::Kind::Tab, "assets-tab", "Assets").selected = true;
|
||||
tabs.add(ui::Kind::Tab, "console-tab", "Console");
|
||||
auto& search = bottom.add(ui::Kind::Row, "asset-search");
|
||||
search.layout.height = 30;
|
||||
search.add(ui::Kind::Label, "breadcrumb", "Assets / Models").layout.flex = 1;
|
||||
search.add(ui::Kind::TextField, "search", "Search assets...").layout.width = 260;
|
||||
for (const auto& name : {"Door.glb", "Crate.glb", "Ground.material", "Courtyard.scene"}) {
|
||||
auto& row = bottom.add(ui::Kind::TreeRow, "asset-" + std::string(name), name);
|
||||
row.layout.height = 26;
|
||||
row.indent = 1;
|
||||
}
|
||||
ui.find("statusbar")
|
||||
->add(ui::Kind::Label, "status",
|
||||
"Ready Vulkan 1.3 | C++ "
|
||||
"gameplay | Local project");
|
||||
ui.layout(1280, 800);
|
||||
render::Snapshot snapshot;
|
||||
const auto rect = ui.find("viewport")->rect;
|
||||
snapshot.scene_rect = {rect.x, rect.y, rect.width, rect.height};
|
||||
snapshot.eye = {5, 4, 7};
|
||||
snapshot.view_projection =
|
||||
render::multiply(render::perspective(.75f, rect.width / rect.height, .1f, 100),
|
||||
render::look_at(snapshot.eye, {0, 1, 0}));
|
||||
render::DrawItem ground;
|
||||
ground.mesh = render::cube_mesh();
|
||||
ground.model = render::transform({0, -.25f, 0}, {}, {8, .5f, 8});
|
||||
ground.color = {.25f, .29f, .32f, 1};
|
||||
snapshot.draws.push_back(ground);
|
||||
render::DrawItem door;
|
||||
door.mesh = render::cube_mesh();
|
||||
door.model = render::transform({0, 1.25f, 0}, {}, {1.6f, 2.5f, .3f});
|
||||
door.color = {.46f, .28f, .13f, 1};
|
||||
snapshot.draws.push_back(door);
|
||||
render::DrawItem crate;
|
||||
crate.mesh = render::cube_mesh();
|
||||
crate.model = render::transform({-2, .6f, 1}, {0, .2f, 0}, {1.2f, 1.2f, 1.2f});
|
||||
crate.color = {.38f, .25f, .14f, 1};
|
||||
snapshot.draws.push_back(crate);
|
||||
ui.draw(snapshot);
|
||||
renderer.render(snapshot);
|
||||
renderer.capture(argc > 1 ? argv[1] : "ui-test.ppm");
|
||||
if (renderer.stats().validation_errors)
|
||||
throw std::runtime_error("Vulkan validation reported UI rendering errors");
|
||||
std::cout << "UI glyph atlas and retained panels rendered on " << renderer.stats().device
|
||||
<< '\n';
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << e.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
#include <chrono>
|
||||
#include <faset/ui/ui.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace faset;
|
||||
namespace {
|
||||
void check(bool value, const char* message) {
|
||||
if (!value)
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
render::Event key(std::string name, bool control = false, bool shift = false) {
|
||||
render::Event e;
|
||||
e.type = render::Event::Type::KeyDown;
|
||||
e.key = std::move(name);
|
||||
e.control = control;
|
||||
e.shift = shift;
|
||||
return e;
|
||||
}
|
||||
render::Event text(std::string value) {
|
||||
render::Event e;
|
||||
e.type = render::Event::Type::TextInput;
|
||||
e.text = std::move(value);
|
||||
return e;
|
||||
}
|
||||
render::Event mouse(render::Event::Type type, float x, float y) {
|
||||
render::Event e;
|
||||
e.type = type;
|
||||
e.x = x;
|
||||
e.y = y;
|
||||
e.button = 1;
|
||||
return e;
|
||||
}
|
||||
void click(ui::Context& context, const ui::Widget& widget) {
|
||||
const auto r = widget.rect;
|
||||
context.handle(mouse(render::Event::Type::MouseDown, r.x + 5, r.y + 5));
|
||||
context.handle(mouse(render::Event::Type::MouseUp, r.x + 5, r.y + 5));
|
||||
}
|
||||
} // namespace
|
||||
int main() {
|
||||
try {
|
||||
ui::TextBuffer buffer("Привет");
|
||||
check(buffer.backspace() && buffer.text() == "Приве", "UTF-8 backspace split codepoint");
|
||||
check(buffer.undo() && buffer.text() == "Привет", "text undo");
|
||||
buffer.select_all();
|
||||
buffer.insert("Дверь");
|
||||
buffer.left(true);
|
||||
check(buffer.selected_text() == "ь", "UTF-8 selection");
|
||||
buffer.insert("ца");
|
||||
check(buffer.text() == "Дверца", "selection replacement");
|
||||
check(!buffer.insert(std::string("\xc0\x80", 2)), "overlong UTF-8 accepted");
|
||||
buffer.home();
|
||||
buffer.delete_forward();
|
||||
check(buffer.text() == "верца", "UTF-8 delete");
|
||||
buffer.undo();
|
||||
check(buffer.text() == "Дверца", "undo delete");
|
||||
ui::Context context(FASET_TEST_FONT);
|
||||
auto& root = context.root();
|
||||
root.layout.gap = 4;
|
||||
auto& name = root.add(ui::Kind::TextField, "name", "Door");
|
||||
name.layout.height = 32;
|
||||
int commits = 0;
|
||||
name.on_commit = [&](ui::Widget&) { ++commits; };
|
||||
auto& number = root.add(ui::Kind::NumberField, "number");
|
||||
number.value = 4;
|
||||
number.step = .5;
|
||||
number.layout.height = 32;
|
||||
int number_commits = 0, previews = 0;
|
||||
number.on_commit = [&](ui::Widget&) { ++number_commits; };
|
||||
number.on_preview = [&](ui::Widget&) { ++previews; };
|
||||
auto& checkbox = root.add(ui::Kind::Checkbox, "enabled", "Enabled");
|
||||
int checks = 0;
|
||||
checkbox.on_commit = [&](ui::Widget&) { ++checks; };
|
||||
auto& button = root.add(ui::Kind::Button, "save", "Save");
|
||||
int clicks = 0;
|
||||
button.on_click = [&](ui::Widget&) { ++clicks; };
|
||||
context.layout(320, 240);
|
||||
std::string clipboard;
|
||||
context.set_clipboard([&] { return clipboard; },
|
||||
[&](const std::string& s) { clipboard = s; });
|
||||
bool ime = false;
|
||||
int ime_rectangles = 0;
|
||||
context.set_ime([&](bool enabled) { ime = enabled; },
|
||||
[&](ui::Rect r) {
|
||||
check(r.width > 0, "IME area");
|
||||
++ime_rectangles;
|
||||
});
|
||||
check(context.focus("name") && ime, "field focus enables IME");
|
||||
context.handle(key("A", true));
|
||||
context.handle(text("Привет"));
|
||||
check(!context.update_text("name", "External"), "document refresh erased dirty edit");
|
||||
context.handle(key("C", true));
|
||||
context.handle(key("A", true));
|
||||
context.handle(key("C", true));
|
||||
check(clipboard == "Привет", "clipboard copied wrong selection");
|
||||
clipboard = "Дверь";
|
||||
context.handle(key("V", true));
|
||||
check(name.text == "Дверь", "UTF-8 paste");
|
||||
context.handle(key("Z", true));
|
||||
check(name.text == "Привет", "local Ctrl-Z");
|
||||
context.handle(key("Return"));
|
||||
check(commits == 1 && name.text == "Привет", "text commits once");
|
||||
context.handle(key("Return"));
|
||||
check(commits == 1, "unchanged text recommitted");
|
||||
context.handle(key("End"));
|
||||
render::Event composition;
|
||||
composition.type = render::Event::Type::TextEditing;
|
||||
composition.text = "й";
|
||||
composition.edit_length = 1;
|
||||
context.handle(composition);
|
||||
check(name.text == "Привет", "IME preedit changed document field");
|
||||
context.handle(text("й"));
|
||||
context.handle(key("Return"));
|
||||
check(name.text == "Приветй" && commits == 2, "IME commit");
|
||||
context.focus("number");
|
||||
const auto r = number.rect;
|
||||
context.handle(mouse(render::Event::Type::MouseDown, r.x + 20, r.y + 12));
|
||||
context.handle(mouse(render::Event::Type::MouseMove, r.x + 40, r.y + 12));
|
||||
context.handle(mouse(render::Event::Type::MouseMove, r.x + 50, r.y + 12));
|
||||
context.handle(mouse(render::Event::Type::MouseUp, r.x + 50, r.y + 12));
|
||||
check(number.value == 19 && number_commits == 1 && previews == 2,
|
||||
"numeric drag must commit once at release");
|
||||
context.focus("save");
|
||||
check(number_commits == 1, "blur duplicated drag commit");
|
||||
context.focus("number");
|
||||
context.handle(key("A", true));
|
||||
context.handle(text("NaN"));
|
||||
context.handle(key("Return"));
|
||||
check(number_commits == 1 && !number.error.empty(), "invalid numeric input committed");
|
||||
context.handle(key("Escape"));
|
||||
check(number.value == 19, "cancel numeric input changed value");
|
||||
click(context, checkbox);
|
||||
check(checkbox.checked && checks == 1, "checkbox commit");
|
||||
click(context, button);
|
||||
check(clicks == 1, "button click");
|
||||
context.handle(key("Return"));
|
||||
check(clicks == 2, "keyboard button activation");
|
||||
context.handle(key("Tab"));
|
||||
check(context.focused_id() == "name", "focus wraps predictably");
|
||||
check(ime_rectangles > 0, "IME rectangle never sent");
|
||||
ui::Context split(FASET_TEST_FONT);
|
||||
auto& row = split.root().add(ui::Kind::Row, "row");
|
||||
row.layout.flex = 1;
|
||||
row.layout.gap = 0;
|
||||
auto& left = row.add(ui::Kind::Panel, "left");
|
||||
left.layout.width = 100;
|
||||
left.layout.min_width = 50;
|
||||
auto& divider = row.add(ui::Kind::Divider, "divider");
|
||||
divider.layout.width = 5;
|
||||
auto& right = row.add(ui::Kind::Panel, "right");
|
||||
right.layout.flex = 1;
|
||||
right.layout.min_width = 50;
|
||||
int resize_commit = 0;
|
||||
divider.on_commit = [&](ui::Widget&) { ++resize_commit; };
|
||||
split.layout(300, 100);
|
||||
const auto d = divider.rect;
|
||||
split.handle(mouse(render::Event::Type::MouseDown, d.x + 2, 20));
|
||||
split.handle(mouse(render::Event::Type::MouseMove, d.x + 32, 20));
|
||||
split.handle(mouse(render::Event::Type::MouseUp, d.x + 32, 20));
|
||||
check(left.layout.width == 130 && right.rect.width == 165 && resize_commit == 1,
|
||||
"divider resize");
|
||||
const auto moved_divider = divider.rect;
|
||||
split.handle(mouse(render::Event::Type::MouseDown, moved_divider.x + 2, 20));
|
||||
split.handle(mouse(render::Event::Type::MouseMove, moved_divider.x + 42, 20));
|
||||
split.handle(key("Escape"));
|
||||
check(left.layout.width == 130 && resize_commit == 1,
|
||||
"Escape must restore divider without committing");
|
||||
int cancellations = 0;
|
||||
number.on_cancel = [&](ui::Widget&) { ++cancellations; };
|
||||
context.handle(mouse(render::Event::Type::MouseDown, r.x + 10, r.y + 12));
|
||||
context.handle(mouse(render::Event::Type::MouseMove, r.x + 50, r.y + 12));
|
||||
context.handle(key("Escape"));
|
||||
check(number.value == 19 && number_commits == 1 && cancellations == 1,
|
||||
"Escape must cancel numeric drag");
|
||||
ui::Context scrolling(FASET_TEST_FONT);
|
||||
auto& list = scrolling.root().add(ui::Kind::Panel, "list");
|
||||
list.layout.height = 80;
|
||||
list.layout.scroll = true;
|
||||
list.layout.gap = 0;
|
||||
for (int i = 0; i < 10; ++i)
|
||||
list.add(ui::Kind::Label, "row" + std::to_string(i), "Объект " + std::to_string(i));
|
||||
scrolling.layout(300, 200);
|
||||
scrolling.handle(mouse(render::Event::Type::MouseMove, 30, 30));
|
||||
render::Event wheel;
|
||||
wheel.type = render::Event::Type::Wheel;
|
||||
wheel.y = -2;
|
||||
check(scrolling.handle(wheel) && list.scroll_y > 0, "scroll event");
|
||||
render::Snapshot frame;
|
||||
scrolling.draw(frame);
|
||||
check(!frame.ui_quads.empty(), "retained UI emitted no quads");
|
||||
for (const auto& q : frame.ui_quads)
|
||||
check(q.x >= 0 && q.y >= 0 && q.x + q.width <= 300.01f && q.y + q.height <= 80.01f,
|
||||
"CPU clipping escaped scroll panel");
|
||||
check(scrolling.font().measure("Привет", 14) > 20, "Cyrillic shaping failed");
|
||||
ui::DockLayout docks;
|
||||
docks.move("Scene", "left", 0);
|
||||
docks.move("Assets", "left", 1);
|
||||
docks.move("Assets", "left", 0);
|
||||
docks.set_size("Scene", 224);
|
||||
check(docks.panels("left") == std::vector<std::string>({"Assets", "Scene"}),
|
||||
"dock reordering");
|
||||
const auto state = docks.to_json();
|
||||
ui::DockLayout restored;
|
||||
restored.from_json(state);
|
||||
check(restored.to_json() == state, "dock serialization");
|
||||
auto invalid = state;
|
||||
invalid["areas"]["right"] = {"Scene"};
|
||||
bool rejected = false;
|
||||
try {
|
||||
restored.from_json(invalid);
|
||||
} catch (...) {
|
||||
rejected = true;
|
||||
}
|
||||
check(rejected && restored.to_json() == state, "invalid dock load mutated existing layout");
|
||||
ui::Context declarative(FASET_TEST_FONT);
|
||||
declarative.apply_layout({{"id", "root"},
|
||||
{"kind", "column"},
|
||||
{"children", ui::Json::array({{{"id", "run"},
|
||||
{"kind", "button"},
|
||||
{"text", "Play"},
|
||||
{"layout", {{"height", 30}}}}})}});
|
||||
int action = 0;
|
||||
declarative.find("run")->on_click = [&](ui::Widget&) { ++action; };
|
||||
declarative.apply_layout({{"id", "root"},
|
||||
{"kind", "column"},
|
||||
{"children", ui::Json::array({{{"id", "run"},
|
||||
{"kind", "button"},
|
||||
{"text", "Run"},
|
||||
{"layout", {{"height", 34}}}}})}});
|
||||
declarative.layout(200, 100);
|
||||
click(declarative, *declarative.find("run"));
|
||||
check(action == 1 && declarative.find("run")->text == "Run", "layout reload lost callback");
|
||||
std::cout << "UI: UTF-8, shaping, text/IME/clipboard, focus, transactions, "
|
||||
"layout, clipping, docking OK\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user