IfcGeom: make permissive-shape-reuse folding style and instance aware

The permissive-shape-reuse task fold collapsed tasks onto the innermost
single-child taxonomy node. Three defects followed from that.

1. When the shared representation had a single item, the fold descended
   past the representation collection down to the raw geometry item, and
   Converter::create_brep_for_representation_and_product later failed
   with 'RepresentationIdentifier not found' on every folded product,
   aborting the conversion. The public AC20-FZK-Haus model fails to
   convert entirely with these settings.

2. Styles attached to traversed wrapper collections, such as an
   IfcStyledItem on an IfcMappedItem occurrence, were dropped, so folded
   occurrences lost their per-occurrence colours.

3. Folding was applied with use-world-coords enabled even though product
   placements are then baked into the shared geometry, which collapses
   all folded products onto the first product's placement. The regular
   grouping in mapping::reuse_ok_() already refuses reuse in that case.

The fold key is now the pair of the shared node and the effective hoisted
surface style, so occurrences with the same style share one processed
representation while differently styled occurrences keep their own, which
also covers styled IfcMappedItem occurrences from issue #143 that the
regular grouping must skip. Folded groups are rewrapped in a collection
carrying the hoisted style and the first folded task's representation
instance, and folding is skipped under use-world-coords.

Verified against IfcConvert glTF output on synthetic mapped-item models
(styled occurrences, non-identity mapping targets) and AC20-FZK-Haus:
world-space geometry and materials match the unfolded output exactly,
and default-settings output over the IFC4 test corpus is byte-identical.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Petru Conduraru
2026-07-21 16:57:51 +03:00
parent e52e5e2e58
commit 8665725eb6
2 changed files with 208 additions and 7 deletions
+35 -7
View File
@@ -54,13 +54,23 @@ bool IfcGeom::Iterator::initialize() {
tasks_.push_back(res);
}
if (settings_.get<ifcopenshell::geometry::settings::NoParallelMapping>().get() && settings_.get<ifcopenshell::geometry::settings::PermissiveShapeReuse>().get()) {
std::unordered_map<
ifcopenshell::geometry::taxonomy::item::ptr,
std::vector<std::pair<IfcUtil::IfcBaseEntity*, ifcopenshell::geometry::taxonomy::matrix4::ptr>>> folded;
if (settings_.get<ifcopenshell::geometry::settings::NoParallelMapping>().get() && settings_.get<ifcopenshell::geometry::settings::PermissiveShapeReuse>().get() &&
// With world coords enabled, product placements are baked into the BRep, so
// geometries cannot be shared between products, same as in mapping::reuse_ok_().
!settings_.get<ifcopenshell::geometry::settings::UseWorldCoords>().get()) {
// Tasks are folded based on the innermost shared taxonomy node combined with the
// effective surface style hoisted from the traversed wrappers, so that occurrences
// of the same mapped representation with different styles keep their own style.
typedef std::pair<ifcopenshell::geometry::taxonomy::item::ptr, ifcopenshell::geometry::taxonomy::style::ptr> fold_key_t;
struct fold_group_t {
ifcopenshell::geometry::taxonomy::item::ptr original;
std::vector<std::pair<IfcUtil::IfcBaseEntity*, ifcopenshell::geometry::taxonomy::matrix4::ptr>> products;
};
std::map<fold_key_t, fold_group_t> folded;
for (auto& r : tasks_) {
auto i = r.item;
ifcopenshell::geometry::taxonomy::style::ptr style;
Eigen::Matrix4d m4 = Eigen::Matrix4d::Identity();
@@ -69,16 +79,24 @@ bool IfcGeom::Iterator::initialize() {
if (col->matrix) {
m4 *= col->matrix->ccomponents();
}
if (col->surface_style) {
style = col->surface_style;
}
i = col->children[0];
} else {
break;
}
}
auto& group = folded[{ i, style }];
if (!group.original) {
group.original = r.item;
}
for (auto& p : r.products) {
auto pl = ifcopenshell::geometry::taxonomy::matrix4::ptr(p.second->clone_());
pl->components() *= m4;
folded[i].push_back(
group.products.push_back(
{ p.first, pl }
);
}
@@ -89,10 +107,20 @@ bool IfcGeom::Iterator::initialize() {
tasks_.clear();
size_t i = 0;
for (auto& p : folded) {
auto item = p.first.first;
if (item != p.second.original) {
// Reattach the hoisted style and a representation instance for downstream use.
auto wrap = ifcopenshell::geometry::taxonomy::make<ifcopenshell::geometry::taxonomy::collection>();
wrap->children.push_back(std::dynamic_pointer_cast<ifcopenshell::geometry::taxonomy::geom_item>(item));
wrap->matrix = ifcopenshell::geometry::taxonomy::make<ifcopenshell::geometry::taxonomy::matrix4>();
wrap->surface_style = p.first.second;
wrap->instance = p.second.original->instance;
item = wrap;
}
tasks_.emplace_back();
tasks_.back().index = i++;
tasks_.back().item = p.first;
tasks_.back().products = p.second;
tasks_.back().item = item;
tasks_.back().products = std::move(p.second.products);
}
logger_.Notice("SYS", 26, "Merged " + std::to_string(old_size) + " tasks into " + std::to_string(tasks_.size()) + " tasks due to permissive shape reuse");
}
@@ -0,0 +1,173 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 IfcOpenShell contributors
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
# This file was generated with the assistance of an AI coding tool.
"""Tests for permissive-shape-reuse task folding in the iterator (issue #143).
Occurrences of one IfcRepresentationMap whose IfcMappedItems carry their own
IfcStyledItem cannot be grouped by the regular reuse logic. With
no-parallel-mapping and permissive-shape-reuse the iterator folds such tasks
onto the shared taxonomy node, which must keep per-occurrence styles and
placements intact.
"""
import numpy as np
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.guid
import ifcopenshell.util.shape
RED = (1.0, 0.0, 0.0)
BLUE = (0.0, 0.0, 1.0)
GRAY = (0.7, 0.7, 0.7) # default material
def make_model(occurrence_styles=(RED, RED, BLUE, None), target_offset=None):
"""One mapped box representation, one occurrence per entry in occurrence_styles.
Styles are attached with an IfcStyledItem on each occurrence's IfcMappedItem,
which defeats the regular mapped item grouping (StyledByItem count != 0).
"""
f = ifcopenshell.file(schema="IFC4")
origin = f.createIfcCartesianPoint((0.0, 0.0, 0.0))
axis_placement = f.createIfcAxis2Placement3D(origin, None, None)
context = f.createIfcGeometricRepresentationContext(None, "Model", 3, 1e-5, axis_placement, None)
units = f.createIfcUnitAssignment(
[
f.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE"),
f.createIfcSIUnit(None, "PLANEANGLEUNIT", None, "RADIAN"),
]
)
f.createIfcProject(ifcopenshell.guid.new(), None, "Proj", None, None, None, None, [context], units)
profile = f.createIfcRectangleProfileDef(
"AREA", None, f.createIfcAxis2Placement2D(f.createIfcCartesianPoint((0.0, 0.0)), None), 1.0, 1.0
)
solid = f.createIfcExtrudedAreaSolid(profile, axis_placement, f.createIfcDirection((0.0, 0.0, 1.0)), 1.0)
mapped_rep = f.createIfcShapeRepresentation(context, "Body", "SweptSolid", [solid])
rep_map = f.createIfcRepresentationMap(axis_placement, mapped_rep)
surface_styles = {}
for colour in {s for s in occurrence_styles if s is not None}:
rgb = f.createIfcColourRgb(None, *colour)
shading = f.createIfcSurfaceStyleShading(rgb, 0.0)
surface_styles[colour] = f.createIfcSurfaceStyle(str(colour), "POSITIVE", [shading])
if target_offset is not None:
target_origin = f.createIfcCartesianPoint(tuple(map(float, target_offset)))
else:
target_origin = origin
products = []
for i, colour in enumerate(occurrence_styles):
target = f.createIfcCartesianTransformationOperator3D(None, None, target_origin, 1.0, None)
mapped_item = f.createIfcMappedItem(rep_map, target)
if colour is not None:
f.createIfcStyledItem(mapped_item, [surface_styles[colour]], None)
shape_rep = f.createIfcShapeRepresentation(context, "Body", "MappedRepresentation", [mapped_item])
definition = f.createIfcProductDefinitionShape(None, None, [shape_rep])
placement = f.createIfcLocalPlacement(
None, f.createIfcAxis2Placement3D(f.createIfcCartesianPoint((i * 2.0, 0.0, 0.0)), None, None)
)
products.append(
f.createIfcBuildingElementProxy(
ifcopenshell.guid.new(), None, f"Elem{i}", None, None, placement, definition, None, None
)
)
return f, products
def iterate(f, permissive_shape_reuse):
settings = ifcopenshell.geom.settings()
settings.set("apply-default-materials", True)
if permissive_shape_reuse:
settings.set("no-parallel-mapping", True)
settings.set("permissive-shape-reuse", True)
elements = {}
iterator = ifcopenshell.geom.iterator(settings, f, 1)
assert iterator.initialize()
while True:
element = iterator.get()
elements[element.name] = element
if not iterator.next():
break
return elements
def element_colours(element):
return {
tuple(round(c, 3) for c in rgba[:3]) for rgba in ifcopenshell.util.shape.get_material_colors(element.geometry)
}
def world_vertices(element):
matrix = ifcopenshell.util.shape.get_shape_matrix(element)
verts = np.hstack(
[ifcopenshell.util.shape.get_vertices(element.geometry), np.ones((len(element.geometry.verts) // 3, 1))]
)
return {tuple(np.round(row, 5)) for row in (verts @ matrix.T)[:, :3]}
def test_folding_keeps_styles_and_placements():
f, _ = make_model()
elements = iterate(f, permissive_shape_reuse=True)
assert len(elements) == 4
assert element_colours(elements["Elem0"]) == {RED}
assert element_colours(elements["Elem1"]) == {RED}
assert element_colours(elements["Elem2"]) == {BLUE}
assert element_colours(elements["Elem3"]) == {GRAY}
for i in range(4):
matrix = ifcopenshell.util.shape.get_shape_matrix(elements[f"Elem{i}"])
assert np.allclose(matrix[:3, 3], (i * 2.0, 0.0, 0.0))
# occurrences with an identical style share one processed representation
assert elements["Elem0"].geometry.id == elements["Elem1"].geometry.id
assert elements["Elem0"].geometry.id != elements["Elem2"].geometry.id
def test_folding_matches_unfolded_output():
f, _ = make_model()
folded = iterate(f, permissive_shape_reuse=True)
unfolded = iterate(f, permissive_shape_reuse=False)
assert folded.keys() == unfolded.keys()
for name in folded:
assert world_vertices(folded[name]) == world_vertices(unfolded[name])
assert element_colours(folded[name]) == element_colours(unfolded[name])
def test_folding_hoists_non_identity_mapping_target():
f, _ = make_model(target_offset=(0.0, 0.0, 5.0))
folded = iterate(f, permissive_shape_reuse=True)
unfolded = iterate(f, permissive_shape_reuse=False)
assert len(folded) == 4
assert folded["Elem0"].geometry.id == folded["Elem1"].geometry.id
for name in folded:
assert world_vertices(folded[name]) == world_vertices(unfolded[name])
assert element_colours(folded[name]) == element_colours(unfolded[name])
# the mapping target translation must survive the fold
assert min(v[2] for v in world_vertices(folded["Elem0"])) == 5.0
if __name__ == "__main__":
import pytest
pytest.main(["-vvsx", __file__])