#include "assets_image_fixtures.hpp" #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.manifest.at("input_key").at("target_profile") == "desktop-static-pbr-v1" && first.manifest.at("input_key") .at("toolchain") .at("cgltf") .get() .size() == 40, "cache recipe omits the desktop profile or pinned importer toolchain"); require(first.manifest.at("materials").at(0).at("format") == "faset.material" && first.manifest.at("materials").at(0).at("version") == 1, "material data is not explicitly versioned"); 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); AssetPipeline rebuilt_cache(root / "rebuilt-cache"); const auto recovered = rebuilt_cache.import_asset({source}); success(recovered); require(recovered.asset_id == first.asset_id && recovered.generation == first.generation && rebuilt_cache.load_asset(first.asset_id).nodes[0].id == node_id && rebuilt_cache.overrides(first.asset_id) == custom, "fresh cache changed source identity, outputs, or authoring overrides"); 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"); // Standalone images share the same identity, generation and failure guarantees. const auto picture = root / "sprite.PNG"; save(picture, faset::test_images::png_red_green); ImportRequest image_request{picture}; image_request.settings = {{"pixels_per_unit", 20.0}}; auto image_first = pipeline.import_asset(image_request); success(image_first); auto image_asset = pipeline.load_asset(image_first.asset_id); require(image_asset.meshes.empty() && image_asset.textures.size() == 1, "Standalone PNG should produce one owned texture"); require(image_asset.textures[0].mime_type == "image/png" && image_first.manifest["kind"] == "image", "PNG kind/MIME"); require(image_first.manifest["image"]["width"] == 2 && image_first.manifest["image"]["height"] == 1 && image_first.manifest["image"]["pixels_per_unit"] == 20.0, "PNG dimensions and sprite scale recipe"); const auto texture_id = image_asset.textures[0].id; pipeline.set_overrides(image_first.asset_id, {{texture_id, {{"gameplay", "preserved"}}}}); auto image_cache = pipeline.import_asset({picture}); success(image_cache); require(image_cache.cache_hit && image_cache.generation == image_first.generation, "PNG cache/recipe persistence"); save(picture, faset::test_images::png_blue_white); auto image_changed = pipeline.import_asset({picture}); success(image_changed); require(image_changed.asset_id == image_first.asset_id && image_changed.generation != image_first.generation, "PNG reimport content invalidation"); require(pipeline.load_asset(image_first.asset_id).textures[0].id == texture_id, "Image subasset ID changed after content edit"); image_request.settings = {{"pixels_per_unit", 40.0}}; auto image_recipe = pipeline.import_asset(image_request); success(image_recipe); require(image_recipe.generation != image_changed.generation, "Image settings omitted from recipe hash"); save(picture, std::string("broken PNG")); require(pipeline.import_asset({picture}).status == ImportStatus::failed, "Broken PNG published"); require(pipeline.load_asset(image_first.asset_id).generation == image_recipe.generation, "Broken PNG replaced successful generation"); save(picture, faset::test_images::jpeg_red_green); require(pipeline.import_asset({picture}).status == ImportStatus::failed, "Mismatched JPEG content accepted as PNG"); auto oversized = faset::test_images::png_red_green; oversized[16] = 0; oversized[17] = 1; oversized[18] = 0; oversized[19] = 0; save(picture, oversized); require(pipeline.import_asset({picture}).status == ImportStatus::failed, "Oversized image accepted"); save(picture, faset::test_images::png_red_green); ImportJob* image_job_pointer = nullptr; ImportJob image_job([&](const ImportProgress& progress) { if (progress.fraction >= .5f) image_job_pointer->cancel(); }); image_job_pointer = &image_job; require(pipeline.import_asset({picture}, image_job).status == ImportStatus::cancelled, "PNG cancellation ignored"); require(pipeline.load_asset(image_first.asset_id).generation == image_recipe.generation, "Cancelled PNG replaced active generation"); image_request.settings = {{"pixels_per_unit", 0}}; require(pipeline.import_asset(image_request).status == ImportStatus::failed, "Zero pixels_per_unit accepted"); save(picture, faset::test_images::png_blue_white); const auto renamed = root / "renamed.PNG"; fs::rename(picture, renamed); fs::rename(picture.string() + ".faset-import.json", renamed.string() + ".faset-import.json"); fs::rename(picture.string() + ".faset-overrides.json", renamed.string() + ".faset-overrides.json"); auto image_rename = pipeline.import_asset({renamed}); success(image_rename); require(image_rename.cache_hit && image_rename.asset_id == image_first.asset_id, "Image source rename with sidecar lost identity/cache"); require(pipeline.current_manifest(image_first.asset_id)["source"] == renamed.string(), "Image rename retained old source pointer"); require(pipeline.overrides(image_first.asset_id).contains(texture_id), "Image rename/reimport lost overrides"); const auto jpeg = root / "sprite.JPEG"; save(jpeg, faset::test_images::jpeg_red_green); auto jpeg_import = pipeline.import_asset({jpeg}); success(jpeg_import); require(jpeg_import.manifest["image"]["width"] == 2 && pipeline.load_asset(jpeg_import.asset_id).textures[0].mime_type == "image/jpeg", "JPEG decoding/dimensions"); save(root / "same.png", faset::test_images::png_blue_white); auto other_image = pipeline.import_asset({root / "same.png"}); success(other_image); require(other_image.asset_id != image_first.asset_id, "Independent same-content source must have independent AssetId"); 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, standalone PNG/JPEG OK\n"; return 0; } catch (const std::exception& e) { std::cerr << e.what() << "\nFixtures retained: " << root << '\n'; return 1; } }