#include #include #include #include #include #include #include using namespace faset::assets; namespace fs = std::filesystem; namespace { void require(bool value, const std::string& message) { if (!value) throw std::runtime_error(message); } void u32(std::vector& data, std::uint32_t value) { for (int i = 0; i < 4; ++i) data.push_back(static_cast(value >> (8 * i))); } void save(const fs::path& file, const std::vector& data) { std::ofstream out(file, std::ios::binary); out.write(reinterpret_cast(data.data()), static_cast(data.size())); } void save(const fs::path& file, const std::string& text) { std::ofstream(file, std::ios::binary) << text; } std::vector geometry(float x) { std::vector 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(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 result; u32(result, 0x46546c67); u32(result, 2); u32(result, static_cast(12 + 8 + json.size() + 8 + binary.size())); u32(result, static_cast(json.size())); u32(result, 0x4e4f534a); result.insert(result.end(), json.begin(), json.end()); u32(result, static_cast(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); } } } // 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())); 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({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 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"); // 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; } }