From 7ebdd046b689df8ffda21fdf810f3577f3e3cc0f Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sat, 18 Jul 2026 23:00:58 +0300 Subject: [PATCH] Optimise IfcPatch recipe: make toposort backend configurable aothms asked for the toposort dependency ordering used by the dedup walk to try igraph's C-backed topological_sorting() first, since it should shave off additional time on top of the non-recursive get_info fix. Falls back to the pure python toposort package with a warning if igraph is not installed. Generated with the assistance of an AI coding tool. --- src/ifcpatch/ifcpatch/recipes/Optimise.py | 35 +++++++++++++++-- src/ifcpatch/test/test_Optimise.py | 46 +++++++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/ifcpatch/ifcpatch/recipes/Optimise.py b/src/ifcpatch/ifcpatch/recipes/Optimise.py index dd7e3bc84c..581a89c643 100644 --- a/src/ifcpatch/ifcpatch/recipes/Optimise.py +++ b/src/ifcpatch/ifcpatch/recipes/Optimise.py @@ -16,9 +16,40 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcPatch. If not, see . +import logging + import ifcopenshell +def _toposort(graph: dict[int, set[int]], logger: logging.Logger) -> list[int]: + """Flatten a dependency graph of entity ids into dependency order. + + Uses igraph's C-backed topological sort when available, otherwise falls + back to the pure python toposort package with a warning. + """ + try: + import igraph + except ImportError: + logger.warning( + "igraph is not installed, falling back to the slower pure python toposort. " + "Install python-igraph for better performance." + ) + from toposort import toposort_flatten + + return toposort_flatten(graph) + + ids = list(graph) + index = {id_: i for i, id_ in enumerate(ids)} + for references in graph.values(): + for reference in references: + if reference not in index: + index[reference] = len(ids) + ids.append(reference) + edges = [(index[reference], index[id_]) for id_, references in graph.items() for reference in references] + order = igraph.Graph(n=len(ids), edges=edges, directed=True).topological_sorting(mode="out") + return [ids[i] for i in order] + + class Patcher: def __init__(self, file, logger): """Optimise the filesize of an IFC model @@ -52,8 +83,6 @@ class Patcher: self.optimized_file = ifcopenshell.file(schema=self.file.schema) def patch(self): - from toposort import toposort_flatten as toposort - def generate_instances_and_references(): """ Generator which yields an entity id and @@ -90,7 +119,7 @@ class Patcher: info_to_id = {} - for id in toposort(dict(generate_instances_and_references())): + for id in _toposort(dict(generate_instances_and_references()), self.logger): inst = self.file[id] info = map_value(inst.get_info(include_identifier=False, recursive=False, return_type=tuple), as_key=True) if info in info_to_id: diff --git a/src/ifcpatch/test/test_Optimise.py b/src/ifcpatch/test/test_Optimise.py index e4beaf122d..aa6352e937 100644 --- a/src/ifcpatch/test/test_Optimise.py +++ b/src/ifcpatch/test/test_Optimise.py @@ -18,11 +18,18 @@ # This file was generated with the assistance of an AI coding tool. +import logging +import sys +from unittest import mock + +import pytest + import ifcopenshell import ifcopenshell.guid import ifcpatch import test.bootstrap +from ifcpatch.recipes.Optimise import _toposort def add_context(f: ifcopenshell.file) -> ifcopenshell.entity_instance: @@ -71,3 +78,42 @@ class TestOptimise(test.bootstrap.IFC4): assert len(output.by_type("IfcPolyline")) == 2 walls_after = output.by_type("IfcWall") assert curve_of(walls_after[0]) != curve_of(walls_after[1]) + + def test_folding_still_works_on_the_pure_python_fallback(self): + context = add_context(self.file) + coords = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] + add_wall_with_curve(self.file, context, coords) + add_wall_with_curve(self.file, context, coords) + + with mock.patch.dict(sys.modules, {"igraph": None}): + output = ifcpatch.execute({"file": self.file, "recipe": "Optimise", "arguments": []}) + + assert len(output.by_type("IfcPolyline")) == 1 + + +GRAPH = {4: {2, 3}, 2: {1}, 3: {1}, 1: set()} + + +def assert_dependency_order(order: list[int], graph: dict[int, set[int]]) -> None: + position = {id_: i for i, id_ in enumerate(order)} + assert sorted(order) == sorted(graph) + for id_, references in graph.items(): + for reference in references: + assert position[reference] < position[id_] + + +class TestToposortBackends: + def test_igraph_backend_orders_dependencies_first(self): + pytest.importorskip("igraph") + assert_dependency_order(_toposort(GRAPH, logging.getLogger(__name__)), GRAPH) + + def test_igraph_backend_includes_references_missing_from_the_keys(self): + pytest.importorskip("igraph") + assert _toposort({2: {1}}, logging.getLogger(__name__)) == [1, 2] + + def test_pure_python_fallback_warns_when_igraph_is_unavailable(self, caplog): + with mock.patch.dict(sys.modules, {"igraph": None}): + with caplog.at_level(logging.WARNING): + order = _toposort(GRAPH, logging.getLogger(__name__)) + assert_dependency_order(order, GRAPH) + assert "python-igraph" in caplog.text