diff --git a/.gitmodules b/.gitmodules index b84afae4d1..50ce2e7799 100755 --- a/.gitmodules +++ b/.gitmodules @@ -124,3 +124,9 @@ [submodule "3rdparty/OffsetAllocator"] path = 3rdparty/OffsetAllocator url = https://github.com/sebbbi/OffsetAllocator +[submodule "3rdparty/spz"] + path = 3rdparty/spz + url = https://github.com/nianticlabs/spz +[submodule "3rdparty/zstd"] + path = 3rdparty/zstd + url = https://github.com/facebook/zstd diff --git a/3rdparty/3rdparty.cmake b/3rdparty/3rdparty.cmake index 614980a30e..598029e72f 100644 --- a/3rdparty/3rdparty.cmake +++ b/3rdparty/3rdparty.cmake @@ -31,4 +31,5 @@ include(3rdparty/mimalloc.cmake) include(3rdparty/sh4lt.cmake) include(3rdparty/shmdata.cmake) include(3rdparty/snappy.cmake) +include(3rdparty/zstd.cmake) endif() diff --git a/3rdparty/spz b/3rdparty/spz new file mode 160000 index 0000000000..7ae1621e54 --- /dev/null +++ b/3rdparty/spz @@ -0,0 +1 @@ +Subproject commit 7ae1621e54e4b42c3c9c192b366d09116e558e19 diff --git a/3rdparty/zstd b/3rdparty/zstd new file mode 160000 index 0000000000..885c79ba4a --- /dev/null +++ b/3rdparty/zstd @@ -0,0 +1 @@ +Subproject commit 885c79ba4ae8345e006f61bc97b270d4cf7ff076 diff --git a/3rdparty/zstd.cmake b/3rdparty/zstd.cmake new file mode 100644 index 0000000000..42e4256d67 --- /dev/null +++ b/3rdparty/zstd.cmake @@ -0,0 +1,49 @@ +if(SCORE_USE_SYSTEM_LIBRARIES) + find_package(zstd GLOBAL CONFIG) +endif() + +if(NOT TARGET zstd::libzstd_static AND NOT TARGET zstd::libzstd_shared AND NOT TARGET zstd) + set(ZSTD_BUILD_PROGRAMS OFF CACHE INTERNAL "" FORCE) + set(ZSTD_BUILD_TESTS OFF CACHE INTERNAL "" FORCE) + set(ZSTD_BUILD_SHARED OFF CACHE INTERNAL "" FORCE) + set(ZSTD_BUILD_STATIC ON CACHE INTERNAL "" FORCE) + set(ZSTD_BUILD_DICTBUILDER OFF CACHE INTERNAL "" FORCE) + + set(old_BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS}) + set(BUILD_SHARED_LIBS OFF) + + if(NOT MSVC AND NOT CMAKE_CROSSCOMPILING) + if(CMAKE_BUILD_TYPE MATCHES ".*Deb.*") + set(old_CFLAGS "${CMAKE_C_FLAGS}") + set(old_CXXFLAGS "${CMAKE_CXX_FLAGS}") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -O3 -march=native") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -march=native") + endif() + endif() + + add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/3rdparty/zstd/build/cmake" EXCLUDE_FROM_ALL) + + if(NOT MSVC AND NOT CMAKE_CROSSCOMPILING) + if(CMAKE_BUILD_TYPE MATCHES ".*Deb.*") + set(CMAKE_C_FLAGS "${old_CFLAGS}") + set(CMAKE_CXX_FLAGS "${old_CXXFLAGS}") + endif() + endif() + + set(BUILD_SHARED_LIBS ${old_BUILD_SHARED_LIBS}) +endif() + +# Make later find_package(zstd) calls (e.g. 3rdparty/spz) resolve to the +# targets configured above: some prebuilt SDKs ship zstd configs pointing to +# files that do not exist, and a not-found result would trigger FetchContent +# fallbacks that clash with the vendored targets. +file(WRITE "${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/zstd-config.cmake" [=[ +if(TARGET libzstd_static AND NOT TARGET zstd::libzstd_static) + add_library(zstd::libzstd_static INTERFACE IMPORTED GLOBAL) + target_link_libraries(zstd::libzstd_static INTERFACE libzstd_static) +endif() +if(TARGET libzstd_shared AND NOT TARGET zstd::libzstd_shared) + add_library(zstd::libzstd_shared INTERFACE IMPORTED GLOBAL) + target_link_libraries(zstd::libzstd_shared INTERFACE libzstd_shared) +endif() +]=]) \ No newline at end of file diff --git a/src/plugins/score-plugin-threedim/CMakeLists.txt b/src/plugins/score-plugin-threedim/CMakeLists.txt index 35286ca6d4..75216e5879 100644 --- a/src/plugins/score-plugin-threedim/CMakeLists.txt +++ b/src/plugins/score-plugin-threedim/CMakeLists.txt @@ -29,6 +29,24 @@ if(NOT TARGET fastgltf) add_subdirectory("${3RDPARTY_FOLDER}/fastgltf" "${CMAKE_CURRENT_BINARY_DIR}/fastgltf" EXCLUDE_FROM_ALL) endif() +# spz — Niantic / Adobe reference SPZ decoder for compressed 3DGS files. +# v1-3 only (v4/ZSTD stubbed; see 3rdparty/spz/CMakeLists.txt). Pulls +# in zlib via ZLIB::ZLIB. +if(NOT TARGET spz) + set(SPZ_BUILD_TOOLS OFF CACHE BOOL "" FORCE) + set(SPZ_BUILD_PYTHON_BINDINGS OFF CACHE BOOL "" FORCE) + set(SPZ_BUILD_EXTENSIONS OFF CACHE BOOL "" FORCE) + add_subdirectory("${3RDPARTY_FOLDER}/spz" "${CMAKE_CURRENT_BINARY_DIR}/spz" EXCLUDE_FROM_ALL) + + if(MSVC) + # The prebuilt sysroot's zconf.h includes unconditionally; + # provide a stub so spz's zlib usage compiles with MSVC / clang-cl. + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/spz-msvc-compat/unistd.h" + "#include \n#include \n") + target_include_directories(spz PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/spz-msvc-compat") + endif() +endif() + # libssynth add_library( ssynth STATIC @@ -158,6 +176,8 @@ add_library( Threedim/InjectBuffer.cpp Threedim/InjectTexture.hpp Threedim/InjectTexture.cpp + Threedim/TagAs.hpp + Threedim/TagAs.cpp Threedim/PBRMesh.hpp Threedim/PBRMesh.cpp Threedim/MaterialOverride.hpp @@ -199,6 +219,16 @@ add_library( Threedim/PCLToGeometry.cpp Threedim/Ply.hpp Threedim/Ply.cpp + Threedim/PrimitiveCloud/PlyParser.hpp + Threedim/PrimitiveCloud/PlyParser.cpp + Threedim/PrimitiveCloud/SplatBinary.hpp + Threedim/PrimitiveCloud/SplatBinary.cpp + Threedim/PrimitiveCloud/SpzCodec.hpp + Threedim/PrimitiveCloud/SpzCodec.cpp + Threedim/PrimitiveCloud/SceneFromCloud.hpp + Threedim/PrimitiveCloud/SceneFromCloud.cpp + Threedim/PrimitiveCloud/FormatOverride.hpp + Threedim/PrimitiveCloud/FormatOverride.cpp Threedim/Primitive.hpp Threedim/Primitive.cpp Threedim/StructureSynth.hpp @@ -255,4 +285,4 @@ target_include_directories( target_link_libraries( score_plugin_threedim PRIVATE score_plugin_engine score_plugin_avnd score_plugin_gfx fmt::fmt ssynth Eigen3::Eigen - fastgltf::fastgltf) + fastgltf::fastgltf spz) diff --git a/src/plugins/score-plugin-threedim/Threedim/AssetLoader.cpp b/src/plugins/score-plugin-threedim/Threedim/AssetLoader.cpp index 7ffe623982..44756f2b92 100644 --- a/src/plugins/score-plugin-threedim/Threedim/AssetLoader.cpp +++ b/src/plugins/score-plugin-threedim/Threedim/AssetLoader.cpp @@ -3,6 +3,11 @@ #include "FbxParser.hpp" #include "GltfParser.hpp" #include "Ply.hpp" +#include "PrimitiveCloud/FormatOverride.hpp" +#include "PrimitiveCloud/PlyParser.hpp" +#include "PrimitiveCloud/SceneFromCloud.hpp" +#include "PrimitiveCloud/SplatBinary.hpp" +#include "PrimitiveCloud/SpzCodec.hpp" #include "SceneFromMeshes.hpp" #include "VcgImporters.hpp" @@ -155,12 +160,7 @@ AssetLoader::ins::asset_t::process(file_type tv) } else if(hasSuffixCI(fname, "gltf") || hasSuffixCI(fname, "glb")) { - auto t0 = std::chrono::steady_clock::now(); loaded = runInnerParser(tv, &GltfParser::ins::gltf_t::process); - auto t1 = std::chrono::steady_clock::now(); - qDebug() << "LOADING TIME" - << (std::chrono::duration_cast(t1 - t0) / 1e6) - .count(); } else if(hasSuffixCI(fname, "obj")) { @@ -176,14 +176,32 @@ AssetLoader::ins::asset_t::process(file_type tv) } else if(hasSuffixCI(fname, "ply")) { - Threedim::float_vec buf; - auto meshes = Threedim::PlyFromFile(fname, buf); - if(!meshes.empty()) + // Sniff the header first: a PLY whose vertex element carries + // splat-style columns (or no face element) goes through the + // primitive-cloud path; everything else stays on the existing + // mesh path. The sniff only reads the textual header, no row data. + if(Threedim::PrimitiveCloud::ply_is_splat_shaped(fname)) { - const QString label = QFileInfo(QString::fromStdString(std::string{fname})) - .fileName(); - loaded = Threedim::sceneStateFromMeshes( - std::move(meshes), std::move(buf), label.toStdString()); + auto cloud = Threedim::PrimitiveCloud::parse_ply(fname); + if(cloud) + { + const QString label + = QFileInfo(QString::fromStdString(std::string{fname})).fileName(); + loaded = Threedim::PrimitiveCloud::sceneStateFromCloud( + std::move(cloud), label.toStdString()); + } + } + else + { + Threedim::float_vec buf; + auto meshes = Threedim::PlyFromFile(fname, buf); + if(!meshes.empty()) + { + const QString label + = QFileInfo(QString::fromStdString(std::string{fname})).fileName(); + loaded = Threedim::sceneStateFromMeshes( + std::move(meshes), std::move(buf), label.toStdString()); + } } } else if(hasSuffixCI(fname, "stl")) @@ -210,6 +228,34 @@ AssetLoader::ins::asset_t::process(file_type tv) std::move(meshes), std::move(buf), label.toStdString()); } } + else if(hasSuffixCI(fname, "splat")) + { + // Antimatter15 binary .splat: 32 bytes/primitive, fixed schema. + auto cloud = Threedim::PrimitiveCloud::parse_splat_binary(tv.bytes); + if(cloud) + { + const QString label + = QFileInfo(QString::fromStdString(std::string{fname})).fileName(); + loaded = Threedim::PrimitiveCloud::sceneStateFromCloud( + std::move(cloud), label.toStdString()); + } + } + else if(hasSuffixCI(fname, "spz")) + { + // Niantic .spz v1-3: gzip-compressed column-grouped 3DGS data. + // Decoded via the vendored Niantic library (3rdparty/spz), + // transposed into the canonical 62-float row layout that the + // 3dgs.classic preset reads. v4 (NGSP-magic + ZSTD) returns + // nullptr — see 3rdparty/spz/CMakeLists.txt for the rationale. + auto cloud = Threedim::PrimitiveCloud::parse_spz(tv.bytes); + if(cloud) + { + const QString label + = QFileInfo(QString::fromStdString(std::string{fname})).fileName(); + loaded = Threedim::PrimitiveCloud::sceneStateFromCloud( + std::move(cloud), label.toStdString()); + } + } else { // Built-ins all missed — consult the addon-registered parsers. @@ -223,21 +269,33 @@ AssetLoader::ins::asset_t::process(file_type tv) return {}; return [state = std::move(loaded)](AssetLoader& self) mutable { - self.m_raw_state = std::move(state); + self.m_parsed_state = std::move(state); + self.rebuild_format_state(); // m_parsed → m_overridden self.m_cached_xform.valid = false; // force wrap rebuild self.rebuild_wrapped_state(); }; } +void AssetLoader::rebuild_format_state() +{ + m_cached_format_override = inputs.format_override.value; + m_overridden_state = Threedim::PrimitiveCloud::applyFormatOverride( + m_parsed_state, m_cached_format_override); + // The wrapped state derives from m_overridden_state and must be + // rebuilt whenever the override changes. + m_cached_xform.valid = false; + rebuild_wrapped_state(); +} + void AssetLoader::rebuild_wrapped_state() { m_wrapped_state = Threedim::wrapSceneWithTransform( - m_raw_state, inputs, m_cached_xform, m_version_counter, m_xform_ref); + m_overridden_state, inputs, m_cached_xform, m_version_counter, m_xform_ref); } void AssetLoader::operator()() { - if(!m_raw_state) + if(!m_parsed_state) { outputs.scene_out.scene.state = nullptr; outputs.scene_out.dirty = 0; @@ -298,6 +356,13 @@ void AssetLoader::release(score::gfx::RenderList& r) if(raw_transform_slot.valid()) r.registry().free(raw_transform_slot); m_xform_ref = {}; + // Clear cached scene_state so the next operator()() rebuilds against + // the post-release registry. Producer-state-drift Option A — see + // matching comment in Light::release. m_parsed_state stays valid + // (parser output, no slot refs); only m_overridden_state and + // m_wrapped_state embed registry refs and need clearing. + m_overridden_state.reset(); + m_wrapped_state.reset(); } } // namespace Threedim diff --git a/src/plugins/score-plugin-threedim/Threedim/AssetLoader.hpp b/src/plugins/score-plugin-threedim/Threedim/AssetLoader.hpp index c2215d1a45..4775820bee 100644 --- a/src/plugins/score-plugin-threedim/Threedim/AssetLoader.hpp +++ b/src/plugins/score-plugin-threedim/Threedim/AssetLoader.hpp @@ -96,6 +96,7 @@ class AssetLoader halp_meta( extensions, "3D assets (*.fbx *.gltf *.glb *.obj *.ply *.stl *.off " + "*.splat *.spz " "*.usd *.usda *.usdc *.usdz)"); static std::function process(file_type data); } asset; @@ -103,6 +104,16 @@ class AssetLoader PositionControl position; RotationControl rotation; ScaleControl scale; + + // Stamps every primitive_cloud_component emitted by this asset + // with `format_id = value` when non-empty. Empty falls back to the + // parser's autodetection (PLY column sniffing, .splat / .spz + // hardcoded). Used to route unrecognised PLY columns or addon- + // produced files through a FlattenedSceneFilterNode in mode 12. + struct format_override_t : halp::lineedit<"Format override (auto if empty)", ""> + { + void update(AssetLoader& n) { n.rebuild_format_state(); } + } format_override; } inputs; struct outs @@ -126,13 +137,25 @@ class AssetLoader score::gfx::Edge* e); void release(score::gfx::RenderList& r); - // Raw scene as parsed from the file — stable as long as the file doesn't - // change. Wrapped into m_wrapped_state by applying TRS controls. - std::shared_ptr m_raw_state; + // Raw scene as parsed from the file — stable as long as the file + // doesn't change. The pipeline is: + // m_parsed_state (parser output, never mutated) + // ↓ applyFormatOverride(format_override.value) + // m_overridden_state (format_id rewrites applied, or = parsed) + // ↓ wrapSceneWithTransform(position/rotation/scale) + // m_wrapped_state (final, published downstream) + std::shared_ptr m_parsed_state; + std::shared_ptr m_overridden_state; std::shared_ptr m_wrapped_state; + std::string m_cached_format_override; CachedTRS m_cached_xform; int64_t m_version_counter{0}; + // Re-runs applyFormatOverride from the parsed state. Triggered by the + // lineedit's update() callback when the user edits the override + // field; also called once after parsing. + void rebuild_format_state(); + score::gfx::GpuResourceRegistry::Slot raw_transform_slot; ossia::gpu_slot_ref m_xform_ref{}; diff --git a/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/FormatOverride.cpp b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/FormatOverride.cpp new file mode 100644 index 0000000000..14ba2d1d4b --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/FormatOverride.cpp @@ -0,0 +1,98 @@ +#include "FormatOverride.hpp" + +#include + +#include +#include + +namespace Threedim::PrimitiveCloud +{ + +namespace +{ + +// Recursively rewrites primitive_cloud_components inside a scene_node's +// children list. Returns a fresh scene_node shared_ptr when something +// was rewritten (or a nested scene_node was rewritten), the original +// otherwise — so unchanged subtrees keep their identity for downstream +// fingerprinting. +ossia::scene_node_ptr rewriteNode( + const ossia::scene_node_ptr& in, std::string_view override) +{ + if(!in || !in->children || in->children->empty()) + return in; + + bool any_rewrite = false; + std::vector fresh_children; + fresh_children.reserve(in->children->size()); + + for(const auto& payload : *in->children) + { + if(auto* pc = ossia::get_if(&payload)) + { + if(*pc && (*pc)->format_id != override) + { + auto fresh = std::make_shared(**pc); + fresh->format_id = std::string{override}; + fresh_children.emplace_back( + ossia::primitive_cloud_component_ptr{std::move(fresh)}); + any_rewrite = true; + continue; + } + } + else if(auto* sn = ossia::get_if(&payload)) + { + auto rewritten = rewriteNode(*sn, override); + if(rewritten.get() != sn->get()) + { + fresh_children.emplace_back(std::move(rewritten)); + any_rewrite = true; + continue; + } + } + fresh_children.emplace_back(payload); + } + + if(!any_rewrite) + return in; + + auto fresh = std::make_shared(*in); + fresh->children = std::make_shared>( + std::move(fresh_children)); + return fresh; +} + +} // namespace + +std::shared_ptr applyFormatOverride( + std::shared_ptr state, std::string_view override) +{ + if(!state) + return nullptr; + if(override.empty()) + return std::const_pointer_cast(state); + + auto out = std::make_shared(*state); + + if(state->roots && !state->roots->empty()) + { + auto fresh_roots = std::make_shared>(); + fresh_roots->reserve(state->roots->size()); + bool any_rewrite = false; + for(const auto& root : *state->roots) + { + auto rewritten = rewriteNode(root, override); + if(rewritten.get() != root.get()) + any_rewrite = true; + fresh_roots->push_back(std::move(rewritten)); + } + if(any_rewrite) + out->roots = std::move(fresh_roots); + } + + out->version = state->version + 1; + out->dirty_index = state->dirty_index + 1; + return out; +} + +} // namespace Threedim::PrimitiveCloud diff --git a/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/FormatOverride.hpp b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/FormatOverride.hpp new file mode 100644 index 0000000000..19698b4926 --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/FormatOverride.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include +#include + +namespace Threedim::PrimitiveCloud +{ + +// Shallow-clones `state` and rewrites every primitive_cloud_component +// reachable through the scene tree to carry `override` as its +// `format_id`. Heavy fields (raw_data buffer_resource, extra_buffers, +// bounds, …) are shared via shared_ptr — no GPU upload duplicates. +// +// Used by AssetLoader's "Format override" line edit and the TagAs +// pass-through node so unrecognised PLY columns / procedural producers +// without an autodetected format_id can still be routed by a +// FlattenedSceneFilterNode in mode 12 (format_id == match_str). +// +// `override.empty()` returns the input verbatim (`const_pointer_cast` +// to drop the const, but no actual mutation is performed). A null +// `state` returns null. Otherwise the returned shared_ptr is freshly +// allocated; its `version` and `dirty_index` are bumped by 1 so +// downstream change-detection sees a fresh frame. +// +// Walks scene_node children recursively. Nested scene_node_ptr inside +// children is itself deep-cloned so the rewrite is leak-free for the +// const tree shape. +std::shared_ptr applyFormatOverride( + std::shared_ptr state, + std::string_view override); + +} // namespace Threedim::PrimitiveCloud diff --git a/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/PlyParser.cpp b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/PlyParser.cpp new file mode 100644 index 0000000000..a6e5de3ea0 --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/PlyParser.cpp @@ -0,0 +1,276 @@ +#include "PlyParser.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace Threedim::PrimitiveCloud +{ + +namespace +{ + +// Standard mesh column set. A PLY whose vertex element has only these +// columns (and a face element) is a regular triangle mesh and goes +// through the existing AssetLoader mesh path. +bool is_mesh_column(std::string_view name) noexcept +{ + static constexpr std::string_view mesh_cols[] = { + "x", "y", "z", + "nx", "ny", "nz", + "red", "green", "blue", "alpha", + "r", "g", "b", "a", + "s", "t", "u", "v", + "texture_u", "texture_v", + }; + for(auto c : mesh_cols) + if(name == c) + return true; + return false; +} + +// Bytes per PLY scalar type. Lists aren't supported on the splat path +// (caller filters them out) so countType is irrelevant here. +uint32_t byte_size_for(miniply::PLYPropertyType t) noexcept +{ + using PT = miniply::PLYPropertyType; + switch(t) + { + case PT::Char: case PT::UChar: return 1; + case PT::Short: case PT::UShort: return 2; + case PT::Int: case PT::UInt: return 4; + case PT::Float: return 4; + case PT::Double: return 8; + default: return 0; + } +} + +// Round `v` up to the next multiple of `align` (a power of two). +uint32_t align_up(uint32_t v, uint32_t align) noexcept +{ + return (v + (align - 1)) & ~(align - 1); +} + +// Detect whether the vertex element looks like a splat. Returns true +// if it carries any column NOT in the standard mesh set OR if there +// is no `face` element in the file. +bool detect_splat_shape(miniply::PLYReader& reader) +{ + bool has_face = false; + bool has_extra = false; + + for(uint32_t i = 0, end = reader.num_elements(); i < end; ++i) + { + auto* el = reader.get_element(i); + if(!el) continue; + if(el->name == "face") + { + has_face = true; + continue; + } + if(el->name == miniply::kPLYVertexElement) + { + for(auto& p : el->properties) + { + // List columns aren't a splat thing — skip. + if(p.countType != miniply::PLYPropertyType::None) + continue; + if(!is_mesh_column(p.name)) + { + has_extra = true; + break; + } + } + } + } + return has_extra || !has_face; +} + +// Recognise a known column-name fingerprint and return the canonical +// format_id. Empty result means "unknown / wired by hand". +std::string detect_format_id(const miniply::PLYElement& vtx) +{ + bool has_f_dc = false; + bool has_f_rest = false; + bool has_scale = false; + bool has_rot = false; + bool has_opacity = false; + for(auto& p : vtx.properties) + { + if(p.countType != miniply::PLYPropertyType::None) + continue; + const auto& n = p.name; + if(n == "f_dc_0" || n == "f_dc_1" || n == "f_dc_2") has_f_dc = true; + else if(n.rfind("f_rest_", 0) == 0) has_f_rest = true; + else if(n == "scale_0" || n == "scale_1" || n == "scale_2") has_scale = true; + else if(n == "rot_0" || n == "rot_1" || n == "rot_2" || n == "rot_3") has_rot = true; + else if(n == "opacity") has_opacity = true; + } + if(has_f_dc && has_f_rest && has_scale && has_rot && has_opacity) + return "3dgs.classic"; + return {}; +} + +} // namespace + +bool ply_is_splat_shaped(std::string_view path) +{ + // miniply::PLYReader expects a NUL-terminated path. string_view from + // halp::file_port::filename is null-terminated in practice but not + // guaranteed; copy to be safe. + std::string p{path}; + miniply::PLYReader reader(p.c_str()); + if(!reader.valid()) + return false; + return detect_splat_shape(reader); +} + +ossia::primitive_cloud_component_ptr parse_ply(std::string_view path) +{ + std::string p{path}; + miniply::PLYReader reader(p.c_str()); + if(!reader.valid()) + return nullptr; + + if(!detect_splat_shape(reader)) + return nullptr; + + // Walk to the vertex element. + while(reader.has_element()) + { + if(!reader.element_is(miniply::kPLYVertexElement)) + { + reader.next_element(); + continue; + } + if(!reader.load_element()) + return nullptr; + break; + } + if(!reader.has_element()) + return nullptr; + + const auto* vtx = reader.element(); + if(!vtx) + return nullptr; + const uint32_t N = reader.num_rows(); + if(N == 0) + return nullptr; + + // Skip list columns: not part of the splat schema. We collect the + // scalar-only column subset and lay them out tightly in row order. + // The conventional layout is: each scalar at its natural alignment, + // row stride padded to 4 (almost every splat PLY is all-float so + // this is essentially "sum of bytes per column"; we do the more + // conservative thing for mixed-type files). + struct Col + { + uint32_t prop_idx; + miniply::PLYPropertyType type; + uint32_t offset_in_row; + uint32_t size; + std::string name; + }; + std::vector cols; + cols.reserve(vtx->properties.size()); + + uint32_t row_offset = 0; + uint32_t row_align = 1; + for(uint32_t i = 0; i < (uint32_t)vtx->properties.size(); ++i) + { + const auto& p = vtx->properties[i]; + if(p.countType != miniply::PLYPropertyType::None) + continue; // list — skip + const uint32_t sz = byte_size_for(p.type); + if(sz == 0) + continue; + row_offset = align_up(row_offset, sz); + cols.push_back(Col{i, p.type, row_offset, sz, p.name}); + row_offset += sz; + if(sz > row_align) + row_align = sz; + } + if(cols.empty()) + return nullptr; + const uint32_t row_stride = align_up(row_offset, row_align); + + // Allocate the packed row buffer. shared_ptr wraps the + // storage; the buffer_resource keeps it alive via its data field. + const std::size_t bytes = std::size_t(N) * row_stride; + auto storage = std::shared_ptr(new uint8_t[bytes]()); + + // Extract each scalar column at its row offset. + for(const auto& c : cols) + { + uint32_t idx = c.prop_idx; + reader.extract_properties_with_stride( + &idx, 1, c.type, + storage.get() + c.offset_in_row, row_stride); + } + + // AABB: find x/y/z by name, read each position from the packed buffer. + ossia::aabb bounds{}; + bounds.min[0] = bounds.min[1] = bounds.min[2] = 1.f; + bounds.max[0] = bounds.max[1] = bounds.max[2] = -1.f; + { + const Col* cx = nullptr; const Col* cy = nullptr; const Col* cz = nullptr; + for(const auto& c : cols) + { + if(c.name == "x") cx = &c; + else if(c.name == "y") cy = &c; + else if(c.name == "z") cz = &c; + } + if(cx && cy && cz + && cx->type == miniply::PLYPropertyType::Float + && cy->type == miniply::PLYPropertyType::Float + && cz->type == miniply::PLYPropertyType::Float) + { + const uint8_t* base = storage.get(); + for(uint32_t i = 0; i < N; ++i) + { + float x, y, z; + std::memcpy(&x, base + i * row_stride + cx->offset_in_row, sizeof(float)); + std::memcpy(&y, base + i * row_stride + cy->offset_in_row, sizeof(float)); + std::memcpy(&z, base + i * row_stride + cz->offset_in_row, sizeof(float)); + bounds.expand(x, y, z); + } + } + } + + // Wrap as a buffer_resource. Storage uses storage_buffer usage so + // ScenePreprocessor uploads it as an SSBO. + auto br = std::make_shared(); + br->resource = ossia::buffer_data{ + .data = std::shared_ptr(storage, storage.get()), + .byte_size = (int64_t)bytes, + .usage_hint = ossia::buffer_data::usage::storage_buffer}; + br->content_hash = (uint64_t)(uintptr_t)storage.get(); + + auto out = std::make_shared(); + out->raw_data = std::move(br); + out->row_stride = row_stride; + out->primitive_count = N; + out->topology = ossia::primitive_topology::points; + out->format_id = detect_format_id(*vtx); + // For known formats, name the per-row struct so ScenePreprocessor + // exposes raw_data as a per-vertex `splat: ` ATTRIBUTE and the + // CSF can declare a matching TYPES entry. Empty falls back to the + // legacy AUXILIARY raw_splats path. + if(out->format_id == "3dgs.classic") + out->struct_type_name = "Splat3DGS"; + out->bounds = bounds; + out->stable_id = ossia::mint_stable_id(); + + // (format_params left empty for v1: format CSF authors declare the + // LAYOUT block themselves matching the PLY column order. Adding a + // reflective column-table here later is a pure addition — no + // consumer depends on its absence.) + + return out; +} + +} // namespace Threedim::PrimitiveCloud diff --git a/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/PlyParser.hpp b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/PlyParser.hpp new file mode 100644 index 0000000000..0dad735832 --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/PlyParser.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include +#include + +namespace Threedim::PrimitiveCloud +{ + +// Cheap header-only sniff: is the PLY file at `path` shaped like a +// primitive cloud (no `face` element, or has columns outside the +// standard mesh set {x,y,z,nx,ny,nz,red,green,blue,alpha,s,t,u,v})? +// Reads only the textual header, doesn't load row data. +bool ply_is_splat_shaped(std::string_view path); + +// Parse `path` and produce a primitive_cloud_component. The component's +// raw_data is a single tightly-packed buffer of the PLY rows: each row +// is a struct of the columns in their PLY-declared order, std430-style +// natural alignment (each float at +4, each int at +4, each uchar at +// +1 with no inter-field padding — but the row stride is rounded to +// the largest field alignment within the row, see +// internal::row_stride_for). +// +// Returns nullptr if the PLY is not splat-shaped, or if parsing fails. +// +// Sets format_id to a recognized signature when columns match a known +// fingerprint: +// - has f_dc_0/1/2 + f_rest_* + scale_0/1/2 + rot_0/1/2/3 + opacity +// -> "3dgs.classic" +// - else empty (the user wires the chain by hand or saves a preset) +ossia::primitive_cloud_component_ptr parse_ply(std::string_view path); + +} // namespace Threedim::PrimitiveCloud diff --git a/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SceneFromCloud.cpp b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SceneFromCloud.cpp new file mode 100644 index 0000000000..e59b02daf4 --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SceneFromCloud.cpp @@ -0,0 +1,44 @@ +#include "SceneFromCloud.hpp" + +#include +#include + +namespace Threedim::PrimitiveCloud +{ + +std::shared_ptr sceneStateFromCloud( + ossia::primitive_cloud_component_ptr cloud, + std::string_view source_label) +{ + if(!cloud) + return nullptr; + + auto children = std::make_shared>(); + children->push_back(ossia::primitive_cloud_component_ptr{cloud}); + + auto node = std::make_shared(); + // Stable id keyed on the cloud's raw_data pointer. Required by the + // registry's slot allocator: a 0 id is uncacheable and the cloud + // disappears between frames. + uint64_t key = 0; + if(cloud->raw_data) + key = (uint64_t)((uintptr_t)cloud->raw_data.get()); + if(key == 0) + key = (uint64_t)((uintptr_t)cloud.get()); + node->id.value = key; + node->name = source_label.empty() + ? std::string("primitive_cloud") + : std::string(source_label); + node->children = std::move(children); + + auto roots = std::make_shared>(); + roots->push_back(std::move(node)); + + auto state = std::make_shared(); + state->roots = std::move(roots); + state->version = 1; + state->dirty_index = 1; + return state; +} + +} // namespace Threedim::PrimitiveCloud diff --git a/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SceneFromCloud.hpp b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SceneFromCloud.hpp new file mode 100644 index 0000000000..550c2cf758 --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SceneFromCloud.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +#include +#include + +namespace Threedim::PrimitiveCloud +{ + +// Wrap a parsed primitive_cloud_component into a fresh scene_state with +// one scene_node carrying it as its sole payload. Mirrors +// SceneFromMeshes::sceneStateFromMeshes for the splat path. +// +// `source_label` becomes the scene_node name (typically the source +// filename). Returns nullptr if `cloud` is null. +std::shared_ptr sceneStateFromCloud( + ossia::primitive_cloud_component_ptr cloud, + std::string_view source_label = {}); + +} // namespace Threedim::PrimitiveCloud diff --git a/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SplatBinary.cpp b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SplatBinary.cpp new file mode 100644 index 0000000000..43136ad83f --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SplatBinary.cpp @@ -0,0 +1,56 @@ +#include "SplatBinary.hpp" + +#include +#include +#include + +namespace Threedim::PrimitiveCloud +{ + +ossia::primitive_cloud_component_ptr parse_splat_binary(std::string_view bytes) +{ + constexpr uint32_t kRowSize = 32; + if(bytes.empty() || (bytes.size() % kRowSize) != 0) + return nullptr; + + const uint32_t N = (uint32_t)(bytes.size() / kRowSize); + if(N == 0) + return nullptr; + + // Copy into a stable shared buffer. The input string_view points at + // halp's mmap or text-file storage which doesn't outlive this call. + auto storage = std::shared_ptr(new uint8_t[bytes.size()]); + std::memcpy(storage.get(), bytes.data(), bytes.size()); + + // AABB from first 12 bytes of each row (xyz floats). + ossia::aabb bounds{}; + bounds.min[0] = bounds.min[1] = bounds.min[2] = 1.f; + bounds.max[0] = bounds.max[1] = bounds.max[2] = -1.f; + for(uint32_t i = 0; i < N; ++i) + { + float x, y, z; + std::memcpy(&x, storage.get() + i * kRowSize + 0, sizeof(float)); + std::memcpy(&y, storage.get() + i * kRowSize + 4, sizeof(float)); + std::memcpy(&z, storage.get() + i * kRowSize + 8, sizeof(float)); + bounds.expand(x, y, z); + } + + auto br = std::make_shared(); + br->resource = ossia::buffer_data{ + .data = std::shared_ptr(storage, storage.get()), + .byte_size = (int64_t)bytes.size(), + .usage_hint = ossia::buffer_data::usage::storage_buffer}; + br->content_hash = (uint64_t)(uintptr_t)storage.get(); + + auto out = std::make_shared(); + out->raw_data = std::move(br); + out->row_stride = kRowSize; + out->primitive_count = N; + out->topology = ossia::primitive_topology::points; + out->format_id = "3dgs.splat-binary"; + out->bounds = bounds; + out->stable_id = ossia::mint_stable_id(); + return out; +} + +} // namespace Threedim::PrimitiveCloud diff --git a/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SplatBinary.hpp b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SplatBinary.hpp new file mode 100644 index 0000000000..886337dd25 --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SplatBinary.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include + +namespace Threedim::PrimitiveCloud +{ + +// Parse an Antimatter15 .splat file (32 bytes per primitive, +// fixed schema). +// +// On-disk row layout (little-endian, packed, no padding): +// bytes 0..11 position xyz, 3 × float32 +// bytes 12..23 scale_xyz, 3 × float32 (linear, NOT log-space) +// bytes 24..27 color rgba, 4 × uint8 unorm +// bytes 28..31 rotation quat, 4 × uint8 (sign-encoded as +// (q + 1) * 127.5 around index 0; recipient +// reconstructs by (b - 128) / 128) +// +// We pass these bytes through verbatim. The "3dgs.splat-binary" preset's +// CSF declares the matching LAYOUT, dequantizes color8 to color, and +// reconstructs the quat from the int8s. +// +// Returns nullptr if `bytes.size() % 32 != 0` or the input is empty. +ossia::primitive_cloud_component_ptr parse_splat_binary(std::string_view bytes); + +} // namespace Threedim::PrimitiveCloud diff --git a/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SpzCodec.cpp b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SpzCodec.cpp new file mode 100644 index 0000000000..64076bf3ac --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SpzCodec.cpp @@ -0,0 +1,160 @@ +#include "SpzCodec.hpp" + +#include + +#include +#include +#include +#include + +namespace Threedim::PrimitiveCloud +{ + +namespace +{ + +// Canonical row layout matching what the 3dgs.classic preset's +// AUXILIARY LAYOUT in 01_Decode.cs expects. Field offsets in floats. +struct CanonicalRow +{ + static constexpr uint32_t kFloats = 62; + static constexpr uint32_t kBytes = kFloats * sizeof(float); + + static constexpr uint32_t kPos = 0; // 3 floats + static constexpr uint32_t kNormal = 3; // 3 floats (zero-filled) + static constexpr uint32_t kSHDC = 6; // 3 floats + static constexpr uint32_t kSHRest = 9; // 45 floats (channel-major) + static constexpr uint32_t kAlpha = 54; // 1 float (pre-sigmoid) + static constexpr uint32_t kScale = 55; // 3 floats (log-space) + static constexpr uint32_t kRot = 58; // 4 floats (w,x,y,z) +}; + +} // namespace + +ossia::primitive_cloud_component_ptr parse_spz(std::string_view bytes) +{ + if(bytes.empty()) + return nullptr; + + // The Niantic library expects the gzipped/NGSP payload as a + // std::vector. Copy in (single allocation; the cost is + // dwarfed by the gzip inflate). Specify RUB→RDF in the unpack + // options so the library handles the basis flip for us. + std::vector data( + reinterpret_cast(bytes.data()), + reinterpret_cast(bytes.data()) + bytes.size()); + + spz::UnpackOptions opts; + opts.to = spz::CoordinateSystem::RDF; + + spz::GaussianCloud cloud = spz::loadSpz(data, opts); + if(cloud.numPoints <= 0 || cloud.positions.empty()) + return nullptr; + + const uint32_t N = (uint32_t)cloud.numPoints; + const uint32_t shDeg = (uint32_t)cloud.shDegree; + const uint32_t shCoefs = (shDeg == 0) ? 0 + : (shDeg == 1) ? 3 + : (shDeg == 2) ? 8 + : (shDeg == 3) ? 15 + : 24; // degree 4 + const uint32_t restPad = 15; // 3dgs.classic preset always reads 15 R/G/B coefs + + if(cloud.positions.size() != (size_t)N * 3 + || cloud.scales.size() != (size_t)N * 3 + || cloud.rotations.size() != (size_t)N * 4 + || cloud.alphas.size() != (size_t)N + || cloud.colors.size() != (size_t)N * 3) + { + return nullptr; + } + if(shCoefs > 0 && cloud.sh.size() != (size_t)N * shCoefs * 3) + return nullptr; + + const std::size_t totalBytes + = (std::size_t)N * (std::size_t)CanonicalRow::kBytes; + auto storage = std::shared_ptr(new uint8_t[totalBytes]()); + + ossia::aabb bounds{}; + bounds.min[0] = bounds.min[1] = bounds.min[2] = 1.f; + bounds.max[0] = bounds.max[1] = bounds.max[2] = -1.f; + + // Effective coefficient count we'll actually fill per-channel + // (clamped to 15 — preset hardcodes 45 = 3·15 rest floats; degree-4 + // input gets truncated to degree 3 here, lossy but renderable). + const uint32_t fillCoefs = (shCoefs > restPad) ? restPad : shCoefs; + + float* base = reinterpret_cast(storage.get()); + for(uint32_t i = 0; i < N; ++i) + { + float* row = base + (std::size_t)i * CanonicalRow::kFloats; + + // Position. + const float x = cloud.positions[i * 3 + 0]; + const float y = cloud.positions[i * 3 + 1]; + const float z = cloud.positions[i * 3 + 2]; + row[CanonicalRow::kPos + 0] = x; + row[CanonicalRow::kPos + 1] = y; + row[CanonicalRow::kPos + 2] = z; + bounds.expand(x, y, z); + + // Normals — not stored in SPZ; leave zero-filled. + + // SH DC (= colors). + row[CanonicalRow::kSHDC + 0] = cloud.colors[i * 3 + 0]; + row[CanonicalRow::kSHDC + 1] = cloud.colors[i * 3 + 1]; + row[CanonicalRow::kSHDC + 2] = cloud.colors[i * 3 + 2]; + + // SH rest. SPZ packs (R,G,B) inner per coefficient; PLY canonical + // is channel-major (R block, G block, B block) per row. Transpose. + if(fillCoefs > 0) + { + const float* sh_src + = cloud.sh.data() + (std::size_t)i * shCoefs * 3; + float* shR = row + CanonicalRow::kSHRest + 0 * restPad; + float* shG = row + CanonicalRow::kSHRest + 1 * restPad; + float* shB = row + CanonicalRow::kSHRest + 2 * restPad; + for(uint32_t c = 0; c < fillCoefs; ++c) + { + shR[c] = sh_src[c * 3 + 0]; + shG[c] = sh_src[c * 3 + 1]; + shB[c] = sh_src[c * 3 + 2]; + } + // Remaining coefs (fillCoefs..restPad) stay zero. + } + + // Alpha — both formats store the pre-sigmoid value; pass through. + row[CanonicalRow::kAlpha] = cloud.alphas[i]; + + // Scale (log-space). + row[CanonicalRow::kScale + 0] = cloud.scales[i * 3 + 0]; + row[CanonicalRow::kScale + 1] = cloud.scales[i * 3 + 1]; + row[CanonicalRow::kScale + 2] = cloud.scales[i * 3 + 2]; + + // Rotation. SPZ: (x,y,z,w). PLY canonical: (w,x,y,z). + row[CanonicalRow::kRot + 0] = cloud.rotations[i * 4 + 3]; // w + row[CanonicalRow::kRot + 1] = cloud.rotations[i * 4 + 0]; // x + row[CanonicalRow::kRot + 2] = cloud.rotations[i * 4 + 1]; // y + row[CanonicalRow::kRot + 3] = cloud.rotations[i * 4 + 2]; // z + } + + auto br = std::make_shared(); + br->resource = ossia::buffer_data{ + .data = std::shared_ptr(storage, storage.get()), + .byte_size = (int64_t)totalBytes, + .usage_hint = ossia::buffer_data::usage::storage_buffer}; + br->content_hash = (uint64_t)(uintptr_t)storage.get(); + + auto out = std::make_shared(); + out->raw_data = std::move(br); + out->row_stride = CanonicalRow::kBytes; + out->primitive_count = N; + out->topology = ossia::primitive_topology::points; + out->format_id = "3dgs.classic"; + out->struct_type_name = "Splat3DGS"; + out->bounds = bounds; + out->stable_id = ossia::mint_stable_id(); + return out; +} + +} // namespace Threedim::PrimitiveCloud diff --git a/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SpzCodec.hpp b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SpzCodec.hpp new file mode 100644 index 0000000000..975e1358de --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud/SpzCodec.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include +#include + +namespace Threedim::PrimitiveCloud +{ + +// Decode a Niantic SPZ v1-3 file into a primitive_cloud_component. +// +// SPZ stores splats column-grouped (positions, then scales, then +// rotations, then alphas, then colors, then SH) inside a gzip- +// compressed payload, in the RUB coordinate system. We unpack via +// the vendored Niantic library, rotate to RDF (the convention every +// existing 3dgs.classic preset assumes), then transpose into the +// canonical 62-float / 248-byte PLY-compatible row layout: +// +// floats 0..2 x, y, z +// floats 3..5 nx, ny, nz (zero — not in SPZ) +// floats 6..8 f_dc_0..2 (SH DC = colors) +// floats 9..53 f_rest_0..44 (R coeffs, then G, then B; padded +// with zero for shDegree<3) +// float 54 opacity (pre-sigmoid) +// floats 55..57 scale_0..2 (log-space) +// floats 58..61 rot_0..3 (PLY convention w,x,y,z) +// +// Returns nullptr on parse failure or v4 files (ZSTD support not +// vendored — converting v4 → v3 with the upstream `spz-tool` works +// around it). Sets format_id = "3dgs.classic" so the existing preset +// picks it up transparently. +ossia::primitive_cloud_component_ptr parse_spz(std::string_view bytes); + +} // namespace Threedim::PrimitiveCloud diff --git a/src/plugins/score-plugin-threedim/Threedim/TagAs.cpp b/src/plugins/score-plugin-threedim/Threedim/TagAs.cpp new file mode 100644 index 0000000000..ad9bcd15c8 --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/TagAs.cpp @@ -0,0 +1,52 @@ +#include "TagAs.hpp" + +#include "PrimitiveCloud/FormatOverride.hpp" + +namespace Threedim +{ + +void TagAs::rebuild() +{ + const auto& in = inputs.scene_in.scene; + const ossia::scene_state* in_state = in.state.get(); + const int64_t in_version = in_state ? in_state->version : -1; + const auto& cur_format = inputs.format_id.value; + + m_cached_in_state = in_state; + m_cached_in_version = in_version; + m_cached_format_id = cur_format; + + if(!in_state) + { + m_cached_out = in.state; + m_pending_dirty = 0xFF; + return; + } + + // applyFormatOverride is the same helper AssetLoader uses, with the + // same passthrough-when-empty contract. Returns the input verbatim + // when format_id is empty so wiring stays cheap during edits. + m_cached_out = Threedim::PrimitiveCloud::applyFormatOverride( + in.state, cur_format); + m_pending_dirty = 0xFF; +} + +void TagAs::operator()() +{ + // The upstream scene_state ptr / version can change without a + // port-update event (e.g. when a producer republishes the same + // shared_ptr after an internal mutation). Detect and rebuild. + const auto* in_state = inputs.scene_in.scene.state.get(); + const int64_t in_version = in_state ? in_state->version : -1; + const bool upstream_changed + = m_cached_in_state != in_state + || m_cached_in_version != in_version; + if(!m_cached_out || upstream_changed) + rebuild(); + + outputs.scene_out.scene.state = m_cached_out; + outputs.scene_out.dirty = m_pending_dirty; + m_pending_dirty = 0; +} + +} // namespace Threedim diff --git a/src/plugins/score-plugin-threedim/Threedim/TagAs.hpp b/src/plugins/score-plugin-threedim/Threedim/TagAs.hpp new file mode 100644 index 0000000000..483b3bb459 --- /dev/null +++ b/src/plugins/score-plugin-threedim/Threedim/TagAs.hpp @@ -0,0 +1,78 @@ +#pragma once +#include +#include + +#include + +#include +#include +#include + +namespace Threedim +{ + +// Mid-pipeline format-id stamp. Walks every primitive_cloud_component +// reachable from the upstream scene_state and shallow-clones it with +// `format_id = inputs.format_id.value`. Heavy fields (raw_data, +// extra_buffers, bounds) are shared via shared_ptr — no GPU upload +// duplicates. +// +// Wiring: +// ThirdPartyProducer → TagAs(format_id="my-custom-format") +// → ScenePreprocessor +// → FlattenedSceneFilter(mode=12, match="my-custom-format") +// → CustomDecode → CustomDraw → Window +// +// Use this when the upstream producer can't be modified (third-party +// node, legacy plugin) but the cloud needs to flow through a +// FlattenedSceneFilter in mode 12 (format_id == match_str). Empty +// `format_id` is passthrough — no rewrite, original scene_state +// forwarded as-is. +class TagAs +{ +public: + halp_meta(name, "Tag As Format") + halp_meta(category, "Visuals/3D/Scene") + halp_meta(c_name, "tag_as_format") + halp_meta(authors, "ossia team") + halp_meta( + manual_url, + "https://ossia.io/score-docs/processes/tag-as-format.html") + halp_meta(uuid, "8e3d7c2a-5f91-4b6c-a8e2-1d9f4c7b3e5a") + + struct ins + { + struct + { + halp_meta(name, "Scene In"); + ossia::scene_spec scene; + uint8_t dirty{0}; + } scene_in; + + struct : halp::lineedit<"Format ID", ""> + { void update(TagAs& n) { n.rebuild(); } } format_id; + } inputs; + + struct outs + { + struct + { + halp_meta(name, "Scene Out"); + ossia::scene_spec scene; + uint8_t dirty{0}; + } scene_out; + } outputs; + + void rebuild(); + void operator()(); + + // Cached output kept stable while inputs are unchanged — preserves + // ScenePreprocessor's fingerprint fast-path. + std::shared_ptr m_cached_out; + uint8_t m_pending_dirty{0xFF}; + const ossia::scene_state* m_cached_in_state{}; + int64_t m_cached_in_version{-1}; + std::string m_cached_format_id; +}; + +} diff --git a/src/plugins/score-plugin-threedim/score_plugin_threedim.cpp b/src/plugins/score-plugin-threedim/score_plugin_threedim.cpp index 655147517c..2d3f004e8c 100644 --- a/src/plugins/score-plugin-threedim/score_plugin_threedim.cpp +++ b/src/plugins/score-plugin-threedim/score_plugin_threedim.cpp @@ -35,8 +35,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -233,8 +235,15 @@ class AssetLibraryHandler final QSet acceptedFiles() const noexcept override { + // Extension list must stay aligned with AssetDropHandler::fileExtensions + // below — the Library panel surfaces files by acceptedFiles, the canvas + // drag-drop accepts them via fileExtensions, and AssetLoader::process + // routes the underlying parser by extension. .splat and .spz arrive + // through the splat_binary / spz parsers (see PrimitiveCloud/SplatBinary + // and SpzCodec); they were missing from the Library list even though + // the runtime fully handles them. return {"fbx", "gltf", "glb", "obj", "ply", "stl", "off", - "usd", "usda", "usdc", "usdz"}; + "usd", "usda", "usdc", "usdz", "splat", "spz"}; } Library::Subcategories categories; @@ -275,6 +284,7 @@ class AssetDropHandler final : public Process::ProcessDropHandler QSet fileExtensions() const noexcept override { return {"fbx", "gltf", "glb", "obj", "ply", "stl", "off", + "splat", "spz", "usd", "usda", "usdc", "usdz"}; } @@ -373,7 +383,9 @@ class VoxDropHandler final : public Process::ProcessDropHandler /** * This file instantiates the classes that are provided by this plug-in. */ -score_plugin_threedim::score_plugin_threedim() = default; +score_plugin_threedim::score_plugin_threedim() +{ +} score_plugin_threedim::~score_plugin_threedim() = default; std::vector score_plugin_threedim::factories( @@ -406,6 +418,7 @@ std::vector score_plugin_threedim::factories( oscr::instantiate_fx(fx, ctx, key); oscr::instantiate_fx(fx, ctx, key); oscr::instantiate_fx(fx, ctx, key); + oscr::instantiate_fx(fx, ctx, key); oscr::instantiate_fx(fx, ctx, key); oscr::instantiate_fx(fx, ctx, key); oscr::instantiate_fx(fx, ctx, key); @@ -438,6 +451,13 @@ std::vector score_plugin_threedim::factories( oscr::instantiate_fx(fx, ctx, key); oscr::instantiate_fx(fx, ctx, key); oscr::instantiate_fx(fx, ctx, key); + // Splat (legacy GaussianSplatNode) factories removed: AssetLoader now + // routes .splat / .spz / 3DGS .ply files through primitive_cloud_component + // and the new ScenePreprocessor / 3dgs.tile rendering pipeline. The legacy + // Splat process kept its own GaussianSplatNode renderer; superseded. + // Existing projects that referenced the legacy Splat UUID + // ("cdc15a16-e856-4e02-9339-7d9e48da10ce") get a UUID-rewrite alias to + // AssetLoader at load time (see C-22d). auto add = instantiate_factories< score::ApplicationContext, FW // ("object") the running application registers. // // ObjectGallery --list [filter] @@ -43,14 +44,54 @@ #undef private #include #include +#include #include #include +#include +#include + #include +#include #include +// Coverage support: this harness intentionally exits via std::_Exit (to bypass a +// gfx teardown crash) and often SEGVs on shutdown, both of which skip the normal +// atexit profile writer. When built with -fprofile-instr-generate we flush +// counters explicitly before _Exit and from a SEGV/ABRT handler so instrumented +// runs still yield a .profraw. Mach-O has no ELF-style weak undefined externals +// (the static link still requires the symbol), so resolve dynamically there. +#if defined(__APPLE__) +#include +#else +extern "C" __attribute__((weak)) int __llvm_profile_write_file(void); +#endif + namespace { +void flush_coverage() +{ +#if defined(__APPLE__) + if(auto f = reinterpret_cast( + dlsym(RTLD_DEFAULT, "__llvm_profile_write_file"))) + f(); +#else + if(&__llvm_profile_write_file != nullptr) + __llvm_profile_write_file(); +#endif +} +void coverage_signal_handler(int sig) +{ + flush_coverage(); + std::signal(sig, SIG_DFL); + std::raise(sig); +} +void install_coverage_flush() +{ + std::signal(SIGSEGV, coverage_signal_handler); + std::signal(SIGABRT, coverage_signal_handler); +} + bool matches(const QString& name, const QString& filter) { return filter.isEmpty() || name.contains(filter, Qt::CaseInsensitive); @@ -143,10 +184,221 @@ void run_gallery(const QString& filter, int seconds) qApp->exit(0); }); } + +// True if the image has more than one distinct colour (i.e. actually rendered +// something, not a single flat clear-colour / transparent frame). +bool non_blank(const QImage& img) +{ + if(img.isNull() || img.width() < 2 || img.height() < 2) + return false; + const QRgb first = img.pixel(0, 0); + for(int y = 0; y < img.height(); y += 4) + for(int x = 0; x < img.width(); x += 4) + if(img.pixel(x, y) != first) + return true; + return false; +} + +// Render the first object matching `filter` offscreen and check it produced a +// non-blank frame. Exit codes: 0 = rendered non-blank, 2 = blank frame, +// 3 = object has no texture output (needs a display chain), 4 = grab failed. +// A crash during rendering kills the process with its signal — the driver that +// runs this per object treats that as the object's result. +void run_render_check(const QString& filter, int seconds) +{ + const auto& ctx = score::GUIAppContext(); + + auto doc = ctx.docManager.newDocument( + ctx, Id(0), + *ctx.interfaces().begin()); + qApp->processEvents(); + + auto& device_plug = doc->context().plugin(); + { + CommandDispatcher<> disp{doc->context().commandStack}; + Device::DeviceSettings settings; + settings.name = "window"; + settings.protocol = Gfx::WindowProtocolFactory::static_concreteKey(); + disp.submit(device_plug, settings); + } + + auto& scenario_dm + = static_cast(doc->model().modelDelegate()); + auto& itv = scenario_dm.baseInterval(); + + QString target; + auto& pl = ctx.interfaces(); + for(auto& factory : pl) + { + if(!matches(factory.prettyName(), filter)) + continue; + target = factory.prettyName(); + CommandDispatcher<> disp{doc->context().commandStack}; + disp.submit( + itv, factory.concreteKey(), factory.customConstructionData(), QPointF{}); + break; + } + if(target.isEmpty()) + { + std::printf("RENDER no object matches '%s'\n", filter.toUtf8().constData()); + qApp->exit(1); + return; + } + + int textured = 0; + for(auto outlet : itv.findChildren()) + { + outlet->setAddress(State::AddressAccessor{{{"window"}, {}}}); + ++textured; + } + if(textured == 0) + { + std::printf("RENDER SKIP %-40s (no texture output)\n", target.toUtf8().constData()); + qApp->exit(3); + return; + } + + auto& eng = ctx.guiApplicationPlugin(); + QTimer::singleShot(100, [&eng, &itv] { eng.execution().play_interval(itv); }); + QTimer::singleShot(seconds * 1000, [&ctx, doc, target] { + int rc = 4; + if(auto* d = doc->context() + .plugin() + .list() + .findDevice("window")) + { + if(auto* wd = qobject_cast(d)) + { + const QString path = "/tmp/objectgallery_render.png"; + wd->grabTo(path); + QApplication::processEvents(); + const QImage img{path}; + const bool ok = non_blank(img); + // Reaching here means the object rendered a full run without crashing + // — the primary signal. non_blank is reported as a hint: the offscreen + // readback path does not yet composite the routed texture, so it is + // currently informational, not a pass/fail gate. + rc = 0; + std::printf( + "RENDER OK %-40s %dx%d content=%s\n", target.toUtf8().constData(), + img.width(), img.height(), ok ? "yes" : "blank"); + } + } + std::fflush(stdout); + // The render already ran; skip the gfx-context teardown (which currently + // crashes in the offscreen readback release path) via a hard exit. If the + // object had crashed *during* rendering, we would never reach here — that + // is exactly the render-path failure this check is meant to catch. + flush_coverage(); + std::_Exit(rc); + }); +} + +// Load a shader tester file (.fs/.frag/.glsl -> ISF Shader, .cs/.comp/.csf -> +// Compute Shader) into a process, route its texture output to an offscreen +// window and render it. Exit 0 = compiled and rendered without crashing (a +// shader compile failure or render crash is caught), 3 = no texture output, +// 5 = unsupported extension. Same offscreen/hard-exit handling as --render. +void run_shader_check(const QString& path, int seconds) +{ + const QFileInfo fi{path}; + const QString ext = fi.suffix().toLower(); + QString wantName; + if(ext == "fs" || ext == "frag" || ext == "glsl") + wantName = "ISF Shader"; + else if(ext == "cs" || ext == "comp" || ext == "csf") + wantName = "Compute Shader"; + else + { + std::printf("SHADER UNSUPPORTED %s\n", path.toUtf8().constData()); + qApp->exit(5); + return; + } + + const auto& ctx = score::GUIAppContext(); + auto doc = ctx.docManager.newDocument( + ctx, Id(0), + *ctx.interfaces().begin()); + qApp->processEvents(); + + auto& device_plug = doc->context().plugin(); + { + CommandDispatcher<> disp{doc->context().commandStack}; + Device::DeviceSettings settings; + settings.name = "window"; + settings.protocol = Gfx::WindowProtocolFactory::static_concreteKey(); + disp.submit(device_plug, settings); + } + + auto& scenario_dm + = static_cast(doc->model().modelDelegate()); + auto& itv = scenario_dm.baseInterval(); + + // The shader process is created with the file path as its construction data. + bool created = false; + auto& pl = ctx.interfaces(); + for(auto& factory : pl) + { + if(factory.prettyName() != wantName) + continue; + CommandDispatcher<> disp{doc->context().commandStack}; + disp.submit( + itv, factory.concreteKey(), fi.absoluteFilePath(), QPointF{}); + created = true; + break; + } + if(!created) + { + std::printf("SHADER no '%s' process\n", wantName.toUtf8().constData()); + qApp->exit(1); + return; + } + + int textured = 0; + for(auto outlet : itv.findChildren()) + { + outlet->setAddress(State::AddressAccessor{{{"window"}, {}}}); + ++textured; + } + if(textured == 0) + { + std::printf( + "SHADER SKIP %-46s (no texture output)\n", fi.fileName().toUtf8().constData()); + qApp->exit(3); + return; + } + + auto& eng = ctx.guiApplicationPlugin(); + QTimer::singleShot(100, [&eng, &itv] { eng.execution().play_interval(itv); }); + QTimer::singleShot(seconds * 1000, [&ctx, doc, fi] { + int rc = 0; + if(auto* d = doc->context() + .plugin() + .list() + .findDevice("window")) + { + if(auto* wd = qobject_cast(d)) + { + const QString out = "/tmp/objectgallery_render.png"; + wd->grabTo(out); + QApplication::processEvents(); + const QImage img{out}; + std::printf( + "SHADER OK %-46s %dx%d content=%s\n", + fi.fileName().toUtf8().constData(), img.width(), img.height(), + non_blank(img) ? "yes" : "blank"); + } + } + std::fflush(stdout); + flush_coverage(); + std::_Exit(rc); + }); +} } int main(int argc, char** argv) { + install_coverage_flush(); QLocale::setDefault(QLocale::C); std::setlocale(LC_ALL, "C"); @@ -154,7 +406,18 @@ int main(int argc, char** argv) // startup, so the execution engine's thread-kind checks pass when we play. ossia::set_thread_pinned(ossia::thread_type::Ui, 0); - score::MinimalGUIApplication app(argc, argv); + // Only the live gallery needs a visible main window; --list and --render run + // headless (a shown window's presenter/RHI-flush is unnecessary and crashes + // offscreen). Determine the mode from argv before the app is built. + bool headless = false; + for(int i = 1; i < argc; ++i) + { + const std::string a = argv[i]; + if(a == "--list" || a == "--render" || a == "--shader") + headless = true; + } + + score::MinimalGUIApplication app(argc, argv, /*show=*/!headless); const QStringList args = qApp->arguments(); const bool list = args.contains("--list"); @@ -184,6 +447,38 @@ int main(int argc, char** argv) return app.exec(); } + // --render : render one object offscreen and check for a non-blank + // frame, exiting with a status code. Run one object per process (a driver + // loops over them) so a render-path crash is isolated to that object. + const QString renderTarget = arg_value(args, "--render"); + if(!renderTarget.isEmpty()) + { + // Force the window device to render offscreen (into a readback) so grabTo + // works without popping a window — even under X11. + qputenv("SCORE_FORCE_OFFSCREEN_WINDOW", "window"); + const int secs = arg_value(args, "--seconds").toInt() > 0 + ? arg_value(args, "--seconds").toInt() + : 2; + QMetaObject::invokeMethod( + &app, [&, renderTarget, secs] { run_render_check(renderTarget, secs); }, + Qt::QueuedConnection); + return app.exec(); + } + + // --shader : load a shader tester file and render it. + const QString shaderFile = arg_value(args, "--shader"); + if(!shaderFile.isEmpty()) + { + qputenv("SCORE_FORCE_OFFSCREEN_WINDOW", "window"); + const int secs = arg_value(args, "--seconds").toInt() > 0 + ? arg_value(args, "--seconds").toInt() + : 2; + QMetaObject::invokeMethod( + &app, [&, shaderFile, secs] { run_shader_check(shaderFile, secs); }, + Qt::QueuedConnection); + return app.exec(); + } + QMetaObject::invokeMethod( &app, [&] { run_gallery(filter, seconds); }, Qt::QueuedConnection); return app.exec(); diff --git a/tests/integration/csf-sweep.sh b/tests/integration/csf-sweep.sh new file mode 100755 index 0000000000..60178652f1 --- /dev/null +++ b/tests/integration/csf-sweep.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# CSF / ISF shader render-regression: load every shader tester into its process +# and render it on BOTH GL backends (llvmpipe and the machine's GPU via X11), +# recording whether it compiles and renders without crashing. +# +# tests/integration/csf-sweep.sh [corpus-dir] (run from the build root) +# +# Default corpus: the csf-testers/ folder of the csf-examples package. Pass a +# directory to sweep any set of .fs/.frag/.glsl/.cs/.comp/.csf shaders. +# +# Per file, ObjectGallery --shader is invoked once per backend, isolated in its +# own process so a shader compile-failure or render crash is attributed to that +# file/backend. Exit 0 = ok, 3 = no texture output (skipped), 5 = unsupported, +# anything else / signal / timeout = failure. +set -u +GALLERY="tests/integration/ObjectGallery" +CORPUS="${1:-$HOME/Documents/ossia/score/packages/csf-examples/csf-testers}" +DISP="${DISPLAY:-:0}" +SECS=1 + +run() { # file backend_env... + local file="$1"; shift + env DISPLAY="$DISP" SCORE_AUDIO_BACKEND=dummy "$@" \ + timeout 40 "$GALLERY" --shader "$file" --seconds "$SECS" >/dev/null 2>&1 +} + +mapfile -t FILES < <(find "$CORPUS" -type f \ + \( -name '*.fs' -o -name '*.frag' -o -name '*.glsl' \ + -o -name '*.cs' -o -name '*.comp' -o -name '*.csf' \) | sort) + +echo "Rendering ${#FILES[@]} shaders from $CORPUS on llvmpipe + NVIDIA(X11)..." +# Two separate passes: mixing software (llvmpipe) and the NVIDIA driver on the +# same X server back-to-back leaves GL state that breaks the next context, so +# run every file on one backend, then every file on the other. +declare -A SW HW +for f in "${FILES[@]}"; do run "$f" LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe; SW["$f"]=$?; done +for f in "${FILES[@]}"; do run "$f"; HW["$f"]=$?; done + +fail_sw=0; fail_hw=0; ok=0; skip=0 +for f in "${FILES[@]}"; do + sw=${SW["$f"]}; hw=${HW["$f"]} + s=OK; [ "$sw" = 3 ] && s=skip; [ "$sw" = 5 ] && s=unsup + [ "$sw" != 0 ] && [ "$sw" != 3 ] && [ "$sw" != 5 ] && { s="FAIL($sw)"; fail_sw=$((fail_sw+1)); } + h=OK; [ "$hw" = 3 ] && h=skip; [ "$hw" = 5 ] && h=unsup + [ "$hw" != 0 ] && [ "$hw" != 3 ] && [ "$hw" != 5 ] && { h="FAIL($hw)"; fail_hw=$((fail_hw+1)); } + [ "$sw" = 0 ] && [ "$hw" = 0 ] && ok=$((ok+1)) + { [ "$sw" = 3 ] || [ "$hw" = 3 ]; } && skip=$((skip+1)) + [ "$s" = OK ] && [ "$h" = OK ] || printf "%-46s llvmpipe=%-9s nvidia=%-9s\n" "$(basename "$f")" "$s" "$h" +done +echo "---" +echo "rendered-ok(both)=$ok skipped=$skip failures: llvmpipe=$fail_sw nvidia=$fail_hw (of ${#FILES[@]})" +[ "$fail_sw" = 0 ] && [ "$fail_hw" = 0 ] diff --git a/tests/integration/render-sweep.sh b/tests/integration/render-sweep.sh new file mode 100755 index 0000000000..9845360178 --- /dev/null +++ b/tests/integration/render-sweep.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Render-regression sweep: render each object on BOTH GL backends and record +# whether it renders without crashing. Run from the build root. +# +# tests/integration/render-sweep.sh [name-filter] +# +# For each object (optionally filtered), invokes ObjectGallery --render once per +# backend, isolated in its own process so a render-path crash is attributed to +# that object/backend. Exit codes: 0 = rendered ok, 3 = no texture output +# (skipped), anything else / signal / timeout = render failure. +set -u +GALLERY="tests/integration/ObjectGallery" +FILTER="${1:-}" +SECS=1 +DISP="${DISPLAY:-:0}" + +# GL backends: software (llvmpipe) and the machine's real GPU via X11. +run_one() { # name backend_env... + local name="$1"; shift + local out + out=$(DISPLAY="$DISP" SCORE_AUDIO_BACKEND=dummy "$@" \ + timeout 40 "$GALLERY" --render "$name" --seconds "$SECS" 2>/dev/null) + local rc=$? + printf '%s\n' "$out" | grep '^RENDER' + return $rc +} + +mapfile -t NAMES < <(DISPLAY="$DISP" "$GALLERY" --list "$FILTER" 2>/dev/null \ + | sed -n 's/ *[0-9a-f-]\{36\}$//p' | sed 's/ *$//' | sort -u) +echo "Sweeping ${#NAMES[@]} objects on llvmpipe + $( [ -n "${NV:-}" ] && echo hw ) NVIDIA(X11)..." +crash_sw=0; crash_hw=0; ok=0; skip=0 +for name in "${NAMES[@]}"; do + [ -z "$name" ] && continue + run_one "$name" LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe >/dev/null; sw=$? + run_one "$name" >/dev/null; hw=$? + tag_sw=OK; [ "$sw" = 3 ] && tag_sw=skip; [ "$sw" != 0 ] && [ "$sw" != 3 ] && { tag_sw="CRASH($sw)"; crash_sw=$((crash_sw+1)); } + tag_hw=OK; [ "$hw" = 3 ] && tag_hw=skip; [ "$hw" != 0 ] && [ "$hw" != 3 ] && { tag_hw="CRASH($hw)"; crash_hw=$((crash_hw+1)); } + [ "$sw" = 0 ] && [ "$hw" = 0 ] && ok=$((ok+1)) + { [ "$sw" = 3 ] || [ "$hw" = 3 ]; } && skip=$((skip+1)) + printf "%-42s llvmpipe=%-10s nvidia=%-10s\n" "$name" "$tag_sw" "$tag_hw" +done +echo "---" +echo "ok=$ok skip=$skip crashes: llvmpipe=$crash_sw nvidia=$crash_hw" +[ "$crash_sw" = 0 ] && [ "$crash_hw" = 0 ] diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 59515e95cb..ed78e8b666 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -161,3 +161,65 @@ if(TARGET score_plugin_threedim) PLUGINS score_plugin_threedim score_plugin_gfx score_plugin_avnd LIBS ${QT_PREFIX}::Gui) endif() +# --- threedim asset loaders ------------------------------------------------ +# The plugin is built with -fvisibility=hidden, so the loader entry points +# (GltfParser/FbxParser/AssetLoader::process, ObjFromString, ...) are not +# reachable through libscore_plugin_threedim.so. Compile the loader layer +# straight into the test binary instead; linking the plugins still provides +# the include dirs + the exported gfx/registry symbols. +if(TARGET score_plugin_threedim) + set(_threedim_src "${SCORE_ROOT_SOURCE_DIR}/src/plugins/score-plugin-threedim/Threedim") + + # C sources go in a separate static lib: mixing C into the test target + # breaks the score_lib_pch REUSE_FROM (PCH is C++-only). + add_library(test_unit_threedim_3rdparty STATIC + "${SCORE_ROOT_SOURCE_DIR}/3rdparty/miniply/miniply.cpp" + "${SCORE_ROOT_SOURCE_DIR}/3rdparty/mikktspace/mikktspace.c" + "${SCORE_ROOT_SOURCE_DIR}/3rdparty/ufbx/ufbx.c") + target_include_directories(test_unit_threedim_3rdparty SYSTEM PUBLIC + "${SCORE_ROOT_SOURCE_DIR}/3rdparty/miniply" + "${SCORE_ROOT_SOURCE_DIR}/3rdparty/mikktspace" + "${SCORE_ROOT_SOURCE_DIR}/3rdparty/ufbx") + set_target_properties(test_unit_threedim_3rdparty + PROPERTIES FOLDER "Tests" POSITION_INDEPENDENT_CODE ON) + + score_add_test(test_unit_threedim_loaders + SOURCES + ThreedimLoaderTest.cpp + "${_threedim_src}/AssetLoader.cpp" + "${_threedim_src}/GltfParser.cpp" + "${_threedim_src}/FbxParser.cpp" + "${_threedim_src}/TinyObj.cpp" + "${_threedim_src}/Ply.cpp" + "${_threedim_src}/VcgImporters.cpp" + "${_threedim_src}/SceneFromMeshes.cpp" + "${_threedim_src}/PrimitiveCloud/PlyParser.cpp" + "${_threedim_src}/PrimitiveCloud/SplatBinary.cpp" + "${_threedim_src}/PrimitiveCloud/SpzCodec.cpp" + "${_threedim_src}/PrimitiveCloud/SceneFromCloud.cpp" + "${_threedim_src}/PrimitiveCloud/FormatOverride.cpp" + PLUGINS score_plugin_threedim score_plugin_gfx score_plugin_avnd + LIBS test_unit_threedim_3rdparty fastgltf spz "${QT_PREFIX}::Gui") + target_include_directories(test_unit_threedim_loaders SYSTEM PRIVATE + "${SCORE_ROOT_SOURCE_DIR}/3rdparty/vcglib" + "${SCORE_ROOT_SOURCE_DIR}/3rdparty/eigen") +endif() + +# PrimitiveCloud parsers (PLY / .splat / .spz + scene helpers). The parser +# entry points are not exported from the dynamic score_plugin_threedim +# (hidden visibility), so compile the parser translation units directly +# into the test. +set(_pc_dir "${SCORE_ROOT_SOURCE_DIR}/src/plugins/score-plugin-threedim/Threedim/PrimitiveCloud") +score_add_test(test_unit_primitivecloud + SOURCES + PrimitiveCloudTest.cpp + "${_pc_dir}/PlyParser.cpp" + "${_pc_dir}/SplatBinary.cpp" + "${_pc_dir}/SpzCodec.cpp" + "${_pc_dir}/SceneFromCloud.cpp" + "${_pc_dir}/FormatOverride.cpp" + "${3RDPARTY_FOLDER}/miniply/miniply.cpp" + LIBS spz) +target_include_directories(test_unit_primitivecloud SYSTEM PRIVATE + "${SCORE_ROOT_SOURCE_DIR}/src/plugins/score-plugin-threedim" + "${3RDPARTY_FOLDER}/miniply") diff --git a/tests/unit/PrimitiveCloudTest.cpp b/tests/unit/PrimitiveCloudTest.cpp new file mode 100644 index 0000000000..118b5c9f6b --- /dev/null +++ b/tests/unit/PrimitiveCloudTest.cpp @@ -0,0 +1,1078 @@ +// Unit tests for the PrimitiveCloud point-cloud / Gaussian-splat parsers +// (Threedim/PrimitiveCloud/): PlyParser, SplatBinary, SpzCodec, +// FormatOverride, SceneFromCloud. Pure parsing logic, no app context. +// +// All inputs are generated in-test: tiny ASCII / binary-LE / binary-BE +// PLY files, hand-packed 32-byte .splat rows, and .spz buffers encoded +// with the vendored Niantic spz library (spz::saveSpz round-trip). +// Corrupt / truncated / empty inputs assert the graceful-failure paths +// (nullptr, no crash — ASAN validates no OOB in the parsers). + +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include + +using Catch::Approx; +namespace PC = Threedim::PrimitiveCloud; + +namespace +{ + +// ---------------------------------------------------------------- helpers + +struct TempTree +{ + QTemporaryDir dir; + + std::string write(const char* name, const std::string& bytes) + { + REQUIRE(dir.isValid()); + const auto path = dir.filePath(QString::fromUtf8(name)).toStdString(); + std::ofstream f(path, std::ios::binary | std::ios::trunc); + REQUIRE(f.good()); + f.write(bytes.data(), (std::streamsize)bytes.size()); + f.close(); + REQUIRE(f.good()); + return path; + } +}; + +void append_f32_le(std::string& out, float v) +{ + static_assert(sizeof(float) == 4); + char b[4]; + std::memcpy(b, &v, 4); // test hosts are little-endian (x86_64 CI) + out.append(b, 4); +} + +void append_f32_be(std::string& out, float v) +{ + char b[4]; + std::memcpy(b, &v, 4); + std::swap(b[0], b[3]); + std::swap(b[1], b[2]); + out.append(b, 4); +} + +void append_u8(std::string& out, uint8_t v) +{ + out.push_back((char)v); +} + +const ossia::buffer_data* raw_bytes(const ossia::primitive_cloud_component_ptr& c) +{ + REQUIRE(c); + REQUIRE(c->raw_data); + auto* bd = ossia::get_if(&c->raw_data->resource); + REQUIRE(bd); + REQUIRE(bd->data); + return bd; +} + +float read_f32(const ossia::buffer_data* bd, size_t byte_offset) +{ + REQUIRE((int64_t)(byte_offset + 4) <= bd->byte_size); + float v; + std::memcpy(&v, (const uint8_t*)bd->data.get() + byte_offset, 4); + return v; +} + +uint8_t read_u8(const ossia::buffer_data* bd, size_t byte_offset) +{ + REQUIRE((int64_t)(byte_offset + 1) <= bd->byte_size); + return ((const uint8_t*)bd->data.get())[byte_offset]; +} + +// 15-column all-float 3DGS-classic-shaped vertex schema, used by the +// ascii / binary-LE / binary-BE cases below. Column order: +// x y z f_dc_0 f_dc_1 f_dc_2 f_rest_0 opacity +// scale_0 scale_1 scale_2 rot_0 rot_1 rot_2 rot_3 +constexpr int kClassicCols = 15; +constexpr float kClassicRows[2][kClassicCols] = { + {1.f, 2.f, 3.f, 0.5f, 0.25f, -0.5f, 0.125f, 1.5f, -1.f, -2.f, -3.f, 1.f, 0.f, + 0.f, 0.f}, + {-4.f, 5.f, -6.f, 0.1f, 0.2f, 0.3f, -0.125f, -1.5f, -0.5f, -1.5f, -2.5f, 0.f, + 1.f, 0.f, 0.f}, +}; + +std::string classic_header(const char* format) +{ + std::string h; + h += "ply\n"; + h += "format "; + h += format; + h += " 1.0\n"; + h += "element vertex 2\n"; + for(const char* col : + {"x", "y", "z", "f_dc_0", "f_dc_1", "f_dc_2", "f_rest_0", "opacity", + "scale_0", "scale_1", "scale_2", "rot_0", "rot_1", "rot_2", "rot_3"}) + { + h += "property float "; + h += col; + h += "\n"; + } + h += "end_header\n"; + return h; +} + +void check_classic_cloud(const ossia::primitive_cloud_component_ptr& cloud) +{ + REQUIRE(cloud); + CHECK(cloud->primitive_count == 2); + CHECK(cloud->row_stride == kClassicCols * 4); + CHECK(cloud->topology == ossia::primitive_topology::points); + CHECK(cloud->format_id == "3dgs.classic"); + CHECK(cloud->struct_type_name == "Splat3DGS"); + CHECK(cloud->stable_id != 0); + + const auto* bd = raw_bytes(cloud); + REQUIRE(bd->byte_size == 2 * kClassicCols * 4); + for(int r = 0; r < 2; ++r) + for(int c = 0; c < kClassicCols; ++c) + CHECK(read_f32(bd, r * kClassicCols * 4 + c * 4) == kClassicRows[r][c]); + + CHECK_FALSE(cloud->bounds.empty()); + CHECK(cloud->bounds.min[0] == -4.f); + CHECK(cloud->bounds.min[1] == 2.f); + CHECK(cloud->bounds.min[2] == -6.f); + CHECK(cloud->bounds.max[0] == 1.f); + CHECK(cloud->bounds.max[1] == 5.f); + CHECK(cloud->bounds.max[2] == 3.f); +} + +ossia::primitive_cloud_component_ptr make_cloud(std::string format_id = {}) +{ + auto storage = std::shared_ptr(new uint8_t[16]()); + auto br = std::make_shared(); + br->resource = ossia::buffer_data{ + .data = std::shared_ptr(storage, storage.get()), + .byte_size = 16, + .usage_hint = ossia::buffer_data::usage::storage_buffer}; + auto c = std::make_shared(); + c->raw_data = std::move(br); + c->row_stride = 16; + c->primitive_count = 1; + c->format_id = std::move(format_id); + c->stable_id = ossia::mint_stable_id(); + return c; +} + +} // namespace + +// ================================================================ PlyParser + +TEST_CASE("PLY: ascii 3DGS-classic file parses with exact values", "[ply]") +{ + TempTree t; + std::string body; + for(const auto& row : kClassicRows) + { + for(int c = 0; c < kClassicCols; ++c) + { + body += std::to_string(row[c]); + body += (c + 1 == kClassicCols) ? "\n" : " "; + } + } + const auto path = t.write("classic_ascii.ply", classic_header("ascii") + body); + + CHECK(PC::ply_is_splat_shaped(path)); + check_classic_cloud(PC::parse_ply(path)); +} + +TEST_CASE("PLY: binary_little_endian 3DGS-classic file parses with exact values", + "[ply]") +{ + TempTree t; + std::string body; + for(const auto& row : kClassicRows) + for(float v : row) + append_f32_le(body, v); + const auto path + = t.write("classic_le.ply", classic_header("binary_little_endian") + body); + + CHECK(PC::ply_is_splat_shaped(path)); + check_classic_cloud(PC::parse_ply(path)); +} + +TEST_CASE("PLY: binary_big_endian 3DGS-classic file parses with exact values", + "[ply]") +{ + TempTree t; + std::string body; + for(const auto& row : kClassicRows) + for(float v : row) + append_f32_be(body, v); + const auto path + = t.write("classic_be.ply", classic_header("binary_big_endian") + body); + + CHECK(PC::ply_is_splat_shaped(path)); + check_classic_cloud(PC::parse_ply(path)); +} + +TEST_CASE("PLY: mesh-shaped file (xyz + face) is rejected", "[ply]") +{ + TempTree t; + const auto path = t.write( + "mesh.ply", + "ply\n" + "format ascii 1.0\n" + "element vertex 3\n" + "property float x\n" + "property float y\n" + "property float z\n" + "element face 1\n" + "property list uchar int vertex_indices\n" + "end_header\n" + "0 0 0\n" + "1 0 0\n" + "0 1 0\n" + "3 0 1 2\n"); + + CHECK_FALSE(PC::ply_is_splat_shaped(path)); + CHECK(PC::parse_ply(path) == nullptr); +} + +TEST_CASE("PLY: mesh columns + face + one extra column is splat-shaped", "[ply]") +{ + TempTree t; + const auto path = t.write( + "meshextra.ply", + "ply\n" + "format ascii 1.0\n" + "element vertex 1\n" + "property float x\n" + "property float y\n" + "property float z\n" + "property float opacity\n" + "element face 1\n" + "property list uchar int vertex_indices\n" + "end_header\n" + "1 2 3 0.5\n" + "3 0 0 0\n"); + + CHECK(PC::ply_is_splat_shaped(path)); + auto cloud = PC::parse_ply(path); + REQUIRE(cloud); + CHECK(cloud->primitive_count == 1); + CHECK(cloud->row_stride == 16); + CHECK(cloud->format_id.empty()); // opacity alone is not the 3dgs fingerprint + CHECK(cloud->struct_type_name.empty()); +} + +TEST_CASE("PLY: mixed uchar/float columns follow natural alignment", "[ply]") +{ + // red(u8) @0, x/y/z(f32) @4/8/12, green(u8) @16 -> stride 20 (align 4). + // No face element => splat-shaped even though all columns are mesh-set. + TempTree t; + const auto path = t.write( + "mixed.ply", + "ply\n" + "format ascii 1.0\n" + "element vertex 2\n" + "property uchar red\n" + "property float x\n" + "property float y\n" + "property float z\n" + "property uchar green\n" + "end_header\n" + "255 1.5 2.5 3.5 128\n" + "7 -1 -2 -3 9\n"); + + CHECK(PC::ply_is_splat_shaped(path)); + auto cloud = PC::parse_ply(path); + REQUIRE(cloud); + CHECK(cloud->primitive_count == 2); + CHECK(cloud->row_stride == 20); + CHECK(cloud->format_id.empty()); + + const auto* bd = raw_bytes(cloud); + REQUIRE(bd->byte_size == 40); + CHECK(read_u8(bd, 0) == 255); + CHECK(read_f32(bd, 4) == 1.5f); + CHECK(read_f32(bd, 8) == 2.5f); + CHECK(read_f32(bd, 12) == 3.5f); + CHECK(read_u8(bd, 16) == 128); + CHECK(read_u8(bd, 20) == 7); + CHECK(read_f32(bd, 24) == -1.f); + CHECK(read_f32(bd, 28) == -2.f); + CHECK(read_f32(bd, 32) == -3.f); + CHECK(read_u8(bd, 36) == 9); + + // AABB computed from the float x/y/z columns. + CHECK(cloud->bounds.min[0] == -1.f); + CHECK(cloud->bounds.max[2] == 3.5f); +} + +TEST_CASE("PLY: double columns get 8-byte alignment; non-float xyz skips AABB", + "[ply]") +{ + // x/y/z(double) @0/8/16, w(f32) @24 -> row_offset 28, align 8 -> stride 32. + TempTree t; + const auto path = t.write( + "doubles.ply", + "ply\n" + "format ascii 1.0\n" + "element vertex 1\n" + "property double x\n" + "property double y\n" + "property double z\n" + "property float custom\n" + "end_header\n" + "1.5 -2.5 3.25 0.5\n"); + + CHECK(PC::ply_is_splat_shaped(path)); // "custom" is an extra column + auto cloud = PC::parse_ply(path); + REQUIRE(cloud); + CHECK(cloud->primitive_count == 1); + CHECK(cloud->row_stride == 32); + + const auto* bd = raw_bytes(cloud); + REQUIRE(bd->byte_size == 32); + double x, y, z; + std::memcpy(&x, (const uint8_t*)bd->data.get() + 0, 8); + std::memcpy(&y, (const uint8_t*)bd->data.get() + 8, 8); + std::memcpy(&z, (const uint8_t*)bd->data.get() + 16, 8); + CHECK(x == 1.5); + CHECK(y == -2.5); + CHECK(z == 3.25); + CHECK(read_f32(bd, 24) == 0.5f); + + // xyz are doubles, not floats -> AABB path skipped -> empty sentinel. + CHECK(cloud->bounds.empty()); +} + +TEST_CASE("PLY: short/int integer columns are sized and aligned", "[ply]") +{ + // x/y/z(f32) @0/4/8, s16 @12, u16 @14, i32 @16, u32 @20 -> stride 24. + TempTree t; + const auto path = t.write( + "ints.ply", + "ply\n" + "format ascii 1.0\n" + "element vertex 1\n" + "property float x\n" + "property float y\n" + "property float z\n" + "property short a\n" + "property ushort b\n" + "property int c\n" + "property uint d\n" + "end_header\n" + "1 2 3 -12345 54321 -100000 4000000000\n"); + + auto cloud = PC::parse_ply(path); + REQUIRE(cloud); + CHECK(cloud->primitive_count == 1); + CHECK(cloud->row_stride == 24); + + const auto* bd = raw_bytes(cloud); + REQUIRE(bd->byte_size == 24); + int16_t a; + uint16_t b; + int32_t c; + uint32_t d; + std::memcpy(&a, (const uint8_t*)bd->data.get() + 12, 2); + std::memcpy(&b, (const uint8_t*)bd->data.get() + 14, 2); + std::memcpy(&c, (const uint8_t*)bd->data.get() + 16, 4); + std::memcpy(&d, (const uint8_t*)bd->data.get() + 20, 4); + CHECK(a == -12345); + CHECK(b == 54321); + CHECK(c == -100000); + CHECK(d == 4000000000u); +} + +TEST_CASE("PLY: list columns inside the vertex element are skipped", "[ply]") +{ + // The list column sits BEFORE the extra scalar so the splat-shape + // sniffer's list-skip branch is exercised too. + TempTree t; + const auto path = t.write( + "listcol.ply", + "ply\n" + "format ascii 1.0\n" + "element vertex 2\n" + "property float x\n" + "property float y\n" + "property float z\n" + "property list uchar float extra\n" + "property float opacity\n" + "end_header\n" + "1 2 3 2 10 20 0.5\n" + "4 5 6 1 30 0.25\n"); + + auto cloud = PC::parse_ply(path); + REQUIRE(cloud); + CHECK(cloud->primitive_count == 2); + CHECK(cloud->row_stride == 16); // x y z opacity only; list skipped + + const auto* bd = raw_bytes(cloud); + CHECK(read_f32(bd, 0) == 1.f); + CHECK(read_f32(bd, 12) == 0.5f); + CHECK(read_f32(bd, 16) == 4.f); + CHECK(read_f32(bd, 28) == 0.25f); +} + +TEST_CASE("PLY: vertex element with only list columns is rejected", "[ply]") +{ + TempTree t; + const auto path = t.write( + "listonly.ply", + "ply\n" + "format ascii 1.0\n" + "element vertex 1\n" + "property list uchar float extra\n" + "end_header\n" + "2 1 2\n"); + + CHECK(PC::ply_is_splat_shaped(path)); // no face element + CHECK(PC::parse_ply(path) == nullptr); // but no scalar column to pack +} + +TEST_CASE("PLY: error paths return false / nullptr without crashing", "[ply]") +{ + TempTree t; + + SECTION("nonexistent path") + { + const auto path = t.dir.filePath("does_not_exist.ply").toStdString(); + CHECK_FALSE(PC::ply_is_splat_shaped(path)); + CHECK(PC::parse_ply(path) == nullptr); + } + SECTION("empty file") + { + const auto path = t.write("empty.ply", ""); + CHECK_FALSE(PC::ply_is_splat_shaped(path)); + CHECK(PC::parse_ply(path) == nullptr); + } + SECTION("garbage header") + { + const auto path = t.write("garbage.ply", "not a ply file at all\n\x01\x02\x03"); + CHECK_FALSE(PC::ply_is_splat_shaped(path)); + CHECK(PC::parse_ply(path) == nullptr); + } + SECTION("truncated binary payload") + { + // Header declares 2 rows; provide half a row of data. + std::string body; + append_f32_le(body, 1.f); + append_f32_le(body, 2.f); + const auto path + = t.write("trunc.ply", classic_header("binary_little_endian") + body); + CHECK(PC::ply_is_splat_shaped(path)); // header itself is fine + CHECK(PC::parse_ply(path) == nullptr); + } + SECTION("ascii row with non-numeric junk") + { + const auto path = t.write( + "junk.ply", + "ply\n" + "format ascii 1.0\n" + "element vertex 1\n" + "property float x\n" + "property float y\n" + "property float z\n" + "property float opacity\n" + "end_header\n" + "foo bar baz quux\n"); + CHECK(PC::parse_ply(path) == nullptr); + } + SECTION("zero vertices") + { + const auto path = t.write( + "zero.ply", + "ply\n" + "format ascii 1.0\n" + "element vertex 0\n" + "property float x\n" + "property float y\n" + "property float z\n" + "property float opacity\n" + "end_header\n"); + CHECK(PC::parse_ply(path) == nullptr); + } + SECTION("no vertex element at all") + { + const auto path = t.write( + "novertex.ply", + "ply\n" + "format ascii 1.0\n" + "element blob 1\n" + "property float value\n" + "end_header\n" + "42\n"); + // No face element and no standard columns -> shaped like a splat... + CHECK(PC::ply_is_splat_shaped(path)); + // ...but there is no vertex element to load. + CHECK(PC::parse_ply(path) == nullptr); + } +} + +// ============================================================== SplatBinary + +namespace +{ +struct SplatRow +{ + float pos[3]; + float scale[3]; + uint8_t rgba[4]; + uint8_t rot[4]; +}; + +std::string pack_splat_rows(std::initializer_list rows) +{ + std::string out; + for(const auto& r : rows) + { + for(float v : r.pos) + append_f32_le(out, v); + for(float v : r.scale) + append_f32_le(out, v); + for(uint8_t v : r.rgba) + append_u8(out, v); + for(uint8_t v : r.rot) + append_u8(out, v); + } + return out; +} +} // namespace + +TEST_CASE(".splat: two-row buffer parses with verbatim passthrough", "[splat]") +{ + const std::string bytes = pack_splat_rows({ + {{1.f, 2.f, 3.f}, {0.5f, 0.25f, 0.125f}, {255, 128, 64, 32}, {255, 128, 0, 64}}, + {{-1.f, -2.f, -3.f}, {1.f, 1.f, 1.f}, {0, 0, 0, 0}, {128, 128, 128, 128}}, + }); + REQUIRE(bytes.size() == 64); + + auto cloud = PC::parse_splat_binary(bytes); + REQUIRE(cloud); + CHECK(cloud->primitive_count == 2); + CHECK(cloud->row_stride == 32); + CHECK(cloud->topology == ossia::primitive_topology::points); + CHECK(cloud->format_id == "3dgs.splat-binary"); + CHECK(cloud->struct_type_name.empty()); + CHECK(cloud->stable_id != 0); + + const auto* bd = raw_bytes(cloud); + REQUIRE(bd->byte_size == 64); + CHECK(std::memcmp(bd->data.get(), bytes.data(), bytes.size()) == 0); + + // Spot-check field decoding at the documented offsets. + CHECK(read_f32(bd, 0) == 1.f); // row0 pos.x + CHECK(read_f32(bd, 12) == 0.5f); // row0 scale.x + CHECK(read_u8(bd, 24) == 255); // row0 color.r + CHECK(read_u8(bd, 28) == 255); // row0 rot[0] + CHECK(read_f32(bd, 32 + 8) == -3.f); // row1 pos.z + + CHECK_FALSE(cloud->bounds.empty()); + CHECK(cloud->bounds.min[0] == -1.f); + CHECK(cloud->bounds.min[1] == -2.f); + CHECK(cloud->bounds.min[2] == -3.f); + CHECK(cloud->bounds.max[0] == 1.f); + CHECK(cloud->bounds.max[1] == 2.f); + CHECK(cloud->bounds.max[2] == 3.f); + + // The parser must own a copy: mutating the input afterwards must not + // affect the parsed buffer. + std::string mutated = bytes; + mutated[0] = (char)0xFF; + CHECK(std::memcmp(bd->data.get(), bytes.data(), bytes.size()) == 0); +} + +TEST_CASE(".splat: single row is accepted", "[splat]") +{ + const std::string bytes = pack_splat_rows( + {{{0.f, 0.f, 0.f}, {1.f, 1.f, 1.f}, {10, 20, 30, 40}, {128, 128, 128, 255}}}); + auto cloud = PC::parse_splat_binary(bytes); + REQUIRE(cloud); + CHECK(cloud->primitive_count == 1); + // Single point at origin: min == max == 0. + CHECK_FALSE(cloud->bounds.empty()); + CHECK(cloud->bounds.min[0] == 0.f); + CHECK(cloud->bounds.max[0] == 0.f); +} + +TEST_CASE(".splat: invalid sizes are rejected", "[splat]") +{ + CHECK(PC::parse_splat_binary({}) == nullptr); + CHECK(PC::parse_splat_binary(std::string(31, 'x')) == nullptr); + CHECK(PC::parse_splat_binary(std::string(33, 'x')) == nullptr); + CHECK(PC::parse_splat_binary(std::string(1, 'x')) == nullptr); + CHECK(PC::parse_splat_binary(std::string(65, 'x')) == nullptr); +} + +// ================================================================= SpzCodec + +namespace +{ + +// Column-major access into the canonical 62-float row. +constexpr uint32_t kRowFloats = 62; +constexpr uint32_t kPos = 0, kNormal = 3, kSHDC = 6, kSHRest = 9, kAlpha = 54, + kScale = 55, kRot = 58; + +float row_at(const ossia::buffer_data* bd, uint32_t row, uint32_t f) +{ + return read_f32(bd, (size_t)row * kRowFloats * 4 + (size_t)f * 4); +} + +spz::GaussianCloud two_point_cloud() +{ + spz::GaussianCloud c; + c.numPoints = 2; + c.shDegree = 0; + c.positions = {0.5f, -1.25f, 2.f, -3.5f, 0.75f, -0.25f}; + c.scales = {-1.5f, -2.f, -2.5f, -3.f, -1.f, -2.f}; + // spz order: x, y, z, w (normalized). + c.rotations = {0.f, 0.f, 0.f, 1.f, 0.5f, 0.5f, 0.5f, 0.5f}; + c.alphas = {0.5f, -0.5f}; + c.colors = {0.25f, -0.5f, 0.75f, -1.f, 0.f, 1.f}; + return c; +} + +std::vector pack_spz(const spz::GaussianCloud& c, uint32_t version) +{ + spz::PackOptions po; + po.version = version; + po.from = spz::CoordinateSystem::RDF; // codec unpacks to RDF -> identity + po.sh1Bits = 8; // full-precision SH for tight margins + po.shRestBits = 8; + std::vector out; + REQUIRE(spz::saveSpz(c, po, &out)); + REQUIRE_FALSE(out.empty()); + return out; +} + +// Unique SH fingerprint per (channel, coefficient) that sits exactly on +// an 8-bit quantization center (n/128), so the pack/unpack round trip is +// lossless and can be compared with a tight margin. +float sh_value(uint32_t ch, uint32_t coef) +{ + return (float)(coef * 3 + ch + 1) / 128.f; +} + +// One point at origin with `coefs` SH coefficients per channel; the SH +// value for (channel ch, coefficient k) is a small unique fingerprint. +spz::GaussianCloud sh_cloud(int degree, uint32_t coefs) +{ + spz::GaussianCloud c; + c.numPoints = 1; + c.shDegree = degree; + c.positions = {0.f, 0.f, 0.f}; + c.scales = {-2.f, -2.f, -2.f}; + c.rotations = {0.f, 0.f, 0.f, 1.f}; + c.alphas = {0.f}; + c.colors = {0.f, 0.f, 0.f}; + c.sh.resize((size_t)coefs * 3); + for(uint32_t k = 0; k < coefs; ++k) + for(uint32_t ch = 0; ch < 3; ++ch) + c.sh[k * 3 + ch] = sh_value(ch, k); + return c; +} + +void check_two_point_cloud(const std::vector& file) +{ + auto cloud = PC::parse_spz( + std::string_view{(const char*)file.data(), file.size()}); + REQUIRE(cloud); + CHECK(cloud->primitive_count == 2); + CHECK(cloud->row_stride == kRowFloats * 4); + CHECK(cloud->topology == ossia::primitive_topology::points); + CHECK(cloud->format_id == "3dgs.classic"); + CHECK(cloud->struct_type_name == "Splat3DGS"); + CHECK(cloud->stable_id != 0); + + const auto* bd = raw_bytes(cloud); + REQUIRE(bd->byte_size == 2 * kRowFloats * 4); + + const auto src = two_point_cloud(); + for(uint32_t i = 0; i < 2; ++i) + { + // Positions: 24-bit fixed point, 12 fractional bits -> ~2.4e-4 step. + for(uint32_t k = 0; k < 3; ++k) + CHECK(row_at(bd, i, kPos + k) + == Approx(src.positions[i * 3 + k]).margin(0.001)); + + // Normals: not in SPZ, zero-filled. + for(uint32_t k = 0; k < 3; ++k) + CHECK(row_at(bd, i, kNormal + k) == 0.f); + + // SH DC (colors): 8-bit with 0.15 color scale -> ~0.026 step. + for(uint32_t k = 0; k < 3; ++k) + CHECK(row_at(bd, i, kSHDC + k) + == Approx(src.colors[i * 3 + k]).margin(0.05)); + + // Degree 0: all 45 f_rest floats must stay zero. + for(uint32_t k = 0; k < 45; ++k) + CHECK(row_at(bd, i, kSHRest + k) == 0.f); + + // Alpha: sigmoid -> u8 -> inv-sigmoid. + CHECK(row_at(bd, i, kAlpha) == Approx(src.alphas[i]).margin(0.05)); + + // Scales (log-space): (s+10)*16 as u8 -> 1/16 step. + for(uint32_t k = 0; k < 3; ++k) + CHECK(row_at(bd, i, kScale + k) + == Approx(src.scales[i * 3 + k]).margin(0.04)); + + // Rotation: spz (x,y,z,w) -> canonical (w,x,y,z); u8 quantized. + const float* q = &src.rotations[i * 4]; + CHECK(row_at(bd, i, kRot + 0) == Approx(q[3]).margin(0.02)); + CHECK(row_at(bd, i, kRot + 1) == Approx(q[0]).margin(0.02)); + CHECK(row_at(bd, i, kRot + 2) == Approx(q[1]).margin(0.02)); + CHECK(row_at(bd, i, kRot + 3) == Approx(q[2]).margin(0.02)); + } + + CHECK_FALSE(cloud->bounds.empty()); + CHECK(cloud->bounds.min[0] == Approx(-3.5f).margin(0.001)); + CHECK(cloud->bounds.min[1] == Approx(-1.25f).margin(0.001)); + CHECK(cloud->bounds.min[2] == Approx(-0.25f).margin(0.001)); + CHECK(cloud->bounds.max[0] == Approx(0.5f).margin(0.001)); + CHECK(cloud->bounds.max[1] == Approx(0.75f).margin(0.001)); + CHECK(cloud->bounds.max[2] == Approx(2.f).margin(0.001)); +} + +} // namespace + +TEST_CASE(".spz: v2 round trip decodes into the canonical row layout", "[spz]") +{ + check_two_point_cloud(pack_spz(two_point_cloud(), 2)); +} + +TEST_CASE(".spz: v3 (smallest-three quaternions) round trip", "[spz]") +{ + check_two_point_cloud(pack_spz(two_point_cloud(), 3)); +} + +TEST_CASE(".spz: SH degree 1 coefficients are transposed channel-major", "[spz]") +{ + spz::GaussianCloud c; + c.numPoints = 1; + c.shDegree = 1; + c.positions = {0.f, 0.f, 0.f}; + c.scales = {-2.f, -2.f, -2.f}; + c.rotations = {0.f, 0.f, 0.f, 1.f}; + c.alphas = {0.f}; + c.colors = {0.f, 0.f, 0.f}; + // 3 coefficients x 3 channels, channel-inner (rgb per coefficient). + c.sh = { + 0.10f, 0.20f, 0.30f, // coef 0: r g b + -0.40f, 0.50f, -0.60f, // coef 1 + 0.70f, -0.25f, 0.05f, // coef 2 + }; + + const auto file = pack_spz(c, 2); + auto cloud = PC::parse_spz( + std::string_view{(const char*)file.data(), file.size()}); + REQUIRE(cloud); + CHECK(cloud->primitive_count == 1); + + const auto* bd = raw_bytes(cloud); + // Canonical layout: R block @kSHRest+0, G block @+15, B block @+30. + for(uint32_t coef = 0; coef < 3; ++coef) + { + CHECK(row_at(bd, 0, kSHRest + 0 * 15 + coef) + == Approx(c.sh[coef * 3 + 0]).margin(0.02)); + CHECK(row_at(bd, 0, kSHRest + 1 * 15 + coef) + == Approx(c.sh[coef * 3 + 1]).margin(0.02)); + CHECK(row_at(bd, 0, kSHRest + 2 * 15 + coef) + == Approx(c.sh[coef * 3 + 2]).margin(0.02)); + } + // Coefficients 3..14 of each channel stay zero. + for(uint32_t ch = 0; ch < 3; ++ch) + for(uint32_t coef = 3; coef < 15; ++coef) + CHECK(row_at(bd, 0, kSHRest + ch * 15 + coef) == 0.f); +} + +TEST_CASE(".spz: SH degree 3 fills all 15 coefficients per channel", "[spz]") +{ + const auto file = pack_spz(sh_cloud(3, 15), 2); + auto cloud = PC::parse_spz( + std::string_view{(const char*)file.data(), file.size()}); + REQUIRE(cloud); + const auto* bd = raw_bytes(cloud); + for(uint32_t ch = 0; ch < 3; ++ch) + for(uint32_t coef = 0; coef < 15; ++coef) + CHECK(row_at(bd, 0, kSHRest + ch * 15 + coef) + == Approx(sh_value(ch, coef)).margin(0.005)); +} + +TEST_CASE(".spz: SH degree 4 is truncated to the 15-coefficient layout", "[spz]") +{ + // Degree 4 carries 24 coefficients per channel; the canonical + // 3dgs.classic row only holds 15 -> coefficients 15..23 are dropped. + const auto file = pack_spz(sh_cloud(4, 24), 2); + auto cloud = PC::parse_spz( + std::string_view{(const char*)file.data(), file.size()}); + REQUIRE(cloud); + CHECK(cloud->row_stride == kRowFloats * 4); // layout unchanged + const auto* bd = raw_bytes(cloud); + for(uint32_t ch = 0; ch < 3; ++ch) + for(uint32_t coef = 0; coef < 15; ++coef) + CHECK(row_at(bd, 0, kSHRest + ch * 15 + coef) + == Approx(sh_value(ch, coef)).margin(0.005)); + // Alpha/scale/rot fields directly after the SH block must be intact + // (no overrun from the truncation). + CHECK(row_at(bd, 0, kAlpha) == Approx(0.f).margin(0.05)); + CHECK(row_at(bd, 0, kScale) == Approx(-2.f).margin(0.04)); + CHECK(row_at(bd, 0, kRot) == Approx(1.f).margin(0.02)); +} + +TEST_CASE(".spz: RUB-tagged input is flipped to RDF", "[spz]") +{ + // Claim the data is already RUB at pack time (no conversion on save); + // the codec always unpacks to RDF, so y and z must come out negated. + auto c = two_point_cloud(); + spz::PackOptions po; + po.version = 2; + po.from = spz::CoordinateSystem::RUB; + std::vector file; + REQUIRE(spz::saveSpz(c, po, &file)); + + auto cloud = PC::parse_spz( + std::string_view{(const char*)file.data(), file.size()}); + REQUIRE(cloud); + const auto* bd = raw_bytes(cloud); + CHECK(row_at(bd, 0, kPos + 0) == Approx(0.5f).margin(0.001)); + CHECK(row_at(bd, 0, kPos + 1) == Approx(1.25f).margin(0.001)); // -y + CHECK(row_at(bd, 0, kPos + 2) == Approx(-2.f).margin(0.001)); // -z +} + +TEST_CASE(".spz: corrupt / truncated / empty inputs fail gracefully", "[spz]") +{ + SECTION("empty") + { + CHECK(PC::parse_spz({}) == nullptr); + } + SECTION("garbage bytes") + { + const std::string junk(64, (char)0xAB); + CHECK(PC::parse_spz(junk) == nullptr); + } + SECTION("truncated valid file") + { + const auto file = pack_spz(two_point_cloud(), 2); + REQUIRE(file.size() > 8); + CHECK(PC::parse_spz(std::string_view{(const char*)file.data(), file.size() / 2}) + == nullptr); + CHECK(PC::parse_spz(std::string_view{(const char*)file.data(), 4}) == nullptr); + } + SECTION("single byte") + { + CHECK(PC::parse_spz(std::string_view{"x", 1}) == nullptr); + } +} + +// ============================================================ SceneFromCloud + +TEST_CASE("SceneFromCloud: wraps a cloud into a one-node scene", "[scene]") +{ + auto cloud = make_cloud("3dgs.classic"); + auto state = PC::sceneStateFromCloud(cloud, "my_file.splat"); + REQUIRE(state); + CHECK(state->version == 1); + CHECK(state->dirty_index == 1); + REQUIRE(state->roots); + REQUIRE(state->roots->size() == 1); + + const auto& node = (*state->roots)[0]; + REQUIRE(node); + CHECK(node->name == "my_file.splat"); + // Node id keyed on the raw_data pointer, and must be non-zero. + CHECK(node->id.value == (uint64_t)(uintptr_t)cloud->raw_data.get()); + CHECK(node->id.value != 0); + + REQUIRE(node->children); + REQUIRE(node->children->size() == 1); + auto* pc + = ossia::get_if(&(*node->children)[0]); + REQUIRE(pc); + CHECK(pc->get() == cloud.get()); // same component, not a copy +} + +TEST_CASE("SceneFromCloud: default label and null-raw_data key fallback", + "[scene]") +{ + auto cloud = std::make_shared(); + auto state + = PC::sceneStateFromCloud(ossia::primitive_cloud_component_ptr{cloud}); + REQUIRE(state); + const auto& node = (*state->roots)[0]; + CHECK(node->name == "primitive_cloud"); + // raw_data is null: id falls back to the component pointer. + CHECK(node->id.value == (uint64_t)(uintptr_t)cloud.get()); + CHECK(node->id.value != 0); +} + +TEST_CASE("SceneFromCloud: null cloud returns null", "[scene]") +{ + CHECK(PC::sceneStateFromCloud(nullptr) == nullptr); + CHECK(PC::sceneStateFromCloud(nullptr, "label") == nullptr); +} + +// ============================================================ FormatOverride + +TEST_CASE("FormatOverride: null state and empty override shortcuts", "[override]") +{ + CHECK(PC::applyFormatOverride(nullptr, "foo") == nullptr); + CHECK(PC::applyFormatOverride(nullptr, "") == nullptr); + + auto state = PC::sceneStateFromCloud(make_cloud(""), "n"); + auto same = PC::applyFormatOverride(state, ""); + CHECK(same.get() == state.get()); // verbatim, no clone, no version bump + CHECK(same->version == state->version); +} + +TEST_CASE("FormatOverride: rewrites the cloud format and bumps versions", + "[override]") +{ + auto cloud = make_cloud(""); + auto state = PC::sceneStateFromCloud(cloud, "n"); + const auto old_version = state->version; + const auto old_dirty = state->dirty_index; + + auto out = PC::applyFormatOverride(state, "custom.format"); + REQUIRE(out); + CHECK(out.get() != state.get()); + CHECK(out->version == old_version + 1); + CHECK(out->dirty_index == old_dirty + 1); + + // Original untouched. + { + auto* pc = ossia::get_if( + &(*(*state->roots)[0]->children)[0]); + REQUIRE(pc); + CHECK((*pc)->format_id.empty()); + } + // Rewritten copy carries the override and shares the raw_data buffer. + { + REQUIRE(out->roots); + REQUIRE(out->roots->size() == 1); + const auto& node = (*out->roots)[0]; + CHECK(node.get() != (*state->roots)[0].get()); // fresh node + auto* pc = ossia::get_if( + &(*node->children)[0]); + REQUIRE(pc); + CHECK(pc->get() != cloud.get()); // fresh component... + CHECK((*pc)->format_id == "custom.format"); + CHECK((*pc)->raw_data.get() == cloud->raw_data.get()); // ...shared payload + CHECK((*pc)->primitive_count == cloud->primitive_count); + CHECK((*pc)->stable_id == cloud->stable_id); + } +} + +TEST_CASE("FormatOverride: matching format keeps node identity", "[override]") +{ + auto state = PC::sceneStateFromCloud(make_cloud("already.set"), "n"); + auto out = PC::applyFormatOverride(state, "already.set"); + REQUIRE(out); + CHECK(out.get() != state.get()); // state itself is still cloned... + CHECK(out->version == state->version + 1); + // ...but the untouched tree keeps its identity for fingerprinting. + CHECK(out->roots.get() == state->roots.get()); + CHECK((*out->roots)[0].get() == (*state->roots)[0].get()); +} + +TEST_CASE("FormatOverride: rewrites nested nodes, preserves siblings", + "[override]") +{ + // root + // +- scene_transform (must keep identity) + // +- inner scene_node + // +- cloud (format "") (must be rewritten) + auto cloud = make_cloud(""); + + auto inner_children = std::make_shared>(); + inner_children->push_back(ossia::primitive_cloud_component_ptr{cloud}); + auto inner = std::make_shared(); + inner->id.value = 2; + inner->name = "inner"; + inner->children = inner_children; + + auto root_children = std::make_shared>(); + root_children->push_back(ossia::scene_transform{}); + root_children->push_back(ossia::scene_node_ptr{inner}); + auto root = std::make_shared(); + root->id.value = 1; + root->name = "root"; + root->children = root_children; + + auto roots = std::make_shared>(); + roots->push_back(root); + auto state = std::make_shared(); + state->roots = roots; + state->version = 7; + state->dirty_index = 3; + + auto out = PC::applyFormatOverride(state, "fmt.x"); + REQUIRE(out); + CHECK(out->version == 8); + CHECK(out->dirty_index == 4); + REQUIRE(out->roots); + REQUIRE(out->roots->size() == 1); + + const auto& new_root = (*out->roots)[0]; + CHECK(new_root.get() != root.get()); // rewritten because a descendant changed + REQUIRE(new_root->children); + REQUIRE(new_root->children->size() == 2); + + // Sibling transform payload survives untouched. + CHECK(ossia::get_if(&(*new_root->children)[0])); + + auto* new_inner = ossia::get_if(&(*new_root->children)[1]); + REQUIRE(new_inner); + CHECK(new_inner->get() != inner.get()); // fresh nested node + auto* pc = ossia::get_if( + &(*(*new_inner)->children)[0]); + REQUIRE(pc); + CHECK((*pc)->format_id == "fmt.x"); + CHECK((*pc)->raw_data.get() == cloud->raw_data.get()); + + // Original tree untouched. + CHECK(cloud->format_id.empty()); + CHECK((*state->roots)[0].get() == root.get()); +} + +TEST_CASE("FormatOverride: childless nodes and null clouds pass through", + "[override]") +{ + auto empty_node = std::make_shared(); + empty_node->id.value = 5; + + auto null_cloud_children = std::make_shared>(); + null_cloud_children->push_back(ossia::primitive_cloud_component_ptr{}); + auto null_cloud_node = std::make_shared(); + null_cloud_node->id.value = 6; + null_cloud_node->children = null_cloud_children; + + auto roots = std::make_shared>(); + roots->push_back(empty_node); + roots->push_back(null_cloud_node); + roots->push_back(nullptr); // null root must not crash + auto state = std::make_shared(); + state->roots = roots; + + auto out = PC::applyFormatOverride(state, "fmt.y"); + REQUIRE(out); + // Nothing rewritable anywhere: tree identity preserved wholesale. + CHECK(out->roots.get() == state->roots.get()); + CHECK(out->version == state->version + 1); +} diff --git a/tests/unit/ThreedimLoaderTest.cpp b/tests/unit/ThreedimLoaderTest.cpp new file mode 100644 index 0000000000..8c46193737 --- /dev/null +++ b/tests/unit/ThreedimLoaderTest.cpp @@ -0,0 +1,1356 @@ +// Unit tests for the threedim 3D-asset loading layer: +// +// - Threedim::ObjFromString (tinyobjloader bridge, TinyObj.cpp) +// - Threedim::sceneStateFromMeshes (SceneFromMeshes.cpp) +// - Threedim::GltfParser (fastgltf bridge, GltfParser.cpp) +// - Threedim::FbxParser (ufbx bridge, FbxParser.cpp) +// - Threedim::AssetLoader dispatch (AssetLoader.cpp) + AssetLoaderRegistry +// +// The parsers are synchronous, static entry points (`ins::xxx_t::process` +// returns an apply-lambda), so everything here runs without any Qt event +// loop, RHI, or application context. +// +// glTF / GLB / FBX fixtures are generated in a temp dir by the test itself +// (single triangle with known positions / normals / UVs / indices, plus a +// base64-embedded buffer for .gltf and a binary chunk for .glb). +// OBJ fixtures come from ~/Documents/ModelsOBJ (cube.obj: 8 verts / 12 tris, +// sphere20.obj: 252 verts / 500 tris, both WITHOUT vn normals); those +// sections are SKIPped when the corpus is absent. +// +// Documented loader behaviours asserted below: +// * OBJ is expanded to an UNINDEXED triangle soup: 12 tris -> 36 vertices, +// index_format::none. Vertex count is faces*3, not the "v" line count. +// * An OBJ without "vn" lines yields NO normal attribute — the OBJ path +// does NOT generate normals (mesh.normals == false, position is the +// only attribute). +// * glTF keeps indexed geometry; indices are widened to uint32. +// * A glTF with normals+UVs but no TANGENT gets tangents generated via +// mikktspace; without normals/UVs nothing is generated. +// * FBX (ufbx) DOES generate missing normals (generate_missing_normals). +// * Corrupt / truncated / OOB-index inputs must return a null result +// (empty std::function) without crashing (ASAN-clean). + +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using Catch::Approx; + +namespace +{ + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +struct TempDir +{ + fs::path dir; + TempDir() + { + auto base = fs::temp_directory_path(); + dir = base + / ("threedim-loader-test-" + std::to_string(uint64_t(QCoreApplication::applicationPid()))); + fs::create_directories(dir); + } + ~TempDir() + { + std::error_code ec; + fs::remove_all(dir, ec); + } + fs::path write(const std::string& name, const std::string& bytes) const + { + auto p = dir / name; + std::ofstream f(p, std::ios::binary); + f.write(bytes.data(), std::streamsize(bytes.size())); + return p; + } +}; + +static std::optional read_file(const fs::path& p) +{ + std::ifstream f(p, std::ios::binary); + if(!f) + return std::nullopt; + std::string s( + (std::istreambuf_iterator(f)), std::istreambuf_iterator()); + return s; +} + +static const fs::path models_dir + = fs::path{::getenv("HOME") ? ::getenv("HOME") : "/home/jcelerier"} + / "Documents" / "ModelsOBJ"; + +static std::string b64(const std::string& in) +{ + static const char tbl[] + = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + out.reserve(((in.size() + 2) / 3) * 4); + std::size_t i = 0; + for(; i + 3 <= in.size(); i += 3) + { + uint32_t v = (uint8_t(in[i]) << 16) | (uint8_t(in[i + 1]) << 8) + | uint8_t(in[i + 2]); + out.push_back(tbl[(v >> 18) & 63]); + out.push_back(tbl[(v >> 12) & 63]); + out.push_back(tbl[(v >> 6) & 63]); + out.push_back(tbl[v & 63]); + } + const auto rem = in.size() - i; + if(rem == 1) + { + uint32_t v = uint8_t(in[i]) << 16; + out.push_back(tbl[(v >> 18) & 63]); + out.push_back(tbl[(v >> 12) & 63]); + out += "=="; + } + else if(rem == 2) + { + uint32_t v = (uint8_t(in[i]) << 16) | (uint8_t(in[i + 1]) << 8); + out.push_back(tbl[(v >> 18) & 63]); + out.push_back(tbl[(v >> 12) & 63]); + out.push_back(tbl[(v >> 6) & 63]); + out += "="; + } + return out; +} + +template +static void put(std::string& s, T v) +{ + char buf[sizeof(T)]; + std::memcpy(buf, &v, sizeof(T)); + s.append(buf, sizeof(T)); +} + +// Single triangle, positions (0,0,0) (1,0,0) (0,1,0), normals all (0,0,1), +// uv (0,0) (1,0) (0,1), u16 indices 0,1,2. Layout: pos @0 (36B), +// nrm @36 (36B), uv @72 (24B), idx @96 (6B) => 102 bytes. +static std::string triangle_buffer_bytes() +{ + std::string b; + const float pos[9] = {0, 0, 0, 1, 0, 0, 0, 1, 0}; + const float nrm[9] = {0, 0, 1, 0, 0, 1, 0, 0, 1}; + const float uv[6] = {0, 0, 1, 0, 0, 1}; + const uint16_t idx[3] = {0, 1, 2}; + for(float f : pos) + put(b, f); + for(float f : nrm) + put(b, f); + for(float f : uv) + put(b, f); + for(uint16_t i : idx) + put(b, i); + return b; +} + +// `uri_or_empty`: data: URI for .gltf; empty (binary GLB buffer) for .glb. +// `with_normals_uvs`: when false, only POSITION is referenced. +static std::string +triangle_gltf_json(const std::string& uri_or_empty, bool with_normals_uvs) +{ + std::string buffer = R"({"byteLength":102)"; + if(!uri_or_empty.empty()) + buffer += R"(,"uri":")" + uri_or_empty + R"(")"; + buffer += "}"; + + std::string attrs = R"("POSITION":0)"; + if(with_normals_uvs) + attrs += R"(,"NORMAL":1,"TEXCOORD_0":2)"; + + // clang-format off + return std::string{R"({ +"asset":{"version":"2.0"}, +"scene":0, +"scenes":[{"nodes":[0]}], +"nodes":[{"mesh":0,"name":"tri"}], +"meshes":[{"primitives":[{"attributes":{)"} + attrs + R"(},"indices":3}]}], +"buffers":[)" + buffer + R"(], +"bufferViews":[ + {"buffer":0,"byteOffset":0,"byteLength":96,"target":34962}, + {"buffer":0,"byteOffset":96,"byteLength":6,"target":34963}], +"accessors":[ + {"bufferView":0,"byteOffset":0,"componentType":5126,"count":3,"type":"VEC3","min":[0,0,0],"max":[1,1,0]}, + {"bufferView":0,"byteOffset":36,"componentType":5126,"count":3,"type":"VEC3"}, + {"bufferView":0,"byteOffset":72,"componentType":5126,"count":3,"type":"VEC2"}, + {"bufferView":1,"byteOffset":0,"componentType":5123,"count":3,"type":"SCALAR"}] +})"; + // clang-format on +} + +static std::string make_glb(const std::string& json, const std::string& bin) +{ + auto pad4 = [](std::string s, char fill) { + while(s.size() % 4) + s.push_back(fill); + return s; + }; + const std::string j = pad4(json, ' '); + const std::string b = pad4(bin, '\0'); + + std::string out; + put(out, 0x46546C67); // 'glTF' + put(out, 2); + put(out, uint32_t(12 + 8 + j.size() + 8 + b.size())); + put(out, uint32_t(j.size())); + put(out, 0x4E4F534A); // 'JSON' + out += j; + put(out, uint32_t(b.size())); + put(out, 0x004E4942); // 'BIN\0' + out += b; + return out; +} + +// Minimal ASCII FBX 7.4 document with a single triangle mesh. +// No normals in the file — FbxParser sets ufbx generate_missing_normals. +static std::string triangle_ascii_fbx() +{ + return R"(; FBX 7.4.0 project file +FBXHeaderExtension: { + FBXHeaderVersion: 1003 + FBXVersion: 7400 +} +GlobalSettings: { + Version: 1000 + Properties70: { + P: "UpAxis", "int", "Integer", "",1 + P: "UnitScaleFactor", "double", "Number", "",100 + } +} +Definitions: { + Version: 100 + Count: 2 + ObjectType: "Geometry" { + Count: 1 + } + ObjectType: "Model" { + Count: 1 + } +} +Objects: { + Geometry: 1000, "Geometry::tri", "Mesh" { + Vertices: *9 { + a: 0,0,0,1,0,0,0,1,0 + } + PolygonVertexIndex: *3 { + a: 0,1,-3 + } + GeometryVersion: 124 + } + Model: 2000, "Model::triangle", "Mesh" { + Version: 232 + } +} +Connections: { + C: "OO",1000,2000 + C: "OO",2000,0 +} +)"; +} + +// --------------------------------------------------------------------------- +// scene_state inspection helpers +// --------------------------------------------------------------------------- + +static const ossia::mesh_component* +find_first_mesh(const ossia::scene_node& n) +{ + if(!n.children) + return nullptr; + for(const auto& payload : *n.children) + { + if(auto* mc = ossia::get_if(&payload)) + return mc->get(); + if(auto* child = ossia::get_if(&payload)) + if(auto* m = find_first_mesh(**child)) + return m; + } + return nullptr; +} + +static const ossia::mesh_component* find_first_mesh(const ossia::scene_state& s) +{ + if(!s.roots) + return nullptr; + for(const auto& r : *s.roots) + if(auto* m = find_first_mesh(*r)) + return m; + return nullptr; +} + +static const ossia::vertex_attribute* +find_attr(const ossia::mesh_primitive& p, ossia::attribute_semantic sem) +{ + for(const auto& a : p.attributes) + if(a.semantic == sem) + return &a; + return nullptr; +} + +// Resolve the CPU float pointer for an attribute (non-GPU-resident buffers). +static const float* +attr_floats(const ossia::mesh_primitive& p, const ossia::vertex_attribute& a) +{ + REQUIRE(a.buffer_index < p.vertex_buffers.size()); + const auto& br = p.vertex_buffers[a.buffer_index]; + REQUIRE(br); + const auto* bd = ossia::get_if(&br->resource); + REQUIRE(bd); + REQUIRE(bd->data); + return reinterpret_cast( + reinterpret_cast(bd->data.get()) + a.byte_offset); +} + +} // namespace + +// =========================================================================== +// OBJ — tinyobjloader bridge +// =========================================================================== + +TEST_CASE("OBJ: cube.obj — exact counts and first-triangle values", + "[threedim][obj]") +{ + const auto data = read_file(models_dir / "cube.obj"); + if(!data) + SKIP("ModelsOBJ corpus not available"); + + Threedim::float_vec buf; + auto meshes = Threedim::ObjFromString(*data, buf); + + // Single "g main" group -> one mesh. + REQUIRE(meshes.size() == 1); + const auto& m = meshes[0]; + + // 8 "v" lines / 12 "f" triangles. The loader outputs an UNINDEXED + // triangle soup: 12 faces x 3 corners = 36 vertices. + CHECK(m.vertices == 36); + + // cube.obj has no "vn" and no "vt": the loader does NOT generate + // normals for OBJ — it simply marks them absent. + CHECK(m.normals == false); + CHECK(m.texcoord == false); + CHECK(m.tangents == false); + CHECK(m.points == false); + CHECK(m.pos_offset == 0); + + // Positions only: 36 vertices * 3 floats. + CHECK(buf.size() == 36 * 3); + + // First face is "f 1 2 3": + // v1 = (0, 1.632990, -1.154700) + // v2 = (1.632990, 0, -1.154700) + // v3 = (0, -1.632990, -1.154700) + CHECK(buf[0] == Approx(0.0f)); + CHECK(buf[1] == Approx(1.632990f)); + CHECK(buf[2] == Approx(-1.154700f)); + CHECK(buf[3] == Approx(1.632990f)); + CHECK(buf[4] == Approx(0.0f)); + CHECK(buf[5] == Approx(-1.154700f)); + CHECK(buf[6] == Approx(0.0f)); + CHECK(buf[7] == Approx(-1.632990f)); + CHECK(buf[8] == Approx(-1.154700f)); +} + +TEST_CASE("OBJ: sphere20.obj — exact counts", "[threedim][obj]") +{ + const auto data = read_file(models_dir / "sphere20.obj"); + if(!data) + SKIP("ModelsOBJ corpus not available"); + + Threedim::float_vec buf; + auto meshes = Threedim::ObjFromString(*data, buf); + + REQUIRE(meshes.size() == 1); + // 252 "v" lines / 500 "f" triangles -> 1500 soup vertices. + CHECK(meshes[0].vertices == 500 * 3); + CHECK(meshes[0].normals == false); + CHECK(meshes[0].texcoord == false); + CHECK(buf.size() == 500 * 3 * 3); + + // First face "f 1 2 3", v1 = (0, 0, 1). + CHECK(buf[0] == Approx(0.0f)); + CHECK(buf[1] == Approx(0.0f)); + CHECK(buf[2] == Approx(1.0f)); +} + +TEST_CASE("OBJ: normals + texcoords -> mikktspace tangents generated", + "[threedim][obj]") +{ + // Two-triangle quad in the XY plane, full v/vt/vn triplets. + const std::string obj = R"(v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 1 1 +vt 0 1 +vn 0 0 1 +f 1/1/1 2/2/1 3/3/1 +f 1/1/1 3/3/1 4/4/1 +)"; + Threedim::float_vec buf; + auto meshes = Threedim::ObjFromString(obj, buf); + REQUIRE(meshes.size() == 1); + const auto& m = meshes[0]; + CHECK(m.vertices == 6); + CHECK(m.normals == true); + CHECK(m.texcoord == true); + // Tangents are generated iff normals AND texcoords are present. + CHECK(m.tangents == true); + // Layout: pos(6*3) + uv(6*2) + nrm(6*3) + tan(6*4). + CHECK(buf.size() == 6 * (3 + 2 + 3 + 4)); + CHECK(m.pos_offset == 0); + CHECK(m.texcoord_offset == 18); + CHECK(m.normal_offset == 30); + CHECK(m.tangent_offset == 48); + + // Normals copied through. + CHECK(buf[m.normal_offset + 0] == Approx(0.f)); + CHECK(buf[m.normal_offset + 1] == Approx(0.f)); + CHECK(buf[m.normal_offset + 2] == Approx(1.f)); + + // Tangent for this axis-aligned UV mapping is (1,0,0), |w| = 1. + CHECK(buf[m.tangent_offset + 0] == Approx(1.f)); + CHECK(buf[m.tangent_offset + 1] == Approx(0.f)); + CHECK(buf[m.tangent_offset + 2] == Approx(0.f)); + CHECK(std::abs(buf[m.tangent_offset + 3]) == Approx(1.f)); + + // Scene assembly exposes all four attributes. + auto state = Threedim::sceneStateFromMeshes( + std::move(meshes), std::move(buf), "quad.obj"); + REQUIRE(state); + const auto* mesh = find_first_mesh(*state); + REQUIRE(mesh); + const auto& prim = mesh->primitives[0]; + CHECK(prim.attributes.size() == 4); + CHECK(find_attr(prim, ossia::attribute_semantic::position)); + CHECK(find_attr(prim, ossia::attribute_semantic::normal)); + CHECK(find_attr(prim, ossia::attribute_semantic::texcoord0)); + const auto* tan = find_attr(prim, ossia::attribute_semantic::tangent); + REQUIRE(tan); + CHECK(tan->format == ossia::vertex_format::float4); +} + +TEST_CASE("OBJ: normals-only and texcoords-only variants", "[threedim][obj]") +{ + SECTION("vn only") + { + const std::string obj = "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\n" + "f 1//1 2//1 3//1\n"; + Threedim::float_vec buf; + auto meshes = Threedim::ObjFromString(obj, buf); + REQUIRE(meshes.size() == 1); + CHECK(meshes[0].vertices == 3); + CHECK(meshes[0].normals == true); + CHECK(meshes[0].texcoord == false); + CHECK(meshes[0].tangents == false); // needs both normals and uvs + CHECK(buf.size() == 3 * (3 + 3)); + CHECK(buf[meshes[0].normal_offset + 2] == Approx(1.f)); + } + SECTION("vt only") + { + const std::string obj = "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\n" + "f 1/1 2/2 3/3\n"; + Threedim::float_vec buf; + auto meshes = Threedim::ObjFromString(obj, buf); + REQUIRE(meshes.size() == 1); + CHECK(meshes[0].vertices == 3); + CHECK(meshes[0].normals == false); + CHECK(meshes[0].texcoord == true); + CHECK(meshes[0].tangents == false); + CHECK(buf.size() == 3 * (3 + 2)); + CHECK(buf[meshes[0].texcoord_offset + 2] == Approx(1.f)); + } +} + +TEST_CASE("OBJ: corrupt / hostile inputs fail gracefully", "[threedim][obj]") +{ + Threedim::float_vec buf; + + SECTION("empty input") + { + auto meshes = Threedim::ObjFromString(std::string_view{}, buf); + CHECK(meshes.empty()); + } + SECTION("binary garbage") + { + const std::string garbage = "\x89PNG\r\n\x1a\n definitely not an obj \0\1\2"; + auto meshes = Threedim::ObjFromString(garbage, buf); + CHECK(meshes.empty()); + } + SECTION("face index out of range (tinyobj does not bounds-check)") + { + // One vertex but the face references vertices 1..3: indices 2 and 3 + // are out of range; the loader's explicit range check must bail. + const std::string obj = "v 0 0 0\nf 1 2 3\n"; + auto meshes = Threedim::ObjFromString(obj, buf); + CHECK(meshes.empty()); + } + SECTION("hugely out-of-range face index") + { + const std::string obj = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 2000000000\n"; + auto meshes = Threedim::ObjFromString(obj, buf); + CHECK(meshes.empty()); + } + SECTION("negative relative index beyond start") + { + const std::string obj = "v 0 0 0\nf -5 -6 -7\n"; + auto meshes = Threedim::ObjFromString(obj, buf); + CHECK(meshes.empty()); + } +} + +TEST_CASE("OBJ -> sceneStateFromMeshes: scene assembly", "[threedim][obj]") +{ + const auto data = read_file(models_dir / "cube.obj"); + if(!data) + SKIP("ModelsOBJ corpus not available"); + + Threedim::float_vec buf; + auto meshes = Threedim::ObjFromString(*data, buf); + REQUIRE(meshes.size() == 1); + + auto state = Threedim::sceneStateFromMeshes( + std::move(meshes), std::move(buf), "cube.obj"); + REQUIRE(state); + REQUIRE(state->roots); + REQUIRE(state->roots->size() == 1); + + const auto& node = *(*state->roots)[0]; + CHECK(node.name == "cube.obj"); + CHECK(node.id.value != 0); + + const auto* mesh = find_first_mesh(*state); + REQUIRE(mesh); + REQUIRE(mesh->primitives.size() == 1); + const auto& prim = mesh->primitives[0]; + + CHECK(prim.vertex_count == 36); + CHECK(prim.index_count == 0); + CHECK(prim.index_type == ossia::index_format::none); + CHECK(prim.topology == ossia::primitive_topology::triangles); + CHECK(prim.stable_id != 0); + + // Position is the ONLY attribute (no normals / uvs in the file, none + // generated by the OBJ path). + REQUIRE(prim.attributes.size() == 1); + const auto* pos = find_attr(prim, ossia::attribute_semantic::position); + REQUIRE(pos); + CHECK(pos->format == ossia::vertex_format::float3); + CHECK(pos->byte_offset == 0); + CHECK(pos->byte_stride == 12); + CHECK(find_attr(prim, ossia::attribute_semantic::normal) == nullptr); + + // AABB over all 36 soup positions == cube extents. + CHECK_FALSE(prim.bounds.empty()); + CHECK(prim.bounds.min[0] == Approx(-1.632990f)); + CHECK(prim.bounds.min[1] == Approx(-1.632990f)); + CHECK(prim.bounds.min[2] == Approx(-1.154700f)); + CHECK(prim.bounds.max[0] == Approx(1.632990f)); + CHECK(prim.bounds.max[1] == Approx(1.632990f)); + CHECK(prim.bounds.max[2] == Approx(1.154700f)); + + // First vertex readable through the shared CPU buffer. + const float* p = attr_floats(prim, *pos); + CHECK(p[0] == Approx(0.0f)); + CHECK(p[1] == Approx(1.632990f)); + CHECK(p[2] == Approx(-1.154700f)); +} + +TEST_CASE("sceneStateFromMeshes: empty input returns null", "[threedim][obj]") +{ + CHECK(Threedim::sceneStateFromMeshes({}, {}, "x") == nullptr); + + // Non-empty meshes but empty buffer -> null too. + std::vector ms{Threedim::mesh{.vertices = 3}}; + CHECK(Threedim::sceneStateFromMeshes(std::move(ms), {}, "x") == nullptr); +} + +// =========================================================================== +// glTF — fastgltf bridge +// =========================================================================== + +static void check_triangle_gltf_scene(const ossia::scene_state& state) +{ + REQUIRE(state.roots); + REQUIRE(state.roots->size() == 1); + const auto& node = *(*state.roots)[0]; + CHECK(node.name == "tri"); + // stable_id = glTF node index + 1. + CHECK(node.id.value == 1); + + const auto* mesh = find_first_mesh(state); + REQUIRE(mesh); + REQUIRE(mesh->primitives.size() == 1); + const auto& prim = mesh->primitives[0]; + + CHECK(prim.vertex_count == 3); + CHECK(prim.index_count == 3); + // glTF u16 indices are widened to u32 by the parser. + CHECK(prim.index_type == ossia::index_format::uint32); + CHECK(prim.topology == ossia::primitive_topology::triangles); + + // position + normal + texcoord0, PLUS a mikktspace-generated tangent + // (the file ships normals + uvs but no TANGENT). + const auto* pos = find_attr(prim, ossia::attribute_semantic::position); + const auto* nrm = find_attr(prim, ossia::attribute_semantic::normal); + const auto* uv = find_attr(prim, ossia::attribute_semantic::texcoord0); + const auto* tan = find_attr(prim, ossia::attribute_semantic::tangent); + REQUIRE(pos); + REQUIRE(nrm); + REQUIRE(uv); + REQUIRE(tan); + CHECK(prim.attributes.size() == 4); + CHECK(pos->format == ossia::vertex_format::float3); + CHECK(nrm->format == ossia::vertex_format::float3); + CHECK(uv->format == ossia::vertex_format::float2); + CHECK(tan->format == ossia::vertex_format::float4); + + // Non-interleaved: one buffer per attribute. + CHECK(prim.vertex_buffers.size() == 4); + + const float* p = attr_floats(prim, *pos); + CHECK(p[0] == Approx(0.f)); + CHECK(p[1] == Approx(0.f)); + CHECK(p[2] == Approx(0.f)); + CHECK(p[3] == Approx(1.f)); + CHECK(p[4] == Approx(0.f)); + CHECK(p[5] == Approx(0.f)); + CHECK(p[6] == Approx(0.f)); + CHECK(p[7] == Approx(1.f)); + CHECK(p[8] == Approx(0.f)); + + const float* n = attr_floats(prim, *nrm); + for(int i = 0; i < 3; i++) + { + CHECK(n[i * 3 + 0] == Approx(0.f)); + CHECK(n[i * 3 + 1] == Approx(0.f)); + CHECK(n[i * 3 + 2] == Approx(1.f)); + } + + const float* t = attr_floats(prim, *uv); + CHECK(t[0] == Approx(0.f)); + CHECK(t[1] == Approx(0.f)); + CHECK(t[2] == Approx(1.f)); + CHECK(t[3] == Approx(0.f)); + CHECK(t[4] == Approx(0.f)); + CHECK(t[5] == Approx(1.f)); + + // Tangent: mikktspace on this flat +Z triangle with identity-ish UVs + // gives (1, 0, 0, w). Handedness must be +-1. + const float* tg = attr_floats(prim, *tan); + CHECK(tg[0] == Approx(1.f)); + CHECK(tg[1] == Approx(0.f)); + CHECK(tg[2] == Approx(0.f)); + CHECK(std::abs(tg[3]) == Approx(1.f)); + + // Index buffer content 0,1,2 as u32. + REQUIRE(prim.index_buffer); + const auto* ibd = ossia::get_if(&prim.index_buffer->resource); + REQUIRE(ibd); + REQUIRE(ibd->byte_size == 3 * int64_t(sizeof(uint32_t))); + const uint32_t* idx = reinterpret_cast(ibd->data.get()); + CHECK(idx[0] == 0); + CHECK(idx[1] == 1); + CHECK(idx[2] == 2); + + // AABB from POSITION stream. + CHECK(prim.bounds.min[0] == Approx(0.f)); + CHECK(prim.bounds.min[1] == Approx(0.f)); + CHECK(prim.bounds.min[2] == Approx(0.f)); + CHECK(prim.bounds.max[0] == Approx(1.f)); + CHECK(prim.bounds.max[1] == Approx(1.f)); + CHECK(prim.bounds.max[2] == Approx(0.f)); +} + +TEST_CASE("glTF: minimal triangle .gltf with base64-embedded buffer", + "[threedim][gltf]") +{ + TempDir tmp; + const auto bin = triangle_buffer_bytes(); + REQUIRE(bin.size() == 102); + const auto json = triangle_gltf_json( + "data:application/octet-stream;base64," + b64(bin), true); + const auto path = tmp.write("tri.gltf", json); + + const std::string pathstr = path.string(); + const auto contents = read_file(path); + REQUIRE(contents); + + auto apply = Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = *contents, .filename = pathstr}); + REQUIRE(apply); + + Threedim::GltfParser parser; + apply(parser); + REQUIRE(parser.m_raw_state); + check_triangle_gltf_scene(*parser.m_raw_state); +} + +TEST_CASE("glTF: .glb binary container variant", "[threedim][gltf]") +{ + TempDir tmp; + const auto bin = triangle_buffer_bytes(); + const auto json = triangle_gltf_json({}, true); // buffer 0 = GLB BIN chunk + const auto glb = make_glb(json, bin); + const auto path = tmp.write("tri.glb", glb); + + const std::string pathstr = path.string(); + const auto contents = read_file(path); + REQUIRE(contents); + + auto apply = Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = *contents, .filename = pathstr}); + REQUIRE(apply); + + Threedim::GltfParser parser; + apply(parser); + REQUIRE(parser.m_raw_state); + check_triangle_gltf_scene(*parser.m_raw_state); +} + +TEST_CASE("glTF: positions-only file -> no normals, no tangents", + "[threedim][gltf]") +{ + TempDir tmp; + const auto bin = triangle_buffer_bytes(); + const auto json = triangle_gltf_json( + "data:application/octet-stream;base64," + b64(bin), false); + const auto path = tmp.write("tri-pos.gltf", json); + + const std::string pathstr = path.string(); + auto apply = Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = {}, .filename = pathstr}); + REQUIRE(apply); + + Threedim::GltfParser parser; + apply(parser); + REQUIRE(parser.m_raw_state); + + const auto* mesh = find_first_mesh(*parser.m_raw_state); + REQUIRE(mesh); + const auto& prim = mesh->primitives[0]; + CHECK(prim.vertex_count == 3); + // The glTF path does NOT generate normals; and tangent generation + // requires normals + uvs, so neither appears. + CHECK(find_attr(prim, ossia::attribute_semantic::position) != nullptr); + CHECK(find_attr(prim, ossia::attribute_semantic::normal) == nullptr); + CHECK(find_attr(prim, ossia::attribute_semantic::tangent) == nullptr); + CHECK(prim.attributes.size() == 1); +} + +TEST_CASE("glTF: materials, vertex colors, node hierarchy, camera, light", + "[threedim][gltf]") +{ + TempDir tmp; + + // Buffer: pos (36B @0), COLOR_0 vec3 (36B @36), u16 idx (6B @72) = 78B. + std::string bin; + const float pos[9] = {0, 0, 0, 1, 0, 0, 0, 1, 0}; + const float col[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1}; + const uint16_t idx[3] = {0, 1, 2}; + for(float f : pos) + put(bin, f); + for(float f : col) + put(bin, f); + for(uint16_t i : idx) + put(bin, i); + REQUIRE(bin.size() == 78); + + // clang-format off + const std::string json = std::string{R"({ +"asset":{"version":"2.0"}, +"extensionsUsed":["KHR_lights_punctual"], +"extensions":{"KHR_lights_punctual":{"lights":[ + {"type":"point","color":[1.0,0.5,0.25],"intensity":3.0}]}}, +"scene":0, +"scenes":[{"nodes":[0]}], +"nodes":[ + {"mesh":0,"name":"parent","translation":[1,2,3],"children":[1,2]}, + {"name":"camchild","camera":0}, + {"name":"lightchild","extensions":{"KHR_lights_punctual":{"light":0}}}], +"cameras":[{"type":"perspective","perspective":{"yfov":1.0,"znear":0.01,"zfar":100.0}}], +"materials":[{"pbrMetallicRoughness":{ + "baseColorFactor":[0.9,0.5,0.25,1.0], + "metallicFactor":0.1,"roughnessFactor":0.8}}], +"meshes":[{"primitives":[{"attributes":{"POSITION":0,"COLOR_0":1},"indices":2,"material":0}]}], +"buffers":[{"byteLength":78,"uri":"data:application/octet-stream;base64,)"} + + b64(bin) + R"("}], +"bufferViews":[ + {"buffer":0,"byteOffset":0,"byteLength":72,"target":34962}, + {"buffer":0,"byteOffset":72,"byteLength":6,"target":34963}], +"accessors":[ + {"bufferView":0,"byteOffset":0,"componentType":5126,"count":3,"type":"VEC3","min":[0,0,0],"max":[1,1,0]}, + {"bufferView":0,"byteOffset":36,"componentType":5126,"count":3,"type":"VEC3"}, + {"bufferView":1,"byteOffset":0,"componentType":5123,"count":3,"type":"SCALAR"}] +})"; + // clang-format on + + const auto path = tmp.write("rich.gltf", json); + const std::string pathstr = path.string(); + + auto apply = Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = {}, .filename = pathstr}); + REQUIRE(apply); + Threedim::GltfParser parser; + apply(parser); + REQUIRE(parser.m_raw_state); + const auto& state = *parser.m_raw_state; + + // Materials surfaced at scene scope AND wired to the primitive. + REQUIRE(state.materials); + REQUIRE(state.materials->size() == 1); + const auto* mesh = find_first_mesh(state); + REQUIRE(mesh); + const auto& prim = mesh->primitives[0]; + REQUIRE(prim.material); + CHECK(prim.material->base_color_factor[0] == Approx(0.9f)); + CHECK(prim.material->base_color_factor[1] == Approx(0.5f)); + CHECK(prim.material->base_color_factor[2] == Approx(0.25f)); + CHECK(prim.material->base_color_factor[3] == Approx(1.0f)); + CHECK(prim.material->metallic_factor == Approx(0.1f)); + CHECK(prim.material->roughness_factor == Approx(0.8f)); + + // COLOR_0 was VEC3 in the file: parser pads to RGBA with alpha = 1. + const auto* c0 = find_attr(prim, ossia::attribute_semantic::color0); + REQUIRE(c0); + CHECK(c0->format == ossia::vertex_format::float4); + const float* cols = attr_floats(prim, *c0); + CHECK(cols[0] == Approx(1.f)); // r of vertex 0 + CHECK(cols[3] == Approx(1.f)); // padded alpha of vertex 0 + CHECK(cols[5] == Approx(1.f)); // g of vertex 1 + CHECK(cols[7] == Approx(1.f)); // padded alpha of vertex 1 + + // Hierarchy: root "parent" carries its TRS as the first payload and the + // two children as scene_node payloads. + REQUIRE(state.roots); + REQUIRE(state.roots->size() == 1); + const auto& root = *(*state.roots)[0]; + CHECK(root.name == "parent"); + REQUIRE(root.children); + const auto* root_xf + = ossia::get_if(&(*root.children)[0]); + REQUIRE(root_xf); + CHECK(root_xf->translation[0] == Approx(1.f)); + CHECK(root_xf->translation[1] == Approx(2.f)); + CHECK(root_xf->translation[2] == Approx(3.f)); + + const ossia::scene_node* cam_node = nullptr; + const ossia::scene_node* light_node = nullptr; + for(const auto& payload : *root.children) + { + if(auto* child = ossia::get_if(&payload)) + { + if((*child)->name == "camchild") + cam_node = child->get(); + if((*child)->name == "lightchild") + light_node = child->get(); + } + } + REQUIRE(cam_node); + REQUIRE(light_node); + + // Camera / light payloads on the child nodes. + const ossia::camera_component* cam = nullptr; + for(const auto& payload : *cam_node->children) + if(auto* c = ossia::get_if(&payload)) + cam = c->get(); + REQUIRE(cam); + CHECK(cam->yfov == Approx(1.0f)); + CHECK(cam->znear == Approx(0.01f)); + CHECK(cam->zfar == Approx(100.0f)); + + const ossia::light_component* light = nullptr; + for(const auto& payload : *light_node->children) + if(auto* l = ossia::get_if(&payload)) + light = l->get(); + REQUIRE(light); + CHECK(light->color[0] == Approx(1.0f)); + CHECK(light->color[1] == Approx(0.5f)); + CHECK(light->color[2] == Approx(0.25f)); + CHECK(light->intensity == Approx(3.0f)); +} + +TEST_CASE("glTF: corrupt / truncated inputs fail gracefully", + "[threedim][gltf]") +{ + TempDir tmp; + + SECTION("empty filename") + { + CHECK_FALSE(Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = "{}", .filename = {}})); + } + SECTION("nonexistent file") + { + const std::string p = (tmp.dir / "missing.gltf").string(); + CHECK_FALSE(Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = {}, .filename = p})); + } + SECTION("truncated JSON") + { + const auto bin = triangle_buffer_bytes(); + auto json = triangle_gltf_json( + "data:application/octet-stream;base64," + b64(bin), true); + json.resize(json.size() / 2); + const auto path = tmp.write("trunc.gltf", json); + const std::string p = path.string(); + CHECK_FALSE(Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = {}, .filename = p})); + } + SECTION("GLB with bad magic") + { + auto glb = make_glb(triangle_gltf_json({}, true), triangle_buffer_bytes()); + glb[0] = 'X'; + const auto path = tmp.write("badmagic.glb", glb); + const std::string p = path.string(); + CHECK_FALSE(Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = {}, .filename = p})); + } + SECTION("GLB truncated mid-BIN-chunk") + { + auto glb = make_glb(triangle_gltf_json({}, true), triangle_buffer_bytes()); + glb.resize(glb.size() - 40); + const auto path = tmp.write("trunc.glb", glb); + const std::string p = path.string(); + CHECK_FALSE(Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = {}, .filename = p})); + } + SECTION("random binary garbage in a .gltf") + { + std::string garbage(256, '\0'); + for(std::size_t i = 0; i < garbage.size(); i++) + garbage[i] = char((i * 37 + 11) & 0xFF); + const auto path = tmp.write("garbage.gltf", garbage); + const std::string p = path.string(); + CHECK_FALSE(Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = {}, .filename = p})); + } +} + +// REGRESSION TEST (bug found by this suite 2026-07-16, fixed 2026-07-17): +// a hostile .gltf whose accessor declares `count` far beyond its +// bufferView's byteLength used to drive a heap-buffer-overflow READ in +// fastgltf::iterateAccessor, reached from GltfParser.cpp +// read_float_accessor <- extract_primitive <- emit_node. +// fastgltf::validate() only checks the accessor's *bufferView index*, +// alignment, and count >= 1 — it never checks +// accessor.byteOffset + count * elementSize <= bufferView.byteLength +// (see 3rdparty/fastgltf/src/fastgltf.cpp, fg::validate). +// GltfParser.cpp now sweeps every accessor's data range against its +// bufferView and the buffer's actually-loaded bytes before iterating +// anything (all_accessors_within_bounds + per-read-site re-checks) and +// rejects the load. This case must load-fail cleanly with no ASAN report. +TEST_CASE("glTF: hostile accessor count beyond bufferView is rejected " + "cleanly (no OOB read)", + "[threedim][gltf][gltf-oob-bug]") +{ + TempDir tmp; + const auto bin = triangle_buffer_bytes(); + auto json = triangle_gltf_json( + "data:application/octet-stream;base64," + b64(bin), true); + auto pos = json.find(R"("count":3,"type":"VEC3","min")"); + REQUIRE(pos != std::string::npos); + json.replace(pos, 9, R"("count":30000)"); + const auto path = tmp.write("oob.gltf", json); + const std::string p = path.string(); + // The hostile file is rejected outright; previously this OOB-read. + CHECK_FALSE(Threedim::GltfParser::ins::gltf_t::process( + halp::text_file_view{.bytes = {}, .filename = p})); +} + +// =========================================================================== +// FBX — ufbx bridge +// =========================================================================== + +TEST_CASE("FBX: minimal ASCII fbx triangle — ufbx generates missing normals", + "[threedim][fbx]") +{ + TempDir tmp; + const auto path = tmp.write("tri.fbx", triangle_ascii_fbx()); + const std::string pathstr = path.string(); + + auto apply = Threedim::FbxParser::ins::fbx_t::process( + halp::text_file_view{.bytes = {}, .filename = pathstr}); + REQUIRE(apply); + + Threedim::FbxParser parser; + apply(parser); + REQUIRE(parser.m_raw_state); + + const auto* mesh = find_first_mesh(*parser.m_raw_state); + REQUIRE(mesh); + REQUIRE(mesh->primitives.size() >= 1); + const auto& prim = mesh->primitives[0]; + + CHECK(prim.vertex_count == 3); + CHECK(prim.topology == ossia::primitive_topology::triangles); + + const auto* pos = find_attr(prim, ossia::attribute_semantic::position); + REQUIRE(pos); + + // Unlike the OBJ path, the FBX path GENERATES missing normals + // (ufbx opts.generate_missing_normals = true). + const auto* nrm = find_attr(prim, ossia::attribute_semantic::normal); + REQUIRE(nrm); + const float* n = attr_floats(prim, *nrm); + // Triangle (0,0,0)(1,0,0)(0,1,0) is in the XY plane: normal = +-Z, unit. + const float len0 + = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); + CHECK(len0 == Approx(1.f).margin(1e-4)); + CHECK(std::abs(n[2]) == Approx(1.f).margin(1e-4)); +} + +TEST_CASE("FBX: corrupt / truncated inputs fail gracefully", "[threedim][fbx]") +{ + TempDir tmp; + + SECTION("empty filename") + { + CHECK_FALSE(Threedim::FbxParser::ins::fbx_t::process( + halp::text_file_view{.bytes = {}, .filename = {}})); + } + SECTION("nonexistent file") + { + const std::string p = (tmp.dir / "missing.fbx").string(); + CHECK_FALSE(Threedim::FbxParser::ins::fbx_t::process( + halp::text_file_view{.bytes = {}, .filename = p})); + } + SECTION("binary garbage") + { + std::string garbage(512, '\0'); + for(std::size_t i = 0; i < garbage.size(); i++) + garbage[i] = char((i * 131 + 7) & 0xFF); + const auto path = tmp.write("garbage.fbx", garbage); + const std::string p = path.string(); + CHECK_FALSE(Threedim::FbxParser::ins::fbx_t::process( + halp::text_file_view{.bytes = {}, .filename = p})); + } + SECTION("truncated binary FBX header") + { + // The binary FBX magic, then nothing. + const std::string trunc = "Kaydara FBX Binary \x00\x1a\x00"; + const auto path = tmp.write("trunc.fbx", trunc); + const std::string p = path.string(); + CHECK_FALSE(Threedim::FbxParser::ins::fbx_t::process( + halp::text_file_view{.bytes = {}, .filename = p})); + } + SECTION("valid ASCII structure but zero meshes") + { + const std::string empty_doc = R"(; FBX 7.4.0 project file +FBXHeaderExtension: { + FBXHeaderVersion: 1003 + FBXVersion: 7400 +} +Objects: { +} +)"; + const auto path = tmp.write("empty.fbx", empty_doc); + const std::string p = path.string(); + CHECK_FALSE(Threedim::FbxParser::ins::fbx_t::process( + halp::text_file_view{.bytes = {}, .filename = p})); + } +} + +// =========================================================================== +// AssetLoader — extension dispatch + registry + TRS wrap +// =========================================================================== + +TEST_CASE("AssetLoader: dispatches .obj from bytes", "[threedim][assetloader]") +{ + const auto data = read_file(models_dir / "cube.obj"); + if(!data) + SKIP("ModelsOBJ corpus not available"); + + auto apply = Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{ + .bytes = *data, + .filename = (models_dir / "cube.obj").string()}); + REQUIRE(apply); + + Threedim::AssetLoader loader; + apply(loader); + REQUIRE(loader.m_parsed_state); + + const auto* mesh = find_first_mesh(*loader.m_parsed_state); + REQUIRE(mesh); + CHECK(mesh->primitives[0].vertex_count == 36); + // Label = QFileInfo(...).fileName(). + CHECK((*loader.m_parsed_state->roots)[0]->name == "cube.obj"); + + // Wrapped state: raw scene re-rooted under a single TRS parent node. + REQUIRE(loader.m_wrapped_state); + REQUIRE(loader.m_wrapped_state->roots); + REQUIRE(loader.m_wrapped_state->roots->size() == 1); + const auto& wrap_root = *(*loader.m_wrapped_state->roots)[0]; + REQUIRE(wrap_root.children); + // First payload = the scene_transform carrying the controls' TRS. + REQUIRE(wrap_root.children->size() == 2); + const auto* xf + = ossia::get_if(&(*wrap_root.children)[0]); + REQUIRE(xf); + CHECK(xf->scale[0] == Approx(1.f)); + + // operator() publishes the wrapped state. + loader(); + CHECK(loader.outputs.scene_out.scene.state == loader.m_wrapped_state); + CHECK(loader.outputs.scene_out.dirty == ossia::scene_port::dirty_transform); +} + +TEST_CASE("AssetLoader: dispatches .gltf / .glb via the file path", + "[threedim][assetloader]") +{ + TempDir tmp; + const auto bin = triangle_buffer_bytes(); + const auto json = triangle_gltf_json( + "data:application/octet-stream;base64," + b64(bin), true); + + SECTION(".gltf") + { + const auto path = tmp.write("tri.gltf", json); + const std::string pathstr = path.string(); + auto apply = Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{.bytes = json, .filename = pathstr}); + REQUIRE(apply); + Threedim::AssetLoader loader; + apply(loader); + REQUIRE(loader.m_parsed_state); + check_triangle_gltf_scene(*loader.m_parsed_state); + } + SECTION(".glb") + { + const auto glb = make_glb(triangle_gltf_json({}, true), bin); + const auto path = tmp.write("tri.glb", glb); + const std::string pathstr = path.string(); + auto apply = Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{.bytes = glb, .filename = pathstr}); + REQUIRE(apply); + Threedim::AssetLoader loader; + apply(loader); + REQUIRE(loader.m_parsed_state); + check_triangle_gltf_scene(*loader.m_parsed_state); + } + SECTION("extension match is case-insensitive") + { + const auto path = tmp.write("TRI.GLTF", json); + const std::string pathstr = path.string(); + auto apply = Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{.bytes = json, .filename = pathstr}); + REQUIRE(apply); + } +} + +TEST_CASE("AssetLoader: dispatches .stl / .off / .ply geometry formats", + "[threedim][assetloader]") +{ + TempDir tmp; + + auto load = [](const fs::path& p) { + const std::string pathstr = p.string(); + auto apply = Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{.bytes = {}, .filename = pathstr}); + REQUIRE(apply); + auto loader = std::make_shared(); + apply(*loader); + REQUIRE(loader->m_parsed_state); + return loader; + }; + + SECTION(".stl (ASCII, vcglib) — face normal expanded per corner") + { + const auto path = tmp.write("tri.stl", R"(solid tri + facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop + endfacet +endsolid tri +)"); + auto loader = load(path); + const auto* mesh = find_first_mesh(*loader->m_parsed_state); + REQUIRE(mesh); + const auto& prim = mesh->primitives[0]; + CHECK(prim.vertex_count == 3); + CHECK(prim.index_type == ossia::index_format::none); + // Documented behaviour: although STL carries a per-face normal, the + // vcglib importer does not report IOM_FACENORMAL in its loadmask for + // this file, so the bridge emits positions only — no normal attribute. + CHECK(find_attr(prim, ossia::attribute_semantic::normal) == nullptr); + const auto* pos = find_attr(prim, ossia::attribute_semantic::position); + REQUIRE(pos); + const float* p = attr_floats(prim, *pos); + CHECK(p[3] == Approx(1.f)); // second vertex x + } + SECTION(".off (ASCII, vcglib)") + { + const auto path = tmp.write("tri.off", "OFF\n3 1 0\n0 0 0\n1 0 0\n0 1 0\n3 0 1 2\n"); + auto loader = load(path); + const auto* mesh = find_first_mesh(*loader->m_parsed_state); + REQUIRE(mesh); + const auto& prim = mesh->primitives[0]; + CHECK(prim.vertex_count == 3); + const auto* pos = find_attr(prim, ossia::attribute_semantic::position); + REQUIRE(pos); + const float* p = attr_floats(prim, *pos); + CHECK(p[0] == Approx(0.f)); + CHECK(p[3] == Approx(1.f)); + } + SECTION(".ply (ASCII mesh, miniply)") + { + const auto path = tmp.write("tri.ply", R"(ply +format ascii 1.0 +element vertex 3 +property float x +property float y +property float z +element face 1 +property list uchar int vertex_indices +end_header +0 0 0 +1 0 0 +0 1 0 +3 0 1 2 +)"); + auto loader = load(path); + const auto* mesh = find_first_mesh(*loader->m_parsed_state); + REQUIRE(mesh); + const auto& prim = mesh->primitives[0]; + CHECK(prim.vertex_count == 3); + const auto* pos = find_attr(prim, ossia::attribute_semantic::position); + REQUIRE(pos); + const float* p = attr_floats(prim, *pos); + CHECK(p[0] == Approx(0.f)); + CHECK(p[3] == Approx(1.f)); + CHECK(p[7] == Approx(1.f)); + } + SECTION("corrupt .stl / .off / .ply fail gracefully") + { + for(const char* name : {"bad.stl", "bad.off", "bad.ply"}) + { + const auto path = tmp.write(name, "garbage \x01\x02\x03 not a mesh"); + const std::string pathstr = path.string(); + CHECK_FALSE(Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{.bytes = {}, .filename = pathstr})); + } + } +} + +TEST_CASE("AssetLoader: unknown / empty inputs yield no apply function", + "[threedim][assetloader]") +{ + SECTION("empty filename") + { + CHECK_FALSE(Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{.bytes = "v 0 0 0", .filename = {}})); + } + SECTION("unknown extension") + { + CHECK_FALSE(Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{.bytes = "data", .filename = "foo.unknownext"})); + } + SECTION("dotless filename") + { + CHECK_FALSE(Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{.bytes = "data", .filename = "no_extension"})); + } + SECTION("corrupt obj bytes with valid extension") + { + CHECK_FALSE(Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{ + .bytes = "v 0 0 0\nf 1 2 9999\n", .filename = "bad.obj"})); + } +} + +// The addon-parser registry: registration, lowercase lookup, last-writer- +// wins, and end-to-end dispatch through AssetLoader for a custom extension. +namespace +{ +static std::shared_ptr s_canned_state; +static std::shared_ptr +canned_parser(const halp::text_file_view&) +{ + return s_canned_state; +} +static std::shared_ptr +null_parser(const halp::text_file_view&) +{ + return nullptr; +} +} + +TEST_CASE("AssetLoaderRegistry: registration and dispatch", + "[threedim][assetloader][registry]") +{ + using R = Threedim::AssetLoaderRegistry; + + SECTION("lookup miss returns nullptr") + { + CHECK(R::lookup("definitely-not-registered") == nullptr); + CHECK(R::lookup("") == nullptr); + } + SECTION("invalid registrations are ignored") + { + R::register_parser("", &canned_parser); + CHECK(R::lookup("") == nullptr); + R::register_parser("nullfn", nullptr); + CHECK(R::lookup("nullfn") == nullptr); + } + SECTION("extension keys are lowercased; last writer wins") + { + R::register_parser("TSTA", &null_parser); + CHECK(R::lookup("tsta") == &null_parser); + R::register_parser("tsta", &canned_parser); + CHECK(R::lookup("tsta") == &canned_parser); + } + SECTION("AssetLoader consults the registry for unknown extensions") + { + // Build a canned one-node scene the fake parser returns. + auto node = std::make_shared(); + node->name = "from-registry"; + auto roots = std::make_shared>(); + roots->push_back(node); + auto st = std::make_shared(); + st->roots = roots; + s_canned_state = st; + + R::register_parser("tstb", &canned_parser); + + auto apply = Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{.bytes = "payload", .filename = "thing.TstB"}); + REQUIRE(apply); + Threedim::AssetLoader loader; + apply(loader); + // The parsed state is exactly the parser-returned pointer. + CHECK(loader.m_parsed_state == st); + REQUIRE(loader.m_wrapped_state); + s_canned_state.reset(); + } + SECTION("registry parser returning null -> no apply function") + { + R::register_parser("tstc", &null_parser); + CHECK_FALSE(Threedim::AssetLoader::ins::asset_t::process( + halp::text_file_view{.bytes = "x", .filename = "f.tstc"})); + } +}