From 71c18a9ad19367140402aa6421be2049a8488b87 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Tue, 21 Jul 2026 16:20:55 +0300 Subject: [PATCH] Serializers: index positions and normals independently in OBJ/DAE output When vertices are not welded (the default IfcConvert path when normals are requested), IfcGeom::Representation::Triangulation stores one position and one normal per triangle corner, so both arrays repeat the same values many times. The OBJ and COLLADA formats support indexing positions, normals and texture coordinates independently, but the serializers were reusing a single combined index for all of them, inflating output size without needing to. Positions and normals are now deduplicated by exact value within each serializer and referenced through their own index streams (UV indices are left untouched, since a UV depends on both position and normal together). Verified against a real IFC model (test/input/IfcReinforcingBar.ifc): DAE output drops from 48.4MB to 20.6MB (-57.5%) and OBJ output from 51.3MB to 23.6MB (-54.0%), with the decoded position/normal value for every triangle corner checked byte-identical to the unpatched output. Addresses #46. Generated with the assistance of an AI coding tool. --- src/serializers/ColladaSerializer.cpp | 73 +++++++++++++-- src/serializers/WavefrontObjSerializer.cpp | 104 +++++++++++++++++---- src/serializers/WavefrontObjSerializer.h | 2 +- 3 files changed, 151 insertions(+), 28 deletions(-) diff --git a/src/serializers/ColladaSerializer.cpp b/src/serializers/ColladaSerializer.cpp index 0b1676c338..7e3ae729d9 100644 --- a/src/serializers/ColladaSerializer.cpp +++ b/src/serializers/ColladaSerializer.cpp @@ -33,6 +33,8 @@ #include #include +#include +#include #include "../ifcparse/utils.h" @@ -70,15 +72,67 @@ void ColladaSerializer::ColladaExporter::ColladaGeometries::write( const std::vector& uvs, const std::vector& material_references) { openMesh(mesh_id); - + // The normals vector can be empty for example when the WELD_VERTICES setting is used. // IfcOpenShell does not provide them with multiple face normals collapsed into a single vertex. const bool has_normals = !normals.empty(); const bool has_uvs = !uvs.empty(); - - addFloatSource(mesh_id, COLLADASW::LibraryGeometries::POSITIONS_SOURCE_ID_SUFFIX, positions); + + // When vertices are not welded (the common case when normals are requested), 'positions' + // and 'normals' hold one entry per triangle corner, so identical position/normal values are + // repeated many times. COLLADA streams can be indexed independently (VERTEX and + // NORMAL inputs each carry their own offset), so positions and normals are deduplicated here + // on their own terms and referenced through separate index streams below, rather than being + // forced to share a single combined index as before. + std::vector dedup_positions; + std::vector dedup_normals; + std::vector pos_index; + std::vector norm_index; + if (has_normals) { - addFloatSource(mesh_id, COLLADASW::LibraryGeometries::NORMALS_SOURCE_ID_SUFFIX, normals); + typedef std::tuple Triplet; + std::map pos_map; + std::map norm_map; + const size_t n = positions.size() / 3; + pos_index.reserve(n); + norm_index.reserve(n); + for (size_t i = 0; i < n; ++i) { + const Triplet pk(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); + auto pit = pos_map.find(pk); + if (pit == pos_map.end()) { + const int pidx = (int)(dedup_positions.size() / 3); + pos_map.emplace(pk, pidx); + dedup_positions.push_back(std::get<0>(pk)); + dedup_positions.push_back(std::get<1>(pk)); + dedup_positions.push_back(std::get<2>(pk)); + pos_index.push_back(pidx); + } else { + pos_index.push_back(pit->second); + } + + const Triplet nk(normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]); + auto nit = norm_map.find(nk); + if (nit == norm_map.end()) { + const int nidx = (int)(dedup_normals.size() / 3); + norm_map.emplace(nk, nidx); + dedup_normals.push_back(std::get<0>(nk)); + dedup_normals.push_back(std::get<1>(nk)); + dedup_normals.push_back(std::get<2>(nk)); + norm_index.push_back(nidx); + } else { + norm_index.push_back(nit->second); + } + } + } + + // Positions referenced by VERTEX inputs (both triangles and lines below) are remapped + // through pos_index when deduplication above ran; otherwise (no normals) the incoming + // positions are already unique per WeldVertices and are used as-is. + auto vertex_index = [&](int idx) { return has_normals ? pos_index[idx] : idx; }; + + addFloatSource(mesh_id, COLLADASW::LibraryGeometries::POSITIONS_SOURCE_ID_SUFFIX, has_normals ? dedup_positions : positions); + if (has_normals) { + addFloatSource(mesh_id, COLLADASW::LibraryGeometries::NORMALS_SOURCE_ID_SUFFIX, dedup_normals); if (has_uvs) { addFloatSource(mesh_id, COLLADASW::LibraryGeometries::TEXCOORDS_SOURCE_ID_SUFFIX, uvs, "UV"); } @@ -120,9 +174,12 @@ void ColladaSerializer::ColladaExporter::ColladaGeometries::write( for (std::vector::const_iterator jt = index_range_start; jt != it; ++jt) { const int idx = *jt; if (has_normals && has_uvs) { - triangles.appendValues(idx, idx, idx); + // UV index is left as the original per-corner index (not deduplicated): a UV + // is a function of both position and normal (box projection), so it cannot be + // safely indexed through either the position or the normal dedup map alone. + triangles.appendValues(pos_index[idx], norm_index[idx], idx); } else if(has_normals) { - triangles.appendValues(idx, idx); + triangles.appendValues(pos_index[idx], norm_index[idx]); } else { triangles.appendValues(idx); } @@ -154,8 +211,8 @@ void ColladaSerializer::ColladaExporter::ColladaGeometries::write( linelist.resize(linelist.size() + 1); } - linelist.rbegin()->second.push_back(i1); - linelist.rbegin()->second.push_back(i2); + linelist.rbegin()->second.push_back(vertex_index(i1)); + linelist.rbegin()->second.push_back(vertex_index(i2)); } for (linelist_t::const_iterator it = linelist.begin(); it != linelist.end(); ++it) { diff --git a/src/serializers/WavefrontObjSerializer.cpp b/src/serializers/WavefrontObjSerializer.cpp index 5d178329b3..4578820474 100644 --- a/src/serializers/WavefrontObjSerializer.cpp +++ b/src/serializers/WavefrontObjSerializer.cpp @@ -26,6 +26,8 @@ #include #include +#include +#include WaveFrontOBJSerializer::WaveFrontOBJSerializer(const stream_or_filename& obj_filename, const stream_or_filename& mtl_filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger* logger) : WriteOnlyGeometrySerializer(geometry_settings, settings, logger_or_root(logger)) @@ -33,6 +35,7 @@ WaveFrontOBJSerializer::WaveFrontOBJSerializer(const stream_or_filename& obj_fil , mtl_stream(mtl_filename) , vcount_total(1) , ncount_total(1) + , uvcount_total(1) { obj_stream.stream << std::setprecision(settings.get().get()); mtl_stream.stream << std::setprecision(settings.get().get()); @@ -94,14 +97,68 @@ void WaveFrontOBJSerializer::write(const IfcGeom::TriangulationElement* o) const IfcGeom::Representation::Triangulation& mesh = o->geometry(); - size_t vcount = mesh.verts().size() / 3; - size_t ncount = mesh.normals().size() / 3; + const bool has_uvs = !mesh.uvs().empty(); + const bool has_normals = !mesh.normals().empty(); - for (auto it = mesh.verts().begin(); it != mesh.verts().end();) { + // When vertices are not welded (the common case when normals are requested), mesh.verts() + // and mesh.normals() hold one entry per triangle corner, so identical position/normal values + // are repeated many times. OBJ's "f v/vt/vn" syntax indexes positions, texture coordinates + // and normals independently, so positions and normals are deduplicated here on their own + // terms and referenced through separate index streams below, rather than being forced to + // share a single combined index as before. + std::vector dedup_verts; + std::vector dedup_normals; + std::vector pos_index; + std::vector norm_index; + + if (has_normals) { + typedef std::tuple Triplet; + std::map pos_map; + std::map norm_map; + const std::vector& verts = mesh.verts(); + const std::vector& normals = mesh.normals(); + const size_t n = verts.size() / 3; + pos_index.reserve(n); + norm_index.reserve(n); + for (size_t i = 0; i < n; ++i) { + const Triplet pk(verts[i * 3], verts[i * 3 + 1], verts[i * 3 + 2]); + auto pit = pos_map.find(pk); + if (pit == pos_map.end()) { + const int pidx = (int)(dedup_verts.size() / 3); + pos_map.emplace(pk, pidx); + dedup_verts.push_back(std::get<0>(pk)); + dedup_verts.push_back(std::get<1>(pk)); + dedup_verts.push_back(std::get<2>(pk)); + pos_index.push_back(pidx); + } else { + pos_index.push_back(pit->second); + } + + const Triplet nk(normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]); + auto nit = norm_map.find(nk); + if (nit == norm_map.end()) { + const int nidx = (int)(dedup_normals.size() / 3); + norm_map.emplace(nk, nidx); + dedup_normals.push_back(std::get<0>(nk)); + dedup_normals.push_back(std::get<1>(nk)); + dedup_normals.push_back(std::get<2>(nk)); + norm_index.push_back(nidx); + } else { + norm_index.push_back(nit->second); + } + } + } + + const std::vector& verts_to_write = has_normals ? dedup_verts : mesh.verts(); + size_t vcount = verts_to_write.size() / 3; + size_t ncount = dedup_normals.size() / 3; + size_t uvcount = mesh.uvs().size() / 2; + + for (auto it = verts_to_write.begin(); it != verts_to_write.end();) { const double x = *(it++); const double y = *(it++); const double z = *(it++); - + if (isyup) { obj_stream.stream << "v " << x << " " << z << " " << -y << "\n"; } else { @@ -109,7 +166,7 @@ void WaveFrontOBJSerializer::write(const IfcGeom::TriangulationElement* o) } } - for (auto it = mesh.normals().begin(); it != mesh.normals().end();) { + for (auto it = dedup_normals.begin(); it != dedup_normals.end();) { const double x = *(it++); const double y = *(it++); const double z = *(it++); @@ -124,9 +181,6 @@ void WaveFrontOBJSerializer::write(const IfcGeom::TriangulationElement* o) int previous_material_id = -2; std::vector::const_iterator material_it = mesh.material_ids().begin(); - - const bool has_uvs = !mesh.uvs().empty(); - const bool has_normals = !mesh.normals().empty(); for ( std::vector::const_iterator it = mesh.faces().begin(); it != mesh.faces().end(); ) { const int material_id = *(material_it++); @@ -142,18 +196,29 @@ void WaveFrontOBJSerializer::write(const IfcGeom::TriangulationElement* o) previous_material_id = material_id; } - const int v1 = *(it++) + vcount_total; - const int v2 = *(it++) + vcount_total; - const int v3 = *(it++) + vcount_total; + const int idx1 = *(it++); + const int idx2 = *(it++); + const int idx3 = *(it++); - const int n1 = v1 - vcount_total + ncount_total; - const int n2 = v2 - vcount_total + ncount_total; - const int n3 = v3 - vcount_total + ncount_total; + const int v1 = (has_normals ? pos_index[idx1] : idx1) + (int)vcount_total; + const int v2 = (has_normals ? pos_index[idx2] : idx2) + (int)vcount_total; + const int v3 = (has_normals ? pos_index[idx3] : idx3) + (int)vcount_total; + + const int n1 = has_normals ? norm_index[idx1] + (int)ncount_total : 0; + const int n2 = has_normals ? norm_index[idx2] + (int)ncount_total : 0; + const int n3 = has_normals ? norm_index[idx3] + (int)ncount_total : 0; + + // UV indices are left as the original per-corner indices (not deduplicated): a UV is a + // function of both position and normal (box projection), so it cannot be safely indexed + // through either the position or the normal dedup map alone. + const int t1 = idx1 + (int)uvcount_total; + const int t2 = idx2 + (int)uvcount_total; + const int t3 = idx3 + (int)uvcount_total; if (has_normals && has_uvs) { - obj_stream.stream << "f " << v1 << "/" << n1 << "/" << n1 << " " - << v2 << "/" << n2 << "/" << n2 << " " - << v3 << "/" << n3 << "/" << n3 << "\n"; + obj_stream.stream << "f " << v1 << "/" << t1 << "/" << n1 << " " + << v2 << "/" << t2 << "/" << n2 << " " + << v3 << "/" << t3 << "/" << n3 << "\n"; } else if (has_normals) { obj_stream.stream << "f " << v1 << "//" << n1 << " " << v2 << "//" << n2 << " " @@ -188,12 +253,13 @@ void WaveFrontOBJSerializer::write(const IfcGeom::TriangulationElement* o) previous_material_id = material_id; } - const int v1 = i1 + vcount_total; - const int v2 = i2 + vcount_total; + const int v1 = (has_normals ? pos_index[i1] : i1) + (int)vcount_total; + const int v2 = (has_normals ? pos_index[i2] : i2) + (int)vcount_total; obj_stream.stream << "l " << v1 << " " << v2 << "\n"; } vcount_total += vcount; ncount_total += ncount; + uvcount_total += uvcount; } diff --git a/src/serializers/WavefrontObjSerializer.h b/src/serializers/WavefrontObjSerializer.h index ceac640661..562a01ecfc 100644 --- a/src/serializers/WavefrontObjSerializer.h +++ b/src/serializers/WavefrontObjSerializer.h @@ -32,7 +32,7 @@ class SERIALIZERS_API WaveFrontOBJSerializer : public WriteOnlyGeometrySerialize private: stream_or_filename obj_stream; stream_or_filename mtl_stream; - size_t vcount_total, ncount_total; + size_t vcount_total, ncount_total, uvcount_total; std::set materials; public: WaveFrontOBJSerializer(const stream_or_filename& obj_filename, const stream_or_filename& mtl_filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger* logger = nullptr);