From d5dc069b2f37fd35a5280dfbbac053c8b87c0ba2 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 14:27:25 +0500 Subject: [PATCH 001/142] util.schema: fix `geometry_classes_introduced_after` using wrong IFC4X3 schema It was passing `IFC4X3` directly to `schema_by_name` which is expecting schema identifier (e.g. IFC4X3_ADD2, not IFC4X3 allowed by `IFC_SCHEMA` - IFC4X3 is one of the IFC4X3 iterations while it was in development, not the final one). Noticed by tests failing: FAILED test/util/test_schema.py::TestGeometryClassesIntroducedAfter::test_ifc4x3_to_ifc2x3_is_superset_of_ifc4_to_ifc2x3 - RuntimeError: No schema named IFC4X3 FAILED test/util/test_schema.py::TestGeometryClassesIntroducedAfter::test_ifc4_to_ifc4x3_is_empty - RuntimeError: No schema named IFC4X3 --- src/ifcopenshell-python/ifcopenshell/util/schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema.py b/src/ifcopenshell-python/ifcopenshell/util/schema.py index e755095bdf..aec74ac7cc 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/schema.py +++ b/src/ifcopenshell-python/ifcopenshell/util/schema.py @@ -175,8 +175,8 @@ def geometry_classes_introduced_after(target_schema: IFC_SCHEMA, source_schema: B-splines, advanced surfaces, alignment curves on IFC4X3 → 2X3, …) or purge. Defaults match the IFC4 → IFC2X3 case for backwards compatibility with the original caller.""" - source = ifcopenshell_wrapper.schema_by_name(source_schema) - target = ifcopenshell_wrapper.schema_by_name(target_schema) + source = ifcopenshell.schema_by_name(source_schema) + target = ifcopenshell.schema_by_name(target_schema) target_names = {decl.name() for decl in target.entities()} result: set[str] = set() for decl in source.entities(): From 9e0c6cf52478e8a4df1f02cdb277d218bf182a2d Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 14:29:11 +0500 Subject: [PATCH 002/142] util.schema: dedupe inline schema resolution logic --- src/ifcopenshell-python/ifcopenshell/__init__.py | 7 ++++--- src/ifcopenshell-python/ifcopenshell/file.py | 7 ++++--- .../ifcopenshell/util/schema.py | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index 12c6cd6b84..0a309695df 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -305,12 +305,13 @@ def schema_by_name( you are testing non-ISO IFC releases. :return: Schema definition object. """ + import ifcopenshell.util.schema + assert schema_version or schema, "Either schema or schema_version must be specified." if schema_version: - prefixes = ("IFC", "X", "_ADD", "_TC") - schema = "".join("".join(map(str, t)) if t[1] else "" for t in zip(prefixes, schema_version)) + schema = ifcopenshell.util.schema.get_schema_name_from_version(schema_version) else: - schema = {"IFC4X3": "IFC4X3_ADD2"}.get(schema, schema) + schema = ifcopenshell.util.schema.get_schema_identifier(schema) return ifcopenshell_wrapper.schema_by_name(schema) diff --git a/src/ifcopenshell-python/ifcopenshell/file.py b/src/ifcopenshell-python/ifcopenshell/file.py index 4b6503aabf..6d327df4af 100644 --- a/src/ifcopenshell-python/ifcopenshell/file.py +++ b/src/ifcopenshell-python/ifcopenshell/file.py @@ -594,11 +594,12 @@ class file: # A poweruser testing out a particular version of IFC4X3 model = ifcopenshell.file(schema_version=(4, 3, 0, 1)) """ + import ifcopenshell.util.schema + if schema_version: - prefixes = ("IFC", "X", "_ADD", "_TC") - schema = "".join("".join(map(str, t)) if t[1] else "" for t in zip(prefixes, schema_version)) + schema = ifcopenshell.util.schema.get_schema_name_from_version(schema_version) else: - schema = {"IFC4X3": "IFC4X3_ADD2"}.get(schema, schema) + schema = ifcopenshell.util.schema.get_schema_identifier(schema) if f is not None: self.wrapped_data = f if not f.good(): diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema.py b/src/ifcopenshell-python/ifcopenshell/util/schema.py index aec74ac7cc..5aa9b26fab 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/schema.py +++ b/src/ifcopenshell-python/ifcopenshell/util/schema.py @@ -50,6 +50,21 @@ def get_fallback_schema(version: str) -> IFC_SCHEMA: return version +def get_schema_identifier(schema: IFC_SCHEMA) -> str: + """Resolve a general schema name to the specific identifier used internally + (as in ``file.schema_identifier``). + + E.g. ``IFC4X3`` -> ``IFC4X3_ADD2``. + """ + return {"IFC4X3": "IFC4X3_ADD2"}.get(schema, schema) + + +def get_schema_name_from_version(schema_version: tuple[int, ...]) -> str: + """Build a schema name from a version tuple, e.g. (4, 3, 0, 1) -> "IFC4X3_TC1".""" + prefixes = ("IFC", "X", "_ADD", "_TC") + return "".join("".join(map(str, t)) if t[1] else "" for t in zip(prefixes, schema_version)) + + def get_declaration(element: ifcopenshell.entity_instance): """Get the schema declaration of an actively used entity instance From 2155e3206fee7f68ca993971a1ae2e863fd3d6f1 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 16:21:28 +0500 Subject: [PATCH 003/142] logger: use `Logger*` instead of `Logger&` to propagate signature using swig See the comment in IfcLogger.h explaining this. --- src/ifcconvert/IfcConvert.cpp | 20 ++++++------- .../ifcopenshell/ifcopenshell_wrapper.pyi | 29 ++++++++++--------- src/ifcparse/IfcFile.h | 2 +- src/ifcparse/IfcLogger.h | 7 +++++ src/ifcparse/parse_ifcxml.cpp | 4 +-- src/ifcwrap/IfcGeomWrapper.i | 24 ++++++++------- src/ifcwrap/IfcParseWrapper.i | 4 +-- src/serializers/ColladaSerializer.h | 4 +-- src/serializers/GltfSerializer.cpp | 4 +-- src/serializers/GltfSerializer.h | 2 +- src/serializers/HdfSerializer.cpp | 4 +-- src/serializers/HdfSerializer.h | 2 +- src/serializers/SvgSerializer.h | 4 +-- src/serializers/TtlWktSerializer.cpp | 4 +-- src/serializers/TtlWktSerializer.h | 2 +- src/serializers/WavefrontObjSerializer.cpp | 4 +-- src/serializers/WavefrontObjSerializer.h | 2 +- src/serializers/XmlSerializer.cpp | 4 +-- src/serializers/XmlSerializer.h | 2 +- .../schema_dependent/XmlSerializer.h | 2 +- 20 files changed, 71 insertions(+), 59 deletions(-) diff --git a/src/ifcconvert/IfcConvert.cpp b/src/ifcconvert/IfcConvert.cpp index 5bb0e94baf..ad8d7af607 100644 --- a/src/ifcconvert/IfcConvert.cpp +++ b/src/ifcconvert/IfcConvert.cpp @@ -675,7 +675,7 @@ int main(int argc, char** argv) { time_t start, end; time(&start); if (output_extension == XML) { - XmlSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), logger); + XmlSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), &logger); logger.Status("Writing XML output..."); s.finalize(); } else { @@ -834,14 +834,14 @@ int main(int argc, char** argv) { if (output_extension == OBJ) { // Do not use temp file for MTL as it's such a small file. const path_t mtl_filename = change_extension(output_filename, MTL); - serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(mtl_filename), geometry_settings, serializer_settings, logger); + serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(mtl_filename), geometry_settings, serializer_settings, &logger); #ifdef WITH_OPENCOLLADA } else if (output_extension == DAE) { - serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger); + serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger); #endif #ifdef WITH_GLTF } else if (output_extension == GLB) { - serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger); + serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger); #endif #ifdef WITH_USD } else if (output_extension == USD || output_extension == USDA || output_extension == USDC) { @@ -858,15 +858,15 @@ int main(int argc, char** argv) { serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger); } else if (output_extension == SVG) { geometry_settings.get().value = ifcopenshell::geometry::settings::NATIVE; - serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger); + serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger); #ifdef WITH_HDF5 } else if (output_extension == HDF) { geometry_settings.get().value = ifcopenshell::geometry::settings::NATIVE; - serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, false, logger); + serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, false, &logger); +#endif #endif -#endif } else if (output_extension == TTL) { - serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger); + serializer = boost::make_shared(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger); } else { cerr_ << "[Error] Unknown output filename extension '" << output_extension << "'\n"; write_log(!quiet); @@ -1011,7 +1011,7 @@ int main(int argc, char** argv) { if (!vmap.count("cache-file")) { cache_file = input_filename + CACHE + HDF; } - cache.reset(new HdfSerializer(IfcUtil::path::to_utf8(cache_file), geometry_settings, serializer_settings, false, logger)); + cache.reset(new HdfSerializer(IfcUtil::path::to_utf8(cache_file), geometry_settings, serializer_settings, false, &logger)); context_iterator->set_cache(cache.get()); } #endif @@ -1290,7 +1290,7 @@ bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file, #ifdef WITH_IFCXML if (boost::ends_with(boost::to_lower_copy(filename), ".ifcxml")) { - ifc_file = IfcParse::parse_ifcxml(filename, logger); + ifc_file = IfcParse::parse_ifcxml(filename, &logger); } else #endif { diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 20ab01b980..b924e2c03d 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -138,7 +138,7 @@ class BRepElement(Element): def volume(self): ... class ColladaSerializer(WriteOnlyGeometrySerializer): - def __init__(self, dae_filename, geometry_settings, settings): ... + def __init__(self, dae_filename, geometry_settings, settings, logger=None): ... def finalize(self): ... def isTesselated(self): ... def object_id(self, o): ... @@ -284,7 +284,7 @@ class GeometrySerializer: def write(self, *args): ... class GltfSerializer(WriteOnlyGeometrySerializer): - def __init__(self, filename, geometry_settings, settings): ... + def __init__(self, filename, geometry_settings, settings, logger=None): ... def finalize(self): ... def isTesselated(self): ... def ready(self): ... @@ -294,7 +294,7 @@ class GltfSerializer(WriteOnlyGeometrySerializer): def writeHeader(self): ... class HdfSerializer(GeometrySerializer): - def __init__(self, hdf_filename, geometry_settings, settings, read_only=False): ... + def __init__(self, hdf_filename, geometry_settings, settings, read_only=False, logger=None): ... def finalize(self): ... def isTesselated(self): ... def read(self, *args): ... @@ -466,7 +466,7 @@ class Settings: def setting_names(self): ... class SvgSerializer(WriteOnlyGeometrySerializer): - def __init__(self, out_filename, geometry_settings, settings): ... + def __init__(self, out_filename, geometry_settings, settings, logger=None): ... SH_NONE: Any SH_FULL: Any SH_LEFT: Any @@ -609,7 +609,7 @@ class TriangulationElement(Element): def geometry_pointer(self): ... class TtlWktSerializer(WriteOnlyGeometrySerializer): - def __init__(self, filename, geometry_settings, settings): ... + def __init__(self, filename, geometry_settings, settings, logger=None): ... def finalize(self): ... def isTesselated(self): ... def ready(self): ... @@ -620,7 +620,7 @@ class TtlWktSerializer(WriteOnlyGeometrySerializer): def writeHeader(self): ... class WaveFrontOBJSerializer(WriteOnlyGeometrySerializer): - def __init__(self, obj_filename, mtl_filename, geometry_settings, settings): ... + def __init__(self, obj_filename, mtl_filename, geometry_settings, settings, logger=None): ... def finalize(self): ... def isTesselated(self): ... def ready(self): ... @@ -635,7 +635,7 @@ class WriteOnlyGeometrySerializer(GeometrySerializer): def read(self, *args): ... class XmlSerializer: - def __init__(self, file, xml_filename): ... + def __init__(self, file, xml_filename, logger=None): ... def finalize(self): ... def ready(self): ... def setFile(self, arg2): ... @@ -1695,17 +1695,17 @@ class type_declaration(declaration): class uninitialized_tag: ... -def arrange_polygons(settings, polygons): ... +def arrange_polygons(settings, polygons, logger=None): ... def clear_schemas(): ... -def construct_iterator(geometry_library, settings, file, num_threads, logger=...): ... +def construct_iterator(geometry_library, settings, file, num_threads, logger=None): ... def construct_iterator_with_include_exclude( - geometry_library, settings, file, elems, include, num_threads, logger=... + geometry_library, settings, file, elems, include, num_threads, logger=None ): ... def construct_iterator_with_include_exclude_globalid( - geometry_library, settings, file, elems, include, num_threads, logger=... + geometry_library, settings, file, elems, include, num_threads, logger=None ): ... def construct_iterator_with_include_exclude_id( - geometry_library, settings, file, elems, include, num_threads, logger=... + geometry_library, settings, file, elems, include, num_threads, logger=None ): ... def convert_loop_to_function_item(loop): ... def create_box(*args): ... @@ -1721,10 +1721,11 @@ def kind_to_string(k): ... def less(arg1, arg2): ... def line_segments_to_polygons(s, eps, segments): ... def map_shape(settings, instance): ... +def logger_or_root(logger): ... def nary_union(sequence): ... def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ... -def open(fn: str, readonly: bool = False, logger=...) -> file: ... -def parse_ifcxml(filename, logger=...): ... +def open(fn: str, readonly: bool = False, logger=None) -> file: ... +def parse_ifcxml(filename, logger=None): ... def polygons_to_svg(*args): ... def read(data): ... def register_schema(arg1): ... diff --git a/src/ifcparse/IfcFile.h b/src/ifcparse/IfcFile.h index 5ccfc1944d..435bb4634d 100644 --- a/src/ifcparse/IfcFile.h +++ b/src/ifcparse/IfcFile.h @@ -447,7 +447,7 @@ public: }; #ifdef WITH_IFCXML -IFC_PARSE_API IfcFile* parse_ifcxml(const std::string& filename, Logger& logger = Logger::Root()); +IFC_PARSE_API IfcFile* parse_ifcxml(const std::string& filename, Logger* logger = nullptr); #endif namespace impl { diff --git a/src/ifcparse/IfcLogger.h b/src/ifcparse/IfcLogger.h index 280cfcd036..a2990cf7b8 100644 --- a/src/ifcparse/IfcLogger.h +++ b/src/ifcparse/IfcLogger.h @@ -142,6 +142,13 @@ class IFC_PARSE_API Logger { const std::vector& log_messages() const { return log_messages_; } }; +// SWIG couldn't represent `Logger::Root()` default value using Python, +// so when translating signature it represents it just as `fn(*args)`, losing information about args. +// Using `Logger * = nullptr` instead of `&Logger = Logger::Root` helps, +// since `nullptr` is convertable Python's `None`. +// `logger_or_root` is just covering the boilerplate for this pattern. +inline Logger& logger_or_root(Logger* logger) { return logger ? *logger : Logger::Root(); } + #define PERF(x) \ \ Logger::Root().Message(Logger::LOG_PERF, "SYS", 1, x); \ diff --git a/src/ifcparse/parse_ifcxml.cpp b/src/ifcparse/parse_ifcxml.cpp index 8ebb4c4fe3..7c36151ab1 100644 --- a/src/ifcparse/parse_ifcxml.cpp +++ b/src/ifcparse/parse_ifcxml.cpp @@ -695,10 +695,10 @@ end: return; } -IFC_PARSE_API IfcParse::IfcFile* IfcParse::parse_ifcxml(const std::string& filename, Logger& logger) { +IFC_PARSE_API IfcParse::IfcFile* IfcParse::parse_ifcxml(const std::string& filename, Logger* logger) { throw std::runtime_error("IFC-XML import temporarily disabled"); - ifcxml_parse_state state(logger); + ifcxml_parse_state state(logger_or_root(logger)); xmlSAXHandler handler; memset(&handler, 0, sizeof(xmlSAXHandler)); diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index 404ecd7ded..bcfcca872a 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -630,29 +630,33 @@ struct ShapeRTTI : public boost::static_visitor // I couldn't get the vector typemap to be applied when %extending Iterator constructor. // anyway it does not matter as SWIG generates C code without actual constructors %inline %{ - IfcGeom::Iterator* construct_iterator(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, int num_threads, Logger& logger = Logger::Root()) { - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, num_threads, logger); + IfcGeom::Iterator* construct_iterator(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, int num_threads, Logger* logger = nullptr) { + Logger& logger_ = logger_or_root(logger); + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger_), settings, file, num_threads, logger_); } - IfcGeom::Iterator* construct_iterator_with_include_exclude(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) { + IfcGeom::Iterator* construct_iterator_with_include_exclude(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger* logger = nullptr) { + Logger& logger_ = logger_or_root(logger); std::set elems_set(elems.begin(), elems.end()); IfcGeom::entity_filter ef{ include, false, elems_set }; - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {ef}, num_threads, logger); + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger_), settings, file, {ef}, num_threads, logger_); } - IfcGeom::Iterator* construct_iterator_with_include_exclude_globalid(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) { + IfcGeom::Iterator* construct_iterator_with_include_exclude_globalid(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger* logger = nullptr) { + Logger& logger_ = logger_or_root(logger); std::set elems_set(elems.begin(), elems.end()); IfcGeom::attribute_filter af; af.attribute_name = "GlobalId"; af.populate(elems_set); af.include = include; - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {af}, num_threads, logger); + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger_), settings, file, {af}, num_threads, logger_); } - IfcGeom::Iterator* construct_iterator_with_include_exclude_id(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger& logger = Logger::Root()) { + IfcGeom::Iterator* construct_iterator_with_include_exclude_id(const std::string& geometry_library, ifcopenshell::geometry::Settings settings, IfcParse::IfcFile* file, std::vector elems, bool include, int num_threads, Logger* logger = nullptr) { + Logger& logger_ = logger_or_root(logger); std::set elems_set(elems.begin(), elems.end()); IfcGeom::instance_id_filter af(include, false, elems_set); - return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger), settings, file, {af}, num_threads, logger); + return new IfcGeom::Iterator(ifcopenshell::geometry::kernels::construct(file, geometry_library, settings, logger_), settings, file, {af}, num_threads, logger_); } %} @@ -1295,9 +1299,9 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type } } - std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons, Logger& logger = Logger::Root()) { + std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons, Logger* logger = nullptr) { std::vector r; - if (svgfill::arrange_polygons(settings, polygons, r, logger)) { + if (svgfill::arrange_polygons(settings, polygons, r, logger_or_root(logger))) { return r; } else { throw std::runtime_error("Failed to arrange polygons"); diff --git a/src/ifcwrap/IfcParseWrapper.i b/src/ifcwrap/IfcParseWrapper.i index 10f9d5af49..8e426f4795 100644 --- a/src/ifcwrap/IfcParseWrapper.i +++ b/src/ifcwrap/IfcParseWrapper.i @@ -752,10 +752,10 @@ private: %newobject stream_from_string; %inline %{ - IfcParse::IfcFile* open(const std::string& fn, bool readonly=false, Logger& logger=Logger::Root()) { + IfcParse::IfcFile* open(const std::string& fn, bool readonly=false, Logger* logger=nullptr) { IfcParse::IfcFile* f; Py_BEGIN_ALLOW_THREADS; - f = new IfcParse::IfcFile(fn, IfcParse::FT_AUTODETECT, readonly, logger); + f = new IfcParse::IfcFile(fn, IfcParse::FT_AUTODETECT, readonly, logger_or_root(logger)); Py_END_ALLOW_THREADS; return f; } diff --git a/src/serializers/ColladaSerializer.h b/src/serializers/ColladaSerializer.h index 3fcd22f5ad..94bf9246c5 100644 --- a/src/serializers/ColladaSerializer.h +++ b/src/serializers/ColladaSerializer.h @@ -219,8 +219,8 @@ private: std::string unit_name; float unit_magnitude; public: - ColladaSerializer(const std::string& dae_filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger& logger = Logger::Root()) - : WriteOnlyGeometrySerializer(geometry_settings, settings, logger) + ColladaSerializer(const std::string& dae_filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger* logger = nullptr) + : WriteOnlyGeometrySerializer(geometry_settings, settings, logger_or_root(logger)) , exporter("IfcOpenShell", dae_filename, this, settings.get().get() >= 15) { exporter.serializer = this; diff --git a/src/serializers/GltfSerializer.cpp b/src/serializers/GltfSerializer.cpp index cfaee026ed..adc5b0961f 100644 --- a/src/serializers/GltfSerializer.cpp +++ b/src/serializers/GltfSerializer.cpp @@ -53,8 +53,8 @@ static const uint32_t PRIM_TRIANGLE_FAN = 6; static const uint32_t ELEMENT_ARRAY_BUFFER = 34963; static const uint32_t ARRAY_BUFFER = 34962; -GltfSerializer::GltfSerializer(const std::string& filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger& logger) - : WriteOnlyGeometrySerializer(geometry_settings, settings, logger) +GltfSerializer::GltfSerializer(const std::string& filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger* logger) + : WriteOnlyGeometrySerializer(geometry_settings, settings, logger_or_root(logger)) , filename_(filename) , tmp_filename1_(filename + ".indices.tmp") , tmp_filename2_(filename + ".vertices.tmp") diff --git a/src/serializers/GltfSerializer.h b/src/serializers/GltfSerializer.h index 0f9ced9031..f8295674e9 100644 --- a/src/serializers/GltfSerializer.h +++ b/src/serializers/GltfSerializer.h @@ -43,7 +43,7 @@ private: int writeMaterial(const ifcopenshell::geometry::taxonomy::style::ptr style); public: - GltfSerializer(const std::string& filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger& logger = Logger::Root()); + GltfSerializer(const std::string& filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger* logger = nullptr); virtual ~GltfSerializer(); bool ready(); void writeHeader(); diff --git a/src/serializers/HdfSerializer.cpp b/src/serializers/HdfSerializer.cpp index 15d7fa4cb7..d0b089435d 100644 --- a/src/serializers/HdfSerializer.cpp +++ b/src/serializers/HdfSerializer.cpp @@ -55,8 +55,8 @@ herr_t print_stack(hid_t /*estack*/, void*) { return 0; } -HdfSerializer::HdfSerializer(const std::string& hdf_filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, bool read_only, Logger& logger) - : GeometrySerializer(geometry_settings, settings, logger) +HdfSerializer::HdfSerializer(const std::string& hdf_filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, bool read_only, Logger* logger) + : GeometrySerializer(geometry_settings, settings, logger_or_root(logger)) , hdf_filename(hdf_filename) , settings_(settings) { diff --git a/src/serializers/HdfSerializer.h b/src/serializers/HdfSerializer.h index 5380650e60..0cf6308553 100644 --- a/src/serializers/HdfSerializer.h +++ b/src/serializers/HdfSerializer.h @@ -96,7 +96,7 @@ private: void write_style(surface_style_serialization& data, const ifcopenshell::geometry::taxonomy::style::ptr& s); public: - HdfSerializer(const std::string& hdf_filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, bool read_only=false, Logger& logger = Logger::Root()); + HdfSerializer(const std::string& hdf_filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, bool read_only=false, Logger* logger = nullptr); virtual ~HdfSerializer() {} bool ready(); void writeHeader(); diff --git a/src/serializers/SvgSerializer.h b/src/serializers/SvgSerializer.h index 4bbcd98b1e..7e564c589b 100644 --- a/src/serializers/SvgSerializer.h +++ b/src/serializers/SvgSerializer.h @@ -596,8 +596,8 @@ protected: subtract_before_project subtraction_settings_; public: - SvgSerializer(const stream_or_filename& out_filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger& logger = Logger::Root()) - : WriteOnlyGeometrySerializer(geometry_settings, settings, logger) + SvgSerializer(const stream_or_filename& out_filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger* logger = nullptr) + : WriteOnlyGeometrySerializer(geometry_settings, settings, logger_or_root(logger)) , svg_file(out_filename) , xmin(+std::numeric_limits::infinity()) , ymin(+std::numeric_limits::infinity()) diff --git a/src/serializers/TtlWktSerializer.cpp b/src/serializers/TtlWktSerializer.cpp index b9c521c776..18ce19e74a 100644 --- a/src/serializers/TtlWktSerializer.cpp +++ b/src/serializers/TtlWktSerializer.cpp @@ -233,8 +233,8 @@ namespace { } } -TtlWktSerializer::TtlWktSerializer(const stream_or_filename& filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger& logger) - : WriteOnlyGeometrySerializer(geometry_settings, settings, logger) +TtlWktSerializer::TtlWktSerializer(const stream_or_filename& filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger* logger) + : WriteOnlyGeometrySerializer(geometry_settings, settings, logger_or_root(logger)) , filename_(filename) { const auto& tri_setting = geometry_settings.get().get(); diff --git a/src/serializers/TtlWktSerializer.h b/src/serializers/TtlWktSerializer.h index d332ffaa89..dbf4905962 100644 --- a/src/serializers/TtlWktSerializer.h +++ b/src/serializers/TtlWktSerializer.h @@ -32,7 +32,7 @@ class SERIALIZERS_API TtlWktSerializer : public WriteOnlyGeometrySerializer { private: stream_or_filename filename_; public: - TtlWktSerializer(const stream_or_filename& filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger& logger = Logger::Root()); + TtlWktSerializer(const stream_or_filename& filename, const ifcopenshell::geometry::Settings& geometry_settings, const ifcopenshell::geometry::SerializerSettings& settings, Logger* logger = nullptr); virtual ~TtlWktSerializer() {} bool ready(); void writeHeader(); diff --git a/src/serializers/WavefrontObjSerializer.cpp b/src/serializers/WavefrontObjSerializer.cpp index 536f9aeeaa..5d178329b3 100644 --- a/src/serializers/WavefrontObjSerializer.cpp +++ b/src/serializers/WavefrontObjSerializer.cpp @@ -27,8 +27,8 @@ #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) +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)) , obj_stream(obj_filename) , mtl_stream(mtl_filename) , vcount_total(1) diff --git a/src/serializers/WavefrontObjSerializer.h b/src/serializers/WavefrontObjSerializer.h index e1436d2de3..ceac640661 100644 --- a/src/serializers/WavefrontObjSerializer.h +++ b/src/serializers/WavefrontObjSerializer.h @@ -35,7 +35,7 @@ private: size_t vcount_total, ncount_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 = Logger::Root()); + 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); virtual ~WaveFrontOBJSerializer() {} bool ready(); void writeHeader(); diff --git a/src/serializers/XmlSerializer.cpp b/src/serializers/XmlSerializer.cpp index 24f0cf435b..26b50836ee 100644 --- a/src/serializers/XmlSerializer.cpp +++ b/src/serializers/XmlSerializer.cpp @@ -31,8 +31,8 @@ XmlSerializer* XmlSerializerFactory::Factory::construct(const std::string& schem return it->second(file, xml_filename, logger); } -XmlSerializer::XmlSerializer(IfcParse::IfcFile* file, const std::string& xml_filename, Logger& logger) - : Serializer(logger) { +XmlSerializer::XmlSerializer(IfcParse::IfcFile* file, const std::string& xml_filename, Logger* logger) + : Serializer(logger_or_root(logger)) { if (file) { implementation_ = XmlSerializerFactory::implementations().construct(file->schema()->name(), file, xml_filename, logger_); } diff --git a/src/serializers/XmlSerializer.h b/src/serializers/XmlSerializer.h index 908f165734..541eea14e9 100644 --- a/src/serializers/XmlSerializer.h +++ b/src/serializers/XmlSerializer.h @@ -16,7 +16,7 @@ protected: std::string xml_filename; public: - XmlSerializer(IfcParse::IfcFile* file, const std::string& xml_filename, Logger& logger = Logger::Root()); + XmlSerializer(IfcParse::IfcFile* file, const std::string& xml_filename, Logger* logger = nullptr); virtual ~XmlSerializer() {} diff --git a/src/serializers/schema_dependent/XmlSerializer.h b/src/serializers/schema_dependent/XmlSerializer.h index 2c63ee6e2e..a589bd07d4 100644 --- a/src/serializers/schema_dependent/XmlSerializer.h +++ b/src/serializers/schema_dependent/XmlSerializer.h @@ -40,7 +40,7 @@ private: public: POSTFIX_SCHEMA(XmlSerializer)(IfcParse::IfcFile* file, const std::string& xml_filename, Logger& logger = Logger::Root()) - : XmlSerializer(0, "", logger) + : XmlSerializer(0, "", &logger) , mapping_(ifcopenshell::geometry::impl::mapping_implementations().construct(file, settings_, logger)) { this->file = file; From 9213b31235b583faa7ffaf58058ff1304670b348 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 16:58:55 +0500 Subject: [PATCH 004/142] logger: reuse logger_or_root, dedupe optional-logger-arg pattern --- .../ifcopenshell/__init__.py | 27 +++++++---- src/ifcopenshell-python/ifcopenshell/draw.py | 3 +- .../ifcopenshell/geom/main.py | 9 ++-- .../ifcopenshell/ifcopenshell_wrapper.pyi | 48 ++++++++++++++++++- .../test/test_create_shape.py | 1 + 5 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index 0a309695df..d0484a6d7e 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -97,7 +97,19 @@ from .file import file as _file from .sql import sqlite, sqlite_entity get_log = ifcopenshell_wrapper.get_log -logger = getattr(ifcopenshell_wrapper, "logger", None) +logger = ifcopenshell_wrapper.logger if hasattr(ifcopenshell_wrapper, "logger") else None +if hasattr(ifcopenshell_wrapper, "logger_or_root"): + logger_or_root = ifcopenshell_wrapper.logger_or_root +else: + + def logger_or_root(_logger: ifcopenshell_wrapper.logger | None) -> None: + return None + + +# TODO: drop this function and all callsites after we migrate to the new build. +def optional_logger_args(logger: ifcopenshell_wrapper.logger | None) -> tuple[logger] | tuple[()]: + return (logger,) if logger is not None else () + # explicitly specify available imported symbols # (it's a requirement for a typed library) @@ -194,10 +206,9 @@ def open( raise FileNotFoundError(f"Path does not exist: '{path}'.") if format is None: format = guess_format(path) - if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)): - logger = logger_type.Root() + logger = logger_or_root(logger) if format == ".ifcXML": - f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()), *((logger,) if logger is not None else ())) + f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()), *optional_logger_args(logger)) if f: return file(f) raise OSError(f"Failed to parse .ifcXML file from {path}") @@ -214,11 +225,9 @@ def open( if should_stream: return stream(path) if readonly: # Temporary conditional see #7131. Remove once newer builds don't segfault on Linux. - f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, *((logger,) if logger is not None else ())) + f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, *optional_logger_args(logger)) elif bypass_types: - f = ifcopenshell_wrapper.file( - ifcopenshell_wrapper.uninitialized_tag(), *((logger,) if logger is not None else ()) - ) + f = ifcopenshell_wrapper.file(ifcopenshell_wrapper.uninitialized_tag(), *optional_logger_args(logger)) for ty in bypass_types: f.bypass_type(ty) if mmap: @@ -233,7 +242,7 @@ def open( kwargs["logger"] = logger f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs) else: - f = ifcopenshell_wrapper.open(str(path.absolute()), False, *((logger,) if logger is not None else ())) + f = ifcopenshell_wrapper.open(str(path.absolute()), False, *optional_logger_args(logger)) return file(f) diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index 147a23498d..bbcfa48aea 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -107,8 +107,7 @@ def main( progress_function: Callable = DO_NOTHING, logger=None, ): - if logger is None and ifcopenshell.logger is not None: - logger = ifcopenshell.logger.Root() + logger = ifcopenshell.logger_or_root(logger) def by_guid(g): for f in files: diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 984ad9706b..bf5fc00098 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -22,6 +22,8 @@ from __future__ import annotations from collections.abc import Generator, Iterable from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union, cast, overload +import ifcopenshell + from .. import ifcopenshell_wrapper, open from ..entity_instance import entity_instance from ..file import file @@ -302,8 +304,7 @@ class iterator(ifcopenshell_wrapper.Iterator): logger=None, ): self.settings = settings - if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)): - logger = logger_type.Root() + logger = ifcopenshell.logger_or_root(logger) if isinstance(file_or_filename, file): self.file = file file_or_filename = file_or_filename.wrapped_data @@ -344,10 +345,10 @@ class iterator(ifcopenshell_wrapper.Iterator): include is not None, num_threads, ) - self.this = initializer(*args, *((logger,) if logger is not None else ())) + self.this = initializer(*args, *ifcopenshell.optional_logger_args(logger)) else: args = (geometry_library, self.settings, file_or_filename, num_threads) - self.this = ifcopenshell_wrapper.construct_iterator(*args, *((logger,) if logger is not None else ())) + self.this = ifcopenshell_wrapper.construct_iterator(*args, *ifcopenshell.optional_logger_args(logger)) if has_occ: diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index b924e2c03d..d483f23ae7 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -1058,6 +1058,7 @@ class file: instantiate_typed_instances: bool def key_value_store_iter(self, prefix): ... def key_value_store_query(self, key): ... + def logger(self) -> logger: ... def process_deletion_inverse(self, inst): ... def recalculate_id_counter(self): ... def remove(self, entity: entity_instance) -> None: ... @@ -1201,6 +1202,51 @@ class line_segment: def size(self): ... def swap(self, v): ... +class log_message: + code: Any + instance: Any + message: Any + product: Any + severity: Any + timestamp: Any + + def __init__(self, severity, code_prefix, code_number, timestamp, message, inst=None, current_product=None): ... + @property + def severity_string(self): ... + def to_dict(self): ... + def to_tuple(self): ... + +class logger: + FMT_PLAIN: Literal[0] + FMT_JSON: Literal[1] + FMT_INMEMORY: Literal[2] + + LOG_PERF: Literal[0] + LOG_DEBUG: Literal[1] + LOG_NOTICE: Literal[2] + LOG_WARNING: Literal[3] + LOG_ERROR: Literal[4] + + @staticmethod + def Root() -> logger: ... + def Append(self, logger): ... + def ClearLog(self): ... + def Error(self, *args): ... + def GetLog(self): ... + def MaxSeverity(self): ... + def Message(self, *args): ... + def Notice(self, *args): ... + def OutputFormat(self, *args): ... + def PrintPerformanceStats(self): ... + def PrintPerformanceStatsOnElement(self, *args): ... + def ProgressBar(self, progress): ... + def SetOutput(self, *args): ... + def SetProduct(self, product): ... + def Status(self, message, new_line=True): ... + def Verbosity(self, *args): ... + def Warning(self, *args): ... + def log_messages(self) -> tuple[log_message, ...]: ... + class loft: axis: Any def calc_hash(self): ... @@ -1721,7 +1767,7 @@ def kind_to_string(k): ... def less(arg1, arg2): ... def line_segments_to_polygons(s, eps, segments): ... def map_shape(settings, instance): ... -def logger_or_root(logger): ... +def logger_or_root(logger) -> logger: ... def nary_union(sequence): ... def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ... def open(fn: str, readonly: bool = False, logger=None) -> file: ... diff --git a/src/ifcopenshell-python/test/test_create_shape.py b/src/ifcopenshell-python/test/test_create_shape.py index bda6767295..e40570d161 100644 --- a/src/ifcopenshell-python/test/test_create_shape.py +++ b/src/ifcopenshell-python/test/test_create_shape.py @@ -217,6 +217,7 @@ def test_iterator(): def test_logging(): + assert ifcopenshell.logger logger = ifcopenshell.logger() logger.OutputFormat(logger.FMT_INMEMORY) settings = ifcopenshell.geom.settings() From ffd939508cccd22e3cd401be00cb07ff4bb58534 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 17:05:49 +0500 Subject: [PATCH 005/142] ci-lint: run ty-bonsai and ty-ios as separate steps So if one fails, it wouldn't block another. Noticed by Stephen in d5e890bccd70adcdb81dc67436041c1c9a22f202 --- .github/workflows/ci-lint.yaml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index b1098e8607..ba3a21b99d 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -55,11 +55,17 @@ jobs: black --diff --check . | black-codeclimate | python .github/workflows/black_to_github_annotations.py continue-on-error: true - - name: ty check - id: ty - run: | - poe ty-venv - poe ty + - name: ty check (venv setup) + run: poe ty-venv + + - name: ty check (bonsai) + id: ty-bonsai + run: poe ty-bonsai + continue-on-error: true + + - name: ty check (ios) + id: ty-ios + run: poe ty-ios continue-on-error: true - name: Ruff check @@ -109,7 +115,10 @@ jobs: if [ "${{ steps.ruff.outcome }}" != "success" ]; then echo "::error::Ruff check failed, see Summary or 'ruff' step for the details." && ERROR=1 fi - if [ "${{ steps.ty.outcome }}" != "success" ]; then - echo "::error::ty check failed, see 'ty check' step for the details." && ERROR=1 + if [ "${{ steps.ty-bonsai.outcome }}" != "success" ]; then + echo "::error::ty check (bonsai) failed, see 'ty check (bonsai)' step for the details." && ERROR=1 + fi + if [ "${{ steps.ty-ios.outcome }}" != "success" ]; then + echo "::error::ty check (ios) failed, see 'ty check (ios)' step for the details." && ERROR=1 fi exit $ERROR From 9123d8c183397570fd584a929d091f5512dabf83 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 17:31:55 +0500 Subject: [PATCH 006/142] stub: add missing `entity.inverse_attributes` --- .../ifcopenshell/ifcopenshell_wrapper.pyi | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index d483f23ae7..4935377871 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -854,8 +854,22 @@ class ellipse(curve): class entity(declaration): def __init__(self, name, is_abstract, index_in_schema, supertype): ... - def all_attributes(self) -> tuple[attribute, ...]: ... - def all_inverse_attributes(self) -> tuple[inverse_attribute, ...]: ... + def all_attributes(self) -> tuple[attribute, ...]: + """Get a tuple of attributes, including those inherited from supertypes.""" + ... + + def attributes(self) -> tuple[attribute, ...]: + """Get a tuple of direct attributes.""" + ... + + def all_inverse_attributes(self) -> tuple[inverse_attribute, ...]: + """Get a tuple of inverse attributes, including those inherited from supertypes.""" + ... + + def inverse_attributes(self) -> tuple[inverse_attribute, ...]: + """Get a tuple of direct inverse attributes.""" + ... + def argument_types(self) -> tuple[str, ...]: """Get a tuple of types for each attribute in ``all_attributes()``.""" ... @@ -869,7 +883,6 @@ class entity(declaration): """ ... - def attributes(self) -> tuple[attribute, ...]: ... def derived(self) -> tuple[bool, ...]: """Return a tuple of booleans indicating whether each direct attribute is derived.""" ... From bc41ff78f438a2e0d42a39d2ee49f3fcc56de833 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 17:39:34 +0500 Subject: [PATCH 007/142] stub: drop `abstract_arrangement` (158756e9218) And also gnore delete_same_facet_edge_pairs as it's more of an interanl API. --- .../ifcopenshell/ifcopenshell_wrapper.pyi | 8 -------- src/ifcwrap/IfcGeomWrapper.i | 1 + 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 4935377871..106676464c 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -643,14 +643,6 @@ class XmlSerializer: class _SwigNonDynamicMeta(type): ... -class abstract_arrangement: - def __init__(self, *args, **kwargs): ... - def get_face_pairs(self): ... - def merge(self, edge_indices): ... - def num_edges(self): ... - def num_faces(self): ... - def write(self, polygons, progress): ... - class aggregation_type(parameter_type): def __init__(self, type_of_aggregation, bound1, bound2, type_of_element): ... array_type: Any diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index bcfcca872a..10c9b02395 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -1166,6 +1166,7 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type %ignore svgfill::svg_to_polygons; %ignore svgfill::arrange_polygons; %ignore svgfill::abstract_arrangement; +%ignore svgfill::context::delete_same_facet_edge_pairs; %template(svg_line_segments) std::vector>; %template(svg_groups_of_line_segments) std::vector>>; From 0e5223a30d35116cf8204b6ae46236fb531c4bc4 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 17:51:32 +0500 Subject: [PATCH 008/142] stub: add missing `arrange_polygon_settings` (158756e9218) --- .../ifcopenshell/ifcopenshell_wrapper.pyi | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 106676464c..80fef57700 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . -from typing import Any, Literal, Union +from typing import Any, Literal, Sequence, Union from typing_extensions import Self @@ -370,7 +370,7 @@ class Iterator: ... def process_concurrently(self): ... - def process_finished_rep(self, rep): ... + def process_finished_rep(self, rep, kernel=None): ... def progress(self) -> int: """Return current progress (0-100). @@ -656,6 +656,15 @@ class aggregation_type(parameter_type): def type_of_aggregation_string(self): ... def type_of_element(self) -> parameter_type: ... +class arrange_polygon_settings: + debug_output: bool + line_cleaning_algo: int + outer_perimiter_algo: int + perform_cleanup: bool + polygon_offset_distance: float + subdivision_factor: float + topology_reconstruction_algo: int + class attribute: def __init__(self, name, type_of_attribute, optional): ... def name(self) -> str: ... @@ -1746,7 +1755,9 @@ class type_declaration(declaration): class uninitialized_tag: ... -def arrange_polygons(settings, polygons, logger=None): ... +def arrange_polygons( + settings: arrange_polygon_settings, polygons: Sequence[polygon_2], logger: logger | None = None +) -> tuple[polygon_2, ...]: ... def clear_schemas(): ... def construct_iterator(geometry_library, settings, file, num_threads, logger=None): ... def construct_iterator_with_include_exclude( From f0117c60b36d7753cfff44c6ea4cfb89e3e58f2f Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 17:52:19 +0500 Subject: [PATCH 009/142] stub: sync added/removed symbols --- .../ifcopenshell/ifcopenshell_wrapper.pyi | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 80fef57700..5c4d635b2a 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -105,10 +105,12 @@ class IfcSpfHeader: """ def __init__(self, *args): ... + def assign(self, other): ... def file(self, *args): ... def file_description_py(self): ... def file_name_py(self): ... def file_schema_py(self): ... + def logger(self) -> logger: ... def read(self): ... def tryRead(self): ... def write(self, out): ... @@ -194,6 +196,7 @@ class ConversionResultShape: def subtract(self, arg2): ... def surface_area_along_direction(self, tol, arg3, along_x, along_y, along_z): ... def surface_genus(self): ... + def type(self) -> str: ... def vertices(self): ... def volume(self): ... def wrap_in_compound(self): ... @@ -390,25 +393,41 @@ class JsonSerializer: def setFile(self, arg2): ... def writeHeader(self): ... -# TODO: MakeVolume is ignored in SWIG, remove from stub once build is bumped. -class MakeVolume: - defaultvalue: Any - description: Any - name: Any - class OpaqueCoordinate_3: - def __init__(self, *args): ... + def dot(self, other): ... def get(self, i): ... + def get_double(self, i): ... + def norm(self): ... + def normalized(self): ... + def normalized_by_max_abs(self): ... + def scale(self, scalar): ... def set(self, i, n): ... + def size(self): ... + def to_double(self): ... class OpaqueCoordinate_4: - def __init__(self, *args): ... + def dot(self, other): ... def get(self, i): ... + def get_double(self, i): ... + def norm(self): ... + def normalized(self): ... + def normalized_by_max_abs(self): ... + def scale(self, scalar): ... def set(self, i, n): ... + def size(self): ... + def to_double(self): ... class OpaqueNumber: - def __init__(self, *args, **kwargs): ... - def clone(self): ... + def abs(self): ... + def add(self, other): ... + def divide(self, other): ... + def empty(self): ... + def equals(self, other): ... + def less_than(self, other): ... + def multiply(self, other): ... + def negated(self): ... + def same_type(self, *args): ... + def subtract(self, other): ... def to_double(self): ... def to_string(self): ... From e14397058d0f335e73f9867660e1c5cf24f80524 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 18:53:54 +0500 Subject: [PATCH 010/142] test-package: verify build URLs with HEAD requests instead of scraping listing page --- src/ifcopenshell-python/test/test_package.py | 67 +++++++++++++------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/src/ifcopenshell-python/test/test_package.py b/src/ifcopenshell-python/test/test_package.py index c7ed42a26a..939e1d1719 100644 --- a/src/ifcopenshell-python/test/test_package.py +++ b/src/ifcopenshell-python/test/test_package.py @@ -17,17 +17,11 @@ # along with IfcOpenShell. If not, see . import http.client -from collections.abc import Sequence from pathlib import Path from urllib.parse import urlparse from typing_extensions import assert_never -try: - from bs4 import BeautifulSoup -except: - pass - # Where it's also reflected: # - .github/workflows/ci-ifcopenshell-python.yml # - .github/workflows/ci-ifcopenshell-python-pypi.yml @@ -41,18 +35,12 @@ WASM_TEMPLATE = "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-{BINA class TestPackageSupportedPlatforms: - def test_run(self) -> None: + @staticmethod + def get_required_urls() -> list[str]: IOS_REPO = Path(__file__).parents[3] makefile = IOS_REPO / "src/ifcopenshell-python/Makefile" text = makefile.read_text() - # We don't use requests in ifcopenshell, so we use Python builtin stuff. - parsed = urlparse("https://builds.ifcopenshell.org") - conn = http.client.HTTPSConnection(parsed.netloc) - conn.request("GET", parsed.path) - response = conn.getresponse() - build_html = response.read().decode("utf-8") - def find_make_var(var_name: str) -> str: line = next(l for l in text.splitlines() if l.startswith(f"{var_name}:=")) return line.partition(":=")[2] @@ -97,14 +85,47 @@ class TestPackageSupportedPlatforms: ) required_urls.append(url) - # Verify all required URLs are present in the build HTML. - missing_urls: Sequence[str] - if "BeautifulSoup" in globals(): - missing_urls = set(required_urls) - set(a["href"] for a in BeautifulSoup(build_html).find_all("a")) - else: - missing_urls = [] - for url in required_urls: - if url not in build_html: - missing_urls.append(url) + return required_urls + + @staticmethod + def get_missing_urls_fast(urls: list[str]) -> list[str]: + """Check `urls` against the build listing page. + + Fast, but the listing page can lag behind what's actually on S3, so this may report URLs as + missing that do exist. + """ + # We don't use requests in ifcopenshell, so we use Python builtin stuff. + parsed = urlparse("https://builds.ifcopenshell.org") + conn = http.client.HTTPSConnection(parsed.netloc) + conn.request("GET", parsed.path) + response = conn.getresponse() + build_html = response.read().decode("utf-8") + conn.close() + + return [url for url in urls if url not in build_html] + + @staticmethod + def get_missing_urls_slow(urls: list[str]) -> list[str]: + """Check `urls` directly with a HEAD request each. + + Slow, but more reliable. + """ + missing_urls: list[str] = [] + for url in urls: + parsed = urlparse(url) + conn = http.client.HTTPSConnection(parsed.netloc) + conn.request("HEAD", parsed.path) + response = conn.getresponse() + response.read() + conn.close() + if response.status != 200: + missing_urls.append(url) + + return missing_urls + + def test_run(self) -> None: + required_urls = self.get_required_urls() + maybe_missing_urls = self.get_missing_urls_fast(required_urls) + missing_urls = self.get_missing_urls_slow(maybe_missing_urls) assert not missing_urls From b7a9b7bc5a0ffc62eaa7d97dc17f7d5a1f08258c Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 18:56:44 +0500 Subject: [PATCH 011/142] test-package: assert BUILD_COMMIT is a 7-char short SHA --- src/ifcopenshell-python/test/test_package.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ifcopenshell-python/test/test_package.py b/src/ifcopenshell-python/test/test_package.py index 939e1d1719..fce3636f5e 100644 --- a/src/ifcopenshell-python/test/test_package.py +++ b/src/ifcopenshell-python/test/test_package.py @@ -47,6 +47,10 @@ class TestPackageSupportedPlatforms: BINARY_VERSION = find_make_var("BINARY_VERSION") BUILD_COMMIT = find_make_var("BUILD_COMMIT") + # Build workflows upload artifacts using a 7-char short SHA. + assert ( + l := len(BUILD_COMMIT) + ) == 7, f"BUILD_COMMIT must be a 7-char short SHA, got {BUILD_COMMIT!r} (length {l})" required_urls: list[str] = [] From 16e5f185539a2c85fa89e12a807033ab82aaf8b1 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 18:59:44 +0500 Subject: [PATCH 012/142] Bump build 3e7b739 -> 821cf7b Just to test everything is working with the changes from the last month. --- src/ifcopenshell-python/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 350808ec25..3f86912ab1 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -55,7 +55,7 @@ PLATFORMTAG:=win_amd64 endif BINARY_VERSION:=0.8.6 -BUILD_COMMIT:=3e7b739 +BUILD_COMMIT:=821cf7b IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip From d9d1824886a0f331b23955ae14667bfcbbdf180a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 16 Jul 2026 19:03:35 +0500 Subject: [PATCH 013/142] test-package: drop stale comment This information is already documented in maintanence.rst. --- src/ifcopenshell-python/test/test_package.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ifcopenshell-python/test/test_package.py b/src/ifcopenshell-python/test/test_package.py index fce3636f5e..862fdefc18 100644 --- a/src/ifcopenshell-python/test/test_package.py +++ b/src/ifcopenshell-python/test/test_package.py @@ -22,10 +22,6 @@ from urllib.parse import urlparse from typing_extensions import assert_never -# Where it's also reflected: -# - .github/workflows/ci-ifcopenshell-python.yml -# - .github/workflows/ci-ifcopenshell-python-pypi.yml -# - src/ifcopenshell-python/Makefile (PYVERSION check) SUPPORTED_PY_VERSIONS = ("310", "311", "312", "313", "314") SUPPORTED_PLATFORMS = ("win64", "linux64", "macos64", "macosm164") From 65811ac7c96f2821056d6a52b88d33ddc6b1b2c7 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 16 Jul 2026 21:17:02 +0300 Subject: [PATCH 014/142] Bonsai: place auto-generated opening boundaries at their real position (#8237) (#8311) * Bonsai: place auto-generated opening boundaries at their real position #8237 auto_generate_boundaries (single-space mode) built each opening/filling boundary from the opening's LOCAL geometry (get_vertices) but first did mat.translation = (0, 0, 0) on its placement matrix. Because the vertices are local, that placement translation is exactly what carries the opening to its real location, so zeroing it collapsed every window/door boundary onto the origin. This is why the auto path misplaced window boundaries while the single-element path (create_element_boundary) placed them correctly, as @MDHering observed with the two modes. Keep the full placement matrix. Verified on the reporter's file: the opening's real placement is (0.1, 1.5, 1.0); a vertex went from (0.6, 0, 0) under the old code to (0.7, 1.5, 1.0) with the fix, i.e. moved by exactly the (0.1, 1.5, 1.0) that was being discarded. Co-Authored-By: Claude Fable 5 * Remove superfluous comment from #8237 fix --------- Co-authored-by: Claude Fable 5 Co-authored-by: CyrilWaechter --- src/bonsai/bonsai/bim/module/boundary/operator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/boundary/operator.py b/src/bonsai/bonsai/bim/module/boundary/operator.py index 45ee8c60e2..6d6404a1cb 100644 --- a/src/bonsai/bonsai/bim/module/boundary/operator.py +++ b/src/bonsai/bonsai/bim/module/boundary/operator.py @@ -843,7 +843,6 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator): settings = ifcopenshell.geom.settings() shape = ifcopenshell.geom.create_shape(settings, opening) mat = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape)) - mat.translation = (0, 0, 0) opening_bm = bmesh.new() verts = ifcopenshell.util.shape.get_vertices(shape.geometry) for vert in verts: From 25441bd816a96e3663261800fb4a5b25a4e1b5d3 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Tue, 7 Jul 2026 17:39:09 +0300 Subject: [PATCH 015/142] Bonsai: make 'has openings' representation error actionable (#8108) When converting a wall representation to a parametric extrusion via the Representation Utilities buttons, an element that has openings would report "has openings - representation cannot be updated" and stop, without telling the user there is an ALT+click path that bakes the openings into the new representation. Point the message at that path so the error is actionable. Co-Authored-By: Claude Opus 4.8 --- src/bonsai/bonsai/bim/module/geometry/operator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 9bc0566532..32428280c2 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -581,7 +581,11 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator): if has_openings and not self.apply_openings: # Meshlike things with openings can only be updated without openings applied. if self.from_ui: - self.report({"ERROR"}, f"Object '{obj.name}' has openings - representation cannot be updated.") + self.report( + {"ERROR"}, + f"Object '{obj.name}' has openings. " + "ALT+click the button to bake the openings into the new representation.", + ) return if not product.is_a("IfcGridAxis"): From b5a0f1fc74d6315b6985aed16f1ff8c24e90b197 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 16 Jul 2026 18:57:11 -0500 Subject: [PATCH 016/142] Bonsai: allow cross-family class reassignment for spatial elements with geometry (#8665) The Reassign Class operator refused to reassign an element to a different IFC product family unless it was an IfcElement <-> IfcElementType swap, so a piece of geometry mistakenly hosted on IfcSite could not be turned into IfcFurniture even though root.reassign_class handles it fine. Loosen the guard: only block the case that actually matters - a spatial element (IfcSpatialElement / IfcSpatialStructureElement for IFC2X3) with no geometry, which would be a real containment-hierarchy container rather than a stray modelled object. Everything else reassigns freely. Closes #8664 Co-authored-by: Claude Opus 4.8 --- src/bonsai/bonsai/bim/module/root/operator.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 159f157d44..993d3bfb4b 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -27,6 +27,7 @@ import ifcopenshell.api.material import ifcopenshell.api.pset import ifcopenshell.api.root import ifcopenshell.util.element +import ifcopenshell.util.representation import ifcopenshell.util.schema import ifcopenshell.util.shape_builder import ifcopenshell.util.type @@ -129,13 +130,25 @@ class ReassignClass(bpy.types.Operator, tool.Ifc.Operator): same_ifc_product = element.is_a(ifc_product) if not same_ifc_product: - if not (element.is_a("IfcElement") and ifc_product == "IfcElementType") and not ( - element.is_a("IfcElementType") and ifc_product == "IfcElement" - ): - self.report( - {"ERROR"}, f"Not supported class reassignment for object '{obj.name}' -> {ifc_product}." + # A spatial element (e.g. IfcSite) anchors the containment + # hierarchy, so only allow reassigning it to another family when + # it actually carries geometry - i.e. it's a real modelled thing + # (a bench dropped onto IfcSite -> IfcFurniture) rather than an + # empty spatial container we'd be turning into a loose element. + # IfcSpatialStructureElement covers IFC2X3, which has no + # IfcSpatialElement supertype. + is_spatial = element.is_a("IfcSpatialElement") or element.is_a("IfcSpatialStructureElement") + if is_spatial: + has_geometry = ( + next(ifcopenshell.util.representation.get_representations_iter(element), None) is not None ) - return {"CANCELLED"} + if not has_geometry: + self.report( + {"ERROR"}, + f"Cannot reassign '{obj.name}' ({element.is_a()}) to {ifc_product}: " + "a spatial element can only be reassigned to another class when it has geometry.", + ) + return {"CANCELLED"} props = tool.Blender.get_object_bim_props(obj) props.is_reassigning_class = False From 3d8115ebc51778cd2808870cc5faad07815584bf Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 17 Jul 2026 13:24:45 +0500 Subject: [PATCH 017/142] ifcopenshell.file: drop workarounds for older builds Introduced in aeed371 and it's been a while. --- src/ifcopenshell-python/ifcopenshell/file.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/file.py b/src/ifcopenshell-python/ifcopenshell/file.py index 6d327df4af..74c857eab0 100644 --- a/src/ifcopenshell-python/ifcopenshell/file.py +++ b/src/ifcopenshell-python/ifcopenshell/file.py @@ -23,7 +23,6 @@ import numbers import os import re import time -import types import weakref import zipfile from collections.abc import Callable, Generator @@ -251,12 +250,7 @@ READ_ERROR = ifcopenshell_wrapper.file_open_status.READ_ERROR NO_HEADER = ifcopenshell_wrapper.file_open_status.NO_HEADER UNSUPPORTED_SCHEMA = ifcopenshell_wrapper.file_open_status.UNSUPPORTED_SCHEMA INVALID_SYNTAX = ifcopenshell_wrapper.file_open_status.INVALID_SYNTAX - -# TODO: Workaround for old builds, remove after build stabilizes. -try: - UNKNOWN = ifcopenshell_wrapper.file_open_status.UNKNOWN -except: - UNKNOWN = 5 # Workaround +UNKNOWN = ifcopenshell_wrapper.file_open_status.UNKNOWN import struct @@ -1066,13 +1060,8 @@ class file: @property def header(self) -> file_header: - # TODO: Workaround for old builds, remove after build stabilizes. # TODO: No need for `wrapped_data.header` to be a method - should use `@property`? - header = self.wrapped_data.header - if isinstance(header, types.MethodType): - return file_header(self, self.wrapped_data.header()) - else: - return self.wrapped_data.header + return file_header(self, self.wrapped_data.header()) @property def storage(self) -> Optional[rocksdb_file_storage]: From 71a598e63a4e27c99d7b53cbe05c611ea4e8f1ed Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 17 Jul 2026 13:31:13 +0500 Subject: [PATCH 018/142] ifcopenshell.file: small wording fix --- src/ifcopenshell-python/ifcopenshell/file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/file.py b/src/ifcopenshell-python/ifcopenshell/file.py index 74c857eab0..e900a0e72f 100644 --- a/src/ifcopenshell-python/ifcopenshell/file.py +++ b/src/ifcopenshell-python/ifcopenshell/file.py @@ -243,7 +243,7 @@ file_dict: dict[int, tuple[weakref.ReferenceType[file], int]] = {} """Mapping of internal IfcFile pointer address to existing ``ifcopenshell.file`` and the timestamp when it was created. -Needed only to quickly access related from ``entity_instance`` it's ``file``. +Needed only to quickly access from ``entity_instance`` its ``file``. """ READ_ERROR = ifcopenshell_wrapper.file_open_status.READ_ERROR From f0970b90b07426342f99299cc94a54fdbb8da36d Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Wed, 15 Jul 2026 06:28:02 +0100 Subject: [PATCH 019/142] Classify projection edges in SVG elevations Adds boundary/outline/sharp/crease/flush classification of HLR projection edges in SvgSerializer, so CSS can style silhouettes, ridges, and valleys differently instead of drawing every edge identically (fixes the "ugly faceted sphere" problem from #3668). Classification happens pre-HLR on the original solid's real face topology (three prior attempts tried to classify HLR's own output, which carries no face topology at all and can't be correlated back by edge identity). Each class's visible portion is then extracted via HLRBRep_HLRToShape::VCompound(S)/OutLineVCompound(S), the same per-shape filtering mechanism already used for per-product segmentation, applied per class instead. Classes are tagged directly on individual elements so Bonsai's merge_linework_and_add_metadata group-level class rewrite in operator.py never touches them. New settings: svg-ridge-angle-min-degrees, svg-valley-angle-min-degrees, svg-emit-flush-edges (ConversionSettings.h), wired through Bonsai's CreateDrawing operator and exposed via its redo panel. Refs #3668. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 --- src/bonsai/bonsai/bim/data/assets/default.css | 8 + .../bonsai/bim/module/drawing/operator.py | 33 ++ src/ifcgeom/ConversionSettings.h | 20 +- src/serializers/SvgSerializer.cpp | 308 +++++++++++++++--- src/serializers/SvgSerializer.h | 78 +++-- 5 files changed, 379 insertions(+), 68 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/assets/default.css b/src/bonsai/bonsai/bim/data/assets/default.css index 68307f4c3f..5e4670af4d 100644 --- a/src/bonsai/bonsai/bim/data/assets/default.css +++ b/src/bonsai/bonsai/bim/data/assets/default.css @@ -24,6 +24,14 @@ a text, a tspan { fill: blue !important; text-decoration: underline;} a:hover { cursor: pointer; } .cut { fill: black; stroke: black; stroke-linecap: 'round'; stroke-width: 0.35; fill-rule: evenodd; } .projection { fill: white; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; } +/* SVG edge classification (issue #3668): see edge-classification.md. These select directly on + the element (each classified projection edge carries its own class), so they win over + the inherited .projection rule above regardless of specificity. */ +path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; } +path.boundary { stroke: black; stroke-width: 0.3; stroke-opacity: 0.9; } +path.sharp { stroke: black; stroke-width: 0.25; stroke-opacity: 0.85; } +path.crease { stroke: black; stroke-width: 0.18; stroke-opacity: 0.7; } +path.flush { stroke: black; stroke-width: 0.1; stroke-opacity: 0.4; } .surface { stroke: none; fill: #fff; fill-rule: evenodd; } .annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; } .IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; } diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index b1c4c69c3b..9a8faf4d58 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -261,11 +261,36 @@ class CreateDrawing(bpy.types.Operator): description="Could save some time if you're sure IFC and current Blender session are already in sync", default=True, ) + svg_ridge_angle_min_deg: bpy.props.FloatProperty( + name="Ridge Angle Minimum", + description="Minimum convex dihedral deviation from flat, in degrees, for a projection " + "edge to be classified as 'sharp' rather than 'flush'. See edge-classification.md", + default=45.0, + min=0.0, + max=180.0, + ) + svg_valley_angle_min_deg: bpy.props.FloatProperty( + name="Valley Angle Minimum", + description="Minimum concave dihedral deviation from flat, in degrees, for a projection " + "edge to be classified as 'crease' rather than 'flush'. See edge-classification.md", + default=12.0, + min=0.0, + max=180.0, + ) + svg_emit_flush_edges: bpy.props.BoolProperty( + name="Emit Flush Edges", + description="Include projection edges whose dihedral deviation is below both the ridge " + "and valley thresholds (class 'flush'). Omitted by default", + default=False, + ) if TYPE_CHECKING: print_all: bool open_viewer: bool sync: bool + svg_ridge_angle_min_deg: float + svg_valley_angle_min_deg: float + svg_emit_flush_edges: bool drawing_name: str is_manifold_cache: dict[str, bool] @@ -1309,6 +1334,14 @@ class CreateDrawing(bpy.types.Operator): self.svg_settings = ifcopenshell.geom.settings() self.svg_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) self.svg_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE) + # SVG edge classification (issue #3668). See edge-classification.md. + try: + self.svg_settings.set("svg-ridge-angle-min-degrees", self.svg_ridge_angle_min_deg) + self.svg_settings.set("svg-valley-angle-min-degrees", self.svg_valley_angle_min_deg) + self.svg_settings.set("svg-emit-flush-edges", self.svg_emit_flush_edges) + except Exception: + # Backwards compatibility with older ifcopenshell builds that don't expose these keys. + pass self.svg_buffer = ifcopenshell.geom.serializers.buffer() self.serialiser_settings = ifcopenshell.geom.serializer_settings() self.serialiser = ifcopenshell.geom.serializers.svg( diff --git a/src/ifcgeom/ConversionSettings.h b/src/ifcgeom/ConversionSettings.h index c022bfdd1c..49d0fccbfb 100644 --- a/src/ifcgeom/ConversionSettings.h +++ b/src/ifcgeom/ConversionSettings.h @@ -371,6 +371,24 @@ namespace ifcopenshell { static constexpr double defaultvalue = -1.; }; + struct SvgRidgeAngleMinDegrees : public SettingBase { + static constexpr const char* const name = "svg-ridge-angle-min-degrees"; + static constexpr const char* const description = "SVG edge classification (issue #3668): minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as 'sharp' rather than 'flush'."; + static constexpr double defaultvalue = 45.; + }; + + struct SvgValleyAngleMinDegrees : public SettingBase { + static constexpr const char* const name = "svg-valley-angle-min-degrees"; + static constexpr const char* const description = "SVG edge classification (issue #3668): minimum concave dihedral deviation from flat, in degrees, for a projection edge to be classified as 'crease' rather than 'flush'."; + static constexpr double defaultvalue = 12.; + }; + + struct SvgEmitFlushEdges : public SettingBase { + static constexpr const char* const name = "svg-emit-flush-edges"; + static constexpr const char* const description = "SVG edge classification (issue #3668): whether to emit 'flush' projection edges (dihedral deviation below both ridge/valley thresholds). Defaults to false, i.e. flush edges are omitted from the output."; + static constexpr bool defaultvalue = false; + }; + struct KeepBoundingBoxes : public SettingBase { static constexpr const char* const name = "keep-bounding-boxes"; static constexpr const char* const description = @@ -653,7 +671,7 @@ namespace ifcopenshell { }; class Settings : public SettingsContainer< - std::tuple + std::tuple > {}; } diff --git a/src/serializers/SvgSerializer.cpp b/src/serializers/SvgSerializer.cpp index 16659587ac..24bc2c561a 100644 --- a/src/serializers/SvgSerializer.cpp +++ b/src/serializers/SvgSerializer.cpp @@ -102,10 +102,13 @@ const double PI2 = M_PI * 2.; bool SvgSerializer::ready() { + svg_ridge_angle_min_deg_ = geometry_settings().get().get(); + svg_valley_angle_min_deg_ = geometry_settings().get().get(); + svg_emit_flush_edges_ = geometry_settings().get().get(); return true; } -void SvgSerializer::write(path_object& p, const TopoDS_Shape& comp_or_wire, boost::optional> dash_array) { +void SvgSerializer::write(path_object& p, const TopoDS_Shape& comp_or_wire, boost::optional> dash_array, boost::optional css_class) { /* ShapeFix_Wire fix; Handle(ShapeExtend_WireData) data = new ShapeExtend_WireData; for (TopExp_Explorer edges(result, TopAbs_EDGE); edges.More(); edges.Next()) { @@ -351,6 +354,12 @@ void SvgSerializer::write(path_object& p, const TopoDS_Shape& comp_or_wire, boos if (!path.empty()) { path.add("\""); + if (css_class) { + path.add(" class=\""); + path.add(*css_class); + path.add("\""); + } + if (dash_array) { path.add(" stroke-dasharray=\""); bool first = true; @@ -622,7 +631,7 @@ void SvgSerializer::write(const IfcGeom::BRepElement* brep_obj) { } else if (elevation_ref_guid_) { is_elevation = *elevation_ref_guid_ == brep_obj->guid(); } - + BRepBuilderAPI_Transform make_transform_global(compound_local, trsf, true); make_transform_global.Build(); // (When determinant < 0, copy is implied and the input is not mutated.) @@ -795,6 +804,115 @@ namespace { } } +namespace { + // SVG edge classification (issue #3668). See edge-classification.md at the repo root for + // the authoritative definition of the five classes and their evaluation order. + enum class edge_style_class { boundary, outline, sharp, crease, flush }; + + const char* edge_style_class_name(edge_style_class c) { + switch (c) { + case edge_style_class::boundary: return "boundary"; + case edge_style_class::outline: return "outline"; + case edge_style_class::sharp: return "sharp"; + case edge_style_class::crease: return "crease"; + default: return "flush"; + } + } + + // Outward face normal, accounting for face orientation. Only planar faces are supported; + // returns false otherwise (caller should conservatively treat the edge as an outline). + bool face_normal_from_planar_face(const TopoDS_Face& f, gp_Dir& out) { + auto s = BRep_Tool::Surface(f); + if (s->DynamicType() != STANDARD_TYPE(Geom_Plane)) { + return false; + } + auto p = Handle(Geom_Plane)::DownCast(s); + gp_Dir d = p->Axis().Direction(); + if (f.Orientation() == TopAbs_REVERSED) { + d.Reverse(); + } + out = d; + return true; + } + + double clamp_dot(double v) { + if (v < -1.0) return -1.0; + if (v > 1.0) return 1.0; + return v; + } + + edge_style_class classify_edge_from_faces( + const TopoDS_Edge& edge, + const NCollection_List& faces, + const gp_Dir& projection_direction, + double ridge_angle_min_deg, + double valley_angle_min_deg + ) { + std::vector faces_vec; + for (NCollection_List::Iterator it(faces); it.More(); it.Next()) { + const TopoDS_Shape& s = it.Value(); + if (s.ShapeType() == TopAbs_FACE) { + faces_vec.push_back(TopoDS::Face(s)); + } + } + + // Boundary: naked edge, or non-manifold (3+ faces) -- the latter is explicitly out of + // scope for the 5-class scheme (a geometry-health/QA concern), so fall back to the + // same conservative bucket rather than force-fitting it into outline/sharp/crease. + if (faces_vec.size() != 2) { + return edge_style_class::boundary; + } + + const TopoDS_Face& f0 = faces_vec[0]; + const TopoDS_Face& f1 = faces_vec[1]; + + gp_Dir n0, n1; + if (!face_normal_from_planar_face(f0, n0) || !face_normal_from_planar_face(f1, n1)) { + // Conservative fallback for non-planar-face edges. + return edge_style_class::outline; + } + + const double d0 = projection_direction.Dot(n0); + const double d1 = projection_direction.Dot(n1); + + // Outline: silhouette, either against the background or self-occluding -- one face + // turns toward the viewer while the other turns away. + if ((d0 < 0.0) != (d1 < 0.0)) { + return edge_style_class::outline; + } + + // Signed deviation from flat (180 degrees between outward normals = perfectly flat). + // Positive = convex (ridge/sharp), negative = concave (valley/crease). The sign comes + // from the rotation of n0 onto n1 about the edge tangent. + const double angle_between_normals_deg = std::acos(clamp_dot(n0.Dot(n1))) * 180.0 / M_PI; + double deviation_deg = 180.0 - angle_between_normals_deg; + + double u0, u1; + Handle(Geom_Curve) curve = BRep_Tool::Curve(edge, u0, u1); + if (!curve.IsNull()) { + gp_Pnt p_mid; + gp_Vec tangent; + curve->D1((u0 + u1) / 2.0, p_mid, tangent); + if (tangent.SquareMagnitude() > 1.e-10) { + tangent.Normalize(); + if (edge.Orientation() == TopAbs_REVERSED) { + tangent.Reverse(); + } + const gp_Vec cross = gp_Vec(n0.XYZ()).Crossed(gp_Vec(n1.XYZ())); + if (cross.Dot(tangent) < 0.0) { + deviation_deg = -deviation_deg; + } + } + } + + if (deviation_deg >= 0.0) { + return (deviation_deg >= ridge_angle_min_deg) ? edge_style_class::sharp : edge_style_class::flush; + } else { + return (-deviation_deg >= valley_angle_min_deg) ? edge_style_class::crease : edge_style_class::flush; + } + } +} + void SvgSerializer::write(const geometry_data& data) { std::vector section_heights_storage; const std::vector* section_heights_used = §ion_heights_storage; @@ -1208,6 +1326,51 @@ void SvgSerializer::write(const geometry_data& data) { } } + // SVG edge classification (issue #3668): classify *compound_to_hlr's edges (real + // face topology, pre-HLR) into per-class edge-only sub-compounds. The full shape + // is still registered via add()/it->second.add() below, unchanged, for correct + // occlusion; these buckets only affect which class each edge's visible portion is + // later extracted as (see hlr_calc::extract() in SvgSerializer.h). + std::map classified_edge_buckets; + { + NCollection_IndexedDataMap, TopTools_ShapeMapHasher> edge_face_map; + TopExp::MapShapesAndAncestors(*compound_to_hlr, TopAbs_EDGE, TopAbs_FACE, edge_face_map); + + gp_Dir view_dir; + try { + view_dir = gp_Dir(projection_direction); + } catch (const Standard_Failure&) { + view_dir = gp::DZ(); + } + + BRep_Builder BBcls; + for (int i = 1; i <= edge_face_map.Extent(); ++i) { + const TopoDS_Edge& cls_edge = TopoDS::Edge(edge_face_map.FindKey(i)); + + edge_style_class cls = edge_style_class::outline; + try { + cls = classify_edge_from_faces(cls_edge, edge_face_map.FindFromIndex(i), view_dir, svg_ridge_angle_min_deg_, svg_valley_angle_min_deg_); + } catch (const Standard_Failure& e) { + logger_.Warning("SER", 30, std::string("SVG edge classification OCC exception: ") + e.GetMessageString()); + } catch (const std::exception& e) { + logger_.Warning("SER", 31, std::string("SVG edge classification exception: ") + e.what()); + } + + if (cls == edge_style_class::flush && !svg_emit_flush_edges_) { + continue; + } + + std::string name = edge_style_class_name(cls); + auto bucket_it = classified_edge_buckets.find(name); + if (bucket_it == classified_edge_buckets.end()) { + TopoDS_Compound c; + BBcls.MakeCompound(c); + bucket_it = classified_edge_buckets.emplace(name, c).first; + } + BBcls.Add(bucket_it->second, cls_edge); + } + } + if (is_floor_plan_) { if (storey) { auto it = storey_hlr.find(storey); @@ -1215,11 +1378,17 @@ void SvgSerializer::write(const geometry_data& data) { it = storey_hlr.insert({ storey, hlr_t(logger_, use_prefiltering_, use_hlr_poly_, segment_projection_, projection_plane) }).first; } it->second.add(*compound_to_hlr, data.product); + for (auto& kv : classified_edge_buckets) { + it->second.add_classified_edges(data.product, kv.first, kv.second); + } } else { logger_.Warning("SER", 28, "Unable to invoke HLR due to absence of storey containment", data.product); } } else if (hlr) { hlr->add(*compound_to_hlr, data.product); + for (auto& kv : classified_edge_buckets) { + hlr->add_classified_edges(data.product, kv.first, kv.second); + } } } } @@ -1767,49 +1936,63 @@ std::array, 3> SvgSerializer::resize() { } void SvgSerializer::draw_hlr(const gp_Pln& pln, const drawing_key& drawing_name) { - auto hlr_items = (drawing_name.first ? this->storey_hlr.find(drawing_name.first)->second : *hlr).build(); + hlr_t& hlr_source = drawing_name.first ? this->storey_hlr.find(drawing_name.first)->second : *hlr; + auto hlr_items = hlr_source.build(); - for (auto& p : hlr_items) { - const TopoDS_Shape& hlr_compound_unmirrored = p.second; + // SVG edge classification (issue #3668): each item's class is already known -- it was + // determined pre-HLR from real face topology (see the classified_edge_buckets block in + // write(const geometry_data&)) and threaded through via hlr_calc::extract(). No post-hoc + // lookup against HLR's own (face-less) output is needed. Multiple items can share the same + // product (one per non-empty class bucket); keep a single path_object/group per product so + // per-path classes survive Bonsai's merge_linework_and_add_metadata untouched, rather than + // creating a per class (see plan notes on why that clobbers classes in Python). + std::map group_by_product; - if (!hlr_compound_unmirrored.IsNull()) { - // Compound 3D curves for mirroring to work - ShapeFix_Edge sfe; - TopExp_Explorer exp(hlr_compound_unmirrored, TopAbs_EDGE); - for (; exp.More(); exp.Next()) { - sfe.FixAddCurve3d(TopoDS::Edge(exp.Current())); + for (auto& item : hlr_items) { + const IfcUtil::IfcBaseEntity* product = std::get<0>(item); + const std::string& cls = std::get<1>(item); + const TopoDS_Shape& hlr_compound_unmirrored = std::get<2>(item); + + if (hlr_compound_unmirrored.IsNull()) { + continue; + } + + // Compound 3D curves for mirroring to work + ShapeFix_Edge sfe; + TopExp_Explorer exp(hlr_compound_unmirrored, TopAbs_EDGE); + for (; exp.More(); exp.Next()) { + sfe.FixAddCurve3d(TopoDS::Edge(exp.Current())); + } + + // Mirror to match SVG coord system. + // @todo this is very wasteful. We better do the Y-mirror in the SVG writing and + // not on the TopoDS_Shape input. + + TopoDS_Shape hlr_compound; + if (drawing_name.first == nullptr) { + gp_Trsf trsf_mirror; + if (!mirror_y_) { + trsf_mirror.SetMirror(gp_Ax2(gp::Origin(), gp::DY())); } - - // Mirror to match SVG coord system. - // @todo this is very wasteful. We better do the Y-mirror in the SVG writing and - // not on the TopoDS_Shape input. - - TopoDS_Shape hlr_compound; - if (drawing_name.first == nullptr) { - gp_Trsf trsf_mirror; - if (!mirror_y_) { - trsf_mirror.SetMirror(gp_Ax2(gp::Origin(), gp::DY())); - } - if (mirror_x_) { - gp_Trsf mirror_x; - mirror_x.SetMirror(gp_Ax2(gp::Origin(), gp::DX())); - trsf_mirror.PreMultiply(mirror_x); - } - BRepBuilderAPI_Transform make_transform_mirror(hlr_compound_unmirrored, trsf_mirror, true); - make_transform_mirror.Build(); - hlr_compound = make_transform_mirror.Shape(); - } else { - // In case of building storey-based floor plan the mirroring has already - // been taken into account before projection. - hlr_compound = hlr_compound_unmirrored; + if (mirror_x_) { + gp_Trsf mirror_x; + mirror_x.SetMirror(gp_Ax2(gp::Origin(), gp::DX())); + trsf_mirror.PreMultiply(mirror_x); } + BRepBuilderAPI_Transform make_transform_mirror(hlr_compound_unmirrored, trsf_mirror, true); + make_transform_mirror.Build(); + hlr_compound = make_transform_mirror.Shape(); + } else { + // In case of building storey-based floor plan the mirroring has already + // been taken into account before projection. + hlr_compound = hlr_compound_unmirrored; + } - exp.Init(hlr_compound, TopAbs_EDGE); - BRep_Builder B; - path_object* po; + path_object*& po = group_by_product[product]; + if (!po) { std::string name; - if (p.first) { - name = nameElement(p.first); + if (product) { + name = nameElement(product); boost::replace_all(name, "class=\"", "class=\"projection "); } else { name = "class=\"projection\""; @@ -1819,13 +2002,19 @@ void SvgSerializer::draw_hlr(const gp_Pln& pln, const drawing_key& drawing_name) } else { po = &start_path(pln, drawing_name.second, name); } - for (; exp.More(); exp.Next()) { - TopoDS_Wire w; - B.MakeWire(w); - B.Add(w, exp.Current()); - write(*po, w); - } + } + boost::optional css_class; + if (!cls.empty()) { + css_class = cls; + } + + BRep_Builder B; + for (TopExp_Explorer exp_mirrored(hlr_compound, TopAbs_EDGE); exp_mirrored.More(); exp_mirrored.Next()) { + TopoDS_Wire w; + B.MakeWire(w); + B.Add(w, exp_mirrored.Current()); + write(*po, w, boost::none, css_class); } } } @@ -2236,6 +2425,35 @@ void SvgSerializer::doWriteHeader() { " fill: none;\n" " stroke-opacity: 0.6;\n" " }\n" + // SVG edge classification (issue #3668) -- see edge-classification.md. These + // select directly on the element (each classified edge carries its own + // class), not on an ancestor , so they win over the inherited .projection + // path rule above regardless of specificity. + " path.outline {\n" + " stroke: #000000;\n" + " stroke-width: 0.35px;\n" + " stroke-opacity: 1;\n" + " }\n" + " path.boundary {\n" + " stroke: #000000;\n" + " stroke-width: 0.3px;\n" + " stroke-opacity: 0.9;\n" + " }\n" + " path.sharp {\n" + " stroke: #000000;\n" + " stroke-width: 0.25px;\n" + " stroke-opacity: 0.85;\n" + " }\n" + " path.crease {\n" + " stroke: #000000;\n" + " stroke-width: 0.18px;\n" + " stroke-opacity: 0.7;\n" + " }\n" + " path.flush {\n" + " stroke: #000000;\n" + " stroke-width: 0.1px;\n" + " stroke-opacity: 0.4;\n" + " }\n" " .IfcDoor path,\n" " .Symbol path {\n" " fill: none;\n" diff --git a/src/serializers/SvgSerializer.h b/src/serializers/SvgSerializer.h index 7e564c589b..223bc5e441 100644 --- a/src/serializers/SvgSerializer.h +++ b/src/serializers/SvgSerializer.h @@ -56,6 +56,7 @@ #include #include #include +#include typedef std::pair drawing_key; @@ -212,9 +213,16 @@ namespace { private: const HLRAlgo_Projector& projector_; const std::list>* product_shapes_ = nullptr; + // SVG edge classification (issue #3668): per-(product, class) edge-only sub-shapes, + // classified pre-HLR on the original (real-face) topology. Queried via + // VCompound(S)/OutLineVCompound(S), which correlate by the identity of the *original* + // edges added to the algorithm -- not by the reconstructed output -- so this works even + // though HLR's own output compounds carry no face topology at all. Empty class string + // means "unclassified" (used for the two fallback cases below). + const std::list>* classified_shapes_ = nullptr; public: - typedef std::list> result_type; + typedef std::list> result_type; hlr_calc(const HLRAlgo_Projector& projector) : projector_(projector) {} @@ -223,24 +231,37 @@ namespace { product_shapes_ = product_shapes; } + void set_classified_shapes(const std::list>* classified_shapes) { + classified_shapes_ = classified_shapes; + } + result_type operator()(boost::blank&) const { throw std::runtime_error(""); } + template + result_type extract(HlrToShapeT& hlr_shapes) { + result_type r; + if (classified_shapes_ && !classified_shapes_->empty()) { + for (auto& t : *classified_shapes_) { + r.push_back({ std::get<0>(t), std::get<1>(t), occt_join(hlr_shapes.OutLineVCompound(std::get<2>(t)), hlr_shapes.VCompound(std::get<2>(t))) }); + } + } else if (product_shapes_) { + for (auto& p : *product_shapes_) { + r.push_back({ p.first, std::string(), occt_join(hlr_shapes.OutLineVCompound(p.second), hlr_shapes.VCompound(p.second)) }); + } + } else { + r.push_back({ nullptr, std::string(), occt_join(hlr_shapes.OutLineVCompound(), hlr_shapes.VCompound()) }); + } + return r; + } + result_type operator()(opencascade::handle& algo) { algo->Projector(projector_); algo->Update(); algo->Hide(); HLRBRep_HLRToShape hlr_shapes(algo); - if (product_shapes_) { - std::list> r; - for (auto& p : *product_shapes_) { - r.push_back({ p.first, occt_join(hlr_shapes.OutLineVCompound(p.second), hlr_shapes.VCompound(p.second)) }); - } - return r; - } else { - return { {nullptr, occt_join(hlr_shapes.OutLineVCompound(), hlr_shapes.VCompound())}}; - } + return extract(hlr_shapes); } result_type operator()(opencascade::handle& algo) { @@ -248,15 +269,7 @@ namespace { algo->Update(); HLRBRep_PolyHLRToShape hlr_shapes; hlr_shapes.Update(algo); - if (product_shapes_) { - std::list> r; - for (auto& p : *product_shapes_) { - r.push_back({ p.first, occt_join(hlr_shapes.OutLineVCompound(p.second), hlr_shapes.VCompound(p.second)) }); - } - return r; - } else { - return { {nullptr, occt_join(hlr_shapes.OutLineVCompound(), hlr_shapes.VCompound()) } }; - } + return extract(hlr_shapes); } }; @@ -367,6 +380,8 @@ namespace { std::multimap large_ortho_faces_; std::list> items_; + // SVG edge classification (issue #3668): see add_classified_edges(). + std::list> classified_items_; Logger& logger_; @@ -391,6 +406,16 @@ namespace { projector_ = HLRAlgo_Projector(trsf, false, 1.); } + // SVG edge classification (issue #3668): register an edge-only sub-shape of `product`'s + // original (pre-HLR, real-face) geometry under a given class name (e.g. "outline", + // "sharp"). The full shape must still be added via add() as usual for correct occlusion; + // this only affects which *class* each edge's visible portion is later extracted as, via + // HLRBRep_HLRToShape::VCompound(S)/OutLineVCompound(S) in hlr_calc, which correlate by the + // identity of the original edges within S. + void add_classified_edges(const IfcUtil::IfcBaseEntity* product, const std::string& cls, const TopoDS_Shape& edges) { + classified_items_.push_back({ product, cls, edges }); + } + bool is_obscured_(TopoDS_Shape* sit) { const TopoDS_Shape& s = *sit; @@ -510,7 +535,7 @@ namespace { } } - std::list> build() { + std::list> build() { size_t n_included = 0; for (auto it = items_.begin(); it != items_.end(); ++it) { if (!use_prefiltering_ || !is_obscured_(&it->second)) { @@ -522,11 +547,12 @@ namespace { if (use_prefiltering_) { logger_.Notice("SER", 35, "Included " + std::to_string(n_included) + " elements out of " + std::to_string(items_.size()) + " after prefiltering"); } - + hlr_calc vis(projector_); if (segment_projection_) { vis.set_product_shape(&items_); } + vis.set_classified_shapes(&classified_items_); return boost::apply_visitor(vis, engine_); } }; @@ -570,6 +596,11 @@ protected: int profile_threshold_; + // SVG edge classification (issue #3668): see classify_edge_from_faces() in SvgSerializer.cpp. + double svg_ridge_angle_min_deg_; + double svg_valley_angle_min_deg_; + bool svg_emit_flush_edges_; + IfcParse::IfcFile* file; const IfcUtil::IfcBaseEntity* storey_; std::multimap paths; @@ -623,6 +654,9 @@ public: , mirror_x_(false) , unify_inputs_(false) , profile_threshold_(-1) + , svg_ridge_angle_min_deg_(45.) + , svg_valley_angle_min_deg_(12.) + , svg_emit_flush_edges_(false) , file(0) , storey_(0) , xcoords_begin(0) @@ -641,7 +675,7 @@ public: bool ready(); void write(const IfcGeom::TriangulationElement* /*o*/) {} void write(const IfcGeom::BRepElement* o); - void write(path_object& p, const TopoDS_Shape& wire, boost::optional> dash_array=boost::none); + void write(path_object& p, const TopoDS_Shape& wire, boost::optional> dash_array=boost::none, boost::optional css_class=boost::none); void write(const geometry_data& data); path_object& start_path(const gp_Pln& p, const IfcUtil::IfcBaseEntity* storey, const std::string& id); path_object& start_path(const gp_Pln& p, const std::string& drawing_name, const std::string& id); From 8857396a1f86d5b93681d3a7e627f0f240123c6c Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Wed, 15 Jul 2026 23:28:43 +0100 Subject: [PATCH 020/142] Fix SVG edge classification sign/threshold bugs Fixes three bugs in classify_edge_from_faces() found via real-world testing against a dedicated stress-test scene (icosphere, Suzanne, cylinders/cones at various orientations, a dihedral-angle sweep rig): - The outline (silhouette) test used a bare sign comparison, so a face at or near exactly edge-on to the camera could land on the wrong side of zero and fall through to angle-based classification instead of being drawn as outline. Now uses a tolerance band around zero, matching an equivalent check already used elsewhere in this file. - The signed deviation-from-flat formula was inverted (180 - angle instead of angle), so small, genuinely near-flat facet angles came out with a large computed deviation and always classified as sharp/crease, never flush. This is why thresholds appeared to have no effect. Also replaced the edge/wire-orientation-based convexity sign (unreliable on real BRep topology, verified wrong against a known fully-convex icosphere) with a simpler position-based test. - A specific edge that was previously missing entirely (not just misclassified) reappears correctly as a side effect of the outline fix above; no separate change was needed for it. A fourth issue (folds viewed through an opening, e.g. a box missing a face, should read as crease rather than sharp) was attempted via a back-facing sign flip, but reverted: it broke the fixes above broadly, since "both faces back-facing" isn't a rare look-through-a-hole case once HLR has already filtered to visible edges only. Documented in a code comment for whoever picks this up next. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 --- src/serializers/SvgSerializer.cpp | 90 +++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/src/serializers/SvgSerializer.cpp b/src/serializers/SvgSerializer.cpp index 24bc2c561a..7770659aa7 100644 --- a/src/serializers/SvgSerializer.cpp +++ b/src/serializers/SvgSerializer.cpp @@ -872,39 +872,83 @@ namespace { return edge_style_class::outline; } - const double d0 = projection_direction.Dot(n0); - const double d1 = projection_direction.Dot(n1); + // Note the negation: `projection_direction` (as constructed by the caller from the + // drawing plane's axis) points from the scene *towards the camera*, not into the scene. + // A face that's actually front-facing (visible, facing the viewer) has an outward normal + // pointing the same general way as that -- i.e. a *positive* dot product -- so negate + // here to get the more intuitive "front-facing is negative" convention used below. + // Confirmed against this feature's own real-world test scene: the SOUTH ELEVATION + // camera's placement matrix transforms local +Z (what the un-negated projection_direction + // is built from) to world (0, 1, 0), while the camera's actual Blender-convention view + // direction (local -Z) transforms to world (0, -1, 0) -- i.e. exactly opposite. + const double d0 = -projection_direction.Dot(n0); + const double d1 = -projection_direction.Dot(n1); - // Outline: silhouette, either against the background or self-occluding -- one face - // turns toward the viewer while the other turns away. - if ((d0 < 0.0) != (d1 < 0.0)) { + // Front/back/edge-on classification of each face relative to the view direction, using + // a tolerance band around zero rather than a bare sign comparison. A face at or near + // edge-on to the camera (|d| within the band) is common for regular/symmetric + // tessellations viewed from "nice" angles (icospheres, N-gon cylinder/cone + // approximations) and must count as outline on both its edges, not just the one that + // happens to pair it with a clearly front-facing neighbour. + constexpr double kOutlineDotEps = 1.e-5; + const bool front0 = d0 < -kOutlineDotEps; + const bool back0 = d0 > kOutlineDotEps; + const bool front1 = d1 < -kOutlineDotEps; + const bool back1 = d1 > kOutlineDotEps; + + // Outline: silhouette, either a genuine front/back flip, or either face is at/near + // edge-on to the view direction (also covers both faces edge-on at once). + if (!(front0 && front1) && !(back0 && back1)) { return edge_style_class::outline; } - // Signed deviation from flat (180 degrees between outward normals = perfectly flat). - // Positive = convex (ridge/sharp), negative = concave (valley/crease). The sign comes - // from the rotation of n0 onto n1 about the edge tangent. - const double angle_between_normals_deg = std::acos(clamp_dot(n0.Dot(n1))) * 180.0 / M_PI; - double deviation_deg = 180.0 - angle_between_normals_deg; + // Signed deviation from flat (0 degrees between outward normals = perfectly flat, i.e. + // coplanar faces have identical outward normals). Positive = convex (ridge/sharp), + // negative = concave (valley/crease). + // + // Sign via a position-based (not orientation-based) test: find a vertex of f1 that + // isn't one of the shared edge's own endpoints, and check which side of f0's plane it + // falls on. If it's behind f0's plane (opposite side from f0's outward normal), f1 + // curves back towards the solid's interior relative to f0 -- a convex fold, like a box + // corner. This avoids relying on TopoDS_Edge/wire orientation semantics (which proved + // unreliable in practice: an earlier attempt using edge.Orientation() combined with + // cross(n0, n1) gave a self-consistent-looking but wrong sign on real BRep topology -- + // verified against known-convex geometry, e.g. every edge of a convex icosphere, where + // that approach misclassified a majority of edges as concave). + double deviation_deg = std::acos(clamp_dot(n0.Dot(n1))) * 180.0 / M_PI; - double u0, u1; - Handle(Geom_Curve) curve = BRep_Tool::Curve(edge, u0, u1); - if (!curve.IsNull()) { - gp_Pnt p_mid; - gp_Vec tangent; - curve->D1((u0 + u1) / 2.0, p_mid, tangent); - if (tangent.SquareMagnitude() > 1.e-10) { - tangent.Normalize(); - if (edge.Orientation() == TopAbs_REVERSED) { - tangent.Reverse(); - } - const gp_Vec cross = gp_Vec(n0.XYZ()).Crossed(gp_Vec(n1.XYZ())); - if (cross.Dot(tangent) < 0.0) { + TopoDS_Vertex ev0, ev1; + TopExp::Vertices(edge, ev0, ev1); + const gp_Pnt edge_p0 = BRep_Tool::Pnt(ev0); + const gp_Pnt edge_p1 = BRep_Tool::Pnt(ev1); + + for (TopExp_Explorer vexp(f1, TopAbs_VERTEX); vexp.More(); vexp.Next()) { + const gp_Pnt p = BRep_Tool::Pnt(TopoDS::Vertex(vexp.Current())); + if (p.Distance(edge_p0) > Precision::Confusion() && p.Distance(edge_p1) > Precision::Confusion()) { + const bool convex = gp_Vec(edge_p0, p).Dot(gp_Vec(n0.XYZ())) < 0.0; + if (!convex) { deviation_deg = -deviation_deg; } + break; } } + // NOTE: an attempt to flip the sign when "both faces back-facing" (viewing a fold's + // reverse/inside surface through an opening, e.g. a box with a face removed) was tried + // here and reverted -- see edge-classification.md follow-up notes. front0/back0 (and + // front1/back1) reliably distinguish "genuine front/back flip" for the outline test + // above, but using them to guess "are we looking at this fold from behind" is unsound: + // by the time an edge is visible in the output at all, HLR has already decided it's not + // occluded, so for an ordinary closed solid essentially every remaining edge still + // reads as "both back-facing" about as often as "both front-facing" (there's no cheap + // way here to tell "genuinely viewed through a hole" apart from "ordinary far side of a + // closed shape that happens to share this classification bucket"). Enabling either + // polarity of this flip corrupted otherwise-correct classification broadly (verified + // against a fully-convex icosphere test case, where it manufactured large numbers of + // spurious `crease` edges that should have been `flush`). Needs a different approach + // (e.g. an explicit visibility/occlusion signal rather than inferring it from face + // normals) before revisiting. + if (deviation_deg >= 0.0) { return (deviation_deg >= ridge_angle_min_deg) ? edge_style_class::sharp : edge_style_class::flush; } else { From 2e9e75c7bd2364c18cf5ef3a7d91895d5db54f74 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Thu, 16 Jul 2026 00:36:54 +0100 Subject: [PATCH 021/142] Fix Issue 4: gate the back-facing crease flip by threshold Re-enable the view-relative sign flip for folds seen through an opening (e.g. a box with a face removed), reverted in the previous commit after it corrupted unrelated geometry. The earlier revert's diagnosis was slightly off: bucket reassignment can't affect HLR's own visibility computation, so the corruption was actually an asymmetric-threshold artifact -- an unconditional flip re-tested small, correctly-flush deviations against the much smaller valley threshold instead of the ridge one. Gating the flip so it only reinterprets folds that already clear their own pre-flip threshold fixes the box case while leaving every other test object's classification unchanged (verified against the full test scene). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 --- src/serializers/SvgSerializer.cpp | 53 ++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/src/serializers/SvgSerializer.cpp b/src/serializers/SvgSerializer.cpp index 7770659aa7..f8fb64fa0c 100644 --- a/src/serializers/SvgSerializer.cpp +++ b/src/serializers/SvgSerializer.cpp @@ -933,21 +933,44 @@ namespace { } } - // NOTE: an attempt to flip the sign when "both faces back-facing" (viewing a fold's - // reverse/inside surface through an opening, e.g. a box with a face removed) was tried - // here and reverted -- see edge-classification.md follow-up notes. front0/back0 (and - // front1/back1) reliably distinguish "genuine front/back flip" for the outline test - // above, but using them to guess "are we looking at this fold from behind" is unsound: - // by the time an edge is visible in the output at all, HLR has already decided it's not - // occluded, so for an ordinary closed solid essentially every remaining edge still - // reads as "both back-facing" about as often as "both front-facing" (there's no cheap - // way here to tell "genuinely viewed through a hole" apart from "ordinary far side of a - // closed shape that happens to share this classification bucket"). Enabling either - // polarity of this flip corrupted otherwise-correct classification broadly (verified - // against a fully-convex icosphere test case, where it manufactured large numbers of - // spurious `crease` edges that should have been `flush`). Needs a different approach - // (e.g. an explicit visibility/occlusion signal rather than inferring it from face - // normals) before revisiting. + // View-relative flip for folds seen from behind through an opening (e.g. the "Rotated + // Box w/Boundary" test object -- a box with one face removed; the 3 interior lines + // visible through the opening read as the *inside* of an ordinary convex box corner, + // which should look like a crease, not a sharp ridge). Two earlier unconditional + // versions of this flip (triggered on plain back0&&back1, with no further gate) were + // tried and reverted -- see edge-classification.md follow-up notes -- because they + // corrupted otherwise-correct classification broadly, manifesting as spurious `crease` + // edges on a fully-convex icosphere test case that has no opening at all. + // + // That corruption wasn't a fundamental inability to distinguish "genuinely seen through + // a hole" from "ordinary far side of closed geometry": bucket membership here is purely + // a post-hoc query key into an already-completed, correct HLR visibility computation, + // so reclassifying an edge can never make a genuinely hidden edge appear or vice versa. + // The real cause is a threshold-crossing artifact: near the silhouette, facet-normal + // noise on regular/symmetric tessellations (icospheres, N-gon cylinder/cone + // approximations) makes some genuinely near-edge-on facets test as "back" under the + // flat-normal-based back0/back1 test even though they're still visible. An unconditional + // negate then took their small, correctly-`flush` solid-relative deviation and re-tested + // it against the *other* threshold -- `ridge_angle_min_deg` (45 degrees by default) and + // `valley_angle_min_deg` (12 degrees by default) are deliberately asymmetric, so a gentle + // ~20 degree convex facet transition that safely sits under the ridge threshold crosses + // well over the much smaller valley threshold once flipped, becoming a spurious `crease`. + // + // Fix: gate the flip so it can only reinterpret a fold that would already be visible + // (sharp or crease) under its own pre-flip threshold -- i.e. only folds sharp/deep + // enough to draw from the front get reinterpreted as the opposite class from behind. + // Gentle tessellation-noise deviations that are correctly `flush` either way never cross + // the asymmetric threshold gap, because they never reach the flip at all. Verified + // against the full test scene: every object's classification is byte-for-byte unchanged + // except "Rotated Box w/Boundary", whose 3 interior lines now correctly read `crease` + // (previously all 4 non-boundary edges read `sharp`). + if (back0 && back1) { + const bool would_show_unflipped = + (deviation_deg >= 0.0) ? (deviation_deg >= ridge_angle_min_deg) : (-deviation_deg >= valley_angle_min_deg); + if (would_show_unflipped) { + deviation_deg = -deviation_deg; + } + } if (deviation_deg >= 0.0) { return (deviation_deg >= ridge_angle_min_deg) ? edge_style_class::sharp : edge_style_class::flush; From 2ac92f01e46c4e324ed148c4951ec9841d376087 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Thu, 16 Jul 2026 04:02:57 +0100 Subject: [PATCH 022/142] Fix missing silhouette on curved analytic column/pile faces Circular-profile IfcColumn/IfcPile elements produce a genuine analytic cylindrical BRep face (via BRepPrimAPI_MakePrism), not a tessellated facet. The edge classification/extraction pipeline is edge-identity-based end to end, but a smooth surface's silhouette is synthesized by HLR on the fly and has no corresponding pre-existing edge to bucket, so it was silently dropped once any edge in the product had been classified. Add a face-level pass that includes any non-planar face directly in the outline bucket, giving HLR's per-face OutLine reconstruction a face identity to correlate against. Purely additive: diffing the whole test scene's output before and after shows only the two previously-missing tangent lines appear, nothing else changes. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 --- src/serializers/SvgSerializer.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/serializers/SvgSerializer.cpp b/src/serializers/SvgSerializer.cpp index f8fb64fa0c..2a4cb9c2b4 100644 --- a/src/serializers/SvgSerializer.cpp +++ b/src/serializers/SvgSerializer.cpp @@ -1436,6 +1436,29 @@ void SvgSerializer::write(const geometry_data& data) { } BBcls.Add(bucket_it->second, cls_edge); } + + // Non-planar faces (e.g. a real analytic cylindrical wall from a + // circular-profile column/pile, swept via BRepPrimAPI_MakePrism rather than + // faceted) have a silhouette that HLR synthesizes on the fly -- it is not a + // pre-existing topological edge, so the edge-only loop above can never bucket + // it. OutLineVCompound(S) correlates a curved face's silhouette by the + // identity of the originating *face*, not any edge, so add the non-planar + // face itself into the outline bucket alongside whatever edges it already + // contributed (top/bottom/seam), giving HLR's per-face OutLine reconstruction + // something to match against. + for (TopExp_Explorer fexp(*compound_to_hlr, TopAbs_FACE); fexp.More(); fexp.Next()) { + const TopoDS_Face& f = TopoDS::Face(fexp.Current()); + if (BRep_Tool::Surface(f)->DynamicType() != STANDARD_TYPE(Geom_Plane)) { + std::string name = edge_style_class_name(edge_style_class::outline); + auto bucket_it = classified_edge_buckets.find(name); + if (bucket_it == classified_edge_buckets.end()) { + TopoDS_Compound c; + BBcls.MakeCompound(c); + bucket_it = classified_edge_buckets.emplace(name, c).first; + } + BBcls.Add(bucket_it->second, f); + } + } } if (is_floor_plan_) { From 183e4c47f7a413a974c5863f11cfce80308fc638 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Thu, 16 Jul 2026 15:29:22 +0100 Subject: [PATCH 023/142] Add SVG edge classification on/off + render settings Add svg-use-edge-classification (default off, preserving today's linework), svg-render-crease-edges, and svg-render-sharp-edges settings, gating the existing 5-class classification feature so it can be disabled entirely (falling back to the pre-classification whole-shape output) or have individual classes suppressed. Also fixes a bug uncovered while wiring this into Bonsai: ready(), where geometry_settings() actually gets read into the serializer, was only ever invoked explicitly by IfcConvert's CLI driver and isn't exposed to Python. Every Svg* setting -- including the three from previous rounds -- silently stayed at its hardcoded constructor default when the serializer was constructed directly through the Python bindings, as Bonsai does. Fixed by calling ready() from SvgSerializer's own constructor, safe since it only reads geometry_settings() with no other side effects, and settings are always finalized before construction in every call path. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 --- src/ifcgeom/ConversionSettings.h | 20 +++++++++++++++- .../ifcopenshell/geom/main.py | 6 +++++ src/serializers/SvgSerializer.cpp | 23 ++++++++++++++++++- src/serializers/SvgSerializer.h | 18 ++++++++++++++- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/ifcgeom/ConversionSettings.h b/src/ifcgeom/ConversionSettings.h index 49d0fccbfb..621174cce2 100644 --- a/src/ifcgeom/ConversionSettings.h +++ b/src/ifcgeom/ConversionSettings.h @@ -389,6 +389,24 @@ namespace ifcopenshell { static constexpr bool defaultvalue = false; }; + struct SvgUseEdgeClassification : public SettingBase { + static constexpr const char* const name = "svg-use-edge-classification"; + static constexpr const char* const description = "SVG edge classification (issue #3668): enable the 5-class boundary/outline/sharp/crease/flush scheme. When false (the default), falls back to the original unclassified linework."; + static constexpr bool defaultvalue = false; + }; + + struct SvgRenderCreaseEdges : public SettingBase { + static constexpr const char* const name = "svg-render-crease-edges"; + static constexpr const char* const description = "SVG edge classification (issue #3668): whether to emit 'crease' (concave) projection edges. Only relevant when svg-use-edge-classification is enabled."; + static constexpr bool defaultvalue = true; + }; + + struct SvgRenderSharpEdges : public SettingBase { + static constexpr const char* const name = "svg-render-sharp-edges"; + static constexpr const char* const description = "SVG edge classification (issue #3668): whether to emit 'sharp' (convex) projection edges. Only relevant when svg-use-edge-classification is enabled."; + static constexpr bool defaultvalue = true; + }; + struct KeepBoundingBoxes : public SettingBase { static constexpr const char* const name = "keep-bounding-boxes"; static constexpr const char* const description = @@ -671,7 +689,7 @@ namespace ifcopenshell { }; class Settings : public SettingsContainer< - std::tuple + std::tuple > {}; } diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index bf5fc00098..61c7d2a10e 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -109,6 +109,12 @@ SETTING = Literal[ "reorient-shells", "site-local-placement", "surface-colour", + "svg-emit-flush-edges", + "svg-render-crease-edges", + "svg-render-sharp-edges", + "svg-ridge-angle-min-degrees", + "svg-use-edge-classification", + "svg-valley-angle-min-degrees", "triangulation-type", "unify-shapes", "use-material-names", diff --git a/src/serializers/SvgSerializer.cpp b/src/serializers/SvgSerializer.cpp index 2a4cb9c2b4..79101dfb67 100644 --- a/src/serializers/SvgSerializer.cpp +++ b/src/serializers/SvgSerializer.cpp @@ -105,6 +105,9 @@ bool SvgSerializer::ready() { svg_ridge_angle_min_deg_ = geometry_settings().get().get(); svg_valley_angle_min_deg_ = geometry_settings().get().get(); svg_emit_flush_edges_ = geometry_settings().get().get(); + svg_use_edge_classification_ = geometry_settings().get().get(); + svg_render_crease_edges_ = geometry_settings().get().get(); + svg_render_sharp_edges_ = geometry_settings().get().get(); return true; } @@ -1398,8 +1401,20 @@ void SvgSerializer::write(const geometry_data& data) { // is still registered via add()/it->second.add() below, unchanged, for correct // occlusion; these buckets only affect which class each edge's visible portion is // later extracted as (see hlr_calc::extract() in SvgSerializer.h). + // + // Gated behind svg_use_edge_classification_ (default false): the whole block must + // be skipped, not just individually suppressed per-edge, so that when disabled + // classified_edge_buckets stays empty for *every* product in the document, not + // just this one. hlr_calc::extract() only takes the classified-buckets branch + // when its shared classified_shapes_ list is non-empty; if even one product added + // classified buckets while others didn't, those others would silently fall back + // to unclassified linework while this one used classification, an inconsistent + // mix. Leaving classified_edge_buckets empty here means add_classified_edges() is + // never called for this product either, so every product uniformly falls through + // to the pre-existing product_shapes_ fallback -- the original, pre-classification + // linework. std::map classified_edge_buckets; - { + if (svg_use_edge_classification_) { NCollection_IndexedDataMap, TopTools_ShapeMapHasher> edge_face_map; TopExp::MapShapesAndAncestors(*compound_to_hlr, TopAbs_EDGE, TopAbs_FACE, edge_face_map); @@ -1426,6 +1441,12 @@ void SvgSerializer::write(const geometry_data& data) { if (cls == edge_style_class::flush && !svg_emit_flush_edges_) { continue; } + if (cls == edge_style_class::crease && !svg_render_crease_edges_) { + continue; + } + if (cls == edge_style_class::sharp && !svg_render_sharp_edges_) { + continue; + } std::string name = edge_style_class_name(cls); auto bucket_it = classified_edge_buckets.find(name); diff --git a/src/serializers/SvgSerializer.h b/src/serializers/SvgSerializer.h index 223bc5e441..9da7963f7c 100644 --- a/src/serializers/SvgSerializer.h +++ b/src/serializers/SvgSerializer.h @@ -600,6 +600,9 @@ protected: double svg_ridge_angle_min_deg_; double svg_valley_angle_min_deg_; bool svg_emit_flush_edges_; + bool svg_use_edge_classification_; + bool svg_render_crease_edges_; + bool svg_render_sharp_edges_; IfcParse::IfcFile* file; const IfcUtil::IfcBaseEntity* storey_; @@ -657,6 +660,9 @@ public: , svg_ridge_angle_min_deg_(45.) , svg_valley_angle_min_deg_(12.) , svg_emit_flush_edges_(false) + , svg_use_edge_classification_(false) + , svg_render_crease_edges_(true) + , svg_render_sharp_edges_(true) , file(0) , storey_(0) , xcoords_begin(0) @@ -665,7 +671,17 @@ public: , hlr(nullptr) , namespace_prefix_("data-") , subtraction_settings_(ON_SLABS_AT_FLOORPLANS) - {} + { + // ready() only reads geometry_settings() (already valid at this point, since the base + // WriteOnlyGeometrySerializer initializer above has run) and has no other side effects, + // so it's safe to call here. This is needed because ready() is otherwise only invoked + // explicitly by IfcConvert.cpp's CLI driver -- callers that construct this serializer + // directly via the Python bindings (e.g. Bonsai's drawing generation, which never calls + // a ready()-equivalent because it isn't exposed via SWIG) would otherwise silently keep + // every settings::Svg* member at its hardcoded constructor default forever, regardless + // of what ifcopenshell.geom.settings().set(...) was actually configured to. + ready(); + } void addXCoordinate(const boost::shared_ptr& fi) { xcoords.push_back(fi); } void addYCoordinate(const boost::shared_ptr& fi) { ycoords.push_back(fi); } void addSizeComponent(const boost::shared_ptr& fi) { radii.push_back(fi); } From f3a7a35acfdb14118f069d63cc25de2cf932cf4c Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Thu, 16 Jul 2026 15:29:44 +0100 Subject: [PATCH 024/142] Expose SVG edge classification settings in drawing UI Add UseEdgeClassification, RenderCreases, ValleyAngleMinDegrees, RenderSharp, RidgeAngleMinDegrees, and RenderFlush to EPset_Drawing, following the existing HasUnderlay/DPI/PerspectiveShiftX pattern. The master toggle defaults off, preserving current linework output; the three dependent controls only show in the panel once it's on. Removes the previous dormant, transient operator-redo properties for the ridge/valley thresholds and flush-edge toggle, which were never persisted per-drawing or exposed in any panel, replacing them with the persistent camera properties read in setup_serialiser(). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Sonnet 5 --- .../bonsai/bim/data/pset/EPset_Drawing.ifc | 8 +++- .../bonsai/bim/module/drawing/operator.py | 37 ++++----------- src/bonsai/bonsai/bim/module/drawing/prop.py | 44 ++++++++++++++++++ src/bonsai/bonsai/bim/module/drawing/ui.py | 13 ++++++ src/bonsai/bonsai/tool/drawing.py | 18 ++++++++ src/bonsai/test/tool/test_drawing.py | 45 +++++++++++++++++++ 6 files changed, 135 insertions(+), 30 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc index 114b766ff8..4d8bc1ad47 100644 --- a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc +++ b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc @@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D FILE_SCHEMA(('IFC4')); ENDSEC; DATA; -#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2)); +#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31,#32,#33,#34,#35,#36)); #2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); @@ -35,5 +35,11 @@ DATA; #28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); #29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.); #30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.); +#31=IFCSIMPLEPROPERTYTEMPLATE('1cFVJnqT13m8ItkMHaI1tp',$,'UseEdgeClassification','Enable the boundary/outline/sharp/crease/flush SVG edge classification scheme (issue #3668). When false, drawings use the original unclassified linework.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#32=IFCSIMPLEPROPERTYTEMPLATE('2kB$mxBgnBUvhjh0Ti0c4P',$,'RenderCreases','Whether to render ''crease'' (concave) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#33=IFCSIMPLEPROPERTYTEMPLATE('3MSIJNW$T8r9Hl12kk0BY$',$,'ValleyAngleMinDegrees','Minimum concave dihedral deviation from flat, in degrees, for a projection edge to be classified as ''crease'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.); +#34=IFCSIMPLEPROPERTYTEMPLATE('2epSGfC4bFM9gb1X7zBIp4',$,'RenderSharp','Whether to render ''sharp'' (convex) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#35=IFCSIMPLEPROPERTYTEMPLATE('3TZwsEjkr5WRDKcgrYzSIA',$,'RidgeAngleMinDegrees','Minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as ''sharp'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.); +#36=IFCSIMPLEPROPERTYTEMPLATE('2Jua$lO754vgZOkBoHM2gA',$,'RenderFlush','Whether to render ''flush'' edges (dihedral deviation below both ridge/valley thresholds). Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); ENDSEC; END-ISO-10303-21; diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 9a8faf4d58..b6017f0704 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -261,36 +261,11 @@ class CreateDrawing(bpy.types.Operator): description="Could save some time if you're sure IFC and current Blender session are already in sync", default=True, ) - svg_ridge_angle_min_deg: bpy.props.FloatProperty( - name="Ridge Angle Minimum", - description="Minimum convex dihedral deviation from flat, in degrees, for a projection " - "edge to be classified as 'sharp' rather than 'flush'. See edge-classification.md", - default=45.0, - min=0.0, - max=180.0, - ) - svg_valley_angle_min_deg: bpy.props.FloatProperty( - name="Valley Angle Minimum", - description="Minimum concave dihedral deviation from flat, in degrees, for a projection " - "edge to be classified as 'crease' rather than 'flush'. See edge-classification.md", - default=12.0, - min=0.0, - max=180.0, - ) - svg_emit_flush_edges: bpy.props.BoolProperty( - name="Emit Flush Edges", - description="Include projection edges whose dihedral deviation is below both the ridge " - "and valley thresholds (class 'flush'). Omitted by default", - default=False, - ) if TYPE_CHECKING: print_all: bool open_viewer: bool sync: bool - svg_ridge_angle_min_deg: float - svg_valley_angle_min_deg: float - svg_emit_flush_edges: bool drawing_name: str is_manifold_cache: dict[str, bool] @@ -1334,11 +1309,15 @@ class CreateDrawing(bpy.types.Operator): self.svg_settings = ifcopenshell.geom.settings() self.svg_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) self.svg_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE) - # SVG edge classification (issue #3668). See edge-classification.md. + # SVG edge classification (issue #3668). See edge-classification.md. Settings are + # per-drawing, stored in EPset_Drawing and read into self.cprops by import_camera_props. try: - self.svg_settings.set("svg-ridge-angle-min-degrees", self.svg_ridge_angle_min_deg) - self.svg_settings.set("svg-valley-angle-min-degrees", self.svg_valley_angle_min_deg) - self.svg_settings.set("svg-emit-flush-edges", self.svg_emit_flush_edges) + self.svg_settings.set("svg-use-edge-classification", self.cprops.use_edge_classification) + self.svg_settings.set("svg-render-crease-edges", self.cprops.render_creases) + self.svg_settings.set("svg-valley-angle-min-degrees", self.cprops.valley_angle_min_degrees) + self.svg_settings.set("svg-render-sharp-edges", self.cprops.render_sharp) + self.svg_settings.set("svg-ridge-angle-min-degrees", self.cprops.ridge_angle_min_degrees) + self.svg_settings.set("svg-emit-flush-edges", self.cprops.render_flush) except Exception: # Backwards compatibility with older ifcopenshell builds that don't expose these keys. pass diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index f75de7fd34..f22d7128d8 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -536,6 +536,50 @@ class BIMCameraProperties(PropertyGroup): default=True, update=get_update_layer_callback("has_annotation", "HasAnnotation"), ) + use_edge_classification: BoolProperty( + name="Use Edge Classification", + description="Classify projection edges into boundary/outline/sharp/crease/flush " + "instead of drawing all linework identically. See edge-classification.md", + default=False, + update=get_update_layer_callback("use_edge_classification", "UseEdgeClassification"), + ) + render_creases: BoolProperty( + name="Render Creases", + description="Render 'crease' (concave) projection edges", + default=True, + update=get_update_layer_callback("render_creases", "RenderCreases"), + ) + valley_angle_min_degrees: FloatProperty( + name="Valley Angle Minimum", + description="Minimum concave dihedral deviation from flat, in degrees, for a projection " + "edge to be classified as 'crease' rather than 'flush'", + default=12.0, + min=0.0, + max=180.0, + update=get_update_layer_callback("valley_angle_min_degrees", "ValleyAngleMinDegrees"), + ) + render_sharp: BoolProperty( + name="Render Sharp", + description="Render 'sharp' (convex) projection edges", + default=True, + update=get_update_layer_callback("render_sharp", "RenderSharp"), + ) + ridge_angle_min_degrees: FloatProperty( + name="Ridge Angle Minimum", + description="Minimum convex dihedral deviation from flat, in degrees, for a projection " + "edge to be classified as 'sharp' rather than 'flush'", + default=45.0, + min=0.0, + max=180.0, + update=get_update_layer_callback("ridge_angle_min_degrees", "RidgeAngleMinDegrees"), + ) + render_flush: BoolProperty( + name="Render Flush", + description="Render 'flush' projection edges (dihedral deviation below both ridge/valley " + "thresholds). Omitted by default", + default=False, + update=get_update_layer_callback("render_flush", "RenderFlush"), + ) target_view: EnumProperty( name="Target View", default="PLAN_VIEW", diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index e0df93a4a6..640ebf91a3 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -113,6 +113,19 @@ class BIM_PT_camera(Panel): row.prop(props, "fill_mode") row = self.layout.row() row.prop(props, "cut_mode") + + row = self.layout.row() + row.prop(props, "use_edge_classification") + if props.use_edge_classification: + row = self.layout.row() + row.prop(props, "render_creases") + row.prop(props, "valley_angle_min_degrees") + row = self.layout.row() + row.prop(props, "render_sharp") + row.prop(props, "ridge_angle_min_degrees") + row = self.layout.row() + row.prop(props, "render_flush") + row = self.layout.row() row.prop(props, "width") row = self.layout.row() diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 7493b02d33..8b6ca68b0e 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -1072,6 +1072,12 @@ class Drawing(bonsai.core.tool.Drawing): camera_props.has_annotation = True camera_props.target_view = "PLAN_VIEW" camera_props.is_nts = False + camera_props.use_edge_classification = False + camera_props.render_creases = True + camera_props.valley_angle_min_degrees = 12.0 + camera_props.render_sharp = True + camera_props.ridge_angle_min_degrees = 45.0 + camera_props.render_flush = False camera.shift_x = 0.0 camera.shift_y = 0.0 @@ -1101,6 +1107,18 @@ class Drawing(bonsai.core.tool.Drawing): camera_props.has_annotation = bool(pset["HasAnnotation"]) if "IsNTS" in pset: camera_props.is_nts = bool(pset["IsNTS"]) + if "UseEdgeClassification" in pset: + camera_props.use_edge_classification = bool(pset["UseEdgeClassification"]) + if "RenderCreases" in pset: + camera_props.render_creases = bool(pset["RenderCreases"]) + if "ValleyAngleMinDegrees" in pset: + camera_props.valley_angle_min_degrees = float(pset["ValleyAngleMinDegrees"]) + if "RenderSharp" in pset: + camera_props.render_sharp = bool(pset["RenderSharp"]) + if "RidgeAngleMinDegrees" in pset: + camera_props.ridge_angle_min_degrees = float(pset["RidgeAngleMinDegrees"]) + if "RenderFlush" in pset: + camera_props.render_flush = bool(pset["RenderFlush"]) if "DPI" in pset: camera_props.dpi = int(pset["DPI"]) if "LineworkMode" in pset: diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index 9d69fe51f6..a14b4d9d79 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -112,6 +112,51 @@ class TestImportCameraProps(NewFile): assert camera.shift_x == 0.0 assert camera.shift_y == 0.0 + def test_defaults_edge_classification_props_when_pset_is_absent(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.createIfcAnnotation(ObjectType="DRAWING") + camera = bpy.data.cameras.new("Camera") + + subject.import_camera_props(drawing, camera) + + props = subject.get_camera_props(camera) + assert props.use_edge_classification is False + assert props.render_creases is True + assert props.valley_angle_min_degrees == pytest.approx(12.0) + assert props.render_sharp is True + assert props.ridge_angle_min_degrees == pytest.approx(45.0) + assert props.render_flush is False + + def test_imports_edge_classification_props_from_drawing_pset(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.createIfcAnnotation(ObjectType="DRAWING") + pset = ifcopenshell.api.pset.add_pset(ifc, product=drawing, name="EPset_Drawing") + ifcopenshell.api.pset.edit_pset( + ifc, + pset=pset, + properties={ + "UseEdgeClassification": True, + "RenderCreases": False, + "ValleyAngleMinDegrees": 8.0, + "RenderSharp": False, + "RidgeAngleMinDegrees": 30.0, + "RenderFlush": True, + }, + ) + camera = bpy.data.cameras.new("Camera") + + subject.import_camera_props(drawing, camera) + + props = subject.get_camera_props(camera) + assert props.use_edge_classification is True + assert props.render_creases is False + assert props.valley_angle_min_degrees == pytest.approx(8.0) + assert props.render_sharp is False + assert props.ridge_angle_min_degrees == pytest.approx(30.0) + assert props.render_flush is True + class TestSyncPerspectiveCameraShifts(NewFile): def test_round_trips_perspective_camera_shifts_through_drawing_pset(self): From 93c0290131f73c9bbc5984cee84e1fe6af7fbba0 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Fri, 17 Jul 2026 17:54:06 +0100 Subject: [PATCH 025/142] Minor tweak to the default lining weights The crease and sharp weighting seemed flipped to my sensibilities, so now crease is heavier than sharp. I also added a commented out block for debug colours in case someone wants to quickly use bright colours to diagnose future problems. --- src/bonsai/bonsai/bim/data/assets/default.css | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/assets/default.css b/src/bonsai/bonsai/bim/data/assets/default.css index 5e4670af4d..d82d97e8f2 100644 --- a/src/bonsai/bonsai/bim/data/assets/default.css +++ b/src/bonsai/bonsai/bim/data/assets/default.css @@ -29,13 +29,23 @@ a:hover { cursor: pointer; } the inherited .projection rule above regardless of specificity. */ path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; } path.boundary { stroke: black; stroke-width: 0.3; stroke-opacity: 0.9; } -path.sharp { stroke: black; stroke-width: 0.25; stroke-opacity: 0.85; } -path.crease { stroke: black; stroke-width: 0.18; stroke-opacity: 0.7; } +path.crease { stroke: black; stroke-width: 0.25; stroke-opacity: 0.85; } +path.sharp { stroke: black; stroke-width: 0.18; stroke-opacity: 0.7; } path.flush { stroke: black; stroke-width: 0.1; stroke-opacity: 0.4; } -.surface { stroke: none; fill: #fff; fill-rule: evenodd; } -.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; } -.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; } -.IfcGeographicElement { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 1; } + +/* Debug CSS for troubleshooting edge classification */ +/* +path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; } +path.boundary { stroke: orange; stroke-width: 0.3; stroke-opacity: 0.9; } +path.crease { stroke: green; stroke-width: 0.25; stroke-opacity: 0.85; } +path.sharp { stroke: red; stroke-width: 0.18; stroke-opacity: 0.7; } +path.flush { stroke: blue; stroke-width: 0.1; stroke-opacity: 0.4; } +*/ + +.surface {fill: white; stroke-width: 0.1;} +.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; } +.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; } +/* .IfcGeographicElement { fill: none; stroke: rgb(150, 150, 150); stroke-linecap: 'round'; stroke-dasharray: 1, 2;} */ .PredefinedType-LINEWORK { stroke: black; stroke-width: 0.25; } .PredefinedType-LINEWORK.dashed { stroke-dasharray: 3, 2; } .PredefinedType-LINEWORK.fine { stroke-width: 0.18; stroke: #777777; } From d188e3beaf2f79a3ca82afc3a1d43524e53abd3b Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 11 Jul 2026 15:07:06 +0100 Subject: [PATCH 026/142] Allow process/resource type assignment via Type-suffix convention The class-pairing validation added in 10ee5aef4f rejects any type assignment whose class isn't in the buildingSMART implementer agreement map. That map only covers physical product occurrence/type pairs (IfcWallType -> IfcWall, etc); IfcTypeProcess and IfcTypeResource subtypes such as IfcTaskType, IfcProcedureType and the resource types have no entry, so previously-valid assignments like IfcTaskType -> IfcTask were rejected with "allowed occurrence classes: ". These classes still follow the schema's universal Type-suffix naming convention, so derive the pairing the same way the existing ApplicableOccurrence fallback does: strip "Type" from the relating type's class name and accept it only if the schema actually declares that entity. This can only add pairings implied by the type's own class name, so it cannot loosen the existing rejection of genuine mismatches (e.g. IfcWallType -> IfcWindow). Generated with the assistance of an AI coding tool. --- .../ifcopenshell/api/type/assign_type.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py b/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py index 3087811234..54723c754e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py +++ b/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py @@ -196,13 +196,25 @@ class Usecase: allowed_occurrences = set( ifcopenshell.util.type.get_applicable_entities(relating_type.is_a(), schema=self.file.schema) ) + schema = ifcopenshell.schema_by_name(self.file.schema) + # The implementer agreement map has no entry for the abstract # IfcTypeProduct, which Bonsai uses for annotation types. The schema # itself defines IfcTypeProduct.ApplicableOccurrence for exactly this # purpose, so honor it when the leading class token is a valid entity. if applicable_occurrence := getattr(relating_type, "ApplicableOccurrence", None): occurrence_class = applicable_occurrence.split("/", 1)[0] - schema = ifcopenshell.schema_by_name(self.file.schema) + try: + schema.declaration_by_name(occurrence_class) + allowed_occurrences.add(occurrence_class) + except RuntimeError: + pass + # The map only covers physical product occurrence/type pairs (e.g. + # IfcWallType -> IfcWall). Process and resource types (IfcTaskType, + # IfcCrewResourceType, ...) aren't in it, but the schema's universal + # Type-suffix naming convention gives the same pairing directly. + if (type_class := relating_type.is_a()).endswith("Type"): + occurrence_class = type_class[: -len("Type")] try: schema.declaration_by_name(occurrence_class) allowed_occurrences.add(occurrence_class) From 96e2efebc86887d68fec9bf3ca971771b63d61af Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 11 Jul 2026 19:05:56 +0100 Subject: [PATCH 027/142] Route boolean-op kernel logging through the injected Logger src/ifcgeom/kernels/opencascade/boolean_utils.cpp, OpenCascadeKernel.cpp, and boolean_result.cpp logged diagnostics (including the "Processed fully in 2D" family of messages) through the global Logger::Root() singleton. IfcConvert's main() constructs its own Logger and wires it to --log-file via SetOutput(), then threads that instance through Converter/kernel constructors as logger_ (see AbstractKernel). Since Logger::Root() is never itself configured with an output stream, every Notice/Warning/Message call through it was silently dropped instead of reaching the log file - Logger::Message's log1_/log2_ null checks just no-op. This made src/ifcopenshell-python/test/test_wall_opening.py fail: it asserts on specific log messages that the underlying boolean-op code was still emitting correctly, just to nowhere. The geometry itself was never wrong. Add a Logger*, defaulting to null, to boolean_settings (with a log() accessor falling back to Logger::Root() for the few remaining call sites with no injected logger available), thread it through eliminate_narrow_operands and boolean_subtraction_2d_using_builder, and have OpenCascadeKernel/boolean_result.cpp populate it from their inherited logger_ member instead of relying on the global singleton. Generated with the assistance of an AI coding tool. --- .../kernels/opencascade/OpenCascadeKernel.cpp | 15 ++-- .../kernels/opencascade/boolean_result.cpp | 7 +- .../kernels/opencascade/boolean_utils.cpp | 76 +++++++++---------- .../kernels/opencascade/boolean_utils.h | 11 ++- 4 files changed, 59 insertions(+), 50 deletions(-) diff --git a/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp b/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp index 0c8987a225..5a8db5346d 100644 --- a/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp +++ b/src/ifcgeom/kernels/opencascade/OpenCascadeKernel.cpp @@ -48,6 +48,7 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity* bst.attempt_2d = settings_.get().get(); bst.debug = settings_.get().get(); bst.precision = settings_.get().get(); + bst.logger = &logger_; std::vector< std::pair > opening_vector; @@ -118,7 +119,7 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity* auto it3_shape = std::static_pointer_cast(it3->Shape())->shape(); if (it3_shape.IsNull()) { - Logger::Root().Error("GEO", 187, "Null operand"); + logger_.Error("GEO", 187, "Null operand"); continue; } @@ -143,7 +144,7 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity* IfcGeom::util::create_solid_from_faces(list, entity_part, settings_.get().get(), true); is_manifold = util::is_manifold(entity_part); if (is_manifold) { - Logger::Root().Warning("GEO", 188, "Successfully sewed non-manifold first operand"); + logger_.Warning("GEO", 188, "Successfully sewed non-manifold first operand"); } } @@ -161,17 +162,17 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity* failure = "Empty result (no faces) for BOPAlgo_MakerVolume; original was " + std::to_string(IfcGeom::util::count(entity_part, TopAbs_FACE)); } else { is_manifold = util::is_manifold(entity_part_2); - Logger::Root().Warning("GEO", 189, std::string("Sucessfully detected exterior volume to non-manifold first operand; shape is now ") + (is_manifold ? std::string("manifold") : std::string("non-manifold"))); + logger_.Warning("GEO", 189, std::string("Sucessfully detected exterior volume to non-manifold first operand; shape is now ") + (is_manifold ? std::string("manifold") : std::string("non-manifold"))); entity_part = entity_part_2; } } catch (const Standard_Failure& e) { failure.emplace(e.GetMessageString()); } if (failure) { - Logger::Root().Warning("GEO", 190, "MakeVolume failed: " + *failure, entity); + logger_.Warning("GEO", 190, "MakeVolume failed: " + *failure, entity); } } else { - Logger::Root().Warning("GEO", 191, "Non-manifold first operand, use --make-volume to try and make manifold"); + logger_.Warning("GEO", 191, "Non-manifold first operand, use --make-volume to try and make manifold"); } } @@ -214,7 +215,7 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity* if (util::boolean_operation(bst, result, opening_list, BOPAlgo_CUT, intermediate_result)) { result = intermediate_result; } else { - Logger::Root().Message(Logger::LOG_ERROR, "GEO", 192, "Opening subtraction failed for " + boost::lexical_cast(std::distance(jt, it)) + " openings", entity); + logger_.Message(Logger::LOG_ERROR, "GEO", 192, "Opening subtraction failed for " + boost::lexical_cast(std::distance(jt, it)) + " openings", entity); } jt = it; @@ -235,7 +236,7 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity* // where we keep the first operand as is (a compound of faces probably, // unless --orient-shells was activated in which case we're already lost). if (!is_manifold) { - Logger::Root().Warning("GEO", 193, "Retrying boolean operation on individual faces"); + logger_.Warning("GEO", 193, "Retrying boolean operation on individual faces"); } continue; } diff --git a/src/ifcgeom/kernels/opencascade/boolean_result.cpp b/src/ifcgeom/kernels/opencascade/boolean_result.cpp index 64ce43eca1..600a9be96d 100644 --- a/src/ifcgeom/kernels/opencascade/boolean_result.cpp +++ b/src/ifcgeom/kernels/opencascade/boolean_result.cpp @@ -118,14 +118,14 @@ bool OpenCascadeKernel::convert_impl(const taxonomy::boolean_result::ptr br, Con const double first_operand_volume = util::shape_volume(a); if (first_operand_volume <= ALMOST_ZERO) { - Logger::Root().Message(Logger::LOG_WARNING, "GEO", 119, "Empty solid for:", c->instance); + logger_.Message(Logger::LOG_WARNING, "GEO", 119, "Empty solid for:", c->instance); } } else { for (auto& r : cr) { auto S = std::static_pointer_cast(r.Shape())->shape(); if (S.IsNull()) { - Logger::Root().Error("GEO", 120, "Null operand"); + logger_.Error("GEO", 120, "Null operand"); continue; } gp_GTrsf trsf; @@ -140,7 +140,7 @@ bool OpenCascadeKernel::convert_impl(const taxonomy::boolean_result::ptr br, Con // #2665 we also set a precision-independent threshold, because in the boolean op routine // the working fuzziness might still be increased. if (d < tol * 20. || d < 0.00002) { - Logger::Root().Message(Logger::LOG_WARNING, "GEO", 121, "Halfspace subtraction yields unchanged volume:", c->instance); + logger_.Message(Logger::LOG_WARNING, "GEO", 121, "Halfspace subtraction yields unchanged volume:", c->instance); continue; } else { S = result; @@ -159,6 +159,7 @@ bool OpenCascadeKernel::convert_impl(const taxonomy::boolean_result::ptr br, Con bst.attempt_2d = settings_.get().get(); bst.debug = settings_.get().get(); bst.precision = settings_.get().get(); + bst.logger = &logger_; TopoDS_Shape r; diff --git a/src/ifcgeom/kernels/opencascade/boolean_utils.cpp b/src/ifcgeom/kernels/opencascade/boolean_utils.cpp index 7695be65bc..d104c7ff94 100644 --- a/src/ifcgeom/kernels/opencascade/boolean_utils.cpp +++ b/src/ifcgeom/kernels/opencascade/boolean_utils.cpp @@ -405,7 +405,7 @@ bool IfcGeom::util::is_extrusion(const gp_Vec & v, const TopoDS_Shape & s, TopoD return true; } -int IfcGeom::util::eliminate_narrow_operands(double prec, const NCollection_List& bs, NCollection_List & c) { +int IfcGeom::util::eliminate_narrow_operands(double prec, const NCollection_List& bs, NCollection_List & c, Logger& logger) { int N = 0; NCollection_List::Iterator it(bs); for (; it.More(); it.Next()) { @@ -418,7 +418,7 @@ int IfcGeom::util::eliminate_narrow_operands(double prec, const NCollection_List bool is_narrow = min_dimension < prec; - Logger::Root().Notice("GEO", 122, "Min OBB dimension of operand = " + std::to_string(min_dimension)); + logger.Notice("GEO", 122, "Min OBB dimension of operand = " + std::to_string(min_dimension)); if (!is_narrow) { c.Append(it.Value()); @@ -573,7 +573,7 @@ int IfcGeom::util::eliminate_touching_operands(double prec, const TopoDS_Shape & return N; } -bool IfcGeom::util::boolean_subtraction_2d_using_builder(const TopoDS_Shape & a_input, const NCollection_List & b_input, TopoDS_Shape & result, double eps) { +bool IfcGeom::util::boolean_subtraction_2d_using_builder(const TopoDS_Shape & a_input, const NCollection_List & b_input, TopoDS_Shape & result, double eps, Logger& logger) { IfcGeom::impl::tree edge_tree; NCollection_List ab_input = b_input; @@ -703,7 +703,7 @@ bool IfcGeom::util::boolean_subtraction_2d_using_builder(const TopoDS_Shape & a_ if (u11 < U1 && U1 < u12 && u21 < U2 && U2 < u22) { // Edge curves belonging to different operands intersect, don't process // using builder. - Logger::Root().Notice("GEO", 123, "Intersecting boundaries"); + logger.Notice("GEO", 123, "Intersecting boundaries"); return false; } } @@ -750,7 +750,7 @@ bool IfcGeom::util::boolean_subtraction_2d_using_builder(const TopoDS_Shape & a_ // any effect and marked as redundant. Feeding it to the builder algo // will likely cause problems. redundant[std::distance(wires.begin(), it)] = true; - Logger::Root().Notice("GEO", 124, "Subtraction operand outside of outer bound"); + logger.Notice("GEO", 124, "Subtraction operand outside of outer bound"); } } @@ -790,7 +790,7 @@ bool IfcGeom::util::boolean_subtraction_2d_using_builder(const TopoDS_Shape & a_ if (wire_clss[wire_index]->Perform(p2d) == TopAbs_IN) { // A wire is contained within another operand redundant[other_index] = true; - Logger::Root().Notice("GEO", 125, "Subtraction operand contained in other"); + logger.Notice("GEO", 125, "Subtraction operand contained in other"); } } } @@ -848,7 +848,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To std::stringstream ss; ss << "bool-" << std::this_thread::get_id() << "-" << (operation_counter_++); debug_identifier = ss.str(); - Logger::Root().Notice("GEO", 126, "Boolean debug identifier: " + debug_identifier); + settings.log().Notice("GEO", 126, "Boolean debug identifier: " + debug_identifier); } if (fuzziness < 0.) { @@ -884,7 +884,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To a = unify(a_input, fuzziness * 1000.); - Logger::Root().Message( + settings.log().Message( Logger::LOG_DEBUG, "GEO", 127, "Simplified operand A from "s + std::to_string(count(a_input, TopAbs_FACE)) + @@ -896,7 +896,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To NCollection_List::Iterator it(b_input); for (; it.More(); it.Next()) { b.Append(unify(it.Value(), fuzziness)); - Logger::Root().Message( + settings.log().Message( Logger::LOG_DEBUG, "GEO", 128, "Simplified operand B from "s + std::to_string(count(it.Value(), TopAbs_FACE)) + @@ -924,7 +924,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To auto N = bounding_box_overlap(fuzziness, a, b, b_tmp); if (N) { - Logger::Root().Notice("GEO", 129, "Eliminated " + std::to_string(N) + " disjoint operands"); + settings.log().Notice("GEO", 129, "Eliminated " + std::to_string(N) + " disjoint operands"); std::swap(b, b_tmp); } } @@ -935,7 +935,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To b_tmp.Clear(); auto N = eliminate_touching_operands(fuzziness, a, b, b_tmp); if (N) { - Logger::Root().Notice("GEO", 130, "Eliminated " + std::to_string(N) + " touching operands"); + settings.log().Notice("GEO", 130, "Eliminated " + std::to_string(N) + " touching operands"); std::swap(b, b_tmp); } } @@ -944,9 +944,9 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To PERF("boolean subtraction: eliminate narrow"); b_tmp.Clear(); - auto N = eliminate_narrow_operands(fuzziness, b, b_tmp); + auto N = eliminate_narrow_operands(fuzziness, b, b_tmp, settings.log()); if (N) { - Logger::Root().Notice("GEO", 131, "Eliminated " + std::to_string(N) + " narrow operands"); + settings.log().Notice("GEO", 131, "Eliminated " + std::to_string(N) + " narrow operands"); std::swap(b, b_tmp); } } @@ -960,21 +960,21 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To } if (b.Extent() == 0) { - Logger::Root().Warning("GEO", 132, "No other operands remaining, using first operand"); + settings.log().Warning("GEO", 132, "No other operands remaining, using first operand"); result = a; return true; } - if (!is_2d && Logger::LOG_NOTICE >= Logger::Root().Verbosity()) { + if (!is_2d && Logger::LOG_NOTICE >= settings.log().Verbosity()) { PERF("preliminary manifoldness check"); if (!a.IsNull()) { - Logger::Root().Notice("GEO", 133, "Operand A is " + (is_manifold(a) ? ""s : "non-"s) + "manifold"); + settings.log().Notice("GEO", 133, "Operand A is " + (is_manifold(a) ? ""s : "non-"s) + "manifold"); } NCollection_List::Iterator it(b); for (int i = 0; it.More(); it.Next(), ++i) { - Logger::Root().Notice("GEO", 134, "Operand B " + std::to_string(i) + " is " + (is_manifold(it.Value()) ? ""s : "non-"s) + "manifold"); + settings.log().Notice("GEO", 134, "Operand B " + std::to_string(i) + " is " + (is_manifold(it.Value()) ? ""s : "non-"s) + "manifold"); } } @@ -1014,7 +1014,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To const double fuzz = (std::min)(min_length_orig / 3., fuzziness); - Logger::Root().Notice("GEO", 135, "Used fuzziness: " + std::to_string(fuzz)); + settings.log().Notice("GEO", 135, "Used fuzziness: " + std::to_string(fuzz)); const double new_fuzziness = fuzziness * 10.; const bool allow_retry = new_fuzziness - 1e-15 <= settings.precision * 10000. && new_fuzziness < min_length_orig; @@ -1048,7 +1048,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To } if (is_extrusion_a) { - Logger::Root().Notice("GEO", 136, "Operand A 1/1 is an extrusion"); + settings.log().Notice("GEO", 136, "Operand A 1/1 is an extrusion"); NCollection_List::Iterator it(b); for (int nb = 1; it.More(); it.Next(), ++nb) { @@ -1064,10 +1064,10 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To } if (is_extrusion_b) { - Logger::Root().Notice("GEO", 137, "Operand B " + std::to_string(nb) + "/" + std::to_string(b.Extent()) + " is an extrusion"); + settings.log().Notice("GEO", 137, "Operand B " + std::to_string(nb) + "/" + std::to_string(b.Extent()) + " is an extrusion"); if (b_interval.first < a_interval.first + (fuzz * 100.) && b_interval.second > a_interval.second - (fuzz * 100.)) { - Logger::Root().Notice("GEO", 138, "Operand B creates a through hole"); + settings.log().Notice("GEO", 138, "Operand B creates a through hole"); // Align b with a operand gp_Trsf trsf; @@ -1091,7 +1091,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To PERF("boolean operation: 2d builder"); // First try using face builder - boolean_op_2d_success = boolean_subtraction_2d_using_builder(a_face, b_faces, face_result, fuzziness); + boolean_op_2d_success = boolean_subtraction_2d_using_builder(a_face, b_faces, face_result, fuzziness, settings.log()); } if (!boolean_op_2d_success) { @@ -1107,23 +1107,23 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To BRepPrimAPI_MakePrism mp(face_result, gp_Vec(gp::DY()) * (a_interval.second - a_interval.first)); if (mp.IsDone()) { if (b_remainder_3d.Extent()) { - Logger::Root().Notice("GEO", 139, std::to_string(b_remainder_3d.Extent()) + " operands remaining to process in 3D"); + settings.log().Notice("GEO", 139, std::to_string(b_remainder_3d.Extent()) + " operands remaining to process in 3D"); b = b_remainder_3d; s1s.Clear(); s1s.Append(mp.Shape()); } else { - Logger::Root().Notice("GEO", 140, "Processed fully in 2D"); + settings.log().Notice("GEO", 140, "Processed fully in 2D"); result = mp.Shape(); return true; } } else { - Logger::Root().Notice("GEO", 141, "Failed to extrude 2D boolean result. Retrying in 3D."); + settings.log().Notice("GEO", 141, "Failed to extrude 2D boolean result. Retrying in 3D."); } } else { - Logger::Root().Notice("GEO", 142, "Failed to perform 2D boolean operation. Retrying in 3D."); + settings.log().Notice("GEO", 142, "Failed to perform 2D boolean operation. Retrying in 3D."); } } else { - Logger::Root().Notice("GEO", 143, "No second operands can be processed as 2D inner bounds. Retrying in 3D."); + settings.log().Notice("GEO", 143, "No second operands can be processed as 2D inner bounds. Retrying in 3D."); } } } @@ -1145,7 +1145,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To } if (builder->IsDone()) { if (false && builder->DSFiller()->HasWarning(STANDARD_TYPE(BOPAlgo_AlertAcquiredSelfIntersection))) { - Logger::Root().Notice("GEO", 144, "Builder reports self-intersection in output"); + settings.log().Notice("GEO", 144, "Builder reports self-intersection in output"); success = false; /* @@ -1159,7 +1159,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To } */ } else if(builder->DSFiller()->HasWarning(STANDARD_TYPE(BOPAlgo_AlertBadPositioning)) && !TopoDS_Iterator(*builder).More()) { - Logger::Root().Notice("GEO", 145, "Builder reports bad positioning and result is empty"); + settings.log().Notice("GEO", 145, "Builder reports bad positioning and result is empty"); success = false; } else { TopoDS_Shape r = *builder; @@ -1173,7 +1173,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To fix.Perform(); r = fix.Shape(); } catch (...) { - Logger::Root().Error("GEO", 146, "Shape healing failed on boolean result"); + settings.log().Error("GEO", 146, "Shape healing failed on boolean result"); } } @@ -1184,7 +1184,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To success = ana.IsValid() != 0; if (!success) { - Logger::Root().Notice("GEO", 147, "Boolean operation yields invalid result"); + settings.log().Notice("GEO", 147, "Boolean operation yields invalid result"); std::stringstream str; bool any_emitted = false; @@ -1214,7 +1214,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To dump(r); - Logger::Root().Notice("GEO", 148, str.str()); + settings.log().Notice("GEO", 148, str.str()); } } @@ -1334,7 +1334,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To if (op == BOPAlgo_CUT && has_open_shells && all_faces_included_in_result && result_n_faces > first_op_n_faces) { success = false; - Logger::Root().Notice("GEO", 149, "Boolean result discarded because subtractions results in only the addition of faces"); + settings.log().Notice("GEO", 149, "Boolean result discarded because subtractions results in only the addition of faces"); } else { // when there are edges or vertex-edge distances close to the used fuzziness, the // output is not trusted and the operation is attempted with a higher fuzziness. @@ -1380,7 +1380,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To static const char* const reason_strings[] = { "edge length", "vertex-edge", "face-face" }; std::stringstream str; str << "Boolean operation result failing " << reason_strings[reason] << " interference check, with fuzziness " << fuzziness << " with length " << v; - Logger::Root().Notice("GEO", 150, str.str()); + settings.log().Notice("GEO", 150, str.str()); } } @@ -1389,7 +1389,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To } } else { - Logger::Root().Notice("GEO", 151, "Boolean operation yields non-manifold result"); + settings.log().Notice("GEO", 151, "Boolean operation yields non-manifold result"); } } } @@ -1399,7 +1399,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To #if OCC_VERSION_HEX >= 0x70200 if (builder->HasError(STANDARD_TYPE(BOPAlgo_AlertBOPNotAllowed))) { - Logger::Root().Error("GEO", 152, "Invalid operands. Using first operand"); + settings.log().Error("GEO", 152, "Invalid operands. Using first operand"); result = a; success = true; } @@ -1412,14 +1412,14 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To #endif std::string str_str = str.str(); if (str_str.size()) { - Logger::Root().Notice("GEO", 153, str_str); + settings.log().Notice("GEO", 153, str_str); } } if (!success) { if (allow_retry) { return boolean_operation(settings, a, b, op, result, new_fuzziness); } else { - Logger::Root().Notice("GEO", 154, "No longer attempting boolean operation with higher fuzziness"); + settings.log().Notice("GEO", 154, "No longer attempting boolean operation with higher fuzziness"); } } return success && !result.IsNull(); diff --git a/src/ifcgeom/kernels/opencascade/boolean_utils.h b/src/ifcgeom/kernels/opencascade/boolean_utils.h index 34bf4906b8..55bd960130 100644 --- a/src/ifcgeom/kernels/opencascade/boolean_utils.h +++ b/src/ifcgeom/kernels/opencascade/boolean_utils.h @@ -35,6 +35,7 @@ #include +#include "../../../ifcparse/IfcLogger.h" #include "../ifc_geomlibrary_api.h" namespace IfcGeom { @@ -88,13 +89,19 @@ namespace IfcGeom { int eliminate_touching_operands(double prec, const TopoDS_Shape& a, const NCollection_List& bs, NCollection_List& c); - int eliminate_narrow_operands(double prec, const NCollection_List& bs, NCollection_List & c); + int eliminate_narrow_operands(double prec, const NCollection_List& bs, NCollection_List & c, Logger& logger = Logger::Root()); - bool boolean_subtraction_2d_using_builder(const TopoDS_Shape& a_input, const NCollection_List& b_input, TopoDS_Shape& result, double eps); + bool boolean_subtraction_2d_using_builder(const TopoDS_Shape& a_input, const NCollection_List& b_input, TopoDS_Shape& result, double eps, Logger& logger = Logger::Root()); struct boolean_settings { bool debug, attempt_2d; double precision; + // Set by callers that carry a per-conversion Logger (e.g. kernels deriving + // from AbstractKernel). Falls back to the global Logger::Root() singleton, + // which IfcConvert never wires to its --log-file output, so messages logged + // through that fallback are effectively silently dropped. + Logger* logger = nullptr; + Logger& log() const { return logger ? *logger : Logger::Root(); } }; bool boolean_operation(const boolean_settings& settings, const TopoDS_Shape&, const NCollection_List&, BOPAlgo_Operation, TopoDS_Shape&, double fuzziness = -1.); From 6c590bf0082b3c56a9cbca30c60fec376eb89a93 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 11 Jul 2026 19:44:56 +0100 Subject: [PATCH 028/142] Fix schema mismatch in ColumnPSetsOfSets.ifc test fixture The fixture declared FILE_SCHEMA(('IFC2X3')) but used IFCPROPERTYSETDEFINITIONSET(...), a defined type that only exists in IFC4+ (confirmed absent from the generated Ifc2x3-schema.cpp/ Ifc2x3-definitions.h, present in the IFC4 equivalents). The file's own FILE_NAME record ('Column_4x3.ifc') suggests it was originally exported as IFC4X3 and the schema tag was later miscopied to IFC2X3. Traced with an instrumented parser build: on encountering the unrecognized keyword, declaration_by_name() correctly throws "Entity with name 'IFCPROPERTYSETDEFINITIONSET' not found in schema 'IFC2X3'", caught by the existing IfcException handler in in_memory_file_storage::load(). The parser then falls back to parsing the trailing (#136,#138) as a plain nested SET rather than the typed value, so RelatingPropertyDefinition ends up as a bare tuple instead of an IfcPropertySetDefinitionSet-wrapped value with .is_a(). This is correct, expected behavior for content that doesn't match its declared schema - not a parser bug. Fixing the header to IFC4 (which does declare the type) resolves test_stream, test_file, and test_rocks in test_streaming_rocksdb_and_simpletyperefs.py. Generated with the assistance of an AI coding tool. --- src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc b/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc index f124bb79db..0ab5ace614 100644 --- a/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc +++ b/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc @@ -2,7 +2,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]','RevitIdentifiers [ContentGUID: a0df3484-2dab-42c5-b806-8c10d313bee0, VersionGUID: 658c1394-f3a4-43d1-9b3c-eee44a0cd67a, NumberOfSaves: 2]','CoordinateReference [CoordinateBase: Shared Coordinates]'),'2;1'); FILE_NAME('Column_4x3.ifc','2025-03-12T13:53:30+00:00',(''),(''),'ODA SDAI 24.12','Autodesk Revit 25.4.0.32 (ENG) - IFC 25.4.0.32',''); -FILE_SCHEMA(('IFC2X3')); +FILE_SCHEMA(('IFC4')); ENDSEC; DATA; #1=IFCORGANIZATION($,'Autodesk Revit 2025 (ENG)',$,$,$); From 489084c7be6955daeb48d9084c9b30cd9d0472fc Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Mon, 13 Jul 2026 20:03:16 +0100 Subject: [PATCH 029/142] Remove stale ty lint ignore directive --- src/ifcopenshell-python/ifcopenshell/draw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index bbcfa48aea..235dd1416e 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -541,7 +541,7 @@ def main( arranged = W.arrange_polygons( *filter(None, (ARRANGE_POLYGON_SETTINGS,)), - polies, # ty: ignore[too-many-positional-arguments] + polies, *((logger,) if logger is not None else ()), ) svg_data_3 = W.polygons_to_svg(arranged, False) From 47dc1a6c68e6c98557aff152b636079d17c5beab Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 18 Jul 2026 13:00:37 +0500 Subject: [PATCH 030/142] edit_true_north: handle unsetting case when `TrueNorth` is already `None` --- .../ifcopenshell/api/georeference/edit_true_north.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_true_north.py b/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_true_north.py index 5a39f917f4..58ab003e98 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_true_north.py +++ b/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_true_north.py @@ -65,6 +65,9 @@ def edit_true_north(file: ifcopenshell.file, true_north: Optional[Union[tuple[fl ifcopenshell.util.element.remove_deep2(file, old_true_north) continue + if true_north is None: + continue + if context.TrueNorth: if file.get_total_inverses(context.TrueNorth) != 1: context.TrueNorth = file.create_entity("IfcDirection") From 5994fbde275c5a40b64e3b7531698969c8d5252e Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 18 Jul 2026 10:43:06 +0500 Subject: [PATCH 031/142] ty: check `assert_never` Had to bump `ty`, because 0.0.61 added support for `value in [A, B, C]` pattern for type narrowing. --- pyproject.toml | 1 - requirements-tools.txt | 2 +- src/bonsai/bonsai/tool/pset.py | 3 +++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d832213792..7d7f6719fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,6 @@ no-matching-overload = "ignore" not-subscriptable = "ignore" unsupported-dynamic-base = "ignore" unsupported-operator = "ignore" -type-assertion-failure = "ignore" [tool.ty.environment] extra-paths = [ diff --git a/requirements-tools.txt b/requirements-tools.txt index 33a2505707..8f7aa4f792 100644 --- a/requirements-tools.txt +++ b/requirements-tools.txt @@ -1,5 +1,5 @@ black==26.3.1 ruff==0.15.12 poethepoet -ty==0.0.59 +ty==0.0.61 gersemi==0.26.1 diff --git a/src/bonsai/bonsai/tool/pset.py b/src/bonsai/bonsai/tool/pset.py index aa72a85e6f..47d5155eee 100644 --- a/src/bonsai/bonsai/tool/pset.py +++ b/src/bonsai/bonsai/tool/pset.py @@ -124,6 +124,9 @@ class Pset(bonsai.core.tool.Pset): return bpy.context.scene.GroupPsetProperties elif obj_type == "Zone": return bpy.context.scene.ZonePsetProperties + elif obj_type == "Cost": + # No psets for cost items currently. + assert False, obj_type assert_never(obj_type) @classmethod From ca9bbbc4a77af82f1f939d3a24731543fac37b7b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 18 Jul 2026 20:58:14 +0500 Subject: [PATCH 032/142] assign_cost_item_quantity: annotate --- .../api/cost/assign_cost_item_quantity.py | 112 +++++++++--------- 1 file changed, 55 insertions(+), 57 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py index 76cdb3187d..a3e2e96265 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py @@ -112,29 +112,40 @@ def assign_cost_item_quantity( """ usecase = Usecase() usecase.file = file - usecase.settings = { - "cost_item": cost_item, - "products": products or [], - "prop_name": prop_name, - "formula": formula, - "ifc_class": ifc_class, - } - return usecase.execute() + return usecase.execute( + cost_item=cost_item, + products=products or [], + prop_name=prop_name, + formula=formula, + ifc_class=ifc_class, + ) class Usecase: file: ifcopenshell.file - settings: dict[str, Any] - def execute(self): - if self.settings["prop_name"] or self.settings["formula"]: - self.quantities = set(self.settings["cost_item"].CostQuantities or []) - for product in self.settings["products"]: + def execute( + self, + cost_item: ifcopenshell.entity_instance, + products: list[ifcopenshell.entity_instance], + prop_name: str, + formula: str, + ifc_class: str, + ): + self.cost_item = cost_item + self.prop_name = prop_name + if self.prop_name or formula: + self.quantities = set(cost_item.CostQuantities or []) + for product in products: if product.is_a("IfcSpatialElement"): continue - self.assign_cost_control(related_object=product, cost_item=self.settings["cost_item"]) - if self.settings["formula"]: - tree = ast.parse(self.settings["formula"], mode="eval") + ifcopenshell.api.control.assign_control( + self.file, + related_objects=[product], + relating_control=cost_item, + ) + if formula: + tree = ast.parse(formula, mode="eval") collector = VariableExtractor() collector.visit(tree) variables = collector.variables @@ -159,29 +170,24 @@ class Usecase: new_quantity = None for quantity in self.quantities: - if ( - quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1 - ): # Todo improve it + if quantity.Formula == formula and len(products) == 1: # Todo improve it new_quantity = quantity - self.settings["ifc_class"] = quantity.is_a() + ifc_class = quantity.is_a() continue if new_quantity is None: - new_quantity = self.file.create_entity(self.settings["ifc_class"], Name="Unnamed") - new_quantity.Formula = self.settings["formula"] + new_quantity = self.file.create_entity(ifc_class, Name="Unnamed") + new_quantity.Formula = formula self.quantities.add(new_quantity) new_quantity[3] = result continue - if self.settings["prop_name"]: - if ( - self.settings["cost_item"].CostQuantities - and self.settings["cost_item"].CostQuantities[0].Name.lower() != self.settings["prop_name"].lower() - ): + if self.prop_name: + if cost_item.CostQuantities and cost_item.CostQuantities[0].Name.lower() != self.prop_name.lower(): continue self.add_quantity_from_related_object(product) - if self.settings["prop_name"] or self.settings["formula"]: - self.settings["cost_item"].CostQuantities = list(self.quantities) + if self.prop_name or formula: + cost_item.CostQuantities = list(self.quantities) else: self.update_cost_item_count() @@ -189,7 +195,7 @@ class Usecase: self, product: ifcopenshell.entity_instance, v: str, - ) -> float: + ) -> float | None: pset_name = v.split(".")[0] pset = ifcopenshell.util.element.get_pset(product, pset_name) pset_property_name = v.split(".")[1] @@ -199,20 +205,11 @@ class Usecase: self, product: ifcopenshell.entity_instance, v: str, - ) -> float: + ) -> float | None: qtos = ifcopenshell.util.element.get_psets(product, qtos_only=True) quantities = next(iter(qtos.values()), {}) return (quantities or {}).get(v, None) - def assign_cost_control( - self, related_object: ifcopenshell.entity_instance, cost_item: ifcopenshell.entity_instance - ) -> ifcopenshell.entity_instance: - return ifcopenshell.api.control.assign_control( - self.file, - related_objects=[related_object], - relating_control=cost_item, - ) - def add_quantity_from_related_object(self, element: ifcopenshell.entity_instance) -> None: for relationship in element.IsDefinedBy: if relationship.is_a("IfcRelDefinesByProperties"): @@ -222,23 +219,24 @@ class Usecase: if not qto.is_a("IfcElementQuantity"): return for prop in qto.Quantities: - if prop.is_a("IfcPhysicalSimpleQuantity") and prop.Name.lower() == self.settings["prop_name"].lower(): + if prop.is_a("IfcPhysicalSimpleQuantity") and prop.Name.lower() == self.prop_name.lower(): self.quantities.add(prop) def update_cost_item_count(self): + cost_item = self.cost_item # This is a bold assumption # https://forums.buildingsmart.org/t/how-does-a-cost-item-know-that-it-is-counting-a-controlled-product/3564 - if not self.settings["cost_item"].CostQuantities: + if not cost_item.CostQuantities: ifcopenshell.api.cost.add_cost_item_quantity( self.file, - cost_item=self.settings["cost_item"], + cost_item=cost_item, ifc_class="IfcQuantityCount", ) - if len(self.settings["cost_item"].CostQuantities) == 1: - quantity = self.settings["cost_item"].CostQuantities[0] + if len(cost_item.CostQuantities) == 1: + quantity = cost_item.CostQuantities[0] if quantity.is_a("IfcQuantityCount"): count = 0 - for rel in self.settings["cost_item"].Controls: + for rel in cost_item.Controls: for obj in rel.RelatedObjects: # Only increment if not a resource if not obj.is_a("IfcConstructionResource"): @@ -256,7 +254,7 @@ OPERATORS = { } -def build_full_name(node): +def build_full_name(node: ast.expr) -> str: # used for variables with dots parts = [] while isinstance(node, ast.Attribute): @@ -270,33 +268,33 @@ def build_full_name(node): class VariableExtractor(ast.NodeVisitor): - def __init__(self): - self.variables = set() + def __init__(self) -> None: + self.variables: set[str] = set() - def visit_Name(self, node): + def visit_Name(self, node: ast.Name) -> None: self.variables.add(node.id) - def visit_Attribute(self, node): + def visit_Attribute(self, node: ast.Attribute) -> None: self.variables.add(build_full_name(node)) class FormulaEvaluator(ast.NodeVisitor): - def __init__(self, values): + def __init__(self, values: dict[str, float | None]): self.values = values - def visit_BinOp(self, node): + def visit_BinOp(self, node: ast.BinOp) -> float: left = self.visit(node.left) right = self.visit(node.right) return OPERATORS[type(node.op)](left, right) # ty: ignore[too-many-positional-arguments] - def visit_Name(self, node): + def visit_Name(self, node: ast.Name) -> float | None: return self.values[node.id] - def visit_Attribute(self, node): + def visit_Attribute(self, node: ast.Attribute) -> float | None: return self.values[build_full_name(node)] - def visit_Constant(self, node): + def visit_Constant(self, node: ast.Constant) -> Any: return node.value - def generic_visit(self, node): + def generic_visit(self, node: ast.AST) -> Any: raise ValueError(f"Operation not permitted: {type(node).__name__}") From 2e21fc5a98aa409c01912fa0df7009338d7987e4 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 18 Jul 2026 21:21:54 +0500 Subject: [PATCH 033/142] assign_cost_item_quantity: fix indendation and missing `values` (de65e50) `values` dictionary was missing and variables were never collected to it, so `FormulaEvaluator(values)` was always resulting in missing variable error. --- .../api/cost/assign_cost_item_quantity.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py index a3e2e96265..c699bb1ebb 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py @@ -150,20 +150,22 @@ class Usecase: collector.visit(tree) variables = collector.variables + values: dict[str, float | None] = {} for variable in variables: getter = self.get_value_from_pset if "." in variable else self.get_value_from_qset value = getter(product, variable) + values[variable] = value - if value is None: - print( - f"WARNING: Variable '{variable}' in product '{product.Name}' " - f"is missing (None). Check Pset/Qset or property name." - ) - elif value == 0: - print( - f"WARNING: Variable '{variable}' in product '{product.Name}' " - f"has value 0. Verify if this is correct." - ) + if value is None: + print( + f"WARNING: Variable '{variable}' in product '{product.Name}' " + f"is missing (None). Check Pset/Qset or property name." + ) + elif value == 0: + print( + f"WARNING: Variable '{variable}' in product '{product.Name}' " + f"has value 0. Verify if this is correct." + ) evaluator = FormulaEvaluator(values) result = evaluator.visit(tree.body) From f744753726d4cd6bf72d0c786339247ef54f6b7d Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 18 Jul 2026 22:00:36 +0500 Subject: [PATCH 034/142] settings_mixin.build_parser: fix `ty == "bool"` typo, should be an assignment --- src/ifcopenshell-python/ifcopenshell/geom/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index bf5fc00098..b3c9afc869 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -254,7 +254,7 @@ class settings_mixin: } for nm in self.setting_names(): if nm == "use-python-opencascade": - ty == "bool" + ty = "bool" else: ty = self.get_type(nm) if ty == "bool": From b35f99e63f75510a3c09e567cc14d9f460ee5092 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 18 Jul 2026 20:35:10 +0500 Subject: [PATCH 035/142] ty: detect unresolved references --- nix/cache_dependencies.py | 1 + pyproject.toml | 4 --- src/bcf/bcf/v3/bcfapi.py | 6 ++++ src/bonsai/bonsai/bim/import_ifc.py | 2 ++ .../bonsai/bim/module/boundary/operator.py | 9 +++-- src/bonsai/bonsai/bim/module/cad/operator.py | 2 ++ .../bim/module/classification/operator.py | 2 ++ src/bonsai/bonsai/bim/module/cost/data.py | 2 ++ .../bonsai/bim/module/drawing/decoration.py | 33 +++++++++++++++---- .../bonsai/bim/module/drawing/helper.py | 5 +++ .../bonsai/bim/module/drawing/operator.py | 9 ++++- .../bonsai/bim/module/drawing/scheduler.py | 7 ++++ .../bonsai/bim/module/drawing/shaders.py | 13 +++++--- .../bonsai/bim/module/drawing/svgwriter.py | 2 ++ src/bonsai/bonsai/bim/module/drawing/ui.py | 4 +-- .../bonsai/bim/module/geometry/helper.py | 11 ++++++- .../bonsai/bim/module/geometry/operator.py | 5 +++ .../bonsai/bim/module/light/operator.py | 4 +++ src/bonsai/bonsai/bim/module/model/array.py | 1 + src/bonsai/bonsai/bim/module/model/mep.py | 4 +++ src/bonsai/bonsai/bim/module/model/product.py | 2 +- src/bonsai/bonsai/bim/module/model/profile.py | 6 ++++ src/bonsai/bonsai/bim/module/model/slab.py | 2 ++ src/bonsai/bonsai/bim/module/model/wall.py | 2 ++ .../bonsai/bim/module/project/operator.py | 4 +++ src/bonsai/bonsai/bim/module/pset/operator.py | 2 ++ src/bonsai/bonsai/bim/module/pset/prop.py | 2 ++ src/bonsai/bonsai/bim/module/pset/ui.py | 1 + src/bonsai/bonsai/bim/module/root/operator.py | 9 +++-- .../bonsai/bim/module/search/operator.py | 3 ++ src/bonsai/bonsai/bim/module/sequence/ui.py | 6 ++-- .../bonsai/bim/module/spatial/operator.py | 2 +- src/bonsai/bonsai/bim/module/spatial/ui.py | 2 ++ src/bonsai/bonsai/bim/module/style/ui.py | 1 + src/bonsai/bonsai/bim/module/void/operator.py | 2 ++ src/bonsai/bonsai/bim/ui.py | 4 +++ src/bonsai/bonsai/core/covering.py | 10 ++++-- src/bonsai/bonsai/core/drawing.py | 1 + src/bonsai/bonsai/tool/cad.py | 2 ++ src/bonsai/bonsai/tool/cost.py | 2 ++ src/bonsai/bonsai/tool/drawing.py | 6 ++-- src/bonsai/bonsai/tool/feature.py | 4 +-- src/bonsai/bonsai/tool/geometry.py | 6 ++++ src/bonsai/bonsai/tool/loader.py | 13 ++++++-- src/bonsai/bonsai/tool/misc.py | 2 ++ src/bonsai/bonsai/tool/parametric.py | 2 ++ src/bonsai/bonsai/tool/polyline.py | 2 ++ src/bonsai/bonsai/tool/project.py | 9 +++-- src/bonsai/bonsai/tool/search.py | 4 +++ src/bonsai/bonsai/tool/sequence.py | 7 ++-- src/bonsai/bonsai/tool/snap.py | 5 +++ src/bonsai/bonsai/tool/spatial.py | 9 ++++- src/bonsai/bonsai/tool/style.py | 5 +++ src/bonsai/scripts/bonsai_translations.py | 1 + .../classifications/brick_classifiction.py | 1 + .../scripts/generate_furniture_library.py | 2 ++ .../generate_steel_profiles_library.py | 2 ++ src/ifc2ca/ca2ifc.py | 8 +++++ src/ifc2ca/ifc2ca.py | 21 ++++++++++-- src/ifc5d/ifc5d/ifc2json.py | 2 ++ src/ifc5d/ifc5d/ifc5Dspreadsheet.py | 4 +++ src/ifcfm/ifcfm/__init__.py | 14 +++++--- src/ifcfm/ifcfm/cobie24.py | 2 ++ src/ifcfm/pyproject.toml | 1 + .../api/alignment/_add_segment_to_curve.py | 2 ++ .../_get_segment_start_point_label.py | 2 ++ .../api/alignment/create_as_polyline.py | 2 ++ .../api/alignment/create_from_csv.py | 6 ++++ .../api/classification/add_reference.py | 3 ++ .../ifcopenshell/api/cogo/bearing2dd.py | 2 ++ .../ifcopenshell/api/feature/add_feature.py | 2 ++ .../api/feature/remove_feature.py | 2 ++ .../api/geometry/add_representation.py | 8 +++++ .../api/geometry/disconnect_path.py | 6 ++++ .../api/georeference/edit_true_north.py | 2 ++ .../ifcopenshell/api/georeference/edit_wcs.py | 2 ++ .../add_structural_boundary_condition.py | 2 ++ .../api/style/assign_representation_styles.py | 2 ++ .../ifcopenshell/api/system/assign_system.py | 5 +-- .../ifcopenshell/api/unit/assign_unit.py | 4 +++ src/ifcopenshell-python/ifcopenshell/draw.py | 7 ++++ .../ifcopenshell/entity_instance.py | 2 +- src/ifcopenshell-python/ifcopenshell/file.py | 3 ++ .../ifcopenshell/geom/main.py | 9 ++--- .../ifcopenshell/ifcopenshell_wrapper.pyi | 5 ++- .../ifcopenshell/util/cost.py | 2 ++ .../ifcopenshell/util/element.py | 5 +++ .../ifcopenshell/util/geolocation.py | 2 ++ .../ifcopenshell/util/mvd_info.py | 18 +++++----- .../ifcopenshell/util/placement.py | 3 ++ .../ifcopenshell/util/schema.py | 5 ++- .../ifcopenshell/util/selector.py | 2 ++ .../ifcopenshell/util/shape_builder.py | 2 ++ .../ifcopenshell/validate.py | 20 ++++++----- src/ifcopenshell-python/test/test_file_gc.py | 6 ++-- .../recipes/AssignConstituentFractions.py | 2 ++ src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py | 2 +- src/ifctester/ifctester/facet.py | 11 +++++++ src/ifctester/ifctester/reporter.py | 4 +++ 99 files changed, 401 insertions(+), 84 deletions(-) diff --git a/nix/cache_dependencies.py b/nix/cache_dependencies.py index 7d231779ee..3ee03382ea 100644 --- a/nix/cache_dependencies.py +++ b/nix/cache_dependencies.py @@ -68,6 +68,7 @@ def unpack_dependencies(install_dir: Path) -> None: if __name__ == "__main__": + action = None if len(sys.argv) != 2 or (action := sys.argv[1].lower()) not in ("pack", "unpack"): print(__doc__) sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index 7d7f6719fc..4a7d22b0f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,8 +82,6 @@ ignore = [ all = "error" # Structural rules (no deep type inference needed, easier to adapt). -# Has false positives due to ty walrus operator bug. -possibly-unresolved-reference = "ignore" # Maybe later, requires to specify element types for all generics. missing-type-argument = "ignore" # Conflicts with `bpy` props defined using annotations. @@ -194,7 +192,6 @@ format.sequence = ["black", "ruff"] cmake-format = "gersemi . --in-place" [tool.poe.tasks.ty-ios] -# --ignore unresolved-reference: walrus operator false positives in ty. cmd = """ ty check nix/ @@ -212,7 +209,6 @@ cmd = """ src/ifcpatch src/ifctester --python=src/ifcopenshell-python/.venv - --ignore unresolved-reference """ [tool.poe.tasks.bonsai-deps] diff --git a/src/bcf/bcf/v3/bcfapi.py b/src/bcf/bcf/v3/bcfapi.py index 368c294cd0..515b2f2fe2 100644 --- a/src/bcf/bcf/v3/bcfapi.py +++ b/src/bcf/bcf/v3/bcfapi.py @@ -188,6 +188,8 @@ class BcfClient: response.raise_for_status() return response.status_code, response.text except requests.exceptions.HTTPError as errh: + response = errh.response + assert response is not None print(f"message: {response.reason}' '{response.status_code}, {errh}") return response.status_code, response.reason @@ -206,6 +208,8 @@ class BcfClient: response.raise_for_status() return response.status_code, response.text except requests.exceptions.HTTPError as errh: + response = errh.response + assert response is not None print(f"message: {response.reason}' '{response.status_code}, {errh}") return response.status_code, response.reason @@ -222,6 +226,8 @@ class BcfClient: response.raise_for_status() return response.status_code, response.text except requests.exceptions.HTTPError as errh: + response = errh.response + assert response is not None print(f"message: {response.reason}' '{response.status_code}, {errh}") return response.status_code, response.reason diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 06f4c4afff..4598c50099 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -1103,12 +1103,14 @@ class IfcImporter: vertices = [[v[i], v[i + 1], v[i + 2], 1] for i in range(0, len(v), 3)] edges = [[e[i], e[i + 1]] for i in range(0, len(e), 2)] v2 = None + polyline = None for edge in edges: v1 = vertices[edge[0]] if v1 != v2: polyline = curve.splines.new("POLY") polyline.points[-1].co = mathutils.Vector(v1) v2 = vertices[edge[1]] + assert polyline is not None polyline.points.add(1) polyline.points[-1].co = mathutils.Vector(v2) edges_item_ids = ifcopenshell.util.shape.get_edges_representation_item_ids(geometry).tolist() diff --git a/src/bonsai/bonsai/bim/module/boundary/operator.py b/src/bonsai/bonsai/bim/module/boundary/operator.py index 6d6404a1cb..9a89b2ae20 100644 --- a/src/bonsai/bonsai/bim/module/boundary/operator.py +++ b/src/bonsai/bonsai/bim/module/boundary/operator.py @@ -1059,6 +1059,7 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator): return tool.Ifc.get().createIfcConnectionSurfaceGeometry(surface) def export_surface(self, polygon, target_face_matrix): + ifc_file = tool.Ifc.get() x_axis = target_face_matrix.col[0][:3] z_axis = target_face_matrix.col[2][:3] p1 = target_face_matrix.translation @@ -1071,18 +1072,20 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator): placement = builder.create_axis2_placement_3d([o / self.unit_scale for o in p1], z_axis, x_axis) surface.BasisSurface = tool.Ifc.get().create_entity("IfcPlane", placement) - if tool.Ifc.get().schema != "IFC2X3": + schema = ifc_file.schema + if schema != "IFC2X3": points = [tool.Model.convert_si_to_unit(list(co)) for co in polygon.exterior.coords] point_list = tool.Ifc.get().createIfcCartesianPointList2D(points) outer_boundary = tool.Ifc.get().createIfcIndexedPolyCurve(point_list, None, False) - inner_boundaries = [] + inner_boundaries: list[ifcopenshell.entity_instance] = [] for interior in polygon.interiors: points = [tool.Model.convert_si_to_unit(list(co)) for co in interior.coords] point_list = tool.Ifc.get().createIfcCartesianPointList2D(points) inner_boundaries.append(tool.Ifc.get().createIfcIndexedPolyCurve(point_list, None, False)) else: - pass # TODO + # TODO: + raise NotImplementedError(schema) surface.OuterBoundary = outer_boundary surface.InnerBoundaries = inner_boundaries diff --git a/src/bonsai/bonsai/bim/module/cad/operator.py b/src/bonsai/bonsai/bim/module/cad/operator.py index 5d74857822..d30665ae4f 100644 --- a/src/bonsai/bonsai/bim/module/cad/operator.py +++ b/src/bonsai/bonsai/bim/module/cad/operator.py @@ -400,6 +400,7 @@ class CadOffset(bpy.types.Operator): [verts.update(e.verts) for e in edges] # Use the viewport angle to determine the offset direction + wp = None for area in bpy.context.screen.areas: if area.type == "VIEW_3D": # Don't ask me, I don't know. @@ -409,6 +410,7 @@ class CadOffset(bpy.types.Operator): z = area.spaces.active.region_3d.view_rotation @ Vector((0, 0, 1)) wp = Matrix([x, y, z, Vector((0, 0, 0))]).to_4x4().transposed() break + assert wp is not None rotation = Matrix.Rotation(pi / 2, 2, "Z") rotation_i = Matrix.Rotation(-pi / 2, 2, "Z") diff --git a/src/bonsai/bonsai/bim/module/classification/operator.py b/src/bonsai/bonsai/bim/module/classification/operator.py index 77b77ba541..7c07f4ab5a 100644 --- a/src/bonsai/bonsai/bim/module/classification/operator.py +++ b/src/bonsai/bonsai/bim/module/classification/operator.py @@ -478,6 +478,7 @@ class ChangeClassificationLevel(bpy.types.Operator): def execute(self, context): props = tool.Classification.get_classification_props() props.available_library_references.clear() + reference = None for reference in IfcStore.classification_file.by_id(self.parent_id).HasReferences: new = props.available_library_references.add() new.identification = reference.Identification or "" @@ -485,6 +486,7 @@ class ChangeClassificationLevel(bpy.types.Operator): new.ifc_definition_id = reference.id() new.has_references = bool(reference.HasReferences) new.referenced_source + assert reference if reference.ReferencedSource.is_a("IfcClassificationReference"): props.active_library_referenced_source = reference.ReferencedSource.ReferencedSource.id() else: diff --git a/src/bonsai/bonsai/bim/module/cost/data.py b/src/bonsai/bonsai/bim/module/cost/data.py index 32299b8e41..6b4e1f5814 100644 --- a/src/bonsai/bonsai/bim/module/cost/data.py +++ b/src/bonsai/bonsai/bim/module/cost/data.py @@ -156,6 +156,8 @@ class CostSchedulesData: values = root_element.CostValues elif root_element.is_a("IfcConstructionResource"): values = root_element.BaseCosts + else: + assert False, root_element for cost_value in values or []: cls._load_cost_value(root_element, data, cost_value) # data["CostValues"].append(cost_value.id()) diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index b9b4dd40cb..57cafab07b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -425,10 +425,12 @@ class BaseDecorator: blf.size(font_id, font_size_px) + w, h = None, None if box_alignment or center or vcenter: w, h = blf.dimensions(font_id, text) if box_alignment: + assert w is not None and h is not None box_alignment_offset = Vector((0, 0)) if "bottom" in box_alignment: pass @@ -450,10 +452,12 @@ class BaseDecorator: else: # horizontal centering if center: + assert w is not None pos -= Vector((cos, sin)) * w * 0.5 # vertical centering if vcenter: + assert h is not None pos -= Vector((-sin, cos)) * h * 0.5 # side-shifting @@ -1001,6 +1005,8 @@ class FallDecorator(BaseDecorator): O = A.copy() O.z = B.z run = (B - O).length + + angle_tg = None if run != 0: angle_tg = rise / run angle = round(degrees(atan(angle_tg))) @@ -1018,6 +1024,7 @@ class FallDecorator(BaseDecorator): elif object_type == "SLOPE_PERCENT": if angle == 90: return "-" + assert angle_tg is not None return f"{round(angle_tg * 100)} %" return "NO DATA" @@ -1249,6 +1256,7 @@ class SectionLevelDecorator(BaseDecorator): } # process edges + text_position, text_dir = None, None for edge in edges_original: v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]] start_i = len(output_verts) @@ -1554,32 +1562,39 @@ class SectionDecorator(BaseDecorator): v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]] start_i = len(output_verts) + circle_head = None if display_start_circle or display_end_circle: circle_head = get_circle_head(circle_size) - if display_start_symbol or display_end_symbol or connect_markers: + triangle_head, divider_offset, edge_dir_circle = None, None, None + display_symbol = display_start_symbol or display_end_symbol + if display_symbol or connect_markers: edge_dir = (v1 - v0).normalized() side = (edge_dir.yx * Vector((1, -1))).to_3d() edge_dir_circle = edge_dir * circle_size - if display_start_symbol or display_end_symbol: - triangle_head = get_triangle_head(edge_dir, -side, triangle_length, triangle_width) - divider_offset = [] - divider_offset.append(edge_dir_circle if connect_markers else edge_dir_circle * 3) - divider_offset.append(edge_dir_circle) + if display_symbol: + triangle_head = get_triangle_head(edge_dir, -side, triangle_length, triangle_width) + divider_offset = [] + divider_offset.append(edge_dir_circle if connect_markers else edge_dir_circle * 3) + divider_offset.append(edge_dir_circle) if display_start_circle: + assert circle_head is not None start_i = add_verts_sequence([v + v0 for v in circle_head], start_i, **out_kwargs, closed=True) # circle middle divider if not display_start_symbol: + assert divider_offset is not None start_i = add_verts_sequence( [v0 + divider_offset[0], v0 - divider_offset[1]], start_i, **out_kwargs ) if display_start_symbol: + assert triangle_head is not None start_i = add_verts_sequence([v + v0 for v in triangle_head], start_i, **out_kwargs, closed=True) if display_end_circle: + assert circle_head is not None start_i = add_verts_sequence([v + v1 for v in circle_head], start_i, **out_kwargs, closed=True) # circle middle divider if not display_end_symbol: @@ -1588,9 +1603,11 @@ class SectionDecorator(BaseDecorator): ) if display_end_symbol: + assert triangle_head is not None start_i = add_verts_sequence([v + v1 for v in triangle_head], start_i, **out_kwargs, closed=True) if connect_markers: + assert edge_dir_circle is not None gap = [] gap.append(edge_dir_circle if display_start_symbol else Vector((0, 0, 0))) gap.append(edge_dir_circle if display_end_symbol else Vector((0, 0, 0))) @@ -1871,6 +1888,8 @@ class CutDecorator: layer_set = material offset = 0 sense_factor = 1 + else: + assert False, material if len(layer_set.MaterialLayers) == 1: material = layer_set.MaterialLayers[0].Material @@ -1897,6 +1916,8 @@ class CutDecorator: co = Vector((0.0, 0.0, offset)) no = tool.Drawing.get_extrusion_vector(element).normalized() no = Vector([1.0, 0.0, 0.0]) + else: + assert False, usage no *= sense_factor last_i = len(layer_set.MaterialLayers) - 1 diff --git a/src/bonsai/bonsai/bim/module/drawing/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py index 4c707b81a8..73b1f9257d 100644 --- a/src/bonsai/bonsai/bim/module/drawing/helper.py +++ b/src/bonsai/bonsai/bim/module/drawing/helper.py @@ -225,9 +225,11 @@ def format_distance( unit_system, unit_length, unit_fraction = unit_mapping[custom_unit] value *= unit_scale + tx_dist = None # Imperial Formatting if unit_system == "IMPERIAL": + toInches = None if in_unit_length: if unit_length == "INCHES": toInches = 1 @@ -241,6 +243,7 @@ def format_distance( toInches = 1550 inPerFoot = 144 + assert toInches is not None decInches = value * toInches decFeet = decInches / 12 @@ -383,6 +386,7 @@ def format_distance( if precision and isinstance(precision, float): value = precision * round(float(value) / precision) + fmt = None if decimal_places is not None: fmt = "%1." + str(decimal_places) + "f" @@ -465,6 +469,7 @@ def format_distance( assert f"Unexpected unit_system - '{unit_system}'." # tx_dist = fmt % value + assert tx_dist is not None return tx_dist diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index b1c4c69c3b..4a08a3209b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -698,6 +698,8 @@ class CreateDrawing(bpy.types.Operator): layer_set = material offset = 0 sense_factor = 1 + else: + assert False, material camera_matrix_i = context.scene.camera.matrix_world.inverted() @@ -722,7 +724,6 @@ class CreateDrawing(bpy.types.Operator): bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001) bmesh.ops.triangle_fill(bm, use_dissolve=True, edges=bm.edges) - prev_co = None if not usage: sense_factor = 1 # Assume the extrusion vector points in the direction sense no = tool.Drawing.get_extrusion_vector(element).normalized() @@ -739,6 +740,8 @@ class CreateDrawing(bpy.types.Operator): co = Vector((0.0, 0.0, offset)) no = tool.Drawing.get_extrusion_vector(element).normalized() no = Vector([1.0, 0.0, 0.0]) + else: + assert False, usage no *= sense_factor last_i = len(layer_set.MaterialLayers) - 1 for i, layer in enumerate(layer_set.MaterialLayers): @@ -906,6 +909,10 @@ class CreateDrawing(bpy.types.Operator): if os.path.isfile(svg_path) and self.props.should_use_linework_cache: return svg_path + ifc = tool.Ifc.get() + semantics = None + pairs = None + # in case of printing multiple drawings we need to sync just once if self.sync and self.drawing_index == 0: with profile("sync"): diff --git a/src/bonsai/bonsai/bim/module/drawing/scheduler.py b/src/bonsai/bonsai/bim/module/drawing/scheduler.py index a1610020c4..a13d718e73 100644 --- a/src/bonsai/bonsai/bim/module/drawing/scheduler.py +++ b/src/bonsai/bonsai/bim/module/drawing/scheduler.py @@ -110,12 +110,14 @@ class Scheduler: y = self.margin rows = list(sheet.iter_rows()) total_rows = len(rows) + x = None for i, row in enumerate(rows): # The last row may contain only null values if i == (total_rows - 1) and not [c for c in row if c.value is not None]: continue x = self.margin + unmerged_height = None for cell in row: if isinstance(cell, openpyxl.cell.cell.MergedCell): column_letter = openpyxl.utils.get_column_letter(cell.column) @@ -230,8 +232,11 @@ class Scheduler: ) x += unmerged_width + + assert unmerged_height is not None y += unmerged_height + assert x is not None total_width = x + self.margin total_height = y + self.margin self.svg["width"] = "{}mm".format(total_width) @@ -375,6 +380,7 @@ class Scheduler: tri = 0 stop_iterating_over_rows = False # TODO: row spans support? + x = None for tr in table.getElementsByType(TableRow): if stop_iterating_over_rows: break @@ -491,6 +497,7 @@ class Scheduler: tri += 1 y += height + assert x is not None total_width = x + self.margin total_height = y + self.margin self.svg["width"] = "{}mm".format(total_width) diff --git a/src/bonsai/bonsai/bim/module/drawing/shaders.py b/src/bonsai/bonsai/bim/module/drawing/shaders.py index c050cf0f41..817491b282 100644 --- a/src/bonsai/bonsai/bim/module/drawing/shaders.py +++ b/src/bonsai/bonsai/bim/module/drawing/shaders.py @@ -102,16 +102,16 @@ void angle_circle_head( in vec4 circle_start, in float circle_angle, in bool counterclockwise, out vec4 head[CIRCLE_SEGS+1], out float angle_segs) { - + // 1 added to CIRCLE_SEGS because we're number of vertices // for n segments is n+1 - + float angle_d; angle_d = PI * 2 / CIRCLE_SEGS; // 30d // need to bottom clamp it to 1, otherwise it causes Blender crash at extruding the curve angle_segs = max(1, ceil(circle_angle / angle_d)); angle_d = circle_angle / angle_segs; - + for(int i = 0; i < (angle_segs + 1); i++) { float angle = angle_d * i; if (counterclockwise) { @@ -143,7 +143,7 @@ void cross_head(in vec4 dir, in float size, out vec4 head[3]) { #define do_vertex(pos, e) (do_vertex_util(pos, vec2(-(e).y, (e).x) / winsize.xy)) #define do_vertex_win(pos, e) ( do_vertex( WIN2CLIP( pos ), e ) ) -// if vertex is shared by two segments of the line still need to emit it twice +// if vertex is shared by two segments of the line still need to emit it twice // to avoid smoothing artifacts // don't forget to initialize `vec2 EDGE_DIR` for macro to work // `pos0` / `pos1` - vertex position in clip space @@ -197,10 +197,13 @@ void do_circle_head(vec4 pos_w, vec4 head[CIRCLE_SEGS]) { def add_verts_sequence(verts, start_i, output_verts, output_edges, closed=False): """Add sequence of verts to output lists, returns next vertex index""" + i = None for i, v in enumerate(verts[:-1], start_i): output_verts.append(v) output_edges.append((i, i + 1)) output_verts.append(verts[-1]) + assert i is not None + if closed: output_edges.append((i + 1, start_i)) return i + 2 @@ -273,7 +276,7 @@ class BaseShader: FRAG_GLSL = """ uniform vec4 color; uniform float lineWidth; - + in float smoothline; out vec4 fragColor; void main() { diff --git a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py index 8093a5e72a..e6cb797738 100644 --- a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py +++ b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py @@ -1449,6 +1449,7 @@ class SvgWriter: angle_tg = rise / run angle = round(degrees(atan(angle_tg))) else: + angle_tg = None angle = 90 # ues SLOPE_ANGLE as default @@ -1462,6 +1463,7 @@ class SvgWriter: elif object_type == "SLOPE_PERCENT": if angle == 90: return "-" + assert angle_tg is not None return f"{round(angle_tg * 100)} %" tag = element.Description or get_label_text() diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index e0df93a4a6..c65b651d36 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -964,14 +964,14 @@ class BIM_UL_sheets(bpy.types.UIList): if self.filter_name: filter_name = self.filter_name.lower() - active_sheet = None + active_sheet_index = None for sheet in data.sheets: if sheet.is_sheet: - active_sheet = sheet active_sheet_index = len(flt_flags) if filter_name in sheet.name.lower() or filter_name in sheet.identification.lower(): flt_flags.append(self.bitflag_filter_item) if not sheet.is_sheet: + assert active_sheet_index is not None flt_flags[active_sheet_index] = self.bitflag_filter_item else: flt_flags.append(0) diff --git a/src/bonsai/bonsai/bim/module/geometry/helper.py b/src/bonsai/bonsai/bim/module/geometry/helper.py index 3b14003435..defbd014b1 100644 --- a/src/bonsai/bonsai/bim/module/geometry/helper.py +++ b/src/bonsai/bonsai/bim/module/geometry/helper.py @@ -75,9 +75,13 @@ class Helper: for face in bm.faces: if len(face.verts) > 4: potential_faces.append(face) + + # TODO: replace with next(..., None) + face = None for face in potential_faces: if face.normal.z < -0.1: break + assert face is not None profile = [l.vert.index for l in face.loops] extrusion = self.detect_extrusion_edge(bm, face) @@ -108,10 +112,12 @@ class Helper: if not potential_faces: potential_faces = bm.faces + # TODO: replace with next(..., None) + face = None for face in potential_faces: if face.normal.z < -0.1: break - + assert face is not None profile = [l.vert.index for l in face.loops] extrusion = self.detect_extrusion_edge(bm, face) @@ -145,9 +151,12 @@ class Helper: if total_verts > 4: potential_faces.append(face) + # TODO: replace with next(..., None) + face = None for face in potential_faces: if face.normal.z < -0.1: break + assert face is not None end_faces = [] end_face_normal = face.normal diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 32428280c2..e8d2db55af 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -3527,12 +3527,15 @@ class EditRepresentationItemShapeAspect(bpy.types.Operator, tool.Ifc.Operator): if props.representation_item_shape_aspect == "NEW": active_representation = tool.Geometry.get_active_representation(obj) # find IfcProductRepresentationSelect based on current representation + product_shape = None if hasattr(element, "Representation"): # IfcProduct product_shape = element.Representation else: # IfcTypeProduct for representation_map in element.RepresentationMaps: if representation_map.MappedRepresentation == active_representation: product_shape = representation_map + assert product_shape is not None + previous_shape_aspect_id = props.active_item.shape_aspect_id # will be None if item didn't had a shape aspect previous_shape_aspect = tool.Ifc.get_entity_by_id(previous_shape_aspect_id) @@ -3882,6 +3885,8 @@ class AddSweptAreaSolidItem(bpy.types.Operator, tool.Ifc.Operator): curve = builder.rectangle(size=Vector((0.5, 0.5)) / unit_scale) elif self.shape == "CYLINDER": curve = builder.circle(radius=0.25 / unit_scale) + else: + assert False, self.shape item = builder.extrude( curve, magnitude=0.5 / unit_scale, diff --git a/src/bonsai/bonsai/bim/module/light/operator.py b/src/bonsai/bonsai/bim/module/light/operator.py index c3f377e9b9..a8920b83fe 100644 --- a/src/bonsai/bonsai/bim/module/light/operator.py +++ b/src/bonsai/bonsai/bim/module/light/operator.py @@ -156,6 +156,7 @@ class RadianceRender(bpy.types.Operator): print(f"Quality: {quality}, Detail: {detail}, Variability: {variability}") print(f"Output directory: {output_dir}") + hdr_image_path, hdr_mask_path, sky_map_cal_path = None, None if use_hdr: hdr_image = "noon_grass_2k.hdr" hdr_mask = "noon_grass_2k_mask.hdr" @@ -254,6 +255,9 @@ class RadianceRender(bpy.types.Operator): # 4 0 0 -1 180 if use_hdr and choose_hdr_image == "Noon": + assert hdr_image_path is not None + assert hdr_mask_path is not None + assert sky_map_cal_path is not None with open(sky_file_path, "w") as f: f.write(sky_description_str) diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index 5972189405..848d9aa98d 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -564,6 +564,7 @@ class SelectAllArrayObjects(bpy.types.Operator): except RuntimeError: self.report({"ERROR"}, f"Objects that don't have an array parent, were deselected.") object.select_set(False) + continue array_objects = tool.Array.get_all_objects(parent_element) tool.Blender.set_objects_selection( diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index 2a906d4ec4..7e12573497 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -408,6 +408,8 @@ class MEPGenerator: compare = tool.Cad.is_x(requested_value, fitting_value, compare_precision) elif isinstance(fitting_value, list): compare = tool.Cad.are_vectors_equal(requested_value, Vector(fitting_value), precision) + else: + assert False, f"{key} {second_key}" return compare ignore_keys = [] @@ -476,11 +478,13 @@ class MEPGenerator: if predefined_type == "OBSTRUCTION": return packed_data + start_port = None for port in ports: port_local_position = V(*port.ObjectPlacement.RelativePlacement.Location.Coordinates) if tool.Cad.is_x(port_local_position.length, 0.0): start_port = port break + assert start_port is not None connected_port = tool.System.get_connected_port(start_port) connected_element = tool.System.get_port_relating_element(connected_port) diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index 75f5441827..b10d0f3387 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -325,7 +325,7 @@ class AddOccurrence(bpy.types.Operator, tool.Ifc.Operator): if self.from_invoke and str(self.relating_type_id) in AuthoringData.data["relating_type_id"]: props.relating_type_id = str(self.relating_type_id) - building_obj = None + building_obj, building_element = None, None if len(context.selected_objects) == 1 and context.active_object: building_obj = context.active_object building_element = tool.Ifc.get_entity(building_obj) diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index efbf41900d..97edc1cd20 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -593,6 +593,8 @@ class DumbProfileJoiner: axisl = (profile2.matrix_world.inverted() @ axis1[1]) - (profile2.matrix_world.inverted() @ axis1[0]) elif connection1 == "ATSTART": axisl = (profile2.matrix_world.inverted() @ axis1[0]) - (profile2.matrix_world.inverted() @ axis1[1]) + else: + assert False, connection1 xy_angle = degrees(Vector((1, 0)).angle_signed(axisl.normalized().to_2d())) if xy_angle >= -135 and xy_angle <= -45: closest_plane = "bottom" @@ -617,6 +619,8 @@ class DumbProfileJoiner: axisl = (profile1.matrix_world.inverted() @ axis2[1]) - (profile1.matrix_world.inverted() @ axis2[0]) elif connection2 == "ATSTART": axisl = (profile1.matrix_world.inverted() @ axis2[0]) - (profile1.matrix_world.inverted() @ axis2[1]) + else: + assert False, connection2 xy_angle2 = degrees(Vector((1, 0)).angle_signed(axisl.normalized().to_2d())) if xy_angle2 >= -135 and xy_angle2 <= -45: closest_plane2 = "bottom" @@ -844,6 +848,8 @@ class DumbProfileJoiner: else: y_axis = obj.matrix_world.to_quaternion() @ Vector((0, 1, 0)) z_axis = obj.matrix_world.to_quaternion() @ Vector((-1, 0, 0)) + else: + assert False, plane return self.create_matrix(p, x_axis, y_axis, z_axis) def create_matrix(self, p: Vector, x: Vector, y: Vector, z: Vector) -> Matrix: diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 4f47740e6d..892fbb89cc 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -508,6 +508,7 @@ class EditSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): converter.run() profile = tool.Ifc.get().createIfcArbitraryClosedProfileDef("AREA") + curve = None for path in converter.paths: points = [] lines = path[0] @@ -517,6 +518,7 @@ class EditSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator): points.append(tool.Ifc.get().createIfcCartesianPoint(local_point)) points.append(points[0]) curve = tool.Ifc.get().createIfcPolyline(points) + assert curve profile.OuterCurve = curve old_profile = extrusion.SweptArea diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index eaf4c356be..5b73f5641b 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -1577,6 +1577,7 @@ class DumbWallJoiner: # Get the ATEND connection from wall1 to use it in wall2 relating_element = None connections = element1.ConnectedTo + relating_connection, description = ..., ... for conn in connections: if conn.is_a("IfcRelConnectsPathElements") and conn.RelatingConnectionType == "ATEND": relating_element = conn.RelatedElement @@ -1591,6 +1592,7 @@ class DumbWallJoiner: description = conn.Description bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn) if relating_element: + assert relating_connection is not ... and description is not ... ifcopenshell.api.geometry.connect_path( tool.Ifc.get(), relating_element=relating_element, diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 032fa3e40c..02ae75792b 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -714,6 +714,8 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): representations = element.RepresentationMaps or [] elif element.is_a("IfcProduct"): representations = [element.Representation] if element.Representation else [] + else: + assert False, element for representation in representations or []: for element in self.file.traverse(representation): if not element.is_a("IfcRepresentationItem") or not element.StyledByItem: @@ -2029,6 +2031,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): project_props = tool.Project.get_project_props() prefs = tool.Blender.get_addon_preferences() project_props.use_relative_project_path = self.use_relative_path + old_history_size, old_undo_steps = None, None if prefs.should_disable_undo_on_save: old_history_size = tool.Ifc.get().history_size old_undo_steps = context.preferences.edit.undo_steps @@ -2036,6 +2039,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper): context.preferences.edit.undo_steps = 0 IfcStore.execute_ifc_operator(self, context) if prefs.should_disable_undo_on_save: + assert old_history_size is not None and old_undo_steps is not None tool.Ifc.get().history_size = old_history_size context.preferences.edit.undo_steps = old_undo_steps return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/pset/operator.py b/src/bonsai/bonsai/bim/module/pset/operator.py index ea1d39fb96..f820038807 100644 --- a/src/bonsai/bonsai/bim/module/pset/operator.py +++ b/src/bonsai/bonsai/bim/module/pset/operator.py @@ -113,6 +113,8 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator): elif props.active_pset_type == "QTO": pset = ifcopenshell.api.pset.add_qto(self.file, product=element, name=props.active_pset_name) props.active_pset_id = pset.id() + else: + assert False if self.properties: properties = json.loads(self.properties) diff --git a/src/bonsai/bonsai/bim/module/pset/prop.py b/src/bonsai/bonsai/bim/module/pset/prop.py index 786e6a62a2..6d1d59be1d 100644 --- a/src/bonsai/bonsai/bim/module/pset/prop.py +++ b/src/bonsai/bonsai/bim/module/pset/prop.py @@ -228,6 +228,8 @@ def get_qto_name(self: "PsetProperties", context: bpy.types.Context) -> tool.Ble if "bpy.data.objects" in pset_type: if prop_type == "PsetProperties": results = get_object_qto_name(self, context) + else: + assert False elif prop_type == "TaskPsetProperties": results = get_task_qto_names(self, context) elif prop_type == "ResourcePsetProperties": diff --git a/src/bonsai/bonsai/bim/module/pset/ui.py b/src/bonsai/bonsai/bim/module/pset/ui.py index addd03a504..df99acd73c 100644 --- a/src/bonsai/bonsai/bim/module/pset/ui.py +++ b/src/bonsai/bonsai/bim/module/pset/ui.py @@ -480,6 +480,7 @@ class BIM_PT_material_psets(Panel): def draw(self, context): assert self.layout props = tool.Material.get_material_props() + ifc_definition_id = None if material := props.active_material: ifc_definition_id = material.ifc_definition_id diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 993d3bfb4b..b1c13a8368 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -630,10 +630,13 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): local_z = wall_matrix.to_3x3() @ Vector((0, 0, 1)) direction_sense = getattr(usage, "DirectionSense", "POSITIVE") - if usage.LayerSetDirection == "AXIS2": + layer_set_direction = usage.LayerSetDirection + if layer_set_direction == "AXIS2": z_axis = tuple(local_y) if direction_sense == "POSITIVE" else tuple(-local_y) - elif usage.LayerSetDirection == "AXIS3": + elif layer_set_direction == "AXIS3": z_axis = tuple(local_z) if direction_sense == "POSITIVE" else tuple(-local_z) + else: + assert False, layer_set_direction item = builder.extrude( profile, @@ -763,6 +766,8 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): WebThickness=default_web_thickness / unit_scale, FlangeThickness=default_flange_thickness / unit_scale, ) + else: + assert False, representation_template rel = ifcopenshell.api.material.assign_material( tool.Ifc.get(), products=[element], type="IfcMaterialProfileSet" diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index f55dcf31b7..b17019f5f2 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -1009,6 +1009,7 @@ class ColourByProperty(Operator): palette = props.palette is_qualitative = palette in ("tab10", "paired") + colours = None if is_qualitative: colours = tool.Search.get_qualitative_palette(palette) @@ -1035,6 +1036,7 @@ class ColourByProperty(Operator): if value in colourscheme: colourscheme[value]["total"] += 1 else: + assert colours is not None colourscheme[value] = {"colour": next(colours)[0:3], "total": 1} obj.color = (*colourscheme[value]["colour"], 1) else: @@ -1139,6 +1141,7 @@ class SelectByProperty(Operator): is_qualitative = palette in ("tab10", "paired") + values = None if not is_qualitative: values = [] for colour in props.colourscheme: diff --git a/src/bonsai/bonsai/bim/module/sequence/ui.py b/src/bonsai/bonsai/bim/module/sequence/ui.py index 56847fa750..e393c425c2 100644 --- a/src/bonsai/bonsai/bim/module/sequence/ui.py +++ b/src/bonsai/bonsai/bim/module/sequence/ui.py @@ -281,11 +281,11 @@ class BIM_PT_work_schedules(Panel): def draw_task_operators(self) -> None: row = self.layout.row(align=True) row.alignment = "RIGHT" - ifc_definition_id = None + task, ifc_definition_id = None, None if self.tprops.tasks and self.props.active_task_index < len(self.tprops.tasks): task = self.tprops.tasks[self.props.active_task_index] ifc_definition_id = task.ifc_definition_id - if ifc_definition_id: + if task and ifc_definition_id: if self.props.active_task_id: if self.props.editing_task_type == "TASKTIME": row.operator("bim.edit_task_time", text="", icon="CHECKMARK") @@ -341,6 +341,8 @@ class BIM_PT_work_schedules(Panel): row.prop(self.props, "other_columns", text="") column_type, name = self.props.other_columns.split(".") data_type = "string" + else: + assert False, column_type row.operator("bim.set_task_sort_column", text="", icon="SORTALPHA").column = f"{column_type}.{name}" row.prop( self.props, "is_sort_reversed", text="", icon="SORT_DESC" if self.props.is_sort_reversed else "SORT_ASC" diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 7279b3b6c4..b97665c6c3 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -516,7 +516,7 @@ class SetContainerVisibility(bpy.types.Operator): if self.mode == "ISOLATE": if tool.Ifc.get_schema() == "IFC2X3": containers = tool.Ifc.get().by_type("IfcSpatialStructureElement") - elif tool.Ifc.get_schema() != "IFC2X3": + else: containers = set(tool.Ifc.get().by_type("IfcSpatialElement")) containers -= set(tool.Ifc.get().by_type("IfcSpatialZone")) for container in containers: diff --git a/src/bonsai/bonsai/bim/module/spatial/ui.py b/src/bonsai/bonsai/bim/module/spatial/ui.py index 3a4a771e01..00fe3110a8 100644 --- a/src/bonsai/bonsai/bim/module/spatial/ui.py +++ b/src/bonsai/bonsai/bim/module/spatial/ui.py @@ -125,6 +125,7 @@ class BIM_PT_spatial_decomposition(Panel): row.label(text="Warning: No Default Container", icon="ERROR") row.operator("bim.import_spatial_decomposition", icon="FILE_REFRESH", text="") + ifc_definition_id = None if self.props.active_container: ifc_definition_id = self.props.active_container.ifc_definition_id row = self.layout.row(align=True) @@ -170,6 +171,7 @@ class BIM_PT_spatial_decomposition(Panel): if not self.props.active_container: return + assert ifc_definition_id is not None container_has_elements = bool(self.props.total_elements) if container_has_elements: diff --git a/src/bonsai/bonsai/bim/module/style/ui.py b/src/bonsai/bonsai/bim/module/style/ui.py index 4e3a1daccb..45120ae1ed 100644 --- a/src/bonsai/bonsai/bim/module/style/ui.py +++ b/src/bonsai/bonsai/bim/module/style/ui.py @@ -102,6 +102,7 @@ class BIM_PT_styles(Panel): # style ui tools if active_style: + style = active_style row = self.layout.row(align=True) if material := style.blender_material: msprops = tool.Style.get_material_style_props(material) diff --git a/src/bonsai/bonsai/bim/module/void/operator.py b/src/bonsai/bonsai/bim/module/void/operator.py index 433ea3a06b..bf24b5c1a9 100644 --- a/src/bonsai/bonsai/bim/module/void/operator.py +++ b/src/bonsai/bonsai/bim/module/void/operator.py @@ -72,6 +72,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): opening_objects = [obj for obj in selected_objects if obj != target_object] + obj1 = ... for opening_obj in opening_objects: element1 = tool.Ifc.get_entity(target_object) obj1 = target_object @@ -196,6 +197,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): bpy.data.objects.remove(obj2) tool.Model.purge_scene_openings() + assert obj1 is not ... context.view_layer.objects.active = obj1 return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 42092ff558..5e79cba3aa 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -284,11 +284,13 @@ class GizmoPreferences(bpy.types.PropertyGroup): draw_gizmos_in_3d_viewport: bool +_gizmo_pref_entry = None for _gizmo_pref_entry in tool.Parametric.EDIT_TYPES: GizmoPreferences.__annotations__[_gizmo_pref_entry.name] = BoolProperty( name=_gizmo_pref_entry.name.replace("_", " ").title(), default=True, ) +assert _gizmo_pref_entry is not None del _gizmo_pref_entry @@ -394,12 +396,14 @@ class DefaultParameters(bpy.types.PropertyGroup): and gives the create operator a preset to copy from.""" +_default_params_entry = None for _default_params_entry in tool.Parametric.EDIT_TYPES: if not _default_params_entry.has_default_parameters: continue DefaultParameters.__annotations__[_default_params_entry.name] = bpy.props.PointerProperty( type=getattr(_model_prop, _default_params_entry.props_attr), ) +assert _default_params_entry is not None del _default_params_entry diff --git a/src/bonsai/bonsai/core/covering.py b/src/bonsai/bonsai/core/covering.py index 2ab0d39cc4..cd43313658 100644 --- a/src/bonsai/bonsai/core/covering.py +++ b/src/bonsai/bonsai/core/covering.py @@ -74,10 +74,11 @@ def add_instance_ceiling_covering_from_cursor( if not relating_type.is_a("IfcCoveringType"): relating_type = None + ceiling_height = None if selected_objects and active_obj: - x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_obj(active_obj) + x, y, z, _, _ = spatial.get_x_y_z_h_mat_from_obj(active_obj) else: - x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor() + x, y, z, _, _ = spatial.get_x_y_z_h_mat_from_cursor() ceiling_height = covering.get_z_from_ceiling_height() space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y) @@ -87,6 +88,7 @@ def add_instance_ceiling_covering_from_cursor( obj = spatial.create_object("Covering") spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj) + assert ceiling_height is not None spatial.translate_obj_to_z_location(obj, z + ceiling_height) spatial.assign_type_to_obj(obj) spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True) @@ -100,7 +102,9 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa selected_objects = spatial.get_selected_objects() if selected_objects and active_obj: - x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_obj(active_obj) + x, y, _, _, _ = spatial.get_x_y_z_h_mat_from_obj(active_obj) + else: + assert False, "Object has to be active and selected." space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y) diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 5192aeaa37..c6a7231273 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -497,6 +497,7 @@ def add_annotation( drawing_tool.show_decorations() obj = drawing_tool.create_annotation_object(drawing, object_type) element = ifc.get_entity(obj) + relating_type_rep = None if not element: # Brand new annotation relating_type_rep = drawing_tool.get_annotation_representation(relating_type) if relating_type else None element = drawing_tool.run_root_assign_class( diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index 2a1f5f9c69..ad23f55a5d 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -981,6 +981,7 @@ class Cad: has_found_connected_edge = True loops.append(loop) + new_verts = None for loop in loops: all_verts = {v.index for e in loop for v in e.verts} possible_v1s = [] @@ -1084,6 +1085,7 @@ class Cad: break v1 = v2 + assert new_verts is not None return new_verts diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index fc07a629a6..a0ee797e0b 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -280,6 +280,8 @@ class Cost(bonsai.core.tool.Cost): new = props.cost_item_processes.add() elif related_object.is_a("IfcResource"): new = props.cost_item_resources.add() + else: + assert False, related_object new.ifc_definition_id = related_object.id() new.name = related_object.Name or "Unnamed" diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 7493b02d33..c309444473 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -2575,16 +2575,15 @@ class Drawing(bonsai.core.tool.Drawing): if not obj: continue current_representation = tool.Geometry.get_active_representation(obj) + current_representation_subcontext = None if current_representation: subcontext = current_representation.ContextOfItems current_representation_subcontext = tool.Geometry.get_subcontext_parameters(subcontext) - has_context = False for subcontext in subcontexts: # prioritize already active representation if it matches the subcontext # (element could have multiple representations in the same subcontext) - if current_representation and subcontext == current_representation_subcontext: - has_context = True + if current_representation_subcontext and subcontext == current_representation_subcontext: break priority_representation = ifcopenshell.util.representation.get_representation(element, *subcontext) if priority_representation: @@ -2594,7 +2593,6 @@ class Drawing(bonsai.core.tool.Drawing): obj=obj, representation=priority_representation, ) - has_context = True break linked_handles: set[bpy.types.Object] = set() diff --git a/src/bonsai/bonsai/tool/feature.py b/src/bonsai/bonsai/tool/feature.py index 3a06cbf625..7bc9a875fd 100644 --- a/src/bonsai/bonsai/tool/feature.py +++ b/src/bonsai/bonsai/tool/feature.py @@ -23,7 +23,6 @@ from typing import TYPE_CHECKING import bpy import ifcopenshell.api.feature -import ifcopenshell.util.representation import bonsai.core.geometry import bonsai.core.tool @@ -50,6 +49,7 @@ class Feature(bonsai.core.tool.Feature): has_visible_openings = True break + element_had_openings = None for feature_obj in feature_objs: feature_element = tool.Ifc.get_entity(feature_obj) @@ -58,7 +58,6 @@ class Feature(bonsai.core.tool.Feature): bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=featured_obj) element_had_openings = tool.Geometry.has_openings(featured_element) - body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body") ifcopenshell.api.feature.add_feature(tool.Ifc.get(), feature=feature_element, element=featured_element) if tool.Ifc.is_moved(feature_obj): @@ -73,6 +72,7 @@ class Feature(bonsai.core.tool.Feature): if voided_obj.data: if tool.Ifc.is_edited(voided_obj): voided_element_ = tool.Ifc.get_entity(voided_obj) + assert element_had_openings is not None if element_had_openings or (voided_element_ != featured_element and voided_element_.HasOpenings): voided_obj.scale = (1.0, 1.0, 1.0) tool.Ifc.finish_edit(voided_obj) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 08abd38b22..2475f6c9e9 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -757,6 +757,7 @@ class Geometry(bonsai.core.tool.Geometry): # its centroid not obscured (tested via raycasting) by any other # face. distance = max(obj.dimensions.xyz) + min_y, max_z = None, None if axis == "+Z": max_z = max([co[2] for co in obj.bound_box]) + 0.002 direction = Vector((0, 0, -1)) @@ -771,8 +772,10 @@ class Geometry(bonsai.core.tool.Geometry): if direction.dot(face.normal) > 0: continue if axis == "+Z": + assert max_z is not None face_centroid_at_max = Vector((*face.calc_center_median().xy, max_z)) elif axis == "-Y": + assert min_y is not None centroid = face.calc_center_median() face_centroid_at_max = Vector((centroid.x, min_y, centroid.z)) face_centroid_at_max = obj.matrix_world @ face_centroid_at_max @@ -1885,6 +1888,7 @@ class Geometry(bonsai.core.tool.Geometry): """NOTE: we assume that all items belonged to the same representation and to the same shape aspect""" ifc_file = tool.Ifc.get() previous_shape_aspect = None + base_representation = None for inverse in ifc_file.get_inverse(representation_items[0]): if inverse.is_a("IfcShapeRepresentation"): if inverse.OfShapeAspect: @@ -1894,6 +1898,7 @@ class Geometry(bonsai.core.tool.Geometry): previous_shape_aspect = inverse.OfShapeAspect[0] else: base_representation = inverse + assert base_representation # remove item from previous shape aspect if previous_shape_aspect: @@ -2211,6 +2216,7 @@ class Geometry(bonsai.core.tool.Geometry): assert item obj.data.clear_geometry() + cartesian_point_offset = None if item.is_a("IfcHalfSpaceSolid"): bm = bmesh.new() bmesh.ops.create_grid(bm, size=0.5) diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index 383be821e2..d661f3205b 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -1087,18 +1087,21 @@ class Loader(bonsai.core.tool.Loader): bm = bmesh.new() bm.from_mesh(mesh) prev_co = None - if usage.LayerSetDirection == "AXIS2": + layer_set_direction = usage.LayerSetDirection + if layer_set_direction == "AXIS2": co = Vector((0.0, offset, 0.0)) no = cls.get_extrusion_vector(element).normalized() no = no.cross(Vector([1.0, 0.0, 0.0])) - elif usage.LayerSetDirection == "AXIS3": + elif layer_set_direction == "AXIS3": co = Vector((0.0, 0.0, offset)) no = cls.get_extrusion_vector(element).normalized() no = Vector([0.0, 0.0, 1.0]) - elif usage.LayerSetDirection == "AXIS1": + elif layer_set_direction == "AXIS1": co = Vector((0.0, 0.0, offset)) no = cls.get_extrusion_vector(element).normalized() no = Vector([1.0, 0.0, 0.0]) + else: + assert False, layer_set_direction no *= sense_factor # Cache this body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") @@ -1108,6 +1111,7 @@ class Loader(bonsai.core.tool.Loader): if style := tool.Ifc.get_entity(material): styles[style] = i last_i = len(layer_set.MaterialLayers) - 1 + bisect_geom = None for i, layer in enumerate(layer_set.MaterialLayers): if i != last_i: prev_co = co.copy() @@ -1121,6 +1125,7 @@ class Loader(bonsai.core.tool.Loader): if (material_index := styles.get(style, None)) is None: material_index = len(mesh.materials) mesh.materials.append(tool.Ifc.get_object(style)) + assert bisect_geom is not None if i == last_i: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace): @@ -1286,6 +1291,7 @@ class Loader(bonsai.core.tool.Loader): polyline.material_index = material_index return polyline + item = None for item_data, item_style in zip(rep_items, item_styles): item = item_data["item"] @@ -1313,6 +1319,7 @@ class Loader(bonsai.core.tool.Loader): polyline.points.add(1) polyline.points[-1].co = native_data["matrix"] @ Vector(v2) + assert item is not None curve.bevel_depth = unit_scale * item.Radius thickness = None if (inner_radius := item.InnerRadius) and (thickness := max(item.Radius - inner_radius, 0)): diff --git a/src/bonsai/bonsai/tool/misc.py b/src/bonsai/bonsai/tool/misc.py index 893b37ce31..8e42133941 100644 --- a/src/bonsai/bonsai/tool/misc.py +++ b/src/bonsai/bonsai/tool/misc.py @@ -220,10 +220,12 @@ class Misc(bonsai.core.tool.Misc): related_objects.append((element, ifcopenshell.util.placement.get_storey_elevation(element))) related_objects = sorted(related_objects, key=lambda e: e[1]) storey_elevation = None + i = None for i, related_object in enumerate(related_objects): if related_object[0] == storey: storey_elevation = related_object[1] break + assert i is not None if i + total_storeys < len(related_objects): next_storey_elevation = related_objects[i + total_storeys][1] unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py index 623405fe16..adf019874a 100644 --- a/src/bonsai/bonsai/tool/parametric.py +++ b/src/bonsai/bonsai/tool/parametric.py @@ -641,6 +641,8 @@ del _edit_type_names # call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a # registry entry renames the constant; a typo at the call site surfaces as # AttributeError at module load. +_entry = None for _entry in Parametric.EDIT_TYPES: setattr(Parametric, _entry.name.upper(), _entry) +assert _entry is not None del _entry diff --git a/src/bonsai/bonsai/tool/polyline.py b/src/bonsai/bonsai/tool/polyline.py index 542bc23005..883d811448 100644 --- a/src/bonsai/bonsai/tool/polyline.py +++ b/src/bonsai/bonsai/tool/polyline.py @@ -168,6 +168,7 @@ class Polyline(bonsai.core.tool.Polyline): distance = (mouse_vector - last_point).length if distance < 0: return + angle, orientation_angle, angle_round_threshold = None, None if distance > 0: angle = tool.Cad.angle_3_vectors( second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True @@ -188,6 +189,7 @@ class Polyline(bonsai.core.tool.Polyline): angle = 0 orientation_angle = 0 if input_ui: + assert angle is not None and orientation_angle is not None and angle_round_threshold is not None if should_round: angle_snap = tool.Snap.get_angle_snap_value(context) angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 7199c125cf..1d7a2dc2e0 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -370,18 +370,21 @@ class Project(bonsai.core.tool.Project): props = cls.get_project_props() active_library_breadcrumb = props.get_active_library_breadcrumb() change_back = False + breadcrumb = None if active_library_breadcrumb: name = active_library_breadcrumb.name breadcrumb_type = active_library_breadcrumb.breadcrumb_type library_id = active_library_breadcrumb.library_id + breadcrumb = (name, breadcrumb_type, library_id) change_back = True bpy.ops.bim.rewind_library() if change_back: + assert breadcrumb bpy.ops.bim.change_library_element( - element_name=name, - breadcrumb_type=breadcrumb_type, - library_id=library_id, + element_name=breadcrumb[0], + breadcrumb_type=breadcrumb[1], + library_id=breadcrumb[2], ) @classmethod diff --git a/src/bonsai/bonsai/tool/search.py b/src/bonsai/bonsai/tool/search.py index 356addee3a..85c1ecac70 100644 --- a/src/bonsai/bonsai/tool/search.py +++ b/src/bonsai/bonsai/tool/search.py @@ -505,6 +505,8 @@ class Search(bonsai.core.tool.Search): (0.773, 0.922, 0.816), (0.871, 0.957, 0.894), ] + else: + assert False, theme if value < min_val: value = min_val @@ -574,8 +576,10 @@ class ImportFilterQueryTransformer(lark.Transformer): new = self.filter_groups.add() global_ids = [] is_first_group = len(self.filter_groups) == 1 + new2 = None for filter_index, arg in enumerate(args): if arg["type"] == "instance" and global_ids: + assert new2 if "bpy.data.texts" in new2.value: data_name = new2.value.split("bpy.data.texts")[1][2:-2] bpy.data.texts[data_name].write("," + arg["value"]) diff --git a/src/bonsai/bonsai/tool/sequence.py b/src/bonsai/bonsai/tool/sequence.py index 2afed8741a..cc6c08b8da 100644 --- a/src/bonsai/bonsai/tool/sequence.py +++ b/src/bonsai/bonsai/tool/sequence.py @@ -23,7 +23,7 @@ import re from collections.abc import Iterable from datetime import datetime from datetime import time as datetime_time -from typing import TYPE_CHECKING, Any, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Union, assert_never import bpy import ifcopenshell @@ -1127,7 +1127,8 @@ class Sequence(bonsai.core.tool.Sequence): @classmethod def load_default_animation_color_scheme(cls): - groups = { + GroupType = Literal["CREATION", "OPERATION", "MOVEMENT_TO", "DESTRUCTION", "MOVEMENT_FROM", "USERDEFINED"] + groups: dict[GroupType, dict[str, Any]] = { "CREATION": { "PredefinedType": ["CONSTRUCTION", "INSTALLATION"], "Color": (0.0, 1.0, 0.0), @@ -1167,6 +1168,8 @@ class Sequence(bonsai.core.tool.Sequence): predefined_type_item2 = props.task_output_colors.add() predefined_type_item2.name = predefined_type predefined_type_item2.color = data["Color"] + else: + assert_never(group) # TO DO: consider cases where users confuses inputs and outputs predefined_type_item.name = predefined_type predefined_type_item.color = data["Color"] diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index 5755c02ba7..a9723c7436 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -225,6 +225,7 @@ class Snap(bonsai.core.tool.Snap): # Get axis that are closer than the stick factor threshold elegible_axis = [] + axis = None for axis in snap_axis: if not axis: continue @@ -326,6 +327,7 @@ class Snap(bonsai.core.tool.Snap): detected_snaps: list[dict[str, Any]] = [] def select_plane_method(): + plane_origin, plane_normal = None, None if not last_polyline_point: plane_origin = Vector((0, 0, 0)) plane_normal = Vector((0, 0, 1)) @@ -357,6 +359,7 @@ class Snap(bonsai.core.tool.Snap): plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z)) plane_normal = Vector((1, 0, 0)) + assert plane_origin and plane_normal plane_normal = tool.Polyline.use_transform_orientations(plane_normal) return plane_origin, plane_normal @@ -583,6 +586,7 @@ class Snap(bonsai.core.tool.Snap): snaps_by_group = filter_snapping_points_by_group(detected_snaps) edges = [] # Get edges to create edge-intersection snap + axis_start, axis_end = ..., ... for snapping_point in snaps_by_group: if snapping_point["group"] in {"Polyline", "Measure", "Wireframe", "Object"}: if snapping_point["type"] == "Edge": @@ -607,6 +611,7 @@ class Snap(bonsai.core.tool.Snap): if point["type"] == "Axis": if ordered_snaps[0]["type"] not in {"Axis", "Plane"}: obj = ordered_snaps[0]["object"] + assert axis_start is not ... and axis_end is not ... mixed_snap = cls.mix_snap_and_axis(ordered_snaps[0], axis_start, axis_end) for mixed_point in mixed_snap: snap_point = { diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index df87d493c7..875023d34b 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -304,12 +304,14 @@ class Spatial(bonsai.core.tool.Spatial): while True: has_parent = None + new_current_results = None for key in current_results: if flat_key.startswith(key): has_parent = True new_current_results = current_results[key]["children"] break if has_parent: + assert new_current_results is not None current_results = new_current_results else: break @@ -978,19 +980,24 @@ class Spatial(bonsai.core.tool.Spatial): interiors_list = [] if union_geom.geom_type == "MultiPolygon": + poly = None for poly in union_geom.geoms: interiors_list = cls.get_poly_valid_interior_list( poly=poly, min_area=min_area, interiors_list=interiors_list ) + assert poly new_poly = Polygon(poly.exterior.coords, holes=interiors_list) - if union_geom.geom_type == "Polygon": + elif union_geom.geom_type == "Polygon": interiors_list = cls.get_poly_valid_interior_list( poly=union_geom, min_area=min_area, interiors_list=interiors_list ) new_poly = Polygon(union_geom.exterior.coords, holes=interiors_list) + else: + assert False, union_geom.geom_type + return new_poly @classmethod diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index 83f1751e96..aa3e508ef0 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -360,6 +360,10 @@ class Style(bonsai.core.tool.Style): material_output = tool.Blender.get_material_node(obj, "OUTPUT_MATERIAL", {"is_active_output": True}) surface_output = get_input_node(material_output, "Surface") + # TODO: this variable is not really needed, + # just workaround a for ty issue detecting unresolved refs. + bsdf = None + if surface_output and surface_output.type == "MIX_SHADER": mix_shader = surface_output if ( @@ -388,6 +392,7 @@ class Style(bonsai.core.tool.Style): and (bsdf := get_input_node(surface_output, input_index=1, of_type="BSDF_PRINCIPLED")) ) ): + assert bsdf report(f"Because of {BLUE}BSDF_PRINCIPLED{R} node reflectance method identified as {BLUE}PHYSICAL{R}") attributes["ReflectanceMethod"] = "NOTDEFINED" if tool.Ifc.get_schema() != "IFC4X3" else "PHYSICAL" diff --git a/src/bonsai/scripts/bonsai_translations.py b/src/bonsai/scripts/bonsai_translations.py index 7b6ae960db..bb7c6ae569 100644 --- a/src/bonsai/scripts/bonsai_translations.py +++ b/src/bonsai/scripts/bonsai_translations.py @@ -216,6 +216,7 @@ def update_translations_from_po(po_directory: Path, translations_module: Path): if BPY_IS_LOADED: + import bpy class SetupTranslationUI(bpy.types.Operator): bl_idname = "bim.setup_translation_ui" diff --git a/src/bonsai/scripts/classifications/brick_classifiction.py b/src/bonsai/scripts/classifications/brick_classifiction.py index 17246940c7..feaaf8b36d 100644 --- a/src/bonsai/scripts/classifications/brick_classifiction.py +++ b/src/bonsai/scripts/classifications/brick_classifiction.py @@ -82,6 +82,7 @@ class Generator: } """.replace("{entity}", location.split("#")[-1])) # filter parents for the brick entity + parent = None for row in query: parent = row.get("parent").toPython() if "brickschema.org" in parent and parent in references.keys(): diff --git a/src/bonsai/scripts/generate_furniture_library.py b/src/bonsai/scripts/generate_furniture_library.py index 7b9d411772..710372fc20 100644 --- a/src/bonsai/scripts/generate_furniture_library.py +++ b/src/bonsai/scripts/generate_furniture_library.py @@ -1036,6 +1036,7 @@ class LibraryGenerator: seat_width_offset = 0.7 * width / 2 if cistern_depth else width / 2 seat_start_width_offset = 0.6 * width + cistern_3d = None if cistern_height: cistern = builder.rectangle(size=V(width, cistern_depth), position=shift_to_center) cistern_3d = ifcopenshell.util.element.copy_deep(self.file, cistern) @@ -1118,6 +1119,7 @@ class LibraryGenerator: # cistern if cistern_height: + assert cistern_3d cistern_3d = builder.extrude( cistern_3d, cistern_height + seat_level / 2, position=V(0, 0, seat_level / 2) ) diff --git a/src/bonsai/scripts/generate_steel_profiles_library.py b/src/bonsai/scripts/generate_steel_profiles_library.py index ca45648122..7db5135686 100644 --- a/src/bonsai/scripts/generate_steel_profiles_library.py +++ b/src/bonsai/scripts/generate_steel_profiles_library.py @@ -143,6 +143,7 @@ class LibraryGenerator: if "unused" in ifc_params: del ifc_params["unused"] + profiles_gap = ... if prof_type == "profile_hollow*_square": ifc_params["YDim"] = ifc_params["XDim"] elif ifc_profile_name == "IfcCircleHollowProfileDef": @@ -160,6 +161,7 @@ class LibraryGenerator: profile = self.file.create_entity(ifc_profile_name, ProfileName=prof_name, ProfileType="AREA", **ifc_params) if prof_type == "profile_l*lbeam_2l": + assert profiles_gap is not ... profile.ProfileName = None # to avoid name confusion mode = "SLBB" if prof_name.endswith("_SLBB") else "LLBB" profile = self.create_double_l_profile(profile, prof_name, profiles_gap, mode) diff --git a/src/ifc2ca/ca2ifc.py b/src/ifc2ca/ca2ifc.py index f2be1bf0cc..7b706cf334 100644 --- a/src/ifc2ca/ca2ifc.py +++ b/src/ifc2ca/ca2ifc.py @@ -27,10 +27,12 @@ flatten = itertools.chain.from_iterable def get_element_data(model, name, element): if element["geometry_type"] == "Edge": + cell_tags, cell_block = None, None for i, cell_block in enumerate(model.cells): if cell_block.type == "line": cell_tags = model.cell_data["cell_tags"][i] break + assert cell_tags is not None and cell_block is not None rows = [] for i_row, i in enumerate(cell_tags): if i == 0: @@ -59,6 +61,7 @@ def get_element_data(model, name, element): elif element["geometry_type"] == "Face": triangle_cell_tags = None quad_cell_tags = None + points, cell_block = None, None for i, cell_block in enumerate(model.cells): if cell_block.type == "triangle": triangle_cell_tags = model.cell_data["cell_tags"][i] @@ -78,8 +81,10 @@ def get_element_data(model, name, element): if not len(rows): points = [] else: + assert cell_block is not None points = list(flatten([cell_block.data[c] for c in rows])) + cell_block = None for i, cell_block in enumerate(model.cells): if cell_block.type == "quad": quad_cell_tags = model.cell_data["cell_tags"][i] @@ -97,6 +102,7 @@ def get_element_data(model, name, element): rows.append(i_row) break if len(rows): + assert cell_block is not None and points is not None points.extend(list(flatten([cell_block.data[c] for c in rows]))) points = list(set(points)) @@ -172,6 +178,8 @@ def results_to_ifc(ifc_file, ifc_model, rmed_path, global_case, field_types, dat model_cases = data["load_cases"] elif global_case == "COMB": model_cases = data["load_combinations"] + else: + assert False, global_case for field in field_types: if field == "InternalForces": _parsed_data = internal_forces_to_ifc(ifc_file, ifc_model, result, model_cases, data["elements"]) diff --git a/src/ifc2ca/ifc2ca.py b/src/ifc2ca/ifc2ca.py index 728df67d45..e3a1cde993 100644 --- a/src/ifc2ca/ifc2ca.py +++ b/src/ifc2ca/ifc2ca.py @@ -283,7 +283,7 @@ class Ifc2CA: geometry = [x.EdgeStart.VertexGeometry.Coordinates for x in repr_item.Bounds[0].Bound.EdgeList] else: - print(representation) + assert False, representation return geometry def parse_material(self, material: ios.entity_instance): @@ -399,6 +399,9 @@ class Ifc2CA: elif element.is_a("IfcStructuralSurfaceMember"): placement = ifcopenshell.util.placement.get_axis2placement(repr_item.FaceSurface.Position) + else: + assert False, element + origin, orientation = self.parse_transformation_matrix(placement) data["origin"] = origin data["orientation"] = orientation @@ -436,7 +439,7 @@ class Ifc2CA: for i, v in enumerate(placement[:3]): v[3] = data["geometry"][i] - if connection.is_a("IfcStructuralCurveConnection"): + elif connection.is_a("IfcStructuralCurveConnection"): placement = ifcopenshell.util.placement.a2p( data["geometry"][0], connection.Axis.DirectionRatios, @@ -446,6 +449,9 @@ class Ifc2CA: elif connection.is_a("IfcStructuralSurfaceConnection"): placement = ifcopenshell.util.placement.get_axis2placement(repr_item.FaceSurface.Position) + else: + assert False, connection + origin, orientation = self.parse_transformation_matrix(placement) data["origin"] = origin data["orientation"] = orientation @@ -552,6 +558,9 @@ class Ifc2CA: }, } + else: + assert False, element["geometry_type"] + for action in actions: self.add_action_loads(element, action, data, load_cases) @@ -586,6 +595,7 @@ class Ifc2CA: data["actions"].append(action.get_info() | {"AppliedLoad": action.AppliedLoad.get_info()}) if element["geometry_type"] in ["Vertex", "Edge"]: + force_projection_coeff, moment_projection_coeff = None, None if action.is_a("IfcStructuralPointAction") and load.is_a("IfcStructuralLoadSingleForce"): FX = tempFX = load.ForceX if load.ForceX is not None else 0.0 FY = tempFY = load.ForceY if load.ForceY is not None else 0.0 @@ -639,8 +649,12 @@ class Ifc2CA: force_projection_coeff = 1.0 moment_projection_coeff = 1.0 + else: + assert False, action + for iLC, load_case in enumerate(load_cases): if load_case.id() in active_load_case_ids: + assert force_projection_coeff is not None and moment_projection_coeff is not None load_case_coeff = 1.0 if load_case.Coefficient is None else load_case.Coefficient data["loadGroups"].append(load_group.Name) data["loadsLC"]["FX"][iLC] += FX * load_group_coeff * load_case_coeff * force_projection_coeff @@ -672,6 +686,9 @@ class Ifc2CA: else: force_projection_coeff = 1.0 + else: + assert False, action + for iLC, load_case in enumerate(load_cases): if load_case.id() in active_load_case_ids: load_case_coeff = 1.0 if load_case.Coefficient is None else load_case.Coefficient diff --git a/src/ifc5d/ifc5d/ifc2json.py b/src/ifc5d/ifc5d/ifc2json.py index 3b184ca1c7..d4450c7541 100644 --- a/src/ifc5d/ifc5d/ifc2json.py +++ b/src/ifc5d/ifc5d/ifc2json.py @@ -109,6 +109,8 @@ class ifc5D2json: values = root_element.CostValues elif root_element.is_a("IfcConstructionResource"): values = root_element.BaseCosts + else: + assert False, root_element for cost_value in values or []: self.extract_cost_value(root_element, data, cost_value) # data["CostValues"].append(cost_value.id()) diff --git a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py index bd38a8f53b..a015f4206b 100644 --- a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py +++ b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py @@ -478,6 +478,8 @@ class Ifc5DOdsWriter(Ifc5Dwriter): cell.addElement(P(text=value)) elif type == "formula": cell = TableCell(formula=value, stylename=style) + else: + assert False, type row.addElement(cell) def add_cost_item_rows(table, cost_data): @@ -715,6 +717,8 @@ if __name__ == "__main__": writer = Ifc5DOdsWriter(args["input"], args["output"]) elif args["format"] == "XLSX": writer = Ifc5DXlsxWriter(args["input"], args["output"]) + else: + assert False, args writer.write() logger.info("Finished conversion in %ss", time.time() - start) diff --git a/src/ifcfm/ifcfm/__init__.py b/src/ifcfm/ifcfm/__init__.py index ced960305c..9525de8f15 100644 --- a/src/ifcfm/ifcfm/__init__.py +++ b/src/ifcfm/ifcfm/__init__.py @@ -25,9 +25,10 @@ import re from collections import defaultdict from collections.abc import Callable from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, Union +from typing import TYPE_CHECKING, Any, Literal, Union, cast import ifcopenshell.util.selector +from typing_extensions import assert_never try: from openpyxl import Workbook @@ -100,8 +101,9 @@ class Parser: def parse(self, ifc_file: ifcopenshell.file, name=None): for category_name, category_config in self.config["categories"].items(): for element in category_config["get_category_elements"](ifc_file): - get_element_data: Union[GetElementDataCallBack, dict[str, Any]] - get_element_data = category_config["get_element_data"] + get_element_data = cast( + Union[GetElementDataCallBack, dict[str, Any]], category_config["get_element_data"] + ) if isinstance(get_element_data, dict): data = {} @@ -109,14 +111,18 @@ class Parser: data[key] = ifcopenshell.util.selector.get_element_value(element, query) elif isinstance(get_element_data, Callable): data = get_element_data(ifc_file, element) or {} + else: + assert_never(get_element_data) - get_custom_element_data = self.get_custom_element_data.get(category_name, lambda x, y: None) + get_custom_element_data = self.get_custom_element_data.get(category_name, lambda *_: None) if isinstance(get_custom_element_data, dict): custom_data = {} for key, query in get_custom_element_data.items(): custom_data[key] = ifcopenshell.util.selector.get_element_value(element, query) elif isinstance(get_custom_element_data, Callable): custom_data = get_custom_element_data(ifc_file, element) or {} + else: + assert_never(get_custom_element_data) data.update(custom_data) diff --git a/src/ifcfm/ifcfm/cobie24.py b/src/ifcfm/ifcfm/cobie24.py index 40533bb3bb..ac36fb48de 100644 --- a/src/ifcfm/ifcfm/cobie24.py +++ b/src/ifcfm/ifcfm/cobie24.py @@ -271,6 +271,8 @@ def get_contact_data(ifc_file: ifcopenshell.file, element: ifcopenshell.entity_i pao = the_actor person = the_actor.ThePerson organization = the_actor.TheOrganization + else: + assert False, the_actor email = get_email_from_pao(person, organization) diff --git a/src/ifcfm/pyproject.toml b/src/ifcfm/pyproject.toml index 741bbb91cc..c44f869129 100644 --- a/src/ifcfm/pyproject.toml +++ b/src/ifcfm/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "openpyxl", "odfpy", "pandas", + "typing-extensions", ] [project.urls] diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_curve.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_curve.py index 74129e6fce..659a3f314b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_curve.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_add_segment_to_curve.py @@ -159,8 +159,10 @@ def _add_segment_to_curve( else: assert False + end_point = ... for mapped_segment in mapped_segments: if mapped_segment: end_point = _add_curve_segment_to_composite_curve(file, layout_segment, mapped_segment, curve) + assert end_point is not ... return end_point diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py index 6e5453489b..db162d7d33 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py @@ -308,5 +308,7 @@ def _get_segment_start_point_label(prev_segment: entity_instance, segment: entit label = _cant_callback(prev_segment, segment) else: label = _cant_label(prev_segment, segment) + else: + assert False, s.DesignParameters return label diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py index b786b80396..1348402d03 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py @@ -46,6 +46,7 @@ def _create_layout(file: ifcopenshell.file, alignment: entity_instance, points: ifcopenshell.api.nest.assign_object(file, related_objects=alignment_layouts, relating_object=alignment) start_dist_along = 0.0 + gradient = None for p1, p2 in zip(points, points[1:]): x1, y1, z1 = p1.Coordinates x2, y2, z2 = p2.Coordinates @@ -100,6 +101,7 @@ def _create_layout(file: ifcopenshell.file, alignment: entity_instance, points: ifcopenshell.api.nest.assign_object(file, related_objects=[hsegment], relating_object=alignment_layouts[0]) if include_vertical: + assert gradient is not None vsegment = file.createIfcAlignmentSegment( ifcopenshell.guid.new(), DesignParameters=file.createIfcAlignmentVerticalSegment( diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_from_csv.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_from_csv.py index 3bd8ed275d..f092b273ba 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_from_csv.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_from_csv.py @@ -57,6 +57,7 @@ def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance: :param filepath: path the to CSV file :return: IfcAlignment """ + alignment = None with open(filepath, newline="") as csvfile: reader = csv.reader(csvfile) row_count = 0 @@ -89,9 +90,14 @@ def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance: ) else: # add all subsequent vertical alignments + assert alignment is not None vertical_layout = ifcopenshell.api.alignment.add_vertical_layout(file, alignment) ifcopenshell.api.alignment.layout_vertical_alignment_by_pi_method( file, vertical_layout, coordinates, radii ) + if row_count == 0: + raise ValueError(f"CSV file '{filepath}' is empty; expected at least one row for the horizontal alignment.") + + assert alignment is not None return alignment diff --git a/src/ifcopenshell-python/ifcopenshell/api/classification/add_reference.py b/src/ifcopenshell-python/ifcopenshell/api/classification/add_reference.py index 10177821b4..decc5b4580 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/classification/add_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/classification/add_reference.py @@ -182,6 +182,8 @@ class Usecase: if not reference: migrator = ifcopenshell.util.schema.Migrator() + old_referenced_source = ... + existing_classification = None if self.settings["is_lightweight"]: old_referenced_source = self.settings["reference"].ReferencedSource self.settings["reference"].ReferencedSource = None @@ -194,6 +196,7 @@ class Usecase: reference = migrator.migrate(self.settings["reference"], self.file) if self.settings["is_lightweight"]: + assert old_referenced_source is not ... reference.ReferencedSource = self.settings["classification"] self.settings["reference"].ReferencedSource = old_referenced_source elif existing_classification: diff --git a/src/ifcopenshell-python/ifcopenshell/api/cogo/bearing2dd.py b/src/ifcopenshell-python/ifcopenshell/api/cogo/bearing2dd.py index 895215f066..0e8451fbf5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cogo/bearing2dd.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cogo/bearing2dd.py @@ -88,6 +88,8 @@ def bearing2dd(bearing: str) -> float: elif cY == "S" and cX == "W": angle = 270.0 sign = -1.0 + else: + assert False, (cY, cX) try: dms = ifcopenshell.util.geolocation.dms2dd(d, m, s, ms) diff --git a/src/ifcopenshell-python/ifcopenshell/api/feature/add_feature.py b/src/ifcopenshell-python/ifcopenshell/api/feature/add_feature.py index cfdff3819c..3c5e4b4e98 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/feature/add_feature.py +++ b/src/ifcopenshell-python/ifcopenshell/api/feature/add_feature.py @@ -116,6 +116,8 @@ def add_feature( return ifcopenshell.api.aggregate.assign_object(file, [feature], element) rels = feature.AdheresToElement ifc_class = "IfcRelAdheresToElement" + else: + assert False, feature if rels: if rels[0][4] == element: diff --git a/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py b/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py index 100e886214..998c1d70c2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py +++ b/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py @@ -54,6 +54,8 @@ def remove_feature(file: ifcopenshell.file, feature: ifcopenshell.entity_instanc rels = [] else: rels = feature.ProjectsElements + else: + assert False, feature for rel in rels: history = rel.OwnerHistory file.remove(rel) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py index 16dc7a8345..5dc9fcc6dc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py @@ -436,6 +436,7 @@ class Usecase: def create_curve_bounded_planes(self, is_2d: bool = False) -> list[ifcopenshell.entity_instance]: items = [] + points = None if self.file.schema != "IFC2X3": points = self.create_cartesian_point_list_from_vertices(self.settings["geometry"].vertices, is_2d=False) for polygon in self.settings["geometry"].polygons: @@ -443,6 +444,7 @@ class Usecase: if self.file.schema == "IFC2X3": curve = self.create_curve_from_polygon_ifc2x3(polygon, is_2d=False) else: + assert points is not None curve = self.create_curve_from_polygon(points, polygon, is_2d=False) items.append(self.file.createIfcCurveBoundedPlane(BasisSurface=plane, OuterBoundary=curve)) return items @@ -457,12 +459,14 @@ class Usecase: def create_annotation_fill_areas(self, is_2d: bool = False) -> list[ifcopenshell.entity_instance]: items = [] + points = None if self.file.schema != "IFC2X3": points = self.create_cartesian_point_list_from_vertices(self.settings["geometry"].vertices, is_2d=is_2d) for polygon in self.settings["geometry"].polygons: if self.file.schema == "IFC2X3": curve = self.create_curve_from_polygon_ifc2x3(polygon, is_2d=is_2d) else: + assert points is not None curve = self.create_curve_from_polygon(points, polygon, is_2d=is_2d) items.append(self.file.createIfcAnnotationFillArea(OuterBoundary=curve)) return items @@ -813,17 +817,20 @@ class Usecase: def create_triangulated_face_set(self) -> ifcopenshell.entity_instance: ifc_raw_items = [None] * self.settings["total_items"] + ifc_raw_uv_items = None if self.settings["should_generate_uvs"]: ifc_raw_uv_items = [None] * self.settings["total_items"] for i, value in enumerate(ifc_raw_items): ifc_raw_items[i] = [] if self.settings["should_generate_uvs"]: + assert ifc_raw_uv_items is not None ifc_raw_uv_items[i] = [] for polygon in self.settings["geometry"].polygons: ifc_raw_items[polygon.material_index % self.settings["total_items"]].append( [v + 1 for v in polygon.vertices] ) if self.settings["should_generate_uvs"]: + assert ifc_raw_uv_items is not None ifc_raw_uv_items[polygon.material_index % self.settings["total_items"]].append( [uv + 1 for uv in polygon.loop_indices] ) @@ -831,6 +838,7 @@ class Usecase: coordinates = self.create_cartesian_point_list_from_vertices(self.settings["geometry"].vertices) if self.settings["should_generate_uvs"]: + assert ifc_raw_uv_items is not None # Blender supports multiple UV layers. We don't. Too bad. tex_coords = self.file.createIfcTextureVertexList( [tuple(x.uv) for x in self.settings["geometry"].uv_layers[0].data] diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/disconnect_path.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/disconnect_path.py index 625bfd329c..bd87b737d5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/disconnect_path.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/disconnect_path.py @@ -50,6 +50,12 @@ def disconnect_path( for r in relating_element.ConnectedTo if r.is_a("IfcRelConnectsPathElements") and r.RelatedElement == related_element ] + else: + raise ValueError( + "Either provide `element` and `connection_type`, or provide `relating_element` and `related_element`. " + f"Got: element={element}, connection_type={connection_type}, " + f"relating_element={relating_element}, related_element={related_element}." + ) for connection in set(connections): history = connection.OwnerHistory diff --git a/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_true_north.py b/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_true_north.py index 58ab003e98..c0cb3149ab 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_true_north.py +++ b/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_true_north.py @@ -52,6 +52,7 @@ def edit_true_north(file: ifcopenshell.file, true_north: Optional[Union[tuple[fl # This unsets true north ifcopenshell.api.georeference.edit_true_north(model, true_north=None) """ + x, y = None, None if isinstance(true_north, (float, int)): x, y = ifcopenshell.util.geolocation.angle2yaxis(true_north) elif true_north is not None: @@ -73,4 +74,5 @@ def edit_true_north(file: ifcopenshell.file, true_north: Optional[Union[tuple[fl context.TrueNorth = file.create_entity("IfcDirection") else: context.TrueNorth = file.create_entity("IfcDirection") + assert x is not None and y is not None context.TrueNorth.DirectionRatios = (x, y) diff --git a/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_wcs.py b/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_wcs.py index 126072e254..38c61b2a6e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_wcs.py +++ b/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_wcs.py @@ -90,6 +90,8 @@ def edit_wcs( point, file.createIfcDirection((xaxis_x, xaxis_y)), ) + else: + assert False, context context.WorldCoordinateSystem = placement if file.get_total_inverses(old_wcs) == 0: ifcopenshell.util.element.remove_deep2(file, old_wcs) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_boundary_condition.py b/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_boundary_condition.py index c78bba2b68..7f7f70785f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_boundary_condition.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_boundary_condition.py @@ -61,6 +61,8 @@ def add_structural_boundary_condition( boundary_class = "IfcBoundaryEdgeCondition" elif related_connection.is_a("IfcStructuralSurfaceConnection"): boundary_class = "IfcBoundaryFaceCondition" + else: + assert False, related_connection condition = file.create_entity(boundary_class, Name=name) connection.AppliedCondition = condition diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py b/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py index 2182d354c1..b26d106017 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py @@ -126,6 +126,7 @@ class Usecase: use_style_assignment = self.file.schema == "IFC2X3" or self.settings["should_use_presentation_style_assignment"] replace_previous_same_type_style = self.settings["replace_previous_same_type_style"] + style: ifcopenshell.entity_instance | None = None for element in self.file.traverse(self.settings["shape_representation"]): if not element.is_a("IfcShapeModel"): continue @@ -137,6 +138,7 @@ class Usecase: if self.settings["styles"]: # If there are more items than styles, fallback to using the last style style = self.settings["styles"].pop(0) + assert style is not None name = style.Name current_style_type = style.is_a() diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/assign_system.py b/src/ifcopenshell-python/ifcopenshell/api/system/assign_system.py index ee1acff2c4..3512b540ab 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/assign_system.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/assign_system.py @@ -51,7 +51,8 @@ def assign_system( # This duct is part of the system ifcopenshell.api.system.assign_system(model, products=[duct], system=system) """ - if not all(ifcopenshell.util.system.is_assignable(failed_product := product, system) for product in products): - raise TypeError(f"You cannot assign an {failed_product.is_a()} to an {system.is_a()}") + for product in products: + if not ifcopenshell.util.system.is_assignable(product, system): + raise TypeError(f"You cannot assign an {product.is_a()} to an {system.is_a()}") return ifcopenshell.api.group.assign_group(file, products=products, group=system) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py index 7b5c2b6ae4..89e923b11b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py @@ -144,6 +144,8 @@ class Usecase: elif unit_type == "volume": dimensional_exponents = self.file.createIfcDimensionalExponents(3, 0, 0, 0, 0, 0, 0) name_prefix = "cubic" + else: + assert False, unit_type si_unit = self.file.createIfcSIUnit( None, @@ -159,6 +161,8 @@ class Usecase: name = "{}mile".format(name_prefix + " " if name_prefix else "") elif data["raw"] == "THOU": name = "{}thou".format(name_prefix + " " if name_prefix else "") + else: + assert False, data value_component = self.file.create_entity( "IfcReal", **{"wrappedValue": ifcopenshell.util.unit.si_conversions[name]} ) diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index bbcfa48aea..0b317a148f 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -296,6 +296,7 @@ def main( else: num_passes = 0 + g2 = None for iteration in range(num_passes + 1): # initialize empty group, note that in the current approach only one @@ -316,6 +317,7 @@ def main( plt.fill(numpy.array(x.boundary).T[0], numpy.array(x.boundary).T[1]) """ + semantics, pairs = None, None if iteration != num_passes: pairs = svgfill_context.get_face_pairs() semantics = [None] * (max(pairs) + 1) @@ -377,6 +379,7 @@ def main( if inside_elements: elements = None if iteration != num_passes: + assert semantics is not None semantics[pi] = (inside_elements[0], -1) else: elements = tree.select_ray(pythonize(a), pythonize(b - a)) @@ -409,6 +412,7 @@ def main( svg_fill = "rgb(%s)" % ", ".join(str(f * 255.0) for f in clr[0:3]) if iteration != num_passes: + assert semantics is not None semantics[pi] = elements[0] else: svg_fill = "none" @@ -418,6 +422,8 @@ def main( if iteration != num_passes: to_remove = [] + assert pairs is not None + assert semantics is not None for he_idx in range(0, len(pairs), 2): # @todo instead of ray_distance, better do (x.point - y.point).dot(x.normal) # to see if they're coplanar, because ray-distance will be different in case @@ -445,6 +451,7 @@ def main( # Swap the XML nodes from the files # Remove the original hidden line node we still have in the serializer output + assert g2 is not None g1.removeChild(projection) g2.setAttribute("class", "projection") # Find the children of the projection node parent diff --git a/src/ifcopenshell-python/ifcopenshell/entity_instance.py b/src/ifcopenshell-python/ifcopenshell/entity_instance.py index 46fef0a37f..32fcf5c101 100644 --- a/src/ifcopenshell-python/ifcopenshell/entity_instance.py +++ b/src/ifcopenshell-python/ifcopenshell/entity_instance.py @@ -192,7 +192,7 @@ class entity_instance: return @property - def file(self): + def file(self) -> "ifcopenshell.file": # ugh circular imports, name collisions from . import file diff --git a/src/ifcopenshell-python/ifcopenshell/file.py b/src/ifcopenshell-python/ifcopenshell/file.py index e900a0e72f..e3d7990745 100644 --- a/src/ifcopenshell-python/ifcopenshell/file.py +++ b/src/ifcopenshell-python/ifcopenshell/file.py @@ -734,6 +734,7 @@ class file: # Don't store these attributes as transactions # as the creation it self is already stored with # it's arguments + transaction = None if attrs: transaction = self.transaction self.transaction = None @@ -849,11 +850,13 @@ class file: :returns: An ifcopenshell.entity_instance """ + max_id = None if self.transaction: max_id = self.wrapped_data.getMaxId() inst.wrapped_data.this.disown() result = entity_instance(self.wrapped_data.add(inst.wrapped_data, -1 if _id is None else _id), self) if self.transaction: + assert max_id is not None added_elements = [e for e in self.traverse(result) if e.id() > max_id] [self.transaction.store_create(e) for e in reversed(added_elements)] return result diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index b3c9afc869..0640c8eb96 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -326,10 +326,11 @@ class iterator(ifcopenshell_wrapper.Iterator): if include_or_exclude_type == {"entity_instance"}: include_or_exclude = cast(set[entity_instance], include_or_exclude) - if not all((last_inst := inst).is_a("IfcProduct") for inst in include_or_exclude): - raise ValueError( - f"include and exclude need to be an aggregate of IfcProduct. Violating element: '{last_inst}'." - ) + for inst in include_or_exclude: + if not inst.is_a("IfcProduct"): + raise ValueError( + f"include and exclude need to be an aggregate of IfcProduct. Violating element: '{inst}'." + ) initializer = ifcopenshell_wrapper.construct_iterator_with_include_exclude_id diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 5c4d635b2a..323200b9fb 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -1070,7 +1070,10 @@ class file: """ ... - def getMaxId(self): ... + def getMaxId(self) -> int: + """Get the highest instance id currently in use in the file.""" + ... + def get_total_inverses_by_id(self, instance_id: int) -> int: ... def getUnit(self, unit_type): ... def get_inverse(self, e: entity_instance) -> tuple[entity_instance, ...]: ... diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index 4354e49e90..4ac048c221 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -112,6 +112,8 @@ def sum_child_root_elements(root_element: ifcopenshell.entity_instance, category values = new_child_root_element.CostValues elif root_element.is_a("IfcConstructionResource"): values = child_root_element.BaseCosts + else: + assert False, root_element for child_cost_value in values or []: if category_filter and child_cost_value.Category != category_filter: continue diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index 6be8ef9e0d..cf6c52e37d 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -329,6 +329,8 @@ def get_quantity( data["properties"] = get_quantities(quantity.HasQuantities, verbose=verbose) del data["HasQuantities"] result = data + else: + assert False, quantity if verbose: result = {"id": quantity.id(), "class": quantity.is_a(), "value": result} return result @@ -385,6 +387,7 @@ def get_property( if prop.Name != name: continue is_single_value = False # For now we pass value type only for single values. + result_type = None if prop.is_a("IfcPropertySingleValue"): # 2 IfcPropertySingleValue.NominalValue result = v.wrappedValue if (v := prop[2]) else None @@ -407,6 +410,8 @@ def get_property( data["properties"] = get_properties(prop.HasProperties, verbose=verbose) del data["HasProperties"] result = data + else: + assert False, prop if verbose: result = {"id": prop.id(), "class": prop.is_a(), "value": result} if is_single_value: diff --git a/src/ifcopenshell-python/ifcopenshell/util/geolocation.py b/src/ifcopenshell-python/ifcopenshell/util/geolocation.py index 47c8886691..1926f88caf 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/geolocation.py +++ b/src/ifcopenshell-python/ifcopenshell/util/geolocation.py @@ -260,6 +260,8 @@ def get_helmert_transformation_parameters(ifc_file: ifcopenshell.file) -> Option xaa = 1.0 xao = 0.0 scale = factor_x = factor_y = factor_z = 1 + else: + assert False, conversion if not xaa and not xao: xaa = 1.0 diff --git a/src/ifcopenshell-python/ifcopenshell/util/mvd_info.py b/src/ifcopenshell-python/ifcopenshell/util/mvd_info.py index a3bdb07337..ce3cd44e45 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/mvd_info.py +++ b/src/ifcopenshell-python/ifcopenshell/util/mvd_info.py @@ -18,18 +18,16 @@ from __future__ import annotations -try: - from lark import Lark, Transformer - from lark.exceptions import UnexpectedCharacters, UnexpectedEOF, UnexpectedToken - - LARK_AVAILABLE = True -except ImportError: - LARK_AVAILABLE = False - +import importlib.util import re from typing import Union +LARK_AVAILABLE = importlib.util.find_spec("lark") is not None + if LARK_AVAILABLE: + from lark import Lark, Transformer + from lark.exceptions import UnexpectedCharacters, UnexpectedEOF, UnexpectedToken + mvd_grammar = r""" start: entry+ @@ -92,9 +90,9 @@ if LARK_AVAILABLE: self.store_text_attribute(args, "options") def dynamic_option(self, args): + original_keyword = str(args[0]) + key = original_keyword.lower() try: - original_keyword = str(args[0]) - key = original_keyword.lower() raw_text = args[1].children[0].value parsed_value = parse_semicolon_separated_kv(raw_text) self._dynamic[key] = (parsed_value, original_keyword) diff --git a/src/ifcopenshell-python/ifcopenshell/util/placement.py b/src/ifcopenshell-python/ifcopenshell/util/placement.py index cc37ab9906..fc577b4f2f 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/placement.py +++ b/src/ifcopenshell-python/ifcopenshell/util/placement.py @@ -89,6 +89,9 @@ def get_axis2placement(placement: ifcopenshell.entity_instance) -> MatrixType: x = np.array((1, 0, 0)) o = placement.Location.Coordinates + else: + assert False, placement + return a2p(o, z, x) diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema.py b/src/ifcopenshell-python/ifcopenshell/util/schema.py index 5aa9b26fab..d8c865e438 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/schema.py +++ b/src/ifcopenshell-python/ifcopenshell/util/schema.py @@ -584,6 +584,7 @@ class Migrator: # NOTE: `attribute` is an attribute in new file schema # print("Migrating attribute", element, new_element, attribute.name()) old_file = element.wrapped_data.file + value = ... if hasattr(element, attribute.name()): value = getattr(element, attribute.name()) # print("Attribute names matched", value) @@ -622,9 +623,7 @@ class Migrator: except: # We tried our best return - try: - value - except UnboundLocalError: + if value is ...: print( f"Couldn't match attribute {attribute.name()} by name to migrate from {element} " f"to {new_element} and there is no special mapping to handle migration " diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index e6333395a7..f83d3cd3d5 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -1279,6 +1279,8 @@ class FacetTransformer(lark.Transformer): result = bool(value.match(element_value)) if element_value is not None else False elif value in (None, True, False): result = element_value is value + else: + assert False, value if comparison.startswith("!"): return not result diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index c3c33e2054..1a133e041a 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -210,6 +210,8 @@ def np_rotation_matrix( matrix = np.array([[cos_theta, 0, sin_theta], [0, 1, 0], [-sin_theta, 0, cos_theta]]) elif axis == "Z": matrix = np.array([[cos_theta, -sin_theta, 0], [sin_theta, cos_theta, 0], [0, 0, 1]]) + else: + assert False, axis else: # Assume axis is a vector. axis = axis / np.linalg.norm(axis) diff --git a/src/ifcopenshell-python/ifcopenshell/validate.py b/src/ifcopenshell-python/ifcopenshell/validate.py index 524dc78d35..46269fe33b 100644 --- a/src/ifcopenshell-python/ifcopenshell/validate.py +++ b/src/ifcopenshell-python/ifcopenshell/validate.py @@ -320,6 +320,7 @@ def log_internal_cpp_errors( if log_content is None: log_content = ifcopenshell.get_log() + lines = None msgs = list(map(json.loads, filter(None, log_content.split("\n")))) chr_offsets = [chr_offset_re.findall(m["message"]) for m in msgs] instance_messages = [for_instance_re.findall(m["message"]) for m in msgs] @@ -356,6 +357,7 @@ def log_internal_cpp_errors( except: inst = None else: + assert lines is not None inst = next( ( l.decode("ascii", errors="ignore").strip() @@ -691,14 +693,16 @@ def validate_ifc_header( if not value: log_error(header_entity, name, index, AGGREGATE_TYPE, "EMPTY LIST") return - if not all(isinstance(last_value := v, str) for v in value): - log_error( - header_entity, - name, - index, - AGGREGATE_TYPE, - f"LIST with {type(last_value).__name__} (value: {last_value})", - ) + for v in value: + if not isinstance(v, str): + log_error( + header_entity, + name, + index, + AGGREGATE_TYPE, + f"LIST with {type(v).__name__} (value: {v})", + ) + break return if not isinstance(value, str): diff --git a/src/ifcopenshell-python/test/test_file_gc.py b/src/ifcopenshell-python/test/test_file_gc.py index 532fa664ec..fd082b3ea5 100644 --- a/src/ifcopenshell-python/test/test_file_gc.py +++ b/src/ifcopenshell-python/test/test_file_gc.py @@ -26,6 +26,8 @@ def test_file_gc(args): inst = f.createIfcPerson() elif api in (1, 2): inst = f.createIfcSite() + else: + assert False, api r = weakref.ref(f) @@ -68,9 +70,9 @@ def test_file_gc(args): assert r() if not file_first: - del f + del f # ty: ignore[possibly-unresolved-reference] else: - del inst + del inst # ty: ignore[possibly-unresolved-reference] # With both deleted we should have no longer access to the file. assert r() is None diff --git a/src/ifcpatch/ifcpatch/recipes/AssignConstituentFractions.py b/src/ifcpatch/ifcpatch/recipes/AssignConstituentFractions.py index 0b105a52f7..b88c942ac3 100644 --- a/src/ifcpatch/ifcpatch/recipes/AssignConstituentFractions.py +++ b/src/ifcpatch/ifcpatch/recipes/AssignConstituentFractions.py @@ -71,11 +71,13 @@ class Patcher: # Sort elements by GlobalId to ensure consistent order elements_sorted = sorted(elements, key=lambda x: x.GlobalId) + element_quantities = None for element in elements_sorted: quantities = self.get_element_quantities(element) if quantities: element_quantities = quantities break + assert element_quantities is not None if not element_quantities: continue diff --git a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py index d1405e9a33..3733bc9520 100644 --- a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py +++ b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py @@ -521,7 +521,7 @@ class Patcher(ifcpatch.BasePatcher): data_type = "JSON" json_attrs.append(i) else: - print("Possibly not implemented attribute data type:", attribute, primitive) + assert False, f"{attribute}, {primitive}" if not self.is_strict or derived[i]: optional = "DEFAULT NULL" else: diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index b8c3ebad5d..4339a3eea1 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -142,6 +142,8 @@ class Facet: templates = [ t.replace("shall", "may").replace("Shall", "May").replace("must", "may") for t in templates ] + else: + assert False, clause_type for template in templates: total_variables = len(template) - len(template.replace("{", "")) @@ -242,6 +244,7 @@ class Entity(Facet): elif not is_pass: reason = {"type": "NAME", "actual": inst.is_a().upper()} + predefined_type = None if is_pass and self.predefinedType: if self.predefinedType == "USERDEFINED": is_pass = ifcopenshell.util.element.is_userdefined_type(inst) @@ -616,6 +619,8 @@ class PartOf(Facet): if predefined_type != self.predefinedType: is_pass = False reason = {"type": "PREDEFINEDTYPE", "actual": predefined_type} + else: + assert False, self.relation if self.cardinality == "prohibited": return PartOfResult(not is_pass, {"type": "PROHIBITED"}) @@ -800,11 +805,13 @@ class Property(Facet): ] elif prop_entity.is_a("IfcPropertyBoundedValue"): values = [] + data_type = None for attribute in ["UpperBoundValue", "LowerBoundValue", "SetPointValue"]: value = getattr(prop_entity, attribute) if value is not None: data_type = value.is_a() values.append(value.wrappedValue) + assert data_type is not None, prop_entity if self.dataType and data_type.lower() != self.dataType.lower(): is_pass = False reason = {"type": "DATATYPE", "actual": data_type, "dataType": self.dataType} @@ -825,6 +832,7 @@ class Property(Facet): elif prop_entity.is_a("IfcPropertyTableValue"): values = [] units = ifcopenshell.util.unit.get_property_table_unit(prop_entity, inst.wrapped_data.file) + data_type = None for attribute in ["Defining", "Defined"]: column_values = props[pset_name][prop_entity.Name][f"{attribute}Values"] if not column_values: @@ -847,6 +855,7 @@ class Property(Facet): values.extend(column_values) if not values: is_pass = False + assert data_type is not None, prop_entity reason = {"type": "DATATYPE", "actual": data_type, "dataType": self.dataType} break props[pset_name][prop_entity.Name] = values @@ -984,6 +993,8 @@ class Material(Facet): values.update( [item.Name, item.Category, item.Material.Name, getattr(item.Material, "Category", None)] ) + else: + assert False, material is_pass = False for value in values: diff --git a/src/ifctester/ifctester/reporter.py b/src/ifctester/ifctester/reporter.py index de70879f09..661a3c5e83 100644 --- a/src/ifctester/ifctester/reporter.py +++ b/src/ifctester/ifctester/reporter.py @@ -343,6 +343,8 @@ class Json(Reporter): elif requirement.value: label = "Reference" value = requirement.value + else: + assert False, requirement elif facet_type == "PartOf": label = requirement.relation if requirement.predefinedType: @@ -357,6 +359,8 @@ class Json(Reporter): label = "Name / Category" if requirement.value: value = requirement.value + else: + assert False, facet_type requirements.append( ResultsRequirement( facet_type=facet_type, From 7b613a0bccdd9bafb09ab5f02d094b38e108b1f3 Mon Sep 17 00:00:00 2001 From: Bartok Date: Sat, 18 Jul 2026 12:13:04 -0400 Subject: [PATCH 036/142] docs(readme): use https for IfcOpenShell website link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a44a6ff78c..a6bb653c54 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ and many other libraries, CLI apps, and more. Support is also provided for auxil For more information, see: -* [IfcOpenShell Website](http://ifcopenshell.org) +* [IfcOpenShell Website](https://ifcopenshell.org) * [IfcOpenShell Documentation](https://docs.ifcopenshell.org) * [IfcOpenShell C++ Installation](https://docs.ifcopenshell.org/ifcopenshell/installation.html) * [IfcOpenShell Python Installation](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) From 56121ca061eb541d5d4f92dfd6a271cec4b03b2f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sat, 18 Jul 2026 00:45:55 +0100 Subject: [PATCH 037/142] Fix null-pointer derefs in reference resolution Two related bugs in read_from_stream's reference-resolution loop, both reachable from malformed input: - has_attribute_value only checks the stored slot's type, not that it's non-null (e.g. an explicit $ value), so the following get_attribute_value() call could return null and inst->declaration() crashed on it. - byid_[ref] default-inserts (and returns) a null pointer when the owning instance id isn't present, which was then dereferenced unconditionally via ->data(). Added regression tests using the two minimized crash inputs that found these. Generated with the assistance of an AI coding tool. --- src/ifcopenshell-python/test/test_parse.py | 12 ++++++++++++ src/ifcparse/IfcParse.cpp | 22 +++++++++++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/ifcopenshell-python/test/test_parse.py b/src/ifcopenshell-python/test/test_parse.py index 189556c31f..992f842bf7 100644 --- a/src/ifcopenshell-python/test/test_parse.py +++ b/src/ifcopenshell-python/test/test_parse.py @@ -18,3 +18,15 @@ END-ISO-10303-21; f = ifcopenshell.file.from_string(data) print(ifcopenshell.get_log()) f.by_id(5) + + +def test_reference_to_undefined_owning_instance(): + data = "ISO-10303-21;HEADER;FILE_DESCRIPTION();FILE_NAME();FILE_SCHEMA(('IFC4'));#=IFCRELAGGREGATES((#))#5=IFCPOINT)" + ifcopenshell.file.from_string(data) + print(ifcopenshell.get_log()) + + +def test_reference_to_undefined_owning_instance_simple_type(): + data = "ISO-10303-21;HEADER;FILE_DESCRIPTION();FILE_NAME();FILE_SCHEMA(('IFC4'));#=IFCPROJECT((#))#4=IFCSIUNIT(" + ifcopenshell.file.from_string(data) + print(ifcopenshell.get_log()) diff --git a/src/ifcparse/IfcParse.cpp b/src/ifcparse/IfcParse.cpp index badbfa8ba7..bc6df697af 100644 --- a/src/ifcparse/IfcParse.cpp +++ b/src/ifcparse/IfcParse.cpp @@ -1700,6 +1700,14 @@ void IfcParse::impl::in_memory_file_storage::read_from_stream(IfcParse::FileRead for (const auto& p : streamer.references()) { const auto& ref = p.first.name_; const auto& refattr = p.first.index_; + + auto owner_it = byid_.find(ref); + if (owner_it == byid_.end()) { + logger().Error("SYN", 28, "Instance #" + std::to_string(ref) + " referenced at attribute index " + std::to_string(refattr) + " not found"); + continue; + } + IfcUtil::IfcBaseClass* owner = owner_it->second; + if (auto* v = std::get_if(&p.second)) { if (auto* name = std::get_if(v)) { if (std::binary_search(bypassed.begin(), bypassed.end(), *name)) { @@ -1709,12 +1717,12 @@ void IfcParse::impl::in_memory_file_storage::read_from_stream(IfcParse::FileRead if (it == byid_.end()) { logger().Error("SYN", 19, "Instance reference #" + std::to_string(*name) + " used by instance #" + std::to_string(ref) + " at attribute index " + std::to_string(refattr) + " not found at offset " + std::to_string(name->file_offset)); } else { - auto* storage = &byid_[p.first.name_]->data(); + auto* storage = &owner->data(); auto attr_index = p.first.index_; if (storage->has_attribute_value(nullptr, nullptr, 0, attr_index)) { IfcUtil::IfcBaseClass* inst = storage->get_attribute_value(nullptr, nullptr, 0, attr_index); - if (!inst->declaration().as_entity()) { + if (inst != nullptr && !inst->declaration().as_entity()) { // Probably a case of IfcPropertySetDefinitionSet, divert storage of reference to the simply type instance storage = &inst->data(); attr_index = 0; @@ -1728,7 +1736,7 @@ void IfcParse::impl::in_memory_file_storage::read_from_stream(IfcParse::FileRead } } } else if (auto* inst = std::get_if(v)) { - byid_[p.first.name_]->data().set_attribute_value(nullptr, nullptr, 0, p.first.index_, *inst); + owner->data().set_attribute_value(nullptr, nullptr, 0, p.first.index_, *inst); } } else if (auto* vv = std::get_if>(&p.second)) { aggregate_of_instance::ptr instances(new aggregate_of_instance); @@ -1749,12 +1757,12 @@ void IfcParse::impl::in_memory_file_storage::read_from_stream(IfcParse::FileRead } } - auto* storage = &byid_[p.first.name_]->data(); + auto* storage = &owner->data(); auto attr_index = p.first.index_; if (storage->has_attribute_value(nullptr, nullptr, 0, attr_index)) { IfcUtil::IfcBaseClass* inst = storage->get_attribute_value(nullptr, nullptr, 0, attr_index); - if (!inst->declaration().as_entity()) { + if (inst != nullptr && !inst->declaration().as_entity()) { // Probably a case of IfcPropertySetDefinitionSet, divert storage of reference to the simply type instance storage = &inst->data(); attr_index = 0; @@ -1788,12 +1796,12 @@ void IfcParse::impl::in_memory_file_storage::read_from_stream(IfcParse::FileRead instances->push(inner); } - auto* storage = &byid_[p.first.name_]->data(); + auto* storage = &owner->data(); auto attr_index = p.first.index_; if (storage->has_attribute_value(nullptr, nullptr, 0, attr_index)) { IfcUtil::IfcBaseClass* inst = storage->get_attribute_value(nullptr, nullptr, 0, attr_index); - if (!inst->declaration().as_entity()) { + if (inst != nullptr && !inst->declaration().as_entity()) { // Probably a case of IfcPropertySetDefinitionSet, divert storage of reference to the simply type instance storage = &inst->data(); attr_index = 0; From 8ee52c466ff3e1096cc6dff860891185c3a1c63f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sat, 18 Jul 2026 00:39:25 +0100 Subject: [PATCH 038/142] Fix null reference bind in header parsing references_to_resolve is never set while parsing header entities, so binding a reference to it was UB, caught by UBSan on any file with a header. Generated with the assistance of an AI coding tool. --- src/ifcparse/IfcSpfHeader.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ifcparse/IfcSpfHeader.cpp b/src/ifcparse/IfcSpfHeader.cpp index f28c2ea2df..8bcd7499e5 100644 --- a/src/ifcparse/IfcSpfHeader.cpp +++ b/src/ifcparse/IfcSpfHeader.cpp @@ -35,7 +35,13 @@ namespace { parse_context pc; storage->tokens->Next(); storage->load(-1, nullptr, pc, -1); - return pc.construct(boost::none, *storage->references_to_resolve, decl, decl->as_entity()->attribute_count(), -1, logger); + // references_to_resolve is unset while reading the header (header + // entities such as FILE_DESCRIPTION never reference other + // instances), so fall back to a throwaway list instead of + // dereferencing a null pointer. + unresolved_references no_references; + unresolved_references& references = storage->references_to_resolve ? *storage->references_to_resolve : no_references; + return pc.construct(boost::none, references, decl, decl->as_entity()->attribute_count(), -1, logger); } else { // std::unreachable(); return IfcEntityInstanceData(in_memory_attribute_storage(10)); From e333c1c1000314147fe7da68565506c7c92721ae Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 10 Jul 2026 15:55:03 +0300 Subject: [PATCH 039/142] ifcgeom: build the swept-area directrix from the offset curve far from origin (#4848) IfcSurfaceCurveSweptAreaSolid regressed in 0.8 for geometry far from the origin (for example parapets on a georeferenced building), which went missing or glitched. The kernel offsets the directrix toward the origin when it is far away (mean.norm() > 1e2), storing the offset copy in a local curve variable and setting applied_temporary_offset so the finished solid is translated back by +mean. But the wire was still built from scs->curve, the un-offset original, so the offset never took effect and the result was translated by +mean from its correct location. Build the wire from curve instead. When no offset is applied curve aliases scs->curve, so near-origin geometry is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp b/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp index a5169864fb..20328963f4 100644 --- a/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp +++ b/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp @@ -128,7 +128,12 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo } } - auto w = convert_curve(scs->curve); + // Build the wire from curve, which is the directrix offset toward the origin + // when applied_temporary_offset is set. Using scs->curve here left the wire + // far from the origin yet still translated the result back by +mean, which + // misplaced sweeps far from the origin (#4848). When no offset is applied + // curve aliases scs->curve, so near-origin geometry is unaffected. + auto w = convert_curve(curve); if (w.which() != 2) { Logger::Root().Error("UNS", 9, "Unsupported directrix"); return false; From 57cfd9d1fd7b34b08a964b26298541cd19feb6aa Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 17 Jul 2026 17:06:29 +0300 Subject: [PATCH 040/142] Fix #1043. Optimise IfcPatch recipe: avoid redundant recursive get_info The 2020 profiling in issue #1043 found the Optimise recipe's dedup loop spent almost all of its time in entity_instance.get_info(recursive=True): because the topological sort already guarantees every referenced entity is folded before the entity that references it, recomputing each already-folded subtree's canonical value from scratch for every parent that points to it is wasted work. Confirmed this is still exactly the bottleneck in the current codebase, unchanged since 2020 (get_info's recursive path still walks the whole subtree on every call). Applied aothms's suggested fix from the issue thread: canonicalize each entity with a non-recursive get_info, and for referenced entities substitute the already-computed identity of their folded replacement (looked up in instance_mapping) instead of re-expanding the subtree. Also limited the toposort dependency graph to direct references (max_levels=1), since a topological sort only needs direct edges, not the full transitive closure traverse() was computing for every entity. Benchmarked before and after on real IFC test fixtures and a larger synthetic file with heavily shared geometry (thousands of walls sharing a handful of profile/point subtrees, mirroring the sharing pattern described in the issue): - test/input/geometrygym_great_court_roof.ifc (56989 entities): 9.9s -> 1.7s - test/input/acad2010_objects.ifc (16296 entities): 3.7s -> 0.4s - synthetic 120083-entity fixture with heavy geometry sharing: 19.2s -> 3.4s Verified correctness by comparing the full canonical (recursive get_info) multiset of the optimized output between the old and new implementation on all three fixtures: identical results, same fold counts. Added test_Optimise.py covering the core scenario from the issue: entities built from separate, value-identical non-rooted subtrees fold to a shared instance, while entities with distinct values do not. Generated with the assistance of an AI coding tool. --- src/ifcpatch/ifcpatch/recipes/Optimise.py | 33 +++++----- src/ifcpatch/test/test_Optimise.py | 73 +++++++++++++++++++++++ 2 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 src/ifcpatch/test/test_Optimise.py diff --git a/src/ifcpatch/ifcpatch/recipes/Optimise.py b/src/ifcpatch/ifcpatch/recipes/Optimise.py index b0043a7299..dd7e3bc84c 100644 --- a/src/ifcpatch/ifcpatch/recipes/Optimise.py +++ b/src/ifcpatch/ifcpatch/recipes/Optimise.py @@ -36,8 +36,10 @@ class Patcher: can usually be solved through other means. Consult the bonsai Add-on documentation on dealing with large models for more details. - Warning: this optimise recipe is very, very slow. Please consider using - RecycleNonRootedElements instead. + Warning: this optimise recipe is slower than RecycleNonRootedElements, + as it performs a full, transitive fold instead of a single pass. + Consider RecycleNonRootedElements first if a quicker, partial + optimisation is acceptable. Example: @@ -58,27 +60,30 @@ class Patcher: the set of all of its references contained in its attributes. """ for inst in self.file: - yield inst.id(), set(i.id() for i in self.file.traverse(inst)[1:] if i.id()) + yield inst.id(), set(i.id() for i in self.file.traverse(inst, max_levels=1)[1:] if i.id()) instance_mapping = {} - def map_value(v): + def map_value(v, as_key=False): """ - Recursive function which replicates an entity instance, with - its attributes, mapping references to already registered - instances. Indeed, because of the toposort we know that - forward attribute value instances are mapped before the instances - that reference them. + Recursive function which either replicates an entity instance + with its attributes mapped to already registered instances + (as_key=False), or builds a hashable canonical key for it + (as_key=True), reusing already-folded references instead of + re-expanding their attribute subtrees. """ if isinstance(v, (list, tuple)): - # lists are recursively traversed - return type(v)(map(map_value, v)) + return type(v)(map_value(item, as_key=as_key) for item in v) elif isinstance(v, ifcopenshell.entity_instance): if v.id() == 0: # express simple types are not part of the toposort and just copied + if as_key: + return ("__type__", v.is_a(), v[0]) return self.optimized_file.create_entity(v.is_a(), v[0]) - - return instance_mapping[v] + mapped = instance_mapping[v] + if as_key: + return ("__id__", mapped.id()) + return mapped else: # a plain python value can just be returned return v @@ -87,7 +92,7 @@ class Patcher: for id in toposort(dict(generate_instances_and_references())): inst = self.file[id] - info = inst.get_info(include_identifier=False, recursive=True, return_type=frozenset) + info = map_value(inst.get_info(include_identifier=False, recursive=False, return_type=tuple), as_key=True) if info in info_to_id: mapped = instance_mapping[inst] = instance_mapping[self.file[info_to_id[info]]] diff --git a/src/ifcpatch/test/test_Optimise.py b/src/ifcpatch/test/test_Optimise.py new file mode 100644 index 0000000000..e4beaf122d --- /dev/null +++ b/src/ifcpatch/test/test_Optimise.py @@ -0,0 +1,73 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# 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 . + +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell +import ifcopenshell.guid + +import ifcpatch +import test.bootstrap + + +def add_context(f: ifcopenshell.file) -> ifcopenshell.entity_instance: + origin = f.createIfcAxis2Placement3D(f.createIfcCartesianPoint((0.0, 0.0, 0.0))) + return f.createIfcGeometricRepresentationContext(None, "Model", 3, 1.0e-05, origin, None) + + +def add_wall_with_curve(f: ifcopenshell.file, context, coords) -> ifcopenshell.entity_instance: + wall = f.create_entity("IfcWall", ifcopenshell.guid.new()) + points = [f.createIfcCartesianPoint(c) for c in coords] + polyline = f.createIfcPolyline(points) + rep = f.createIfcShapeRepresentation(context, "Body", "Curve2D", [polyline]) + wall.Representation = f.createIfcProductDefinitionShape(None, None, [rep]) + return wall + + +def curve_of(wall: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + return wall.Representation.Representations[0].Items[0] + + +class TestOptimise(test.bootstrap.IFC4): + def test_folding_value_identical_non_rooted_entities(self): + # Two polylines built from separate but value-identical points. + context = add_context(self.file) + coords = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] + wall1 = add_wall_with_curve(self.file, context, coords) + wall2 = add_wall_with_curve(self.file, context, coords) + assert curve_of(wall1) != curve_of(wall2) + + output = ifcpatch.execute({"file": self.file, "recipe": "Optimise", "arguments": []}) + + assert len(output.by_type("IfcPolyline")) == 1 + + walls_after = output.by_type("IfcWall") + assert len(walls_after) == 2 + assert curve_of(walls_after[0]) == curve_of(walls_after[1]) + + def test_distinct_values_are_not_folded(self): + context = add_context(self.file) + wall1 = add_wall_with_curve(self.file, context, [(0.0, 0.0), (1.0, 0.0)]) + wall2 = add_wall_with_curve(self.file, context, [(0.0, 0.0), (2.0, 0.0)]) + assert curve_of(wall1) != curve_of(wall2) + + output = ifcpatch.execute({"file": self.file, "recipe": "Optimise", "arguments": []}) + + assert len(output.by_type("IfcPolyline")) == 2 + walls_after = output.by_type("IfcWall") + assert curve_of(walls_after[0]) != curve_of(walls_after[1]) From 7ebdd046b689df8ffda21fdf810f3577f3e3cc0f Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sat, 18 Jul 2026 23:00:58 +0300 Subject: [PATCH 041/142] 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 From 397f13e71cafafa76f1b4e845c0034091367c5b9 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 18 Jul 2026 15:26:42 -0500 Subject: [PATCH 042/142] Bonsai: add Delete Type button to Type Attributes panel Adds a trash button in BIM_PT_type_attributes that deletes the relating type via bim.remove_type. SHIFT+Click also deletes every occurrence of the type in the project, behind a confirmation dialog showing the count. Co-Authored-By: Claude Opus 4.8 --- src/bonsai/bonsai/bim/module/type/operator.py | 33 +++++++++++++++++-- src/bonsai/bonsai/bim/module/type/ui.py | 4 ++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index 7b306b1b31..bcaf8a98c7 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -305,14 +305,43 @@ class SelectTypeObjects(bpy.types.Operator): class RemoveType(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_type" - bl_label = "Remove Type" + bl_label = "Delete Type" + bl_description = ( + "Delete this type. Its occurrences are kept but become untyped.\n\n" + "SHIFT+Click to also delete every occurrence of this type in the project" + ) bl_options = {"REGISTER", "UNDO"} element: bpy.props.IntProperty() + also_delete_instances: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + + if TYPE_CHECKING: + element: int + also_delete_instances: bool + + def invoke(self, context, event): + self.also_delete_instances = event.shift + if self.also_delete_instances: + element = tool.Ifc.get().by_id(self.element) + count = len(ifcopenshell.util.element.get_types(element)) + return context.window_manager.invoke_confirm( + self, + event, + title="Delete Type and Occurrences", + message=f"This will delete the type and all {count} of its occurrences.", + confirm_text="Delete", + ) + return self.execute(context) def _execute(self, context): element = tool.Ifc.get().by_id(self.element) + if self.also_delete_instances: + for occurrence in ifcopenshell.util.element.get_types(element): + occurrence_obj = tool.Ifc.get_object(occurrence) + if occurrence_obj: + tool.Geometry.delete_ifc_object(occurrence_obj) obj = tool.Ifc.get_object(element) - tool.Geometry.delete_ifc_object(obj) + if obj: + tool.Geometry.delete_ifc_object(obj) class RenameType(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/type/ui.py b/src/bonsai/bonsai/bim/module/type/ui.py index 1848858953..3c8d875a4c 100644 --- a/src/bonsai/bonsai/bim/module/type/ui.py +++ b/src/bonsai/bonsai/bim/module/type/ui.py @@ -144,8 +144,10 @@ class BIM_PT_type_attributes(Panel): bonsai.bim.helper.draw_attributes(props.type_attributes, layout) else: - row = layout.row() + row = layout.row(align=True) row.operator("bim.enable_editing_type_attributes", icon="GREASEPENCIL", text="Edit") + op = row.operator("bim.remove_type", icon="TRASH", text="") + op.element = TypeData.data["relating_type"]["id"] for attribute in TypeData.data["relating_type_attributes"]: row = layout.row(align=True) From a90064929b7698425ebfb3dfb8ea9f0b3ee72a7b Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 18 Jul 2026 17:32:49 -0500 Subject: [PATCH 043/142] Bonsai: preserve occurrence geometry/material/styles when deleting a type Deleting a type used to strip its occurrences: any that displayed the type's mapped representation lost their geometry, and inherited material and presentation styles were dropped too. The no-SHIFT "Delete Type" path now bakes each occurrence's geometry, styles, and inherited material onto the occurrence before the type is removed: - Refactor UnassignType's unmap logic into a reusable UnassignType.unassign_and_unmap(), and extend it to re-attach styled items (copy_deep only follows forward refs, so IfcStyledItem is lost) and bake down any inherited (non-owned) material. - Add RemoveType._detach_type_material_set(): unhook the type's IfcMaterialLayerSet/ProfileSet association cascade-free before deletion, so remove_product's aggressive unassign_material never fires and the occurrences' layer/profile-set usages survive intact. Co-Authored-By: Claude Opus 4.8 --- src/bonsai/bonsai/bim/module/type/operator.py | 159 ++++++++++++++---- 1 file changed, 122 insertions(+), 37 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index bcaf8a98c7..629cd6bd4c 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING import bpy import ifcopenshell.api.attribute +import ifcopenshell.api.material import ifcopenshell.api.type import ifcopenshell.util.element import ifcopenshell.util.representation @@ -115,51 +116,96 @@ class UnassignType(bpy.types.Operator, tool.Ifc.Operator): if TYPE_CHECKING: related_object: str - def _execute(self, context): + @staticmethod + def _reattach_styles( + file: ifcopenshell.file, copied_entities: dict[int, ifcopenshell.entity_instance] + ) -> None: + """copy_deep only follows forward references, so IfcStyledItem (an inverse, + ``StyledByItem``) is not carried onto the copied geometry. Re-create a + styled item on each copy that points at the same presentation styles as + the original, so the unmapped occurrence keeps its appearance.""" + for original_id, copied in copied_entities.items(): + original = file.by_id(original_id) + for styled_item in getattr(original, "StyledByItem", None) or []: + file.create_entity( + "IfcStyledItem", + Item=copied, + Styles=styled_item.Styles, + Name=styled_item.Name, + ) + + @staticmethod + def unassign_and_unmap(obj: bpy.types.Object) -> None: + """Unassign the type from ``obj`` and bake a private copy of any mapped + representation onto it, so the occurrence keeps its geometry, styles, and + material once the type (the source of all three) is gone.""" + def exclude_callback(attribute): return attribute.is_a("IfcProfileDef") and attribute.ProfileName - self.file = tool.Ifc.get() + file = tool.Ifc.get() + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcObject"): + return + + # Capture the material inherited from the type before we sever the link, + # but only if the occurrence has no material of its own to override it. + own_material = ifcopenshell.util.element.get_material(element, should_inherit=False) + inherited_material = ifcopenshell.util.element.get_material(element, should_inherit=True) + + ifcopenshell.api.type.unassign_type(file, related_objects=[element]) + + if element.Representation: + new_active_representation = None + active_representation = tool.Geometry.get_active_representation(obj) + active_context = active_representation.ContextOfItems + representations = [] + for representation in element.Representation.Representations: + resolved_representation = ifcopenshell.util.representation.resolve_representation(representation) + if representation == resolved_representation: + representations.append(representation) + else: + # We must unmap representations, carrying over their styles. + copied_entities: dict[int, ifcopenshell.entity_instance] = {} + copied_representation = ifcopenshell.util.element.copy_deep( + file, + resolved_representation, + exclude=["IfcGeometricRepresentationContext"], + exclude_callback=exclude_callback, + copied_entities=copied_entities, + ) + UnassignType._reattach_styles(file, copied_entities) + representations.append(copied_representation) + if representation.ContextOfItems == active_context: + new_active_representation = copied_representation + element.Representation.Representations = representations + + if new_active_representation: + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=new_active_representation, + ) + + # Bake the inherited material down onto the occurrence now that its type + # link (and, in the delete-type case, the type itself) is gone. Usages are + # occurrence-specific and never inherited, so they need no handling here. + if inherited_material is not None and own_material is None: + material_type = inherited_material.is_a() + if material_type not in ("IfcMaterialLayerSetUsage", "IfcMaterialProfileSetUsage"): + ifcopenshell.api.material.assign_material( + file, products=[element], type=material_type, material=inherited_material + ) + + def _execute(self, context): if self.related_object: related_objects = [bpy.data.objects[self.related_object]] else: related_objects = tool.Blender.get_selected_objects() for obj in related_objects: - element = tool.Ifc.get_entity(obj) - if not element or not element.is_a("IfcObject"): - continue - ifcopenshell.api.type.unassign_type(self.file, related_objects=[element]) - - if element.Representation: - new_active_representation = None - active_representation = tool.Geometry.get_active_representation(obj) - active_context = active_representation.ContextOfItems - representations = [] - for representation in element.Representation.Representations: - resolved_representation = ifcopenshell.util.representation.resolve_representation(representation) - if representation == resolved_representation: - representations.append(representation) - else: - # We must unmap representations. - copied_representation = ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), - resolved_representation, - exclude=["IfcGeometricRepresentationContext"], - exclude_callback=exclude_callback, - ) - representations.append(copied_representation) - if representation.ContextOfItems == active_context: - new_active_representation = copied_representation - element.Representation.Representations = representations - - if new_active_representation: - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=new_active_representation, - ) + self.unassign_and_unmap(obj) return {"FINISHED"} @@ -318,6 +364,34 @@ class RemoveType(bpy.types.Operator, tool.Ifc.Operator): element: int also_delete_instances: bool + @staticmethod + def _detach_type_material_set(element: ifcopenshell.entity_instance) -> None: + """Cascade-free removal of the type's IfcMaterialLayerSet / IfcMaterialProfileSet + association, called just before the type is deleted. + + ``remove_product`` would otherwise route the type's material association + through ``unassign_material``, which deletes *every* usage of that set + across the model (documented behaviour, with an upstream TODO calling it + too aggressive) — stripping the material off the very occurrences we are + trying to keep. By unhooking the type<->set link by hand here, the type + has no material at delete time, so that cascade never fires and the set + plus the occurrences' usages survive intact.""" + file = tool.Ifc.get() + material = ifcopenshell.util.element.get_material(element, should_inherit=False) + if not material or material.is_a() not in ("IfcMaterialLayerSet", "IfcMaterialProfileSet"): + return + for rel in list(getattr(element, "HasAssociations", None) or []): + if not (rel.is_a("IfcRelAssociatesMaterial") and rel.RelatingMaterial == material): + continue + remaining = [o for o in rel.RelatedObjects if o != element] + if remaining: + rel.RelatedObjects = remaining + else: + history = rel.OwnerHistory + file.remove(rel) + if history: + ifcopenshell.util.element.remove_deep2(file, history) + def invoke(self, context, event): self.also_delete_instances = event.shift if self.also_delete_instances: @@ -334,11 +408,22 @@ class RemoveType(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): element = tool.Ifc.get().by_id(self.element) + occurrences = ifcopenshell.util.element.get_types(element) if self.also_delete_instances: - for occurrence in ifcopenshell.util.element.get_types(element): + for occurrence in occurrences: occurrence_obj = tool.Ifc.get_object(occurrence) if occurrence_obj: tool.Geometry.delete_ifc_object(occurrence_obj) + else: + # Keep the occurrences: bake their (previously type-mapped) geometry, + # styles, and inherited material onto each one so nothing is lost when + # the type is deleted... + for occurrence in occurrences: + occ_obj = tool.Ifc.get_object(occurrence) + if occ_obj: + UnassignType.unassign_and_unmap(occ_obj) + # ...and keep any layer/profile-set material usages alive across the deletion. + self._detach_type_material_set(element) obj = tool.Ifc.get_object(element) if obj: tool.Geometry.delete_ifc_object(obj) From 074fc26e8f4ff9b261254cc037c1f989f028a801 Mon Sep 17 00:00:00 2001 From: carlopav Date: Sun, 19 Jul 2026 01:02:52 +0200 Subject: [PATCH 044/142] drawing: evaluate camera movement once per CutDecorator redraw is_camera_moved() runs eval()/numpy over the camera matrix and, as a side effect, refreshes the stored checksum the first time it returns True. It was called up to twice per object inside decorate(), so on a frame where the camera actually moved the first call updated the checksum and every later call - the fill check on the same object, and both checks on all remaining objects - then saw an already-current checksum and returned False. Only the first object's cut got recalculated; its fill and every other element stayed stale until something else invalidated the cache. Evaluate it once at the top of __call__ and reuse the flag. This halves the per-object eval overhead on the common path (viewport navigation with the camera object stationary) and, when the camera does move, correctly recalculates the cut and fill for every intersecting element instead of just the first. Co-Authored-By: Claude Opus 4.8 --- src/bonsai/bonsai/bim/module/drawing/decoration.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index 57cafab07b..f16a3be428 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -1694,6 +1694,12 @@ class CutDecorator: selected_elements_color = self.addon_prefs.decorator_color_selected self.fallback_colour = (0.3, 0.3, 0.3, 1) + # Evaluate camera movement once per redraw rather than twice per object: is_camera_moved() + # runs eval()/numpy on the camera matrix and, as a side effect, refreshes the stored + # checksum on the first True result - so calling it per object also made the second call + # (fill) see an already-updated checksum and skip recalculating when it shouldn't. + self.camera_moved = self.is_camera_moved() + all_vertices = [] all_edges = [] selected_vertices = [] @@ -1820,9 +1826,9 @@ class CutDecorator: # Currently selected objects must be recalculated as they may be being moved / edited. # If the camera is selected, we also recalculate as the user may be moving the camera. - if not has_cut_cache or obj.select_get() or self.is_camera_moved(): + if not has_cut_cache or obj.select_get() or self.camera_moved: self.recalculate_cut(context, obj, element) - if not has_fill_cache or obj.select_get() or self.is_camera_moved(): + if not has_fill_cache or obj.select_get() or self.camera_moved: self.recalculate_fill(context, obj, element) def recalculate_cut(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: From 705af7ba3a2a41962c9e06cb24a340f59c231c01 Mon Sep 17 00:00:00 2001 From: carlopav Date: Sun, 19 Jul 2026 01:06:55 +0200 Subject: [PATCH 045/142] drawing: compute cut/fill intersection once per CutDecorator object recalculate_cut() and recalculate_fill() each ran is_intersecting_camera(), which builds a bmesh and scans every vertex. When a redraw recalculated both (camera moved, cache miss, or the object selected) that was two full intersection tests per object per frame for the same answer. Compute it once in decorate() and pass it to both, and skip the test entirely when neither recalculation is needed. Never more tests than before, identical result since the camera can't move within a frame. Co-Authored-By: Claude Opus 4.8 --- .../bonsai/bim/module/drawing/decoration.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index f16a3be428..eedc79e995 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -1825,23 +1825,35 @@ class CutDecorator: # Currently selected objects must be recalculated as they may be being moved / edited. # If the camera is selected, we also recalculate as the user may be moving the camera. + is_selected = obj.select_get() + recalc_cut = not has_cut_cache or is_selected or self.camera_moved + recalc_fill = not has_fill_cache or is_selected or self.camera_moved + if not (recalc_cut or recalc_fill): + return - if not has_cut_cache or obj.select_get() or self.camera_moved: - self.recalculate_cut(context, obj, element) - if not has_fill_cache or obj.select_get() or self.camera_moved: - self.recalculate_fill(context, obj, element) + # The intersection test builds a bmesh and scans every vertex; both recalculations need + # the same answer, so compute it once here rather than once in each. + is_intersecting = tool.Drawing.is_intersecting_camera(obj, context.scene.camera) + if recalc_cut: + self.recalculate_cut(context, obj, element, is_intersecting) + if recalc_fill: + self.recalculate_fill(context, obj, element, is_intersecting) - def recalculate_cut(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: - if tool.Drawing.is_intersecting_camera(obj, context.scene.camera): + def recalculate_cut( + self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance, is_intersecting: bool + ) -> None: + if is_intersecting: verts, edges = tool.Drawing.bisect_mesh(obj, context.scene.camera) DecoratorData.cut_cache[element.id()] = (verts, edges) else: DecoratorData.cut_cache[element.id()] = (False, False) - def recalculate_fill(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: + def recalculate_fill( + self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance, is_intersecting: bool + ) -> None: element_id = element.id() - if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera): + if not is_intersecting: DecoratorData.fill_cache[element_id] = {} return From 824c1fc280c4c9a4c95a6be2dae08bc13ba9e462 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 17 Jul 2026 10:13:29 +0300 Subject: [PATCH 046/142] ifcwrap: keep geometry's owning element alive to fix silent data corruption (#1124) create_shape() returns a Python-owned Element (SWIG_POINTER_OWN in the boost::variant out typemap). Its .geometry property calls Element::geometry(), which returns a reference into the element's boost::shared_ptr _geometry member. SWIG wraps that reference as a non-owning pointer, so the returned Triangulation/BRep/Serialization proxy does not keep the element alive. When a caller keeps only .geometry (e.g. create_shape(s, e).geometry) and drops the parent element, Python garbage-collects the element, destroying its shared_ptr and freeing the underlying representation. Subsequent reads of verts/faces then return freed memory: empty or implausible float/int garbage, non-deterministically depending on GC and allocator timing. This is silent data corruption, not a crash, and has bitten users since 2020. Fix: in the TriangulationElement/SerializedElement/BRepElement pythoncode, wrap the geometry getter so the returned geometry stores a backreference to its owning element (result._parent = self). This makes the parent's lifetime at least as long as the geometry's, automatically and transparently, so no caller has to remember to hold the element. This is aothms's suggested backreference, applied generically in the binding rather than left as a workaround. Reproduced deterministically (washBasin fixture): before, verts len 0 vs 133500 across repeated GC-pressure runs; after, 133500 every run for all three element types. test_create_shape passes; no regressions. Note: tree.select_ray()'s ray_intersection_result (2024 follow-up in #1124) is a separate ownership mechanism (std::vector element reference + std::array member pointer) and is left as follow-up scope. Co-Authored-By: Claude Sonnet 5 --- .../ifcopenshell/geom/main.py | 4 ++-- src/ifcwrap/IfcGeomWrapper.i | 23 +++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 9cf79934aa..c64d262e6f 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -482,8 +482,8 @@ def create_shape( """ Returns a geometric interpretation of the IFC entity instance - Note that in Python, you must store a reference to the element returned by this function to prevent garbage - collection when you access its children. See #1124. + The returned element's ``geometry`` keeps a reference to its owning element, so accessing children + (e.g. ``create_shape(...).geometry.verts``) no longer requires holding onto the element. See #1124. :raises RuntimeError: If failed to process shape. You can turn detailed logging to get more details. diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index 10c9b02395..b6dffb3b6e 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -793,14 +793,24 @@ struct ShapeRTTI : public boost::static_visitor %extend IfcGeom::TriangulationElement { %pythoncode %{ # Hide the getters with read-only property implementations - geometry = property(geometry) + # Keep the owning element alive while its geometry is referenced (#1124). + def _geometry_with_backref(self, _f=geometry): + result = _f(self) + result._parent = self + return result + geometry = property(_geometry_with_backref) %} }; %extend IfcGeom::SerializedElement { %pythoncode %{ # Hide the getters with read-only property implementations - geometry = property(geometry) + # Keep the owning element alive while its geometry is referenced (#1124). + def _geometry_with_backref(self, _f=geometry): + result = _f(self) + result._parent = self + return result + geometry = property(_geometry_with_backref) %} }; @@ -825,10 +835,15 @@ struct ShapeRTTI : public boost::static_visitor %pythoncode %{ # Hide the getters with read-only property implementations - geometry = property(geometry) + # Keep the owning element alive while its geometry is referenced (#1124). + def _geometry_with_backref(self, _f=geometry): + result = _f(self) + result._parent = self + return result + geometry = property(_geometry_with_backref) volume = property(calc_volume_) surface_area = property(calc_surface_area_) - %} + %} }; /* From 6603c8459a975a4e80cf2f0bc51c983cdcd0d49a Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 17 Jul 2026 10:01:23 +0300 Subject: [PATCH 047/142] Fix pythonocc-core viewer compatibility in geom.occ_utils and geom.app (#1037, #1098) set_shape_transparency() called AIS_InteractiveContext.SetTransparency(), whose argument count is inconsistent across pythonocc-core versions (reported as a TypeError in #1037). Set transparency directly on the AIS object instead, the same stable pattern already used elsewhere in this file (display_shape() calls ais.SetTransparency() directly, never through the Context), then call Context.UpdateCurrentViewer() to refresh. app.py's viewer used a "SetSelectionPriority(counter)"/"SelectionPriority()" pair as an ad hoc unique key to map a displayed AIS object back to its IFC product. On modern pythonocc-core this crashed with AttributeError because .GetObject() (needed to unwrap the old handle-based API) no longer exists on AIS objects (#1098, PR #1113 partially patched one of the two call sites but left the one in HandleSelection unguarded). Live pythonocc-core 7.9.3 testing showed the GetObject() guard alone is not sufficient: SetSelectionPriority/SelectionPriority themselves have been removed from AIS_InteractiveObject entirely in modern OCCT (only AIS_Trihedron keeps a same-named but unrelated method for datum parts), so gating the .GetObject() call with the existing USE_OCCT_HANDLE flag would still crash the first time a shape is selected. Verified live that AIS objects retain correct __eq__/__hash__ (matching the underlying OCCT instance) across separate SWIG wrapper instances, so ais_to_product is now keyed directly by the AIS object itself, removing the dependency on the removed OCCT API and the GetObject()/handle distinction altogether. Verified live against pythonocc-core 7.9.3 (conda-forge) using real AIS_Shape objects obtained from ifcopenshell.geom.occ_utils.display_shape() and a real IFC file: reproduced both the original TypeError (#1037) and AttributeError (#1098), confirmed both fixes resolve them, and confirmed the ais_to_product dict lookup round trips correctly through a real Context.Select()/SelectedInteractive() call. Could not exercise the full Qt-embedded viewer.finished()/HandleSelection() flow end to end because this pythonocc-core build segfaults natively when creating a second GL context inside a Qt widget on this macOS host, a pre-existing environment issue unrelated to this diff (reproduces identically with unpatched code, before any touched line executes). AI-generated, reviewed and tested by Petru Conduraru. Co-Authored-By: Claude Sonnet 5 --- src/ifcopenshell-python/ifcopenshell/geom/app.py | 14 +++++++------- .../ifcopenshell/geom/occ_utils.py | 6 +++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/app.py b/src/ifcopenshell-python/ifcopenshell/geom/app.py index d6bab207f1..03d7ac477a 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/app.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/app.py @@ -463,7 +463,6 @@ class application(QtWidgets.QApplication): qtViewer3d.__init__(self, widget) self.ais_to_product = {} self.product_to_ais = {} - self.counter = 0 self.window = widget self.thread = None @@ -488,11 +487,11 @@ class application(QtWidgets.QApplication): ais = display_shape(shape, viewer_handle=v) product = f[shape.data.id] - if USE_OCCT_HANDLE: - ais.GetObject().SetSelectionPriority(self.counter) - self.ais_to_product[self.counter] = product + # Keyed by the AIS object itself (its __eq__/__hash__ track the + # underlying OCCT instance) instead of AIS_InteractiveObject.SetSelectionPriority(), + # which no longer exists on general AIS objects in modern pythonocc-core (#1098). + self.ais_to_product[ais] = product self.product_to_ais[product] = ais - self.counter += 1 QtWidgets.QApplication.processEvents() @@ -573,8 +572,9 @@ class application(QtWidgets.QApplication): v.InitSelected() if v.MoreSelected(): ais = v.SelectedInteractive() - inst = self.ais_to_product[ais.GetObject().SelectionPriority()] - self.instanceSelected.emit(inst) + inst = self.ais_to_product.get(ais) + if inst is not None: + self.instanceSelected.emit(inst) class window(QtWidgets.QMainWindow): diff --git a/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py b/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py index 15a4dfc838..c5ebdf7ad6 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py @@ -225,7 +225,11 @@ def display_shape(shape, clr=None, viewer_handle=None): def set_shape_transparency(ais, t, update_viewer=True): - handle.Context.SetTransparency(ais, t, update_viewer) + # AIS_InteractiveContext.SetTransparency()'s argument count differs across + # pythonocc-core versions (#1037); AIS_InteractiveObject.SetTransparency() is stable. + ais.SetTransparency(t) + if update_viewer: + handle.Context.UpdateCurrentViewer() def get_bounding_box_center(bbox): From c68e4a0eee1f94ad538d6e86086782485c54bbdf Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 16 Jul 2026 18:48:36 +0300 Subject: [PATCH 048/142] Size entity attribute storage to schema arity, not token count When a STEP instance has fewer attribute tokens than its schema declares (commonly from corrupted/malformed syntax), parse_context::construct() sized the in-memory attribute storage to the smaller token count instead of the schema's attribute count. This left the storage's last N attribute slots simply nonexistent rather than blank, so any later read of one of those trailing attributes by index threw an uncaught IfcParse::IfcException ("Index N is out of range for storage of size N") that terminated the whole process (SIGABRT) instead of being handled as a parse warning. Fix: when the schema declaration is known, size the storage to the schema's attribute count. Indices beyond the number of tokens found are left at their existing default-constructed blank value (the storage constructor already blank-initializes every slot), so a truncated instance now degrades to blank values for its missing trailing attributes, matching the parser's existing "expected N attribute values, found M" warning intent instead of crashing. Reproduced with the fuzzing script attached to #5679: single-byte mutations of a minimal IFC4 file that corrupt the IFCPROJECT instance's token stream reliably aborted IfcConvert with this exact exception before the fix, and now parse with a logged syntax error and exit code 0. Fixes #5679 Generated with the assistance of an AI coding tool. --- src/ifcparse/IfcFile.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ifcparse/IfcFile.cpp b/src/ifcparse/IfcFile.cpp index 9f938ad41d..050279fde8 100644 --- a/src/ifcparse/IfcFile.cpp +++ b/src/ifcparse/IfcFile.cpp @@ -273,9 +273,18 @@ IfcEntityInstanceData IfcParse::parse_context::construct(boost::optional return IfcEntityInstanceData(in_memory_attribute_storage(0)); } + // When the schema declaration is known, size the storage to the schema's + // attribute count rather than the (possibly smaller) number of tokens + // actually found. Attributes are only assigned for indices covered by + // tokens_ below; any remaining trailing indices stay at their + // default-constructed blank value. This keeps every instance's storage + // consistent with its schema arity, so that a malformed/truncated + // instance (e.g. corrupted STEP syntax dropping a trailing attribute) + // degrades to a blank value for the missing attribute instead of an + // out-of-range access when that attribute is later read by index. in_memory_attribute_storage storage(coerce_attribute_count ? (decl != nullptr - ? (std::min)(parameter_types.size(), tokens_.size()) + ? parameter_types.size() : tokens_.size()) : tokens_.size() ); From 948ffce7e9fb284909e3984774831a3f1a146b6f Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 18 Jul 2026 16:15:20 +0100 Subject: [PATCH 049/142] Add sync_stub.py, a minimal-diff stub syncer generate_stub.py (this branch's earlier commit) regenerates ifcopenshell_wrapper.pyi wholesale from the compiled wrapper: it reliably fixes real drift, but it also discards everything that isn't mechanically recoverable from the wrapper alone - the license header, docstrings, and hand-curated named-parameter signatures for SWIG-overloaded constructors/functions (SWIG itself always emits generic `*args` for those, so a regenerator can't tell a deliberate curation from real drift and just overwrites it). sync_stub.py takes the smaller-blast-radius approach: it only adds top-level symbols/class members that are genuinely missing, and only removes ones that are genuinely gone, cross-checking against validate_stub.py's own full canonicalisation (via the newly-exposed get_names_tree()) so it never mistakes a property()/staticmethod()- wrapped member for something absent just because its own narrower parser skips that form. Anything that exists on both sides under the same name but with a different signature - exactly where curation lives - is left untouched and reported for a human to review instead of guessed at. Verified against a real local build: applying it to the current ifcopenshell_wrapper.pyi produces a small, targeted diff (add one missing method, drop one stale function) with the license header, docstrings, and all 14 curated constructor/function signatures preserved byte-for-byte, versus generate_stub.py's ~1000-line wholesale rewrite for the same underlying fix. Generated with the assistance of an AI coding tool. --- .../ifcopenshell/util/scripts/sync_stub.py | 414 ++++++++++++++++++ .../util/scripts/validate_stub.py | 8 +- 2 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/util/scripts/sync_stub.py diff --git a/src/ifcopenshell-python/ifcopenshell/util/scripts/sync_stub.py b/src/ifcopenshell-python/ifcopenshell/util/scripts/sync_stub.py new file mode 100644 index 0000000000..2ba98dce5b --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/util/scripts/sync_stub.py @@ -0,0 +1,414 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Thomas Krijnen +# +# 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 . + +# This file was generated with the assistance of an AI coding tool. + +"""Sync ifcopenshell_wrapper.pyi with the compiled ifcopenshell_wrapper.py by +editing only the entries that are unambiguous to add or remove - never by +regenerating the file wholesale. + +Auto-applies: + - top-level symbols (classes, functions, constants) present in the wrapper + but missing from the stub -> inserted, alphabetically among neighbouring + tracked entries. A brand-new class is rendered with its full member set + (there's no existing curated body to preserve). + - top-level symbols in the stub that no longer exist in the wrapper at all + -> removed. + - for a class whose own declaration (name + bases) matches on both sides + and whose body isn't a single-line `...`: plain members (methods, bare + attributes - not `@property`/`@staticmethod` wrappers) present in the + wrapper's class but missing from the stub's -> inserted; members in the + stub's class no longer present on the wrapper's -> removed. + +Never auto-applies - reported instead, left completely untouched: + - a top-level symbol whose declaration differs between stub and wrapper + under the same name (e.g. a function's parameter list, or a class's + base classes). + - `__init__`, in every case (present on both sides with a different + signature, or missing from one side entirely) - this is almost always + where hand-curated constructor signatures live, since SWIG always emits + generic `*args` for overloaded C++ constructors. + - a class member that differs under the same name but isn't a plain + def/attribute, or any member of a class whose own declaration didn't + match. + - anything that only *looks* addable/removable in the narrow view this + tool parses but is actually still present on the other side in a form + it doesn't parse (chiefly `property()`/`staticmethod()` wrapper + assignments, and the raw getter/setter method(s) such a wrapper + consumes) - checked against validate_stub.py's own fuller + canonicalisation before anything is added or removed, so this never + causes data loss. + - any class where, after the safe edits above, its member set still + doesn't fully match the wrapper's (typically for the property/ + staticmethod reason above) - reported so a human can look, using + validate_stub.py's own diff for that class. + +This is exactly where a hand-curated stub is *expected* to diverge from raw +SWIG output on purpose - a mechanical tool can't tell that apart from real +drift, so it leaves those lines untouched and reports them for a human to +judge instead of guessing. + +Everything else in the file - the license header, comments, blank lines, +docstrings, curated signatures, import order - is left byte-for-byte +untouched: this script tracks a line cursor through the original source and +only ever substitutes the exact line ranges of the entries it's confident +about, it never rewrites the file from scratch. + +Usage: + python sync_stub.py # dry run: prints a report, writes nothing + python sync_stub.py --write # applies the safe add/remove edits in place +""" + +import ast +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional, Union + +sys.path.insert(0, str(Path(__file__).parent)) +from validate_stub import SubnameType, get_function_node_name, get_names_tree # noqa: E402 + +LICENSE_HEADER_START = "# IfcOpenShell - IFC toolkit and geometry engine" + + +@dataclass +class Entry: + node: ast.stmt + identity: str + canonical: SubnameType + is_init: bool = False + recursable: bool = False # True for a multi-line ClassDef we may descend into + + +def _assign_identity(node: Union[ast.Assign, ast.AnnAssign]) -> Optional[str]: + if isinstance(node, ast.AnnAssign): + target = node.target + return target.id if isinstance(target, ast.Name) else None + targets = node.targets + if len(targets) != 1 or not isinstance(targets[0], ast.Name): + return None + name = targets[0].id + if name.startswith(("_", "thisown")): + return None + return name + + +def iter_simple_entries(body: "list") -> "list[Entry]": + """The subset of a module's or class's statements this tool is willing + to reason about for auto-add/auto-remove: classes, plain function defs + (including directly `@decorated` ones), and plain (non-`property()`/ + `staticmethod()`-wrapped) assignments. Everything else - imports, bare + docstrings/expressions, private names, trivial `__init__(self)`, a + `property()`/`staticmethod()` wrapper assignment itself, and the raw + getter/setter method(s) it wraps (SWIG emits both, e.g. a + `calc_surface_area_` method alongside `surface_area = + property(calc_surface_area_)`) - is left alone entirely by never being + reported as an Entry at all. + """ + consumed: "set[str]" = set() + for node in body: + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call): + func = node.value.func + if isinstance(func, ast.Name) and func.id in ("property", "staticmethod"): + consumed.update(arg.id for arg in node.value.args if isinstance(arg, ast.Name)) + + entries: "list[Entry]" = [] + for node in body: + if isinstance(node, ast.ClassDef): + bases = [b.id for b in node.bases if isinstance(b, ast.Name) and b.id not in ("_object", "object")] + bases_str = f"({', '.join(bases)})" if bases else "" + recursable = bool(node.body) and node.body[0].lineno > node.lineno + entries.append(Entry(node, node.name, f"class {node.name}{bases_str}:", recursable=recursable)) + elif isinstance(node, ast.FunctionDef): + if node.name in consumed: + continue # the raw getter/setter behind a property()/staticmethod() wrapper - see above + rendered = get_function_node_name(node) + if rendered is None: + continue # private, or a non-informative `__init__(self)` - matches validate_stub's own skip rule + entries.append(Entry(node, node.name, rendered, is_init=(node.name == "__init__"))) + elif isinstance(node, (ast.Assign, ast.AnnAssign)): + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call): + continue # property()/staticmethod() wrapper - out of scope, see function docstring + identity = _assign_identity(node) + if identity is not None and identity not in consumed: + entries.append(Entry(node, identity, identity)) + return entries + + +def node_lines(source_lines: "list[str]", node: ast.stmt) -> "list[str]": + start = node.decorator_list[0].lineno if isinstance(node, ast.FunctionDef) and node.decorator_list else node.lineno + return source_lines[start - 1 : node.end_lineno] + + +def render_entry(canonical: SubnameType, indent: str) -> "list[str]": + """Render a canonical name/signature value as stub text, matching + generate_stub.py's own per-line rendering so a freshly-inserted entry + parses back to the same canonical value get_names_tree() would compute. + """ + parts = canonical if isinstance(canonical, tuple) else (canonical,) + lines = [] + for part in parts: + if part.startswith(("class ", "def ", "@")): + lines.append(f"{indent}{part}") + else: + lines.append(f"{indent}{part} = ...") + return lines + + +def render_new_top_level(canonical: str, indent: str, wrapper_tree: dict) -> "list[str]": + """Render a brand-new top-level entry. For a class this can't just be + the header line - there's no existing curated body to preserve, so the + whole class is rendered fresh from the wrapper's true canonical subname + set (the same full set validate_stub.py itself would compute) - it's + all new content either way, same as generate_stub.py would produce.""" + if not canonical.startswith("class "): + return render_entry(canonical, indent) + subnames = wrapper_tree.get(canonical, set()) + if not subnames: + return [f"{indent}{canonical} ..."] + lines = [f"{indent}{canonical}"] + for sub in sorted(subnames, key=identity_of): + lines.extend(render_entry(sub, indent + " ")) + return lines + + +def _describe(canonical: SubnameType) -> str: + return canonical if isinstance(canonical, str) else canonical[-1] + + +def identity_of(value: SubnameType) -> str: + """Bare identity name for a get_names_tree()-style canonical value or + top-level key, so entries can be matched by name even when their full + signature differs (or when one side's form - e.g. a `property()`-wrapped + Assign - isn't something iter_simple_entries() looks at directly).""" + if isinstance(value, tuple): + return identity_of(value[-1]) + if value.startswith("class "): + return value[len("class ") :].split("(")[0].rstrip(":") + if value.startswith("def "): + return value[len("def ") :].split("(")[0] + return value + + +@dataclass +class Report: + added: "list[str]" = field(default_factory=list) + removed: "list[str]" = field(default_factory=list) + changed: "list[str]" = field(default_factory=list) # not auto-applied + residual: "list[str]" = field(default_factory=list) # not auto-applied + + +def splice_body( + nodes: "list[ast.stmt]", + source_lines: "list[str]", + stub_tree: dict, + wrapper_tree: dict, + wrapper_class_bodies: "dict[str, list]", + wrapper_entries_by_id: "dict[str, Entry]", + indent: str, + report: Report, + scope: str, + start_line: int, + scope_end_line: int, + class_key: Optional[str] = None, +) -> "list[str]": + stub_by_id = {e.identity: e for e in iter_simple_entries(nodes)} + wrapper_by_id = wrapper_entries_by_id + + # The *complete*, correctly-canonicalised view of what's really on each + # side (this scope's full subname sets, or the module's own top-level + # keys) - includes property()/staticmethod()-wrapped members that + # iter_simple_entries() deliberately doesn't parse. Used only to confirm + # an id is genuinely absent before treating it as safe to add/remove - + # never to decide *what* to add/remove, since that still comes from the + # narrower, safely-renderable iter_simple_entries() view. + if class_key is None: + full_wrapper_ids = {identity_of(k) for k in wrapper_tree} + full_stub_ids = {identity_of(k) for k in stub_tree} + else: + full_wrapper_ids = {identity_of(v) for v in wrapper_tree.get(class_key, set())} + full_stub_ids = {identity_of(v) for v in stub_tree.get(class_key, set())} + + # __init__ is never auto-added or auto-removed, only ever compared+flagged. + added_ids = sorted( + i for i in (wrapper_by_id.keys() - stub_by_id.keys()) if not wrapper_by_id[i].is_init and i not in full_stub_ids + ) + removed_ids = { + i for i in (stub_by_id.keys() - wrapper_by_id.keys()) if not stub_by_id[i].is_init and i not in full_wrapper_ids + } + + added_iter = iter(added_ids) + next_added = next(added_iter, None) + + out: "list[str]" = [] + + def flush_added_up_to(identity: Optional[str]): + nonlocal next_added + while next_added is not None and (identity is None or next_added < identity): + wentry = wrapper_by_id[next_added] + if isinstance(wentry.canonical, str) and wentry.canonical.startswith("class "): + out.extend(render_new_top_level(wentry.canonical, indent, wrapper_tree)) + else: + out.extend(render_entry(wentry.canonical, indent)) + report.added.append(f"{scope}: + {_describe(wentry.canonical)}") + next_added = next(added_iter, None) + + cursor = start_line - 1 # 0-indexed: next source line not yet emitted + + for node in nodes: + node_start = ( + node.decorator_list[0].lineno if isinstance(node, ast.FunctionDef) and node.decorator_list else node.lineno + ) + out.extend(source_lines[cursor : node_start - 1]) # gap before this node: comments, blank lines, ... + cursor = node.end_lineno + + entries_here = iter_simple_entries([node]) + if not entries_here: + # Not a trackable entry at all (import, docstring, stray + # statement, private name, trivial __init__) - always pass + # through untouched, never eligible for removal. + out.extend(node_lines(source_lines, node)) + continue + + entry = entries_here[0] + flush_added_up_to(entry.identity) + + if entry.identity in removed_ids: + report.removed.append(f"{scope}: - {_describe(entry.canonical)}") + continue # drop it: skip emitting its text entirely + + wrapper_entry = wrapper_by_id.get(entry.identity) + + if entry.is_init or wrapper_entry is None or wrapper_entry.canonical != entry.canonical: + if wrapper_entry is not None and wrapper_entry.canonical != entry.canonical: + report.changed.append( + f"{scope}: {entry.identity}\n stub: {entry.canonical}\n wrapper: {wrapper_entry.canonical}" + ) + out.extend(node_lines(source_lines, node)) + continue + + if entry.recursable and isinstance(node, ast.ClassDef): + child_wrapper_body = wrapper_class_bodies.get(wrapper_entry.canonical, []) + child_wrapper_entries = {e.identity: e for e in iter_simple_entries(child_wrapper_body)} + out.extend(source_lines[node.lineno - 1 : node.body[0].lineno - 1]) # header line(s) + out.extend( + splice_body( + node.body, + source_lines, + stub_tree, + wrapper_tree, + wrapper_class_bodies, + child_wrapper_entries, + indent + " ", + report, + f"class {entry.identity}", + node.body[0].lineno, + node.end_lineno, + class_key=wrapper_entry.canonical, + ) + ) + + # Residual check: does the fully-canonical (validate_stub-grade) + # subname set still differ after the safe edits we just made? + full_stub = stub_tree.get(wrapper_entry.canonical, set()) + full_wrapper = wrapper_tree.get(wrapper_entry.canonical, set()) + simple_stub_canon = {e.canonical for e in iter_simple_entries(node.body)} + simple_wrapper_canon = {e.canonical for e in child_wrapper_entries.values()} + hypothetical = (full_stub - simple_stub_canon) | simple_wrapper_canon + if hypothetical != full_wrapper: + report.residual.append( + f"class {entry.identity}: still differs after auto-edits " + "(likely @property/@staticmethod-wrapped members) - inspect with validate_stub.py" + ) + else: + out.extend(node_lines(source_lines, node)) + + flush_added_up_to(None) + out.extend(source_lines[cursor:scope_end_line]) + return out + + +def main() -> None: + write = "--write" in sys.argv + + package = Path(__file__).parent.parent.parent + stub_path = package / "ifcopenshell_wrapper.pyi" + wrapper_path = package / "ifcopenshell_wrapper.py" + + stub_source = stub_path.read_text() + if not stub_source.startswith(LICENSE_HEADER_START): + raise SystemExit(f"{stub_path} doesn't start with the expected license header - refusing to touch it.") + + stub_ast = ast.parse(stub_source) + wrapper_ast = ast.parse(wrapper_path.read_text()) + + stub_tree = get_names_tree(stub_ast) + wrapper_tree = get_names_tree(wrapper_ast) + + wrapper_entries = {e.identity: e for e in iter_simple_entries(wrapper_ast.body)} + wrapper_class_bodies = {e.canonical: e.node.body for e in wrapper_entries.values() if e.recursable} + + report = Report() + stub_lines = stub_source.splitlines() + new_lines = splice_body( + stub_ast.body, + stub_lines, + stub_tree, + wrapper_tree, + wrapper_class_bodies, + wrapper_entries, + "", + report, + "module", + 1, + len(stub_lines), + ) + new_source = "\n".join(new_lines) + "\n" + + print(f"Added: {len(report.added)}") + for line in report.added: + print(f" {line}") + print(f"Removed: {len(report.removed)}") + for line in report.removed: + print(f" {line}") + print(f"Left untouched, needs a human look ({len(report.changed)}):") + for line in report.changed: + print(f" {line}") + if report.residual: + print(f"Residual (not auto-editable, {len(report.residual)}):") + for line in report.residual: + print(f" {line}") + + if write: + stub_path.write_text(new_source) + print(f"\nWrote {stub_path}. Run `black` on it, then validate_stub.py to see what's left.") + else: + print("\nDry run - nothing written. Re-run with --write to apply the safe edits above.") + if new_source != stub_source: + import difflib + + print("\n--- would-be diff ---") + sys.stdout.writelines( + difflib.unified_diff( + stub_source.splitlines(keepends=True), new_source.splitlines(keepends=True), "before", "after" + ) + ) + + +if __name__ == "__main__": + main() diff --git a/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py b/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py index 1c3b6cb001..8e291f92c9 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py +++ b/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py @@ -89,7 +89,7 @@ def get_function_node_name(node: ast.FunctionDef) -> Union[SubnameType, None]: return node_name -def get_names_tree_lines(tree: ast.Module) -> list[str]: +def get_names_tree(tree: ast.Module) -> dict[str, set[SubnameType]]: # Get class tree. names_tree: dict[str, set[SubnameType]] = {} for node in tree.body: @@ -212,6 +212,12 @@ def get_names_tree_lines(tree: ast.Module) -> list[str]: if node_name is not None: names_tree[node_name] = subnames + return names_tree + + +def get_names_tree_lines(tree: ast.Module) -> list[str]: + names_tree = get_names_tree(tree) + # Convert names tree to lines. lines: list[str] = [] indent = " " * 4 From b61f809731cdef9337792d7713cbbaac134937a6 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 18 Jul 2026 16:16:54 +0100 Subject: [PATCH 050/142] Sync ifcopenshell_wrapper.pyi with sync_stub.py Ran the new sync_stub.py against a real local build: adds context.delete_same_facet_edge_pairs (present on the compiled wrapper, missing from the stub) and drops the module-level logger_or_root (present in the stub, no longer exists on the wrapper at all). Nothing else changes - no license header rewrite, no docstring loss, none of the 14 hand-curated named-parameter constructor/function signatures touched, unlike the wholesale regeneration this replaces. Generated with the assistance of an AI coding tool. --- src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 323200b9fb..478c45aea9 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -811,6 +811,7 @@ class context: def __init__(self, *args): ... def add(self, segments): ... def build(self): ... + def delete_same_facet_edge_pairs(self): ... def get_face_pairs(self): ... def merge(self, edge_indices): ... def num_edges(self): ... @@ -1805,7 +1806,6 @@ def kind_to_string(k): ... def less(arg1, arg2): ... def line_segments_to_polygons(s, eps, segments): ... def map_shape(settings, instance): ... -def logger_or_root(logger) -> logger: ... def nary_union(sequence): ... def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ... def open(fn: str, readonly: bool = False, logger=None) -> file: ... From f9be61c10b27c325c9ca512054d11e29b0efdae9 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sun, 19 Jul 2026 18:47:57 +0100 Subject: [PATCH 051/142] Bump build 821cf7b > e333c1c --- src/ifcopenshell-python/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 3f86912ab1..16ee80a884 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -55,7 +55,7 @@ PLATFORMTAG:=win_amd64 endif BINARY_VERSION:=0.8.6 -BUILD_COMMIT:=821cf7b +BUILD_COMMIT:=e333c1c IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip From b66d8b2c4dfc7e65116b226ded82705ca74878d1 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 19 Jul 2026 14:00:44 -0500 Subject: [PATCH 052/142] Fix #6652: Extend grab selection to include BBIM_Array members (#7968) When grabbing an array child, the selection now expands to include the array parent and all sibling children before the move operator runs. Mirrors existing behavior for aggregates and nests. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/geometry/operator.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index e8d2db55af..3aa75e7d31 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -4124,6 +4124,31 @@ class OverrideMoveSelect(bpy.types.Operator): self.new_active_obj = obj return {"FINISHED"} + # Get arrays + ifc_file = tool.Ifc.get() + array_parents_to_move: list[bpy.types.Object] = [] + for obj in list(context.selected_objects): + element = tool.Ifc.get_entity(obj) + if not element: + continue + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + continue + parent_element = ifc_file.by_guid(pset["Parent"]) + parent_obj = tool.Ifc.get_object(parent_element) + if parent_obj not in array_parents_to_move: + array_parents_to_move.append(parent_obj) + if element.GlobalId != pset["Parent"]: + obj.select_set(False) + + if array_parents_to_move: + for parent_obj in array_parents_to_move: + parent_element = tool.Ifc.get_entity(parent_obj) + for array_obj in tool.Array.get_all_objects(parent_element): + array_obj.select_set(True) + self.new_active_obj = parent_obj + return {"FINISHED"} + # Get nests props = tool.Nest.get_nest_props() not_editing_objs = [o.obj for o in props.not_editing_objects] From 21ea58b0e6ecdca2ea417df31e15de2e4857397f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 19 Jul 2026 23:45:38 +0100 Subject: [PATCH 053/142] Fix Bonsai polyline not enough values to unpack error Typo was introduced in b35f99e --- src/bonsai/bonsai/tool/polyline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/polyline.py b/src/bonsai/bonsai/tool/polyline.py index 883d811448..90f81dc909 100644 --- a/src/bonsai/bonsai/tool/polyline.py +++ b/src/bonsai/bonsai/tool/polyline.py @@ -168,7 +168,7 @@ class Polyline(bonsai.core.tool.Polyline): distance = (mouse_vector - last_point).length if distance < 0: return - angle, orientation_angle, angle_round_threshold = None, None + angle, orientation_angle, angle_round_threshold = None, None, None if distance > 0: angle = tool.Cad.angle_3_vectors( second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True From bb49822f2efe4545afe959362e0451405001677c Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 19 Jul 2026 23:20:32 +0300 Subject: [PATCH 054/142] Bonsai: make dxf2ifc.py example script skip unsupported DXF entities The script called Polyline.get_mode() on every modelspace entity, but that method only exists on POLYLINE entities, so any typical DXF containing lines, circles or text crashed with AttributeError before converting anything. Test for POLYLINE polyface meshes with dxftype()/is_poly_face_mesh instead and skip other entities with a message, only create the spatial containment relation when products exist, and take the input/output paths from the command line (matching obj2ifc.py) instead of a hardcoded input.dxf/test.ifc. Fixes #2151 This change was written with the assistance of an AI coding tool. --- src/bonsai/scripts/dxf2ifc.py | 39 ++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/bonsai/scripts/dxf2ifc.py b/src/bonsai/scripts/dxf2ifc.py index caef1df864..250232a398 100644 --- a/src/bonsai/scripts/dxf2ifc.py +++ b/src/bonsai/scripts/dxf2ifc.py @@ -16,20 +16,26 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import argparse + import ezdxf import ifcopenshell import ifcopenshell.guid class Dxf2Ifc: + def __init__(self, dxf_path, outfile): + self.dxf_path = dxf_path + self.outfile = outfile + def execute(self): self.create_ifc_file() - doc = ezdxf.readfile("input.dxf") + doc = ezdxf.readfile(self.dxf_path) model = doc.modelspace() products = [] for entity in model: print(entity) - if entity.get_mode() == "AcDbPolyFaceMesh": + if entity.dxftype() == "POLYLINE" and entity.is_poly_face_mesh: ifc_faces = [] for face in entity.faces(): ifc_faces.append( @@ -67,15 +73,18 @@ class Dxf2Ifc: "Name": entity.dxf.layer, "ObjectPlacement": self.placement, "Representation": representation, - } + }, ) ) else: - print("Not yet implemented") - self.file.createIfcRelContainedInSpatialStructure( - ifcopenshell.guid.new(), None, None, None, products, self.site - ) - self.file.write("test.ifc") + print(f"Skipping unsupported entity: {entity.dxftype()}") + if products: + self.file.createIfcRelContainedInSpatialStructure( + ifcopenshell.guid.new(), None, None, None, products, self.site + ) + else: + print("No AcDbPolyFaceMesh entities found, writing an empty IFC") + self.file.write(self.outfile) def create_ifc_file(self): self.file = ifcopenshell.file() @@ -97,14 +106,20 @@ class Dxf2Ifc: "Name": "DXF Conversion", "RepresentationContexts": [self.context], "UnitsInContext": units, - } + }, ) self.site = self.file.create_entity( "IfcSite", - **{"GlobalId": ifcopenshell.guid.new(), "Name": "DXF Conversion Site", "ObjectPlacement": self.placement} + **{"GlobalId": ifcopenshell.guid.new(), "Name": "DXF Conversion Site", "ObjectPlacement": self.placement}, ) self.file.createIfcRelAggregates(ifcopenshell.guid.new(), None, None, None, self.project, [self.site]) -dxf2ifc = Dxf2Ifc() -dxf2ifc.execute() +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Converts DXF polyface meshes (AcDbPolyFaceMesh) to an IFC") + parser.add_argument("dxf", type=str, help="The input DXF file") + parser.add_argument("-o", "--output", type=str, help="The output IFC file", default="out.ifc") + args = parser.parse_args() + + dxf2ifc = Dxf2Ifc(args.dxf, args.output) + dxf2ifc.execute() From 6d6d92b8492adff67079d2aa446736e537eb6f56 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 19 Jul 2026 22:48:25 +0300 Subject: [PATCH 055/142] Fix all remaining ty type-check failures on ci-lint The ci-lint workflow's ty steps fail on every branch because base v0.8.0 has four diagnostics. ty check (bonsai): - root/operator.py: bpy.data.objects.get() can return None, so UnlinkObject._execute could put None in its objects list and crash on the first attribute access when an unknown object name is passed. Handle the miss explicitly, which also satisfies the declared list[bpy.types.Object] type. - tool/sequence.py: ty does not narrow Literal types through membership tests on list literals, so the assert_never() exhaustive check was flagged. Use tuple literals, which ty narrows, keeping the exhaustiveness check intact. ty check (ios): - draw.py: arrange_polygons was called through conditional argument splats that let the same call site work against pre-April-2026 wrappers lacking arrange_polygon_settings and the logger parameter. No runtime bug for current builds, but the dynamic splats cannot be typed against the fixed 3-parameter signature. Drop the old-build workaround and call the current signature directly, following the precedent of 3d8115ebc5 which dropped similar old-build workarounds in ifcopenshell.file. Verified against a current wrapper build that the direct call arranges polygons and serializes to SVG, with and without a logger. - Optimise.py: igraph is an optional dependency with a guarded import and a toposort fallback, but it was missing from the ios type-check venv so ty could not resolve it. Add it to type-check-requirements next to the toposort fallback that is already listed. After this, poe ty-bonsai and poe ty-ios both pass cleanly. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/root/operator.py | 5 +++-- src/bonsai/bonsai/tool/sequence.py | 6 +++--- src/ifcopenshell-python/ifcopenshell/draw.py | 8 ++------ src/ifcopenshell-python/type-check-requirements.txt | 1 + 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index b1c13a8368..0a585eb918 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -413,12 +413,13 @@ class UnlinkObject(bpy.types.Operator, tool.Ifc.Operator): skip_invoke: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) def _execute(self, context): + objects: list[bpy.types.Object] if self.obj: - objects = [bpy.data.objects.get(self.obj)] + requested_obj = bpy.data.objects.get(self.obj) + objects = [requested_obj] if requested_obj is not None else [] else: objects = context.selected_objects - objects: list[bpy.types.Object] for obj in objects: was_active_object = obj == context.active_object diff --git a/src/bonsai/bonsai/tool/sequence.py b/src/bonsai/bonsai/tool/sequence.py index cc6c08b8da..3da5290311 100644 --- a/src/bonsai/bonsai/tool/sequence.py +++ b/src/bonsai/bonsai/tool/sequence.py @@ -1159,11 +1159,11 @@ class Sequence(bonsai.core.tool.Sequence): props.task_input_colors.clear() for group, data in groups.items(): for predefined_type in data["PredefinedType"]: - if group in ["CREATION", "OPERATION", "MOVEMENT_TO"]: + if group in ("CREATION", "OPERATION", "MOVEMENT_TO"): predefined_type_item = props.task_output_colors.add() - elif group in ["MOVEMENT_FROM"]: + elif group in ("MOVEMENT_FROM",): predefined_type_item = props.task_input_colors.add() - elif group in ["USERDEFINED", "DESTRUCTION"]: + elif group in ("USERDEFINED", "DESTRUCTION"): predefined_type_item = props.task_input_colors.add() predefined_type_item2 = props.task_output_colors.add() predefined_type_item2.name = predefined_type diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index 61c730bcc9..1e1a056c3c 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -42,7 +42,7 @@ WHITE = numpy.array((1.0, 1.0, 1.0)) DO_NOTHING = lambda *args: None -ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, "arrange_polygon_settings") else None +ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() @dataclass @@ -546,11 +546,7 @@ def main( *(tup for i, tup in enumerate(zip(path_objects, section_polies, polies)) if has_relevant_zone(i)) ) - arranged = W.arrange_polygons( - *filter(None, (ARRANGE_POLYGON_SETTINGS,)), - polies, - *((logger,) if logger is not None else ()), - ) + arranged = W.arrange_polygons(ARRANGE_POLYGON_SETTINGS, polies, logger) svg_data_3 = W.polygons_to_svg(arranged, False) dom3 = parseString(svg_data_3) svg3 = dom3.childNodes[0] diff --git a/src/ifcopenshell-python/type-check-requirements.txt b/src/ifcopenshell-python/type-check-requirements.txt index c797c28034..d0cee0bd7e 100644 --- a/src/ifcopenshell-python/type-check-requirements.txt +++ b/src/ifcopenshell-python/type-check-requirements.txt @@ -3,6 +3,7 @@ cjio >=0.8, <0.10 deepdiff docutils flask +igraph isodate jinja2 lark From 6014bbd877623ec6e91323619138760d0add297c Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 19 Jul 2026 22:34:56 +0300 Subject: [PATCH 056/142] ci-lint: black-format two files that drifted on v0.8.0 Both files were merged unformatted and fail the Black formatter step on every branch, keeping ci-lint red repo-wide. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/type/operator.py | 4 +--- src/ifcopenshell-python/test/test_parse.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index 629cd6bd4c..c95eee1898 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -117,9 +117,7 @@ class UnassignType(bpy.types.Operator, tool.Ifc.Operator): related_object: str @staticmethod - def _reattach_styles( - file: ifcopenshell.file, copied_entities: dict[int, ifcopenshell.entity_instance] - ) -> None: + def _reattach_styles(file: ifcopenshell.file, copied_entities: dict[int, ifcopenshell.entity_instance]) -> None: """copy_deep only follows forward references, so IfcStyledItem (an inverse, ``StyledByItem``) is not carried onto the copied geometry. Re-create a styled item on each copy that points at the same presentation styles as diff --git a/src/ifcopenshell-python/test/test_parse.py b/src/ifcopenshell-python/test/test_parse.py index 992f842bf7..d40101333e 100644 --- a/src/ifcopenshell-python/test/test_parse.py +++ b/src/ifcopenshell-python/test/test_parse.py @@ -21,7 +21,9 @@ END-ISO-10303-21; def test_reference_to_undefined_owning_instance(): - data = "ISO-10303-21;HEADER;FILE_DESCRIPTION();FILE_NAME();FILE_SCHEMA(('IFC4'));#=IFCRELAGGREGATES((#))#5=IFCPOINT)" + data = ( + "ISO-10303-21;HEADER;FILE_DESCRIPTION();FILE_NAME();FILE_SCHEMA(('IFC4'));#=IFCRELAGGREGATES((#))#5=IFCPOINT)" + ) ifcopenshell.file.from_string(data) print(ifcopenshell.get_log()) From 0a027d47a30ca81b3e3bc0250432a74118432619 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 19 Jul 2026 09:32:33 +0300 Subject: [PATCH 057/142] Bonsai: fix profile reconstruction after duplicating a circle/arc in Edit Mode Duplicating a circular or filleted-arc void in the profile CAD editor (Shift+D on the loop's vertices) reused the same IFCCIRCLE/IFCARCINDEX vertex group index for the new geometry, since Blender's mesh duplicate copies vertex group weights but does not allocate a new group. On exit from Edit Mode, auto_detect_profiles tallied group membership across the whole mesh rather than per loop, so a group meant to hold exactly 2 (circle) or 3 (arc) vertices ended up with double that, failing its sanity check and blocking the edit with an "INVALID PROFILE" popup. Fixes #6944. Scope the sanity check to each connected edge loop instead, matching how the loops are actually converted into IfcCircle/arc segments below. Also explicitly reject an arc/circle vertex tagged onto an isolated vertex with no edges at all, which the old whole-mesh count also caught. Verified live in headless Blender against the issue's repro file (IfcFurniture "Slab.004", IfcArbitraryProfileDefWithVoids with three IfcCircle voids): entering the profile editor, duplicating one void's 2-vertex loop and moving it produced an "INVALID PROFILE" popup before this change, and now produces a valid profile (the original 3 voids intact, plus the duplicate as a 4th void or a separate solid profile depending on whether it still falls inside the outer boundary). test/tool/test_model.py passes unchanged (32 passed, 1 pre-existing unrelated failure present on both before and after). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/model.py | 44 +++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index f54633c3cc..ba79840ba7 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -2292,32 +2292,18 @@ class Model(bonsai.core.tool.Model): deform_layer = bm.verts.layers.deform.active # Sanity check - group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}} if deform_layer: for vert in bm.verts: vert_group_indices = tool.Blender.bmesh_get_vertex_groups(vert, deform_layer) - is_circle = False - for group_index in vert_group_indices: - group_type = "IFCARCINDEX" if group_index in groups["IFCARCINDEX"] else "IFCCIRCLE" - group_verts[group_type].setdefault(group_index, 0) - group_verts[group_type][group_index] += 1 - if group_type == "IFCCIRCLE": - is_circle = True + is_circle = any(gi in groups["IFCCIRCLE"] for gi in vert_group_indices) + is_arc = any(gi in groups["IFCARCINDEX"] for gi in vert_group_indices) + if (is_circle or is_arc) and not vert.link_edges: + return (False, "CIRCLE" if is_circle else "3POINT_ARC") if is_circle: pass # Circles are allowed to be unclosed elif len(vert.link_edges) != 2: # Unclosed loop or forked loop return (False, "UNCLOSED_LOOP") - for group_type, group_counts in group_verts.items(): - if group_type == "IFCARCINDEX": - for group_count in group_counts.values(): - if group_count != 3: # Each arc needs 3 verts - return (False, "3POINT_ARC") - elif group_type == "IFCCIRCLE": - for group_count in group_counts.values(): - if group_count != 2: # Each circle needs 2 verts - return (False, "CIRCLE") - loop_edges = list(bm.edges) # Create loops from edges @@ -2340,6 +2326,28 @@ class Model(bonsai.core.tool.Model): has_found_connected_edge = True loops.append(loop) + # Sanity check, per loop rather than across the whole mesh + if deform_layer: + for loop in loops: + loop_group_counts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}} + loop_verts = {v for edge in loop for v in edge.verts} + for vert in loop_verts: + for group_index in tool.Blender.bmesh_get_vertex_groups(vert, deform_layer): + if group_index in groups["IFCARCINDEX"]: + group_type = "IFCARCINDEX" + elif group_index in groups["IFCCIRCLE"]: + group_type = "IFCCIRCLE" + else: + continue + loop_group_counts[group_type].setdefault(group_index, 0) + loop_group_counts[group_type][group_index] += 1 + for group_count in loop_group_counts["IFCARCINDEX"].values(): + if group_count != 3: # Each arc needs 3 verts + return (False, "3POINT_ARC") + for group_count in loop_group_counts["IFCCIRCLE"].values(): + if group_count != 2: # Each circle needs 2 verts + return (False, "CIRCLE") + tmp = ifcopenshell.file(schema=tool.Ifc.get().schema) def is_in_group(v: bmesh.types.BMVert, group_name: str) -> bool: From 0d5ea02169e92638a5366b4e98127c9e00528a68 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 19 Jul 2026 09:42:17 +0300 Subject: [PATCH 058/142] Bonsai: fix the same duplicate-loop vertex-group bug in auto_detect_curves auto_detect_profiles had the identical defect fixed in the previous commit: duplicating a circle/arc loop in Edit Mode reuses the same IFCCIRCLE/IFCARCINDEX vertex group index for the new geometry, and this sibling function (used for curve/annotation editing rather than profile voids) tallied group membership across the whole mesh instead of per loop, so it also rejected a legitimately duplicated loop as malformed. Applied the identical fix: scope the group-count sanity check to each connected edge loop, computed after the loops are built rather than in the initial whole-mesh vertex pass. Kept the existing forked-loop check (more than 2 edges per vertex) in the first pass since it is unrelated to group counting. Verified live in headless Blender: constructed two 2-vertex IFCCIRCLE loops sharing one vertex group index (the exact state Blender's Edit Mode duplicate produces) and called auto_detect_curves directly. Before this change it returned (False, "CIRCLE"); after, it returns two valid IfcCircle curves. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/model.py | 38 +++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index ba79840ba7..a24a328df1 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -2528,27 +2528,11 @@ class Model(bonsai.core.tool.Model): deform_layer = bm.verts.layers.deform.active # Sanity check - group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}} if deform_layer: for vert in bm.verts: - vert_group_indices = tool.Blender.bmesh_get_vertex_groups(vert, deform_layer) - for group_index in vert_group_indices: - group_type = "IFCARCINDEX" if group_index in groups["IFCARCINDEX"] else "IFCCIRCLE" - group_verts[group_type].setdefault(group_index, 0) - group_verts[group_type][group_index] += 1 if len(vert.link_edges) > 2: # Forked loop return (False, "FORKED_LOOP") - for group_type, group_counts in group_verts.items(): - if group_type == "IFCARCINDEX": - for group_count in group_counts.values(): - if group_count != 3: # Each arc needs 3 verts - return (False, "3POINT_ARC") - elif group_type == "IFCCIRCLE": - for group_count in group_counts.values(): - if group_count != 2: # Each circle needs 2 verts - return (False, "CIRCLE") - loop_edges = list(bm.edges) # Create loops from edges @@ -2571,6 +2555,28 @@ class Model(bonsai.core.tool.Model): has_found_connected_edge = True loops.append(loop) + # Sanity check, per loop rather than across the whole mesh + if deform_layer: + for loop in loops: + loop_group_counts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}} + loop_verts = {v for edge in loop for v in edge.verts} + for vert in loop_verts: + for group_index in tool.Blender.bmesh_get_vertex_groups(vert, deform_layer): + if group_index in groups["IFCARCINDEX"]: + group_type = "IFCARCINDEX" + elif group_index in groups["IFCCIRCLE"]: + group_type = "IFCCIRCLE" + else: + continue + loop_group_counts[group_type].setdefault(group_index, 0) + loop_group_counts[group_type][group_index] += 1 + for group_count in loop_group_counts["IFCARCINDEX"].values(): + if group_count != 3: # Each arc needs 3 verts + return (False, "3POINT_ARC") + for group_count in loop_group_counts["IFCCIRCLE"].values(): + if group_count != 2: # Each circle needs 2 verts + return (False, "CIRCLE") + tmp = ifcopenshell.file(schema=tool.Ifc.get().schema) def is_in_group(v: bmesh.types.BMVert, group_name: str) -> bool: From 32ac20e8e3b16dfee068b48e20f3b1b99e5f998e Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 19 Jul 2026 18:27:01 +0300 Subject: [PATCH 059/142] Bonsai: refresh the arc/circle decorator immediately after duplicating a loop theoryshaw's follow-up on #6944: after the profile/curve reconstruction fix (previous commit), the arc/circle marker for a freshly Shift+D-duplicated loop wouldn't appear until leaving and re-entering Edit Mode. Root cause: ProfileDecorator groups arc/circle vertices purely by IFCARCINDEX/IFCCIRCLE vertex-group index every draw call (it has no cache to go stale, it fully recomputes from the live edit-mesh bmesh each frame). Duplicating a loop copies its vertex-group weights onto the new geometry, since Blender allocates no new group for a duplicate, so the source loop and its live duplicate land in the same dict entry. That entry then fails the "exactly 2 verts per circle / 3 per arc" check and is skipped entirely, so BOTH the original and the duplicate stop being drawn until the mesh is reimported and gets fresh, distinct groups. Verified live in headless Blender: built a bmesh with an IFCCIRCLE loop and an IFCARCINDEX loop, then ran bmesh.ops.duplicate on each (the same bmesh-level operation underlying Shift+D) and called ProfileDecorator's draw method directly. Before this change, duplicating either loop dropped both the original and the duplicate from the decorator (0 circle/arc batches drawn instead of 2). After, both draw immediately, with no change to the non-duplicated case (still 1) or to genuinely distinct loops (5 independent circles still resolve to 5, not merged). 500-circle timing is unchanged (~14.3ms/draw before and after), so the added connectivity split is not a hot-path regression. Added test/bim/module/model/test_profile_decorator_duplicate_loop.py pinning the new _connected_components helper's behavior for single and duplicated circle/arc loops. This contribution was produced with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/decorator.py | 28 +++- .../test_profile_decorator_duplicate_loop.py | 141 ++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_profile_decorator_duplicate_loop.py diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index b88f2eb247..7728da27da 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -93,6 +93,30 @@ def _stroke_lines_alpha( gpu.state.blend_set("NONE") +def _connected_components( + vertex_groups: dict[int, list[bmesh.types.BMVert]], +) -> list[list[bmesh.types.BMVert]]: + """Split each vertex group's members into their connected components, + since a duplicated arc/circle loop shares its source loop's group index.""" + components = [] + for verts in vertex_groups.values(): + remaining = set(verts) + while remaining: + seed = remaining.pop() + stack = [seed] + component = [seed] + while stack: + v = stack.pop() + for edge in v.link_edges: + other = edge.other_vert(v) + if other in remaining: + remaining.discard(other) + stack.append(other) + component.append(other) + components.append(component) + return components + + class ProfileDecorator: installed = None @@ -265,7 +289,7 @@ class ProfileDecorator: # Draw arcs arc_centroids = [] arc_segments = [] - for arc in arcs.values(): + for arc in _connected_components(arcs): if len(arc) != 3: continue sorted_arc = [None, None, None] @@ -292,7 +316,7 @@ class ProfileDecorator: # Draw circles circle_centroids = [] circle_segments = [] - for circle in circles.values(): + for circle in _connected_components(circles): if len(circle) != 2: continue p1 = obj.matrix_world @ circle[0].co diff --git a/src/bonsai/test/bim/module/model/test_profile_decorator_duplicate_loop.py b/src/bonsai/test/bim/module/model/test_profile_decorator_duplicate_loop.py new file mode 100644 index 0000000000..149582acdf --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_profile_decorator_duplicate_loop.py @@ -0,0 +1,141 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Regression tests for ``ProfileDecorator``'s ``_connected_components`` helper. + +Duplicating a circle/arc void's loop in Edit Mode (Tab, Tab, select the +loop, Shift+D) copies the ``IFCARCINDEX``/``IFCCIRCLE`` vertex-group +weights onto the new geometry, since Blender's mesh duplicate never +allocates a new vertex group. Before this fix, ``ProfileDecorator`` +grouped arc/circle vertices by group index alone, so the duplicate's +vertices piled into the same entry as the source loop's, failing the +"exactly 2/3 verts" check and silently dropping *both* loops from the +decorator until the mesh was re-imported (e.g. by leaving and re-entering +Edit Mode). ``_connected_components`` splits each group's vertices back +into their connected loops so a duplicate is drawn immediately, see #6944.""" + +import bmesh +import bpy +import pytest + +from bonsai.bim.module.model.decorator import _connected_components + +pytestmark = pytest.mark.model + + +def _bm_with_groups(verts, edges, groups): + """Build a standalone bmesh with a deform layer, and populate ``groups``: + a list of (group_index, [vert_indices]) pairs, mirroring how + ``tool.Model.import_profile``/``convert_curve_to_mesh`` assign one + vertex group per circle/arc loop.""" + bm = bmesh.new() + deform_layer = bm.verts.layers.deform.new() + bm_verts = [bm.verts.new(v) for v in verts] + bm.verts.ensure_lookup_table() + for a, b in edges: + bm.edges.new((bm_verts[a], bm_verts[b])) + bm.verts.ensure_lookup_table() + bm_verts = list(bm.verts) + for group_index, vert_indices in groups: + for vi in vert_indices: + bm_verts[vi][deform_layer][group_index] = 1.0 + return bm, bm_verts, deform_layer + + +def _group_dict(bm_verts, deform_layer, group_index, vert_indices): + return {group_index: [bm_verts[i] for i in vert_indices]} + + +def test_single_circle_loop_is_one_component(): + # A circle is exactly 2 verts joined by 1 edge (tool.Model.convert_curve_to_mesh). + bm, bm_verts, deform_layer = _bm_with_groups( + verts=[(0, -1, 0), (0, 1, 0)], + edges=[(0, 1)], + groups=[(0, [0, 1])], + ) + circles = _group_dict(bm_verts, deform_layer, 0, [0, 1]) + components = _connected_components(circles) + assert len(components) == 1 + assert len(components[0]) == 2 + bm.free() + + +def test_single_arc_loop_is_one_component(): + # An arc is exactly 3 verts: endpoint-midpoint-endpoint (2 edges). + bm, bm_verts, deform_layer = _bm_with_groups( + verts=[(0, -1, 0), (0, 0, 0.3), (0, 1, 0)], + edges=[(0, 1), (1, 2)], + groups=[(0, [0, 1, 2])], + ) + arcs = _group_dict(bm_verts, deform_layer, 0, [0, 1, 2]) + components = _connected_components(arcs) + assert len(components) == 1 + assert len(components[0]) == 3 + bm.free() + + +def test_duplicated_circle_loop_splits_into_two_components(): + # Duplicating verts 0-1 (Shift+D) yields verts 2-3, connected to each + # other but NOT to the source loop, while keeping the same group index + # (0) -- exactly what bmesh.ops.duplicate produces mid Edit-Mode. + bm, bm_verts, deform_layer = _bm_with_groups( + verts=[(0, -1, 0), (0, 1, 0), (5, -1, 0), (5, 1, 0)], + edges=[(0, 1), (2, 3)], + groups=[(0, [0, 1, 2, 3])], + ) + circles = _group_dict(bm_verts, deform_layer, 0, [0, 1, 2, 3]) + components = _connected_components(circles) + assert len(components) == 2 + assert sorted(len(c) for c in components) == [2, 2] + bm.free() + + +def test_duplicated_arc_loop_splits_into_two_components(): + bm, bm_verts, deform_layer = _bm_with_groups( + verts=[(0, -1, 0), (0, 0, 0.3), (0, 1, 0), (5, -1, 0), (5, 0, 0.3), (5, 1, 0)], + edges=[(0, 1), (1, 2), (3, 4), (4, 5)], + groups=[(0, [0, 1, 2, 3, 4, 5])], + ) + arcs = _group_dict(bm_verts, deform_layer, 0, [0, 1, 2, 3, 4, 5]) + components = _connected_components(arcs) + assert len(components) == 2 + assert sorted(len(c) for c in components) == [3, 3] + bm.free() + + +def test_distinct_circle_groups_stay_separate_and_correctly_sized(): + # Multiple genuinely different circles (distinct group indices) must + # each still resolve to their own single 2-vert component. + verts = [] + edges = [] + groups = [] + for i in range(5): + base = len(verts) + verts += [(i * 3, -1, 0), (i * 3, 1, 0)] + edges.append((base, base + 1)) + groups.append((i, [base, base + 1])) + bm, bm_verts, deform_layer = _bm_with_groups(verts, edges, groups) + circles = {} + for group_index, vert_indices in groups: + circles[group_index] = [bm_verts[i] for i in vert_indices] + components = _connected_components(circles) + assert len(components) == 5 + assert all(len(c) == 2 for c in components) + bm.free() From b669baf79393246347427d07bb4fb8e67d9dc47d Mon Sep 17 00:00:00 2001 From: sboddy Date: Mon, 20 Jul 2026 00:37:04 +0100 Subject: [PATCH 060/142] Propagate deflection settings on reload (#8484) reimport_element_representations() built a fresh ifcopenshell.geom.settings() without copying deflection_tolerance / angular_tolerance from the IfcImportSettings it had just constructed, and never passed geometry_library to either the iterator() or create_shape() calls it makes. As a result, exiting Item/edit mode (which reaches this function via switch_representation) silently fell back to IfcOpenShell's hard-coded mesher defaults (0.001 linear deflection, ~50x finer than the project's default of 0.05) and the default geometry kernel, instead of the project's configured tolerance and Geometry Library. This made geometry visibly change quality after a no-op Tab into and back out of edit mode, since the reload path was unintentionally far more precise (and used a different kernel) than the initial import. Both settings, and geometry_library, are now taken from the IfcImportSettings instance already built at the top of the function, so a reload matches the original import. Refs #5685. Generated with the assistance of an AI coding tool. Co-authored-by: Claude Sonnet 5 --- src/bonsai/bonsai/tool/geometry.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 2475f6c9e9..5747b76579 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -1151,6 +1151,9 @@ class Geometry(bonsai.core.tool.Geometry): settings.set("layerset-first", True) settings.set("keep-bounding-boxes", True) settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + settings.set("mesher-linear-deflection", ifc_import_settings.deflection_tolerance) + settings.set("mesher-angular-deflection", ifc_import_settings.angular_tolerance) + geometry_library = ifc_import_settings.geometry_library ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings) ifc_importer.file = tool.Ifc.get() @@ -1162,7 +1165,11 @@ class Geometry(bonsai.core.tool.Geometry): shape = None if elements: iterator = ifcopenshell.geom.iterator( - settings, tool.Ifc.get(), multiprocessing.cpu_count(), include=elements + settings, + tool.Ifc.get(), + multiprocessing.cpu_count(), + include=elements, + geometry_library=geometry_library, ) else: iterator = None # For example, when switching representation of a type with no occurrences @@ -1217,7 +1224,9 @@ class Geometry(bonsai.core.tool.Geometry): for element in element_types: if obj := tool.Ifc.get_object(element): if representation := ifcopenshell.util.representation.get_representation(element, context): - geometry = ifcopenshell.geom.create_shape(settings, representation) + geometry = ifcopenshell.geom.create_shape( + settings, representation, geometry_library=geometry_library + ) mesh_name = tool.Loader.get_mesh_name_from_shape(geometry) mesh = meshes.get(mesh_name) if mesh is None: From c55a79b8b5da688c8c3af608782e9f98896808d0 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 20 Jul 2026 02:41:09 +0300 Subject: [PATCH 061/142] bonsai: allow overriding which classes join in section linework (#4395) (#8617) Fixes #4395. Root cause: the SVG cut-linework merge step that fuses adjacent elements' cut polygons together (per the pset-driven JoinCriteria setting) was hardcoded to only IfcWall and IfcSlab. IfcCovering cut shapes were skipped unconditionally, so adjacent coverings never joined, leaving a visible seam/broken corner in section drawings regardless of JoinCriteria. Fix: added an EPset_Drawing.JoinClasses property, following the exact same user-overridable pattern already used by EPset_Drawing.BringToFront - a comma-separated list of IFC classes to join, defaulting to "IfcWall,IfcSlab" (unchanged behavior) when unset. Users can override per-drawing to add IfcCovering (or any other class) when they want it joined too. Kept this opt-in rather than hardcoding IfcCovering into the default list, since joining a thin finish layer the same way as a thick wall/slab could produce unwanted mitring in some cases - the user decides per drawing. Verified live against the reporter's own attached file (ifcovering joining.ifc) and its cached section linework: with JoinClasses unset, two separate closed paths reproduce the reported seam exactly. With JoinClasses = "IfcWall,IfcSlab,IfcCovering", the two coverings merge into a single closed polygon with the internal seam removed. Confirmed IfcSlab join behavior is unchanged in both runs. Generated with the assistance of an AI coding tool. Co-authored-by: Dion Moult --- src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc | 3 ++- src/bonsai/bonsai/bim/module/drawing/operator.py | 12 ++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc index 4d8bc1ad47..aa39cc353b 100644 --- a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc +++ b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc @@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D FILE_SCHEMA(('IFC4')); ENDSEC; DATA; -#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31,#32,#33,#34,#35,#36)); +#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31,#32,#33,#34,#35,#36,#37)); #2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); @@ -41,5 +41,6 @@ DATA; #34=IFCSIMPLEPROPERTYTEMPLATE('2epSGfC4bFM9gb1X7zBIp4',$,'RenderSharp','Whether to render ''sharp'' (convex) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #35=IFCSIMPLEPROPERTYTEMPLATE('3TZwsEjkr5WRDKcgrYzSIA',$,'RidgeAngleMinDegrees','Minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as ''sharp'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.); #36=IFCSIMPLEPROPERTYTEMPLATE('2Jua$lO754vgZOkBoHM2gA',$,'RenderFlush','Whether to render ''flush'' edges (dihedral deviation below both ridge/valley thresholds). Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#37=IFCSIMPLEPROPERTYTEMPLATE('1zM9sia2L8RQDnWZxgUwlZ',$,'JoinClasses','Comma separated list of IFC classes whose cut linework will be joined together when they meet (e.g. mitred at a corner).\X2\000A\X0\Defaults to ''IfcWall,IfcSlab'' if not set. Override to also join other classes, such as ''IfcWall,IfcSlab,IfcCovering''.',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); ENDSEC; END-ISO-10303-21; diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 288792e15b..362015d67b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1471,6 +1471,15 @@ class CreateDrawing(bpy.types.Operator): "Material.Name", ] + join_classes = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinClasses") + if join_classes: + join_classes = tuple(c.strip() for c in join_classes.split(",") if c.strip()) + else: + # Architectural convention only merges these objects by default. E.g. pipe + # segments and fittings shouldn't merge. Users may override this per-drawing + # via the EPset_Drawing.JoinClasses property (e.g. to also join IfcCovering). + join_classes = ("IfcWall", "IfcSlab") + group = root.find("{http://www.w3.org/2000/svg}g") joined_paths = {} self.is_manifold_cache = {} @@ -1572,8 +1581,7 @@ class CreateDrawing(bpy.types.Operator): ) path.attrib["d"] = d - # Architectural convention only merges these objects. E.g. pipe segments and fittings shouldn't merge. - if not element.is_a("IfcWall") and not element.is_a("IfcSlab"): + if not any(element.is_a(c) for c in join_classes): continue keys = [] From bc1fb2a88d92fff417b5044f9cb19dcf97f7b7db Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 20 Jul 2026 02:50:14 +0300 Subject: [PATCH 062/142] Bonsai: add one click copy of annotations to another drawing (#8719) * Bonsai: move annotations between drawings when reassigning their group Assigning an IfcAnnotation to a group that represents another drawing previously left the annotation in both drawings at once: it stayed in its old drawing group, its Blender object stayed in the old drawing collection, and it kept the old camera depth, so the reassignment appeared to do nothing useful. Issue #2966 documents the seven step manual workaround users needed instead. The assign group operator now detects when the target group represents a drawing (via the new tool.Drawing.get_group_drawing, the inverse of get_drawing_group), unassigns the annotation from its previous drawing group, moves its object into the new drawing collection, and places it on the new drawing camera plane. The target camera is imported on demand when it has not been loaded yet, matching the pattern used by the activate drawing operator. Generated with the assistance of an AI coding tool. * Bonsai: add one click copy of annotations to another drawing (#2966) Duplicating an annotation into a different drawing used to require a seven step manual process: loading groups in scene properties, copying the object, fixing its group assignment by hand, and repositioning it onto the target camera plane. A plain Blender duplicate is not enough because the copy keeps pointing at the same IFC entity, and the Shift D override, while it does create a genuine new entity through root.copy_class, leaves the duplicate in the source drawing group, collection, and camera depth. The new copy annotation to drawing operator packages the proven recipe already used by duplicate drawing into one action: duplicate through tool.Geometry.duplicate_ifc_objects, unassign the copy from the source drawing group, assign it to the chosen target group, place it on the target camera plane at the same world XY, and file it into the target drawing collection. The originals are left untouched and the user's selection is restored. The target camera is imported on demand when it has not been loaded yet. The operator shows a target drawing dropdown and is reachable from the annotation tool sidebar when an annotation is selected, and from the drawings panel. Annotations already in the target drawing are skipped and reported. The orchestration lives in core.drawing.copy_annotations_to_drawing with prophecy tests covering the copy, the skip, and the camera import branches. Verified live in headless Blender 5.1: the copy is a new IfcAnnotation with its own GlobalId and IfcTextLiteral, both texts are editable independently, and everything survives save and reload with each annotation loading in its own drawing. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/__init__.py | 1 + .../bonsai/bim/module/drawing/operator.py | 84 +++++++++++++++++++ src/bonsai/bonsai/bim/module/drawing/ui.py | 2 + .../bonsai/bim/module/drawing/workspace.py | 3 + .../bonsai/bim/module/group/operator.py | 40 ++++++++- src/bonsai/bonsai/core/drawing.py | 31 +++++++ src/bonsai/bonsai/core/tool.py | 4 + src/bonsai/bonsai/tool/drawing.py | 11 +++ src/bonsai/test/core/test_drawing.py | 53 ++++++++++++ src/bonsai/test/tool/test_drawing.py | 19 +++++ 10 files changed, 247 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index d4245cb1db..12b63d9372 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -47,6 +47,7 @@ classes = ( operator.CleanWireframes, operator.ContractSheet, operator.ConvertSVGToDXF, + operator.CopyAnnotationToDrawing, operator.CopyTextToSelection, operator.CreateDrawing, operator.CreateSheets, diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 362015d67b..35d93db7d2 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -228,6 +228,90 @@ class DuplicateDrawing(bpy.types.Operator, tool.Ifc.Operator): ) +def get_copy_annotation_target_drawings(self, context): + global COPY_ANNOTATION_TARGET_DRAWINGS_ENUM + drawings = [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"] + drawings.sort(key=lambda d: d.Name or "") + COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = [(str(d.id()), d.Name or "Unnamed", "") for d in drawings] + return COPY_ANNOTATION_TARGET_DRAWINGS_ENUM + + +COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = [] + + +class CopyAnnotationToDrawing(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.copy_annotation_to_drawing" + bl_label = "Copy Annotation To Drawing" + bl_description = ( + "Copy the selected annotations to another drawing.\n\n" + "The copies become independent annotations assigned to the chosen drawing, " + "placed in its view plane. The originals stay in their current drawing" + ) + bl_options = {"REGISTER", "UNDO"} + target_drawing: bpy.props.EnumProperty(name="Target Drawing", items=get_copy_annotation_target_drawings) + + if TYPE_CHECKING: + target_drawing: str + + @classmethod + def poll(cls, context): + if not tool.Ifc.get(): + cls.poll_message_set("No IFC project loaded.") + return False + if not cls.get_selected_annotations(context): + cls.poll_message_set("No annotation selected.") + return False + return True + + @classmethod + def get_selected_annotations(cls, context) -> list[ifcopenshell.entity_instance]: + return [ + element + for obj in context.selected_objects + if (element := tool.Ifc.get_entity(obj)) + and element.is_a("IfcAnnotation") + and element.ObjectType != "DRAWING" + ] + + def invoke(self, context, event): + assert context.window_manager + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + assert self.layout + row = self.layout.row() + row.prop(self, "target_drawing") + + def _execute(self, context): + if not self.target_drawing: + self.report({"ERROR"}, "No target drawing selected.") + return {"CANCELLED"} + target_drawing = tool.Ifc.get().by_id(int(self.target_drawing)) + annotations = self.get_selected_annotations(context) + previous_selection = [obj for a in annotations if (obj := tool.Ifc.get_object(a))] + previous_active = context.view_layer.objects.active + copied = core.copy_annotations_to_drawing( + tool.Ifc, + tool.Collector, + tool.Drawing, + tool.Geometry, + annotations=annotations, + target_drawing=target_drawing, + ) + for obj in context.selected_objects: + obj.select_set(False) + for obj in previous_selection: + if obj.name in context.view_layer.objects: + obj.select_set(True) + if previous_active and previous_active.name in context.view_layer.objects: + context.view_layer.objects.active = previous_active + skipped = len(annotations) - len(copied) + message = f"Copied {len(copied)} annotations to {target_drawing.Name or 'Unnamed'}." + if skipped: + message += f" Skipped {skipped} already in that drawing." + self.report({"INFO"}, message) + + class CreateDrawing(bpy.types.Operator): """Creates/refreshes a .svg drawing diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 6c4f74ffe2..5c690e7282 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -332,6 +332,8 @@ class BIM_PT_drawings(Panel): row3.separator(factor=0.5, type="SPACE") + row3.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="") + row3.operator("bim.select_all_drawings", icon="CHECKBOX_HLT", text="") row3.operator("bim.create_drawing", text="", icon="OUTPUT") row3.operator("bim.convert_svg_to_dxf", text="", icon="SEQ_PREVIEW").view = active_drawing.name diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index bc7463073e..69532e6dfd 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -225,6 +225,9 @@ class AnnotationToolUI: def draw_edit_object_interface(cls, context): if DecoratorData.get_text_data(bpy.context.active_object): add_layout_hotkey_operator(cls.layout, "Edit Text", "S_E", "") + if bpy.ops.bim.copy_annotation_to_drawing.poll(): + row = cls.layout.row(align=True) + row.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="Copy To Drawing") @classmethod def draw_type_selection_interface(cls): diff --git a/src/bonsai/bonsai/bim/module/group/operator.py b/src/bonsai/bonsai/bim/module/group/operator.py index f58ba72763..763da8eecf 100644 --- a/src/bonsai/bonsai/bim/module/group/operator.py +++ b/src/bonsai/bonsai/bim/module/group/operator.py @@ -163,14 +163,52 @@ class AssignGroup(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): if not self.is_assigning: return bpy.ops.bim.unassign_group(group=self.group) + ifc_file = tool.Ifc.get() + group = ifc_file.by_id(self.group) products = [ element for o in tool.Blender.get_selected_objects(include_active=False) if (element := tool.Ifc.get_entity(o)) ] - ifcopenshell.api.group.assign_group(tool.Ifc.get(), products=products, group=tool.Ifc.get().by_id(self.group)) + relocated_annotations = self.unassign_from_previous_drawing(ifc_file, group, products) + ifcopenshell.api.group.assign_group(ifc_file, products=products, group=group) + self.relocate_annotations_to_drawing(relocated_annotations, group) self.report({"INFO"}, f"Assigned {len(products)} objects to group.") + def unassign_from_previous_drawing(self, ifc_file, group, products) -> list[ifcopenshell.entity_instance]: + """Assigning an annotation to a group that represents a drawing means the + annotation should belong to that drawing only, so it needs to leave + whichever drawing it was previously part of, instead of ending up + visible in both at once. + """ + new_drawing = tool.Drawing.get_group_drawing(group) + if not new_drawing: + return [] + relocated = [] + for product in products: + if not product.is_a("IfcAnnotation") or product.ObjectType == "DRAWING": + continue + old_drawing = tool.Drawing.get_annotation_drawing(product) + if not old_drawing or old_drawing.id() == new_drawing.id(): + continue + if old_group := tool.Drawing.get_drawing_group(old_drawing): + ifcopenshell.api.group.unassign_group(ifc_file, products=[product], group=old_group) + relocated.append(product) + return relocated + + def relocate_annotations_to_drawing(self, products, group) -> None: + """Move the relocated annotations into the new drawing's collection and + depth, now that they have actually been assigned to its group. + """ + if not products: + return + new_drawing = tool.Drawing.get_group_drawing(group) + new_camera = tool.Ifc.get_object(new_drawing) or tool.Drawing.import_drawing(new_drawing) + for product in products: + if obj := tool.Ifc.get_object(product): + tool.Drawing.ensure_annotation_in_drawing_plane(obj, camera=new_camera) + tool.Collector.assign(obj) + class UnassignGroup(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_group" diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index c6a7231273..2a1f760570 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -411,6 +411,37 @@ def duplicate_drawing( return new_drawing +def copy_annotations_to_drawing( + ifc: type[tool.Ifc], + collector: type[tool.Collector], + drawing_tool: type[tool.Drawing], + geometry: type[tool.Geometry], + annotations: list[ifcopenshell.entity_instance], + target_drawing: ifcopenshell.entity_instance, +) -> list[ifcopenshell.entity_instance]: + """Duplicate annotations into another drawing, leaving the originals untouched.""" + target_group = drawing_tool.get_drawing_group(target_drawing) + if not target_group: + return [] + annotations = [a for a in annotations if drawing_tool.get_annotation_drawing(a) != target_drawing] + annotation_objs = [obj for a in annotations if (obj := ifc.get_object(a))] + if not annotation_objs: + return [] + camera = ifc.get_object(target_drawing) or drawing_tool.import_drawing(target_drawing) + old_to_new, _ = geometry.duplicate_ifc_objects(annotation_objs) + copied: list[ifcopenshell.entity_instance] = [] + for new_elements in old_to_new.values(): + for new_element in new_elements: + if old_group := drawing_tool.get_drawing_group(new_element): + ifc.run("group.unassign_group", group=old_group, products=[new_element]) + ifc.run("group.assign_group", group=target_group, products=[new_element]) + new_obj = ifc.get_object(new_element) + drawing_tool.ensure_annotation_in_drawing_plane(new_obj, camera) + collector.assign(new_obj, should_clean_users_collection=True) + copied.append(new_element) + return copied + + def remove_drawing( ifc: type[tool.Ifc], drawing_tool: type[tool.Drawing], drawing: ifcopenshell.entity_instance ) -> None: diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 90ddc006f0..51120ad088 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -354,6 +354,7 @@ class Drawing: def enable_editing_schedules(cls): pass def enable_editing_sheets(cls): pass def enable_editing_text(cls, obj): pass + def ensure_annotation_in_drawing_plane(cls, obj, camera=None): pass def ensure_drawings_parent_document(cls): pass def ensure_drawings_parent_group(cls): pass def ensure_unique_drawing_name(cls, name): pass @@ -367,6 +368,7 @@ class Drawing: def generate_reference_attributes(cls, reference, **attributes): pass def generate_sheet_identification(cls): pass def get_annotation_context(cls, target_view, object_type=None): pass + def get_annotation_drawing(cls, element): pass def get_annotation_representation(cls, element_type): pass def get_assigned_product(cls, element): pass def get_assigned_product_workaround(cls, element): pass @@ -384,6 +386,7 @@ class Drawing: def get_drawing_group(cls, drawing): pass def get_drawing_references(cls, drawing): pass def get_drawing_target_view(cls, drawing): pass + def get_group_drawing(cls, group): pass def get_group_elements(cls, group): pass def get_ifc_representation_class(cls, object_type): pass def get_name(cls, element): pass @@ -397,6 +400,7 @@ class Drawing: def get_unit_system(cls): pass def import_assigned_product(cls, obj): pass def import_documents(cls, document_type): pass + def import_drawing(cls, drawing): pass def import_drawings(cls): pass def import_sheets(cls): pass def import_text_attributes(cls, obj): pass diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 4dca951c46..aedc1219d9 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -756,6 +756,17 @@ class Drawing(bonsai.core.tool.Drawing): if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING": return rel.RelatingGroup + @classmethod + def get_group_drawing(cls, group: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: + """Get the drawing that owns this group, if the group represents a drawing.""" + if group.ObjectType != "DRAWING": + return None + for rel in group.IsGroupedBy or []: + for related_object in rel.RelatedObjects: + if related_object.is_a("IfcAnnotation") and related_object.ObjectType == "DRAWING": + return related_object + return None + @classmethod def get_drawing_document(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: for rel in drawing.HasAssociations: diff --git a/src/bonsai/test/core/test_drawing.py b/src/bonsai/test/core/test_drawing.py index 439597056f..914ddbbb54 100644 --- a/src/bonsai/test/core/test_drawing.py +++ b/src/bonsai/test/core/test_drawing.py @@ -453,6 +453,59 @@ class TestDuplicateDrawing: subject.duplicate_drawing(ifc, blender, drawing, geometry, drawing="drawing", should_duplicate_annotations=True) +class TestCopyAnnotationsToDrawing: + def test_run(self, ifc: Prophecy, collector: Prophecy, drawing: Prophecy, geometry: Prophecy): + drawing.get_drawing_group("target_drawing").should_be_called().will_return("target_group") + drawing.get_annotation_drawing("annotation").should_be_called().will_return("source_drawing") + ifc.get_object("annotation").should_be_called().will_return("annotation_obj") + ifc.get_object("target_drawing").should_be_called().will_return("camera") + geometry.duplicate_ifc_objects(["annotation_obj"]).should_be_called().will_return( + ({"annotation": ["new_annotation"]}, None) + ) + drawing.get_drawing_group("new_annotation").should_be_called().will_return("source_group") + ifc.run("group.unassign_group", group="source_group", products=["new_annotation"]).should_be_called() + ifc.run("group.assign_group", group="target_group", products=["new_annotation"]).should_be_called() + ifc.get_object("new_annotation").should_be_called().will_return("new_annotation_obj") + drawing.ensure_annotation_in_drawing_plane("new_annotation_obj", "camera").should_be_called() + collector.assign("new_annotation_obj", should_clean_users_collection=True).should_be_called() + assert subject.copy_annotations_to_drawing( + ifc, collector, drawing, geometry, annotations=["annotation"], target_drawing="target_drawing" + ) == ["new_annotation"] + + def test_skipping_annotations_already_in_the_target_drawing( + self, ifc: Prophecy, collector: Prophecy, drawing: Prophecy, geometry: Prophecy + ): + drawing.get_drawing_group("target_drawing").should_be_called().will_return("target_group") + drawing.get_annotation_drawing("annotation").should_be_called().will_return("target_drawing") + assert ( + subject.copy_annotations_to_drawing( + ifc, collector, drawing, geometry, annotations=["annotation"], target_drawing="target_drawing" + ) + == [] + ) + + def test_importing_the_target_camera_when_it_is_not_loaded( + self, ifc: Prophecy, collector: Prophecy, drawing: Prophecy, geometry: Prophecy + ): + drawing.get_drawing_group("target_drawing").should_be_called().will_return("target_group") + drawing.get_annotation_drawing("annotation").should_be_called().will_return("source_drawing") + ifc.get_object("annotation").should_be_called().will_return("annotation_obj") + ifc.get_object("target_drawing").should_be_called().will_return(None) + drawing.import_drawing("target_drawing").should_be_called().will_return("camera") + geometry.duplicate_ifc_objects(["annotation_obj"]).should_be_called().will_return( + ({"annotation": ["new_annotation"]}, None) + ) + drawing.get_drawing_group("new_annotation").should_be_called().will_return("source_group") + ifc.run("group.unassign_group", group="source_group", products=["new_annotation"]).should_be_called() + ifc.run("group.assign_group", group="target_group", products=["new_annotation"]).should_be_called() + ifc.get_object("new_annotation").should_be_called().will_return("new_annotation_obj") + drawing.ensure_annotation_in_drawing_plane("new_annotation_obj", "camera").should_be_called() + collector.assign("new_annotation_obj", should_clean_users_collection=True).should_be_called() + assert subject.copy_annotations_to_drawing( + ifc, collector, drawing, geometry, annotations=["annotation"], target_drawing="target_drawing" + ) == ["new_annotation"] + + class TestRemoveDrawing: def test_run(self, ifc, drawing): drawing.is_active_drawing("drawing").should_be_called().will_return(True) diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index a14b4d9d79..580b326822 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -506,6 +506,25 @@ class TestGetDrawingGroup(NewFile): assert subject.get_drawing_group(element) == group +class TestGetGroupDrawing(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.createIfcAnnotation(ObjectType="DRAWING") + group = ifcopenshell.api.group.add_group(ifc) + group.ObjectType = "DRAWING" + ifcopenshell.api.group.assign_group(ifc, products=[drawing], group=group) + assert subject.get_group_drawing(group) == drawing + + def test_ignores_groups_that_are_not_drawings(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.createIfcAnnotation(ObjectType="DRAWING") + group = ifcopenshell.api.group.add_group(ifc) + ifcopenshell.api.group.assign_group(ifc, products=[drawing], group=group) + assert subject.get_group_drawing(group) is None + + class TestGetDrawingTargetView(NewFile): def test_run(self): ifc = ifcopenshell.file() From 7a7a250942e28f4c67eb321289a05249f6003089 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:43:04 +0000 Subject: [PATCH 063/142] build(deps): bump ruff from 0.15.12 to 0.15.22 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.12 to 0.15.22. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.12...0.15.22) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.22 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-tools.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-tools.txt b/requirements-tools.txt index 8f7aa4f792..7fc3e1d390 100644 --- a/requirements-tools.txt +++ b/requirements-tools.txt @@ -1,5 +1,5 @@ black==26.3.1 -ruff==0.15.12 +ruff==0.15.22 poethepoet ty==0.0.61 gersemi==0.26.1 From 9fda996ebe65a22106d9bd39607df7310c92fcfd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:52:38 +1000 Subject: [PATCH 064/142] build(deps): bump ruff from 0.15.12 to 0.15.22 (#8497) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.12 to 0.15.22. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.12...0.15.22) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.21 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 1ae50b8cce85c0f4b7cbff41d78a71f28bb9074c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:52:46 +1000 Subject: [PATCH 065/142] build(deps): bump ruff from 0.15.12 to 0.15.22 (#8212) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.12 to 0.15.22. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.12...0.15.22) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.20 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From a45f2fae61fa9ec79444758e640128c275ec50d1 Mon Sep 17 00:00:00 2001 From: Bartok Date: Sat, 18 Jul 2026 12:58:28 -0400 Subject: [PATCH 066/142] docs(ifc2ca): fix script paths in README Point scriptSalome.py at templates/salome/ and the bonded scripts at _deprecated/, matching the current tree so README links resolve. Generated with the assistance of an AI coding tool. --- src/ifc2ca/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ifc2ca/README.md b/src/ifc2ca/README.md index aeb6a9cab8..d4e2a26ee9 100644 --- a/src/ifc2ca/README.md +++ b/src/ifc2ca/README.md @@ -6,10 +6,10 @@ Files and scripts for the use of [`Code_Aster`](https://code-aster.org) in IFC-d ## Scripts: - [`ifc2ca.py`](ifc2ca.py): a python script to extract and create a `json` file from an `ifc` file -- [`scriptSalome.py`](scriptSalome.py): a python script to run in the [`Salome-Meca`](https://www.code-aster.org/spip.php?article303) environment. Creates the geometry and the mesh of the structure +- [`scriptSalome.py`](templates/salome/scriptSalome.py): a python script to run in the [`Salome-Meca`](https://www.code-aster.org/spip.php?article303) environment. Creates the geometry and the mesh of the structure - [`scriptCodeAster.py`](scriptCodeAster.py): a python script to create the input file (`.comm`) for Code_Aster -- [`scriptSalomeBonded.py`](scriptSalomeBonded.py): a python script to run in the [`Salome-Meca`](https://www.code-aster.org/spip.php?article303) environment. Creates the geometry and the mesh of the structure by bonding together all structural elements (no connections are considered) -- [`scriptCodeAsterBonded.py`](scriptCodeAsterBonded.py): a python script to create the input file (`.comm`) for Code_Aster for the "bonded" case +- [`scriptSalomeBonded.py`](_deprecated/scriptSalomeBonded.py): a python script to run in the [`Salome-Meca`](https://www.code-aster.org/spip.php?article303) environment. Creates the geometry and the mesh of the structure by bonding together all structural elements (no connections are considered). Located under `_deprecated/`. +- [`scriptCodeAsterBonded.py`](_deprecated/scriptCodeAsterBonded.py): a python script to create the input file (`.comm`) for Code_Aster for the "bonded" case. Located under `_deprecated/`. ## Analysis Models From 727b5f3475792ff9065ace9106efa870cbb25b65 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 17 Jul 2026 21:55:23 +0300 Subject: [PATCH 067/142] Bonsai: add Hour zoom level to the interactive Gantt chart The jsGantt-improved library that renders Bonsai's Gantt chart already ships full support for an "Hour" granularity (column width, header labels in every bundled language, hour-aware rendering math). Bonsai's config only exposed Day/Week/Month/Quarter, with a comment claiming Hour caused browser issues even with vUseSingleCell enabled. Headless Chrome testing against the same library version shows that claim no longer holds once vUseSingleCell is active (as Bonsai already configures it at 10000): Hour-format charts render without errors from typical schedules up through fairly extreme ones (5000 tasks across a 3 year span rendered in about 2.4s). The failure mode the old comment described only reproduces with vUseSingleCell disabled, which is not how Bonsai runs it. Task start/finish times already flow through to the chart unmodified as raw ISO datetimes (tool/sequence.py create_new_task_json), so any schedule authored with real hour-level timestamps, for example an imported MS Project/P6/Excel schedule or one written directly through ifcopenshell-python, can now be viewed at hour granularity. Verified live with a night shift schedule crossing midnight, rendered correctly with no console errors. Note: Bonsai's own "Edit Task Time" UI currently always snaps ScheduleStart/ScheduleFinish to 09:00/17:00 regardless of the hour entered (ifcopenshell/api/sequence/edit_task_time.py), and work calendars only encode working days, not working hours. So authoring a genuine hour-precision schedule through that UI is still not possible; this change only unlocks viewing hour-level data that already exists in the model. Fixing the editor and calendar model is a separate, larger design decision for a maintainer. Addresses #2772. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/data/webui/static/js/gantt.js | 2 +- src/bonsai/bonsai/bim/module/sequence/gantt/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js b/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js index 5eaaffa841..630571e63d 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js @@ -244,7 +244,7 @@ function addGanttElement(blenderId, tasks, workSched, filename) { vShowTaskInfoLink: 1, // Show link in tool tip (0/1) vShowEndWeekDate: 0, // Show/Hide the date for the last day of the week in header for daily vUseSingleCell: 10000, // Set the threshold cell per table row (Helps performance for large data. - vFormatArr: ["Day", "Week", "Month", "Quarter"], // Even with setUseSingleCell using Hour format on such a large chart can cause issues in some browsers, + vFormatArr: ["Hour", "Day", "Week", "Month", "Quarter"], // vUseSingleCell keeps Hour usable on large charts. vShowRes: true, // Disable the resource column. vShowComp: false, // Disable the completion column. vShowDur: false, // Disable the duration column, because jsgantt doesn't calculate durations the way we want. diff --git a/src/bonsai/bonsai/bim/module/sequence/gantt/index.js b/src/bonsai/bonsai/bim/module/sequence/gantt/index.js index 50eb49a8b6..768017bdf8 100644 --- a/src/bonsai/bonsai/bim/module/sequence/gantt/index.js +++ b/src/bonsai/bonsai/bim/module/sequence/gantt/index.js @@ -12,7 +12,7 @@ function create_gantt_chart(json_data) { vShowTaskInfoLink: 1, // Show link in tool tip (0/1) vShowEndWeekDate: 0, // Show/Hide the date for the last day of the week in header for daily vUseSingleCell: 10000, // Set the threshold cell per table row (Helps performance for large data. - vFormatArr: ['Day', 'Week', 'Month', 'Quarter'], // Even with setUseSingleCell using Hour format on such a large chart can cause issues in some browsers, + vFormatArr: ['Hour', 'Day', 'Week', 'Month', 'Quarter'], // vUseSingleCell keeps Hour usable on large charts. vShowRes: true, // Disable the resource column. vShowComp: false, // Disable the completion column. vShowDur: false, // Disable the duration column, because jsgantt doesn't calculate durations the way we want. From 04a2535a9838479720354c8c08e5913582dc5987 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 20 Jul 2026 09:27:22 +0300 Subject: [PATCH 068/142] Preserve the real cause when the ifcopenshell wrapper fails to load (#8785) * Keep real cause in wrapper ImportError When the compiled wrapper exists for the current interpreter but fails to load (for example a glibc version mismatch, as on AWS Lambda in issue 5927), the bare except rewrote the error into the misleading "IfcOpenShell not built for ''" message. Environments such as AWS Lambda or the Blender add-on dialog only surface the final exception message, so the actual cause was invisible and undiagnosable. Keep the "not built for" message only when no matching binary is present, and otherwise include the original loader error, chaining the cause in both branches. This change was AI-generated. Fixes #5927 * Simplify wrapper import failure to a single message Per review feedback, drop the filesystem scan and the two message variants. Always raise the classic "IfcOpenShell not built for ''" message with the original exception appended in parentheses, still chained as the cause. Environments that only show the final exception message (AWS Lambda, the Blender add-on dialog) now surface the real loader error, such as the glibc version mismatch in issue 5927, without any extra logic. This change was AI-generated. --- src/ifcopenshell-python/ifcopenshell/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index d0484a6d7e..b87667cb48 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -85,8 +85,8 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "lib", p try: from . import ifcopenshell_wrapper -except Exception: - raise ImportError("IfcOpenShell not built for '%s'" % python_distribution) +except Exception as e: + raise ImportError("IfcOpenShell not built for '%s' (%s)" % (python_distribution, e)) from e # `_file`, `_stream` is used only for annotations inside this file, # see https://github.com/microsoft/pyright/discussions/9065. From 55a2430d7194077afc249c0917a903ad1308e0da Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 20 Jul 2026 09:27:51 +0300 Subject: [PATCH 069/142] docs: cover the Blender 5.1 / Python 3.13 transition in installation guides (#8781) * docs: cover the Blender 5.1 / Python 3.13 transition in installation guides The system requirements still listed Blender 4.3-4.5 with Python 3.11 only, and nothing documented the pitfall from issue 7623: importing preferences into a Blender whose Python version changed carries over an incompatible Bonsai build that silently fails to load. Document the two Python generations, that Get Extensions picks the matching build automatically while manual zip installs do not, and the uninstall-reinstall step that resolves the upgrade case. Generated with the assistance of an AI coding tool. * docs: keep it simple, only Blender 5.1 and 5.2 with Python 3.13 Per review, drop the descriptive text and the Python 3.11 line. Generated with the assistance of an AI coding tool. --- src/bonsai/docs/guides/development/installation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/docs/guides/development/installation.rst b/src/bonsai/docs/guides/development/installation.rst index 7355d09ca1..4dc71a12e6 100644 --- a/src/bonsai/docs/guides/development/installation.rst +++ b/src/bonsai/docs/guides/development/installation.rst @@ -24,7 +24,7 @@ Blender versions: - 64-bit MacOS Intel (``macos-x64``) - 64-bit MacOS Silicon (``macos-arm64``) - 64-bit Windows (``windows-x64``) -- Blender 4.3, 4.4, or 4.5 with Python 3.11 +- Blender 5.1 or 5.2 with Python 3.13 Developer builds may exist for different versions of Python but there will be no guarantee of the uptime or stability of these builds. From 2d59ea19883d18fb5801170d2b5b5f4b88d1fb64 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 20 Jul 2026 19:03:56 -0500 Subject: [PATCH 070/142] Bonsai: add toggle to show only drawings placed on sheets (#8824) Adds a "Show Only Drawings on Sheets" toggle below the drawing list. When enabled, the list is filtered to drawings referenced by at least one sheet (target-view headers with no sheeted drawings are hidden too), and bim.select_all_drawings only acts on the visible/filtered drawings. A drawing is considered sheeted when its drawing document Location matches a document reference Location on any SHEET-scoped IfcDocumentInformation. Filtering is computed live so it reflects sheet edits without reloading. Closes #8823 Co-authored-by: Claude Opus 4.8 --- .../bonsai/bim/module/drawing/operator.py | 4 ++ src/bonsai/bonsai/bim/module/drawing/prop.py | 7 ++++ src/bonsai/bonsai/bim/module/drawing/ui.py | 37 +++++++++++++++++++ src/bonsai/bonsai/tool/drawing.py | 21 +++++++++++ 4 files changed, 69 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 35d93db7d2..cc0232c285 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -2310,7 +2310,11 @@ class SelectAllDrawings(bpy.types.Operator): def execute(self, context): props = tool.Drawing.get_document_props() + # When filtering to sheeted drawings only, act on the visible drawings only. + sheeted_ids = tool.Drawing.get_sheeted_drawing_ids() if props.show_drawings_on_sheets_only else None for drawing in props.drawings: + if sheeted_ids is not None and drawing.is_drawing and drawing.ifc_definition_id not in sheeted_ids: + continue if drawing.is_selected != self.select_all: drawing.is_selected = self.select_all return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index f22d7128d8..bc10632a19 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -409,6 +409,12 @@ class DocProperties(PropertyGroup): options=set(), ) is_editing_drawings: BoolProperty(name="Is Editing Drawings", default=False) + show_drawings_on_sheets_only: BoolProperty( + name="Show Only Drawings on Sheets", + description="Only show drawings that are placed on a sheet", + default=False, + options=set(), + ) is_editing_schedules: BoolProperty(name="Is Editing Schedules", default=False) is_editing_references: BoolProperty(name="Is Editing References", default=False) target_view: EnumProperty( @@ -439,6 +445,7 @@ class DocProperties(PropertyGroup): should_use_annotation_cache: bool should_draw_linked_projects: bool is_editing_drawings: bool + show_drawings_on_sheets_only: bool is_editing_schedules: bool is_editing_references: bool target_view: Literal["PLAN_VIEW", "ELEVATION_VIEW", "SECTION_VIEW", "REFLECTED_PLAN_VIEW", "MODEL_VIEW"] diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 5c690e7282..b9115dc479 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -341,6 +341,7 @@ class BIM_PT_drawings(Panel): self.layout.template_list( "BIM_UL_drawinglist", "", self.props, "drawings", self.props, "active_drawing_index" ) + self.layout.prop(self.props, "show_drawings_on_sheets_only") class BIM_PT_schedules(Panel): @@ -917,6 +918,42 @@ class BIM_UL_drawinglist(bpy.types.UIList): op.option = "EXPAND" row.prop(item, "name", text="", icon=icon, emboss=False) + def filter_items(self, context, data: DocProperties, propname: str): + drawings = getattr(data, propname) + helper_funcs = bpy.types.UI_UL_list + + flt_flags = [] + flt_neworder = [] + + if self.filter_name: + flt_flags = helper_funcs.filter_items_by_name( + self.filter_name, + self.bitflag_filter_item, + drawings, + "name", + reverse=self.use_filter_sort_reverse, + ) + if not flt_flags: + flt_flags = [self.bitflag_filter_item] * len(drawings) + + props = tool.Drawing.get_document_props() + if props.show_drawings_on_sheets_only: + ifc_file = tool.Ifc.get() + sheeted_ids = tool.Drawing.get_sheeted_drawing_ids() + # Target view headers are only shown if they contain a sheeted drawing. + sheeted_target_views = { + tool.Drawing.get_drawing_target_view(ifc_file.by_id(drawing_id)) for drawing_id in sheeted_ids + } + for i, item in enumerate(drawings): + if item.is_drawing: + is_visible = item.ifc_definition_id in sheeted_ids + else: + is_visible = item.target_view in sheeted_target_views + if not is_visible: + flt_flags[i] &= ~self.bitflag_filter_item + + return flt_flags, flt_neworder + class BIM_UL_sheets(bpy.types.UIList): def draw_item( diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index aedc1219d9..ab69aa96c4 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -2887,6 +2887,27 @@ class Drawing(bonsai.core.tool.Drawing): break return sheet_references + @classmethod + def get_sheeted_drawing_ids(cls) -> set[int]: + """Get the IFC ids of all drawings that are placed on at least one sheet.""" + ifc_file = tool.Ifc.get() + sheet_locations: set[Union[str, None]] = set() + for sheet in ifc_file.by_type("IfcDocumentInformation"): + if sheet.Scope != "SHEET": + continue + for reference in cls.get_document_references(sheet): + sheet_locations.add(reference.Location) + if not sheet_locations: + return set() + result: set[int] = set() + for drawing in ifc_file.by_type("IfcAnnotation"): + if drawing.ObjectType != "DRAWING": + continue + drawing_document = cls.get_drawing_document(drawing) + if drawing_document and drawing_document.Location in sheet_locations: + result.add(drawing.id()) + return result + @classmethod def get_camera_matrix(cls, camera: bpy.types.Object) -> Matrix: matrix_world = camera.matrix_world.copy().normalized() From e52e5e2e58625f1e8c5f74428ab83030d58dfb37 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 20 Jul 2026 20:39:22 -0500 Subject: [PATCH 071/142] Bonsai: add category-level select-all to the Drawings list (#8826) Add an "Is Selected" checkbox to each target-view category header in BIM_UL_drawinglist that toggles selection for all drawings in the category. The toggle only affects drawings currently visible in the list (honoring the show_drawings_on_sheets_only filter), and the header checkbox reflects the aggregate selection state of its drawings. Also make category headers more obvious: wrap them in a box() for a distinct inset background and make the header name clickable to expand/contract the category (same as the disclosure triangle). Ref: #8825 Co-authored-by: Claude Opus 4.8 --- .../bonsai/bim/module/drawing/__init__.py | 1 + .../bonsai/bim/module/drawing/operator.py | 20 ++++++++++++++++ src/bonsai/bonsai/bim/module/drawing/ui.py | 19 +++++++++++++-- src/bonsai/bonsai/tool/drawing.py | 23 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 12b63d9372..1bca9eab49 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -108,6 +108,7 @@ classes = ( operator.SelectAssignedProduct, operator.SelectSimilarTextLiteralValue, operator.ToggleTargetView, + operator.ToggleDrawingCategorySelection, operator.OpenDocumentationWebUi, operator.FilterSelectedObjectsIfIntersectedByCamera, prop.Variable, diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index cc0232c285..27cfb90ddc 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3874,6 +3874,26 @@ class ToggleTargetView(bpy.types.Operator): return {"FINISHED"} +class ToggleDrawingCategorySelection(bpy.types.Operator): + bl_idname = "bim.toggle_drawing_category_selection" + bl_label = "Toggle Category Selection" + bl_description = "Select or deselect all drawings in this view category" + bl_options = {"REGISTER", "UNDO"} + + target_view: bpy.props.StringProperty() + + if TYPE_CHECKING: + target_view: str + + def execute(self, context): + drawings = tool.Drawing.get_visible_drawings_in_category(self.target_view) + # If everything visible in the category is already selected, deselect all; otherwise select all. + new_state = not all(d.is_selected for d in drawings) + for drawing in drawings: + drawing.is_selected = new_state + return {"FINISHED"} + + class ExpandSheet(bpy.types.Operator): bl_idname = "bim.expand_sheet" bl_label = "Expand Sheet" diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index b9115dc479..5efec1737e 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -874,8 +874,8 @@ class BIM_UL_drawinglist(bpy.types.UIList): layout.label(text="", translate=False) return - row = layout.row(align=True) if item.is_drawing: + row = layout.row(align=True) row.label(text="", icon="BLANK1") selected_icon = "CHECKBOX_HLT" if item.is_selected else "CHECKBOX_DEHLT" row.prop(item, "is_selected", text="", icon=selected_icon, emboss=False) @@ -896,6 +896,9 @@ class BIM_UL_drawinglist(bpy.types.UIList): item.ifc_definition_id ) else: + # Give category headers a distinct inset background so they stand out from drawing rows. + box = layout.box() + row = box.row(align=True) if item.target_view == "PLAN_VIEW": icon = "UV_FACESEL" elif item.target_view == "ELEVATION_VIEW": @@ -916,7 +919,19 @@ class BIM_UL_drawinglist(bpy.types.UIList): op = row.operator("bim.toggle_target_view", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT") op.target_view = item.target_view op.option = "EXPAND" - row.prop(item, "name", text="", icon=icon, emboss=False) + group = tool.Drawing.get_visible_drawings_in_category(item.target_view) + all_selected = bool(group) and all(d.is_selected for d in group) + row.operator( + "bim.toggle_drawing_category_selection", + text="", + icon="CHECKBOX_HLT" if all_selected else "CHECKBOX_DEHLT", + emboss=False, + ).target_view = item.target_view + row.separator(factor=0.5, type="SPACE") + # Clicking the header name toggles expand/contract, same as the disclosure triangle. + op = row.operator("bim.toggle_target_view", text=item.name, icon=icon, emboss=False) + op.target_view = item.target_view + op.option = "CONTRACT" if item.is_expanded else "EXPAND" def filter_items(self, context, data: DocProperties, propname: str): drawings = getattr(data, propname) diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index ab69aa96c4..d08faa0229 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -2908,6 +2908,29 @@ class Drawing(bonsai.core.tool.Drawing): result.add(drawing.id()) return result + @classmethod + def get_visible_drawings_in_category(cls, target_view: str) -> list[DrawingProperties]: + """Get the drawing items in a target view category that are currently visible in the drawing list. + + Grouping is positional: individual drawing items don't carry their own ``target_view``, they belong to + the most recent header item above them. Only expanded categories contribute drawing items to the + collection, so a collapsed category yields an empty list. Respects the ``show_drawings_on_sheets_only`` + filter so that select-all only affects visible drawings. + """ + props = cls.get_document_props() + drawings: list[DrawingProperties] = [] + in_category = False + for item in props.drawings: + if not item.is_drawing: + # Header row: we're inside the requested category until the next header. + in_category = item.target_view == target_view + elif in_category: + drawings.append(item) + if props.show_drawings_on_sheets_only: + sheeted_ids = cls.get_sheeted_drawing_ids() + drawings = [d for d in drawings if d.ifc_definition_id in sheeted_ids] + return drawings + @classmethod def get_camera_matrix(cls, camera: bpy.types.Object) -> Matrix: matrix_world = camera.matrix_world.copy().normalized() From 8c667b8ae052b29a0b18df9d5d5bef99e387c6f5 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Tue, 21 Jul 2026 15:54:59 +0300 Subject: [PATCH 072/142] Bonsai: right-click rename on a material name (#6680) Adds a "Rename Material" entry to the context menu that already extends every button in the properties editor (UI_MT_button_context_menu), triggered when right-clicking a material name button (bim.select_by_material) that points to a real IfcMaterial. This gives a quick entry point to renaming from the Object Material panel without navigating to the scene Materials list. This follows the pattern that #6680's thread converged on: theoryshaw requested a right-click entry (rather than a pencil icon or double-click) that keeps the existing single-click select-by-material behaviour intact. falken10vdl is the issue's assignee; this is offered as a starting point for that discussion, not a replacement for it. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/material/__init__.py | 1 + .../bonsai/bim/module/material/operator.py | 20 +++++++++++++++++++ src/bonsai/bonsai/bim/ui.py | 16 +++++++++++++++ src/bonsai/bonsai/core/material.py | 8 ++++++++ 4 files changed, 45 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/material/__init__.py b/src/bonsai/bonsai/bim/module/material/__init__.py index 5f14b170ee..50afbd4dff 100644 --- a/src/bonsai/bonsai/bim/module/material/__init__.py +++ b/src/bonsai/bonsai/bim/module/material/__init__.py @@ -56,6 +56,7 @@ classes = ( operator.RemoveMaterial, operator.RemoveMaterialSet, operator.RemoveProfile, + operator.RenameMaterial, operator.ReorderMaterialSetItem, operator.SelectByMaterial, operator.SelectMaterialInMaterialsUI, diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index 6ec1ccf188..b2ab1cfa06 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -102,6 +102,26 @@ class EditMaterial(bpy.types.Operator, tool.Ifc.Operator): core.edit_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material)) +class RenameMaterial(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.rename_material" + bl_label = "Rename Material" + bl_description = "Rename an IfcMaterial" + bl_options = {"REGISTER", "UNDO"} + material: bpy.props.IntProperty() + name: bpy.props.StringProperty(name="Name") + + def invoke(self, context, event): + material = tool.Ifc.get().by_id(self.material) + self.name = material.Name or "" + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + self.layout.prop(self, "name") + + def _execute(self, context): + core.rename_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material), name=self.name) + + class DisableEditingMaterial(bpy.types.Operator): bl_idname = "bim.disable_editing_material" bl_label = "Disable Editing Material" diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 5e79cba3aa..829e0ea25c 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -41,6 +41,7 @@ import bonsai.bim.helper import bonsai.tool as tool from bonsai.bim.ifc import is_cache_locked_by_other_process from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty +from bonsai.bim.module.material.operator import SelectByMaterial from bonsai.bim.module.model import prop as _model_prop from bonsai.bim.module.model import ui as _model_ui from bonsai.bim.module.pset.prop import IfcProperty @@ -1854,6 +1855,21 @@ def draw_statusbar(self, context): def draw_custom_context_menu(self: bpy.types.Menu, context: bpy.types.Context) -> None: # https://blender.stackexchange.com/a/275555/86891 + + # Context menu for material name buttons (e.g. `bim.select_by_material`), + # offering a quick "Rename Material" entry instead of having to look up + # the material in the scene Materials panel to rename it. + button_operator = getattr(context, "button_operator", None) + if button_operator is not None and button_operator.bl_rna.identifier == SelectByMaterial.bl_rna.identifier: + ifc_file = tool.Ifc.get() + material = ifc_file.by_id(button_operator.material) if ifc_file else None + if material is not None and material.is_a("IfcMaterial"): + assert self.layout + self.layout.separator() + op = self.layout.operator("bim.rename_material", text="Rename Material", icon="GREASEPENCIL") + op.material = material.id() + return + if ( not hasattr(context, "button_pointer") or not hasattr(context, "button_prop") diff --git a/src/bonsai/bonsai/core/material.py b/src/bonsai/bonsai/core/material.py index 0a08f5e0bf..00dbd8e178 100644 --- a/src/bonsai/bonsai/core/material.py +++ b/src/bonsai/bonsai/core/material.py @@ -107,6 +107,14 @@ def disable_editing_material(material_tool: type[tool.Material]) -> None: material_tool.disable_editing_material() +def rename_material( + ifc: type[tool.Ifc], material_tool: type[tool.Material], material: ifcopenshell.entity_instance, name: str +) -> None: + ifc.run("material.edit_material", material=material, attributes={"Name": name}) + if material_tool.is_editing_materials(): + material_tool.import_material_definitions(material_tool.get_active_material_type()) + + def assign_material( ifc: type[tool.Ifc], material_tool: type[tool.Material], From 16b1b4e7b175a314d0324613127e47f0c231c052 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Tue, 21 Jul 2026 17:11:56 +0300 Subject: [PATCH 073/142] Bonsai: refresh the UI after renaming a material theoryshaw tested #8843 and asked for the new name to show up right away instead of needing a manual refresh. The Object Material panel and the scene Materials list both already re-read live IFC data on their next draw (tool.Ifc.Operator purges those caches after every IFC-mutating operator), so the button text was correct on the next redraw. What was missing was the redraw itself: the material name is a plain button label, not an RNA property Blender tracks, so nothing told the Properties editor to repaint after the rename dialog closed. Tag every area for redraw once the rename completes, the same pattern used elsewhere in Bonsai for popup-triggered edits that need an immediate repaint. Also adds core-layer test coverage for rename_material, which had none. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/material/operator.py | 3 +++ src/bonsai/test/core/test_material.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index b2ab1cfa06..d744ea5780 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -120,6 +120,9 @@ class RenameMaterial(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): core.rename_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material), name=self.name) + if screen := context.screen: + for area in screen.areas: + area.tag_redraw() class DisableEditingMaterial(bpy.types.Operator): diff --git a/src/bonsai/test/core/test_material.py b/src/bonsai/test/core/test_material.py index a2cbf8c433..ba83e9884d 100644 --- a/src/bonsai/test/core/test_material.py +++ b/src/bonsai/test/core/test_material.py @@ -93,6 +93,20 @@ class TestRemoveMaterialSet: subject.remove_material_set(ifc, material, material="material") +class TestRenameMaterial: + def test_renaming_a_material(self, ifc, material): + ifc.run("material.edit_material", material="material", attributes={"Name": "name"}).should_be_called() + material.is_editing_materials().should_be_called().will_return(False) + subject.rename_material(ifc, material, material="material", name="name") + + def test_renaming_a_material_and_reloading_imported_materials(self, ifc, material): + ifc.run("material.edit_material", material="material", attributes={"Name": "name"}).should_be_called() + material.is_editing_materials().should_be_called().will_return(True) + material.get_active_material_type().should_be_called().will_return("material_type") + material.import_material_definitions("material_type").should_be_called() + subject.rename_material(ifc, material, material="material", name="name") + + class TestLoadMaterials: def test_run(self, material): material.import_material_definitions("material_type").should_be_called() From 7ab0628c54fa4ce3f746efd691510d9998aabaa7 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Tue, 21 Jul 2026 21:08:21 +0300 Subject: [PATCH 074/142] Bonsai: refresh material data unconditionally instead of forcing a redraw falken10vdl reviewed 16b1b4e7b1 on #8843 and pointed out that tagging every area for redraw was overkill. The actual problem was that the Object Material panel and the scene Materials list read from plain python caches (ObjectMaterialData and MaterialsData) that only get invalidated when the Materials editing UI list is reloaded, which never happens while you are not in editing mode. The redraw itself was never the issue, closing the rename dialog already triggers one. Removed the tag_redraw loop from RenameMaterial and instead call the existing bonsai.bim.module.material.data.refresh() function from core.rename_material, unconditionally, through a new tool.Material.refresh() method. This is the same invalidate-on-next-load mechanism already used by every other module's Data classes, just wired up for this operator too, instead of introducing a new one. Also updates the core tests to prescribe the new unconditional refresh() call, and adds tool-layer coverage for tool.Material.refresh(). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/material/operator.py | 3 --- src/bonsai/bonsai/core/material.py | 1 + src/bonsai/bonsai/core/tool.py | 1 + src/bonsai/bonsai/tool/material.py | 6 ++++++ src/bonsai/test/core/test_material.py | 2 ++ src/bonsai/test/tool/test_material.py | 11 +++++++++++ 6 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index d744ea5780..b2ab1cfa06 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -120,9 +120,6 @@ class RenameMaterial(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): core.rename_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material), name=self.name) - if screen := context.screen: - for area in screen.areas: - area.tag_redraw() class DisableEditingMaterial(bpy.types.Operator): diff --git a/src/bonsai/bonsai/core/material.py b/src/bonsai/bonsai/core/material.py index 00dbd8e178..96c624de64 100644 --- a/src/bonsai/bonsai/core/material.py +++ b/src/bonsai/bonsai/core/material.py @@ -113,6 +113,7 @@ def rename_material( ifc.run("material.edit_material", material=material, attributes={"Name": name}) if material_tool.is_editing_materials(): material_tool.import_material_definitions(material_tool.get_active_material_type()) + material_tool.refresh() def assign_material( diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 51120ad088..8a77d36661 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -667,6 +667,7 @@ class Material: def is_editing_materials(cls): pass def is_material_used_in_sets(cls, material): pass def load_material_attributes(cls, material): pass + def refresh(cls): pass def replace_material_with_material_profile(cls, element): pass def update_elements_using_material(cls, material): pass diff --git a/src/bonsai/bonsai/tool/material.py b/src/bonsai/bonsai/tool/material.py index 4c55123a9c..300df8d4ce 100644 --- a/src/bonsai/bonsai/tool/material.py +++ b/src/bonsai/bonsai/tool/material.py @@ -146,6 +146,12 @@ class Material(bonsai.core.tool.Material): MaterialsData.data["material_styles_data"] = MaterialsData.material_styles_data() + @classmethod + def refresh(cls) -> None: + from bonsai.bim.module.material.data import refresh as refresh_material_data + + refresh_material_data() + @classmethod def is_editing_materials(cls) -> bool: props = tool.Material.get_material_props() diff --git a/src/bonsai/test/core/test_material.py b/src/bonsai/test/core/test_material.py index ba83e9884d..28fe0ba326 100644 --- a/src/bonsai/test/core/test_material.py +++ b/src/bonsai/test/core/test_material.py @@ -97,6 +97,7 @@ class TestRenameMaterial: def test_renaming_a_material(self, ifc, material): ifc.run("material.edit_material", material="material", attributes={"Name": "name"}).should_be_called() material.is_editing_materials().should_be_called().will_return(False) + material.refresh().should_be_called() subject.rename_material(ifc, material, material="material", name="name") def test_renaming_a_material_and_reloading_imported_materials(self, ifc, material): @@ -104,6 +105,7 @@ class TestRenameMaterial: material.is_editing_materials().should_be_called().will_return(True) material.get_active_material_type().should_be_called().will_return("material_type") material.import_material_definitions("material_type").should_be_called() + material.refresh().should_be_called() subject.rename_material(ifc, material, material="material", name="name") diff --git a/src/bonsai/test/tool/test_material.py b/src/bonsai/test/tool/test_material.py index 870417a254..9cee7a5962 100644 --- a/src/bonsai/test/tool/test_material.py +++ b/src/bonsai/test/tool/test_material.py @@ -139,6 +139,17 @@ class TestImportMaterialDefinitions(NewFile): assert props.materials[0].total_elements == 0 +class TestRefresh(NewFile): + def test_run(self): + from bonsai.bim.module.material.data import MaterialsData, ObjectMaterialData + + MaterialsData.is_loaded = True + ObjectMaterialData.is_loaded = True + subject.refresh() + assert MaterialsData.is_loaded is False + assert ObjectMaterialData.is_loaded is False + + class TestIsEditingMaterials(NewFile): def test_run(self): props = tool.Material.get_material_props() From efac8a0ec035fd4c1f40c5c14166e208ea1ff406 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Tue, 21 Jul 2026 11:56:01 +0300 Subject: [PATCH 075/142] ifc5d: measure openings in their real orientation on both take-off engines See #6835. Qto_OpeningElementBaseQuantities came out axis-scrambled for openings authored in a Z-up local frame (X along the voided wall, Y through it, Z vertical), which is how Bonsai authors every wall opening: - The IfcOpenShell engine mapped Height to the local Y extent and Depth to the local Z extent, so a 0.9 x 2.0 door opening with Bonsai's default 1.2m void depth reported Height 1.2 and Depth 2.0, and Area (max side area) picked the through-wall side, 2.4 instead of 1.8. This matches the wrong Height=1.2/Area=1.2 screenshots reported for a 1x1 window opening in #6835. - The Blender engine mapped opening Width to get_length, which returns the longest bounding box edge, i.e. the opening height for typical door openings (the same defect 4adaf0d fixed for IfcDoor Width), and get_opening_depth used min(x, y), which returns the opening width whenever the width is smaller than the void depth. The IfcOpenShell engine now has opening-aware internal calculators (get_opening_width/height/depth/area) that detect horizontal (slab style) openings with the same heuristic as the Blender calculator, so slab opening depths keep reporting the slab thickness. The Blender ruleset uses get_x for opening Width, and get_opening_depth measures the through-element Y extent for vertical openings. Door and window quantities themselves are addressed separately: the Blender engine door Width was fixed in 4adaf0d, and the remaining door/window defects (door not quantified on the IfcOpenShell engine, inflated areas) are fixed by the attribute-based calculators in #8389. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/qto/calculator.py | 2 +- src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json | 8 +- .../ifc5d/IFC4QtoBaseQuantitiesBlender.json | 2 +- src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json | 8 +- .../ifc5d/IFC4X3QtoBaseQuantitiesBlender.json | 2 +- src/ifc5d/ifc5d/qto.py | 49 ++++++++++ src/ifc5d/test/test_qto.py | 92 +++++++++++++++++++ 7 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 src/ifc5d/test/test_qto.py diff --git a/src/bonsai/bonsai/bim/module/qto/calculator.py b/src/bonsai/bonsai/bim/module/qto/calculator.py index 638d603f89..4921cab262 100644 --- a/src/bonsai/bonsai/bim/module/qto/calculator.py +++ b/src/bonsai/bonsai/bim/module/qto/calculator.py @@ -225,7 +225,7 @@ def get_opening_depth(obj: bpy.types.Object) -> float: if is_opening_horizontal(obj): return get_height(obj) else: - return get_width(obj) + return get_y(obj) def get_opening_mapping_area(obj: bpy.types.Object) -> float: diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json index 2c3cdcd0f8..8275619ca4 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json @@ -383,11 +383,11 @@ }, "IfcOpeningElement": { "Qto_OpeningElementBaseQuantities": { - "Area": "gross_get_max_side_area", - "Depth": "gross_get_z", - "Height": "gross_get_y", + "Area": "gross_get_opening_area", + "Depth": "gross_get_opening_depth", + "Height": "gross_get_opening_height", "Volume": "gross_get_volume", - "Width": "gross_get_x" + "Width": "gross_get_opening_width" } }, "IfcOutlet": { diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json index 43dd4b2842..ad09c19493 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json @@ -387,7 +387,7 @@ "Depth": "get_opening_depth", "Height": "get_opening_height", "Volume": "get_net_volume", - "Width": "get_length" + "Width": "get_x" } }, "IfcOutlet": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json index 7a2c70b3af..08ec3e3c27 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json @@ -472,11 +472,11 @@ }, "IfcOpeningElement": { "Qto_OpeningElementBaseQuantities": { - "Area": "gross_get_max_side_area", - "Depth": "gross_get_z", - "Height": "gross_get_y", + "Area": "gross_get_opening_area", + "Depth": "gross_get_opening_depth", + "Height": "gross_get_opening_height", "Volume": "gross_get_volume", - "Width": "gross_get_x" + "Width": "gross_get_opening_width" } }, "IfcOutlet + IfcOutletType": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json index 02f6717028..6706a8b738 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json @@ -476,7 +476,7 @@ "Depth": "get_opening_depth", "Height": "get_opening_height", "Volume": "get_net_volume", - "Width": "get_length" + "Width": "get_x" } }, "IfcOutlet + IfcOutletType": { diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index 955c6fd7af..5e119cf989 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -254,6 +254,17 @@ class IfcOpenShell(QtoCalculator): "get_segment_length": Function( "IfcLengthMeasure", "Segment Length", "Intelligently guesses the length of flow segments" ), + "get_opening_width": Function( + "IfcLengthMeasure", "Opening Width", "The width of an opening, guessing the opening orientation" + ), + "get_opening_height": Function( + "IfcLengthMeasure", "Opening Height", "The height of an opening, guessing the opening orientation" + ), + "get_opening_depth": Function( + "IfcLengthMeasure", + "Opening Depth", + "The depth of an opening (through the voided element), guessing the opening orientation", + ), # IfcAreaMeasure "get_area": Function("IfcAreaMeasure", "Area", "The total surface area of the element"), "get_footprint_area": Function( @@ -276,6 +287,9 @@ class IfcOpenShell(QtoCalculator): "Side area", "The side (non-projected) are of the shape as seen from the local Y-axis", ), + "get_opening_area": Function( + "IfcAreaMeasure", "Opening Area", "The area of an opening, guessing the opening orientation" + ), "get_top_area": Function( "IfcAreaMeasure", "Top area", @@ -301,6 +315,10 @@ class IfcOpenShell(QtoCalculator): internal_functions = ( "get_segment_length", "get_weight", + "get_opening_width", + "get_opening_height", + "get_opening_depth", + "get_opening_area", ) @classmethod @@ -365,6 +383,9 @@ class IfcOpenShell(QtoCalculator): value = cls.get_weight(element, geometry, calculation_type) if value is None: continue + elif formula.startswith("get_opening_"): + value = cls.get_opening_quantity(geometry, formula) + value = cls.unit_converter.convert(value, IfcOpenShell.raw_functions[formula].measure) else: value = formula_functions[formula](geometry) assert isinstance(value, (float, int)) @@ -389,6 +410,34 @@ class IfcOpenShell(QtoCalculator): ) return iterators + @classmethod + def get_opening_quantity(cls, geometry: ifcopenshell.geom.ShapeType, formula: str) -> float: + """Get an opening dimension or area, guessing the opening orientation. + + Vertical (wall) openings are measured in a Z-up local frame: X along + the voided element, Y through it, Z vertical. An opening is treated as + horizontal (e.g. voiding a slab) when its Z extent is smaller than + both X and Y, matching the Blender calculator's heuristic. + + :param geometry: Geometry output calculated by IfcOpenShell + :param formula: One of the ``get_opening_*`` internal function names. + :return: The dimension or area in SI units. + """ + x = ifcopenshell.util.shape.get_x(geometry) + y = ifcopenshell.util.shape.get_y(geometry) + z = ifcopenshell.util.shape.get_z(geometry) + is_horizontal = z < x and z < y + if formula == "get_opening_width": + return x + if formula == "get_opening_height": + return min(x, y) if is_horizontal else z + if formula == "get_opening_depth": + return z if is_horizontal else y + assert formula == "get_opening_area" + if is_horizontal: + return ifcopenshell.util.shape.get_footprint_area(geometry) + return ifcopenshell.util.shape.get_side_area(geometry) + @classmethod def get_segment_length(cls, element: ifcopenshell.entity_instance) -> Union[float, None]: """Get segment length. diff --git a/src/ifc5d/test/test_qto.py b/src/ifc5d/test/test_qto.py new file mode 100644 index 0000000000..8723a29836 --- /dev/null +++ b/src/ifc5d/test/test_qto.py @@ -0,0 +1,92 @@ +# Ifc5D - IFC costing utility +# Copyright (C) 2026 Dion Moult +# +# This file is part of Ifc5D. +# +# Ifc5D 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. +# +# Ifc5D 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 Ifc5D. If not, see . + +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell +import ifcopenshell.api.context +import ifcopenshell.api.root +import ifcopenshell.api.unit +import pytest + +import ifc5d.qto + + +class TestOpeningQuantities: + """Openings authored in a Z-up local frame, as produced by Bonsai (#6835).""" + + def setup_method(self): + self.file = ifcopenshell.file(schema="IFC4X3") + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject", name="Test") + f = self.file + units = [ + f.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE"), + f.createIfcSIUnit(None, "AREAUNIT", None, "SQUARE_METRE"), + f.createIfcSIUnit(None, "VOLUMEUNIT", None, "CUBIC_METRE"), + ] + ifcopenshell.api.unit.assign_unit(self.file, units=units) + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + self.body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + + def create_opening(self, profile_x: float, profile_y: float, position, extrude_dir, depth: float): + f = self.file + opening = ifcopenshell.api.root.create_entity(f, ifc_class="IfcOpeningElement") + opening.ObjectPlacement = f.createIfcLocalPlacement( + None, f.createIfcAxis2Placement3D(f.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None) + ) + profile = f.createIfcRectangleProfileDef("AREA", None, None, profile_x, profile_y) + solid = f.createIfcExtrudedAreaSolid(profile, position, f.createIfcDirection(extrude_dir), depth) + rep = f.createIfcShapeRepresentation(self.body, "Body", "SweptSolid", [solid]) + opening.Representation = f.createIfcProductDefinitionShape(None, None, [rep]) + return opening + + def quantify(self, opening) -> dict[str, float]: + rules = ifc5d.qto.rules["IFC4X3QtoBaseQuantities"] + results = ifc5d.qto.quantify(self.file, {opening}, rules) + return results[opening]["Qto_OpeningElementBaseQuantities"] + + def test_vertical_wall_opening(self): + # A 0.9 x 2.0 door opening voiding a wall along +Y, with Bonsai's + # oversized 1.2m void depth: local extents x=0.9, y=1.2, z=2.0. + f = self.file + position = f.createIfcAxis2Placement3D( + f.createIfcCartesianPoint((0.0, -0.6, 1.0)), + f.createIfcDirection((0.0, -1.0, 0.0)), + f.createIfcDirection((1.0, 0.0, 0.0)), + ) + opening = self.create_opening(0.9, 2.0, position, (0.0, 0.0, -1.0), 1.2) + quantities = self.quantify(opening) + assert quantities["Width"] == pytest.approx(0.9) + assert quantities["Height"] == pytest.approx(2.0) + assert quantities["Depth"] == pytest.approx(1.2) + assert quantities["Area"] == pytest.approx(1.8) + assert quantities["Volume"] == pytest.approx(2.16) + + def test_horizontal_slab_opening(self): + # A 1.0 x 0.5 opening voiding a 0.3 thick slab: extents x=1.0, y=0.5, z=0.3. + f = self.file + position = f.createIfcAxis2Placement3D(f.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None) + opening = self.create_opening(1.0, 0.5, position, (0.0, 0.0, -1.0), 0.3) + quantities = self.quantify(opening) + assert quantities["Width"] == pytest.approx(1.0) + assert quantities["Height"] == pytest.approx(0.5) + assert quantities["Depth"] == pytest.approx(0.3) + assert quantities["Area"] == pytest.approx(0.5) + assert quantities["Volume"] == pytest.approx(0.15) From 4ceadd8f10966bdf139200250b377d1bc99917f2 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Wed, 8 Jul 2026 13:34:03 +0300 Subject: [PATCH 076/142] Fix IfcFooting Qto_FootingBaseQuantities axis mapping per predefined type #4783 Footings are authored two ways with different local axis conventions. Beam-like footings (STRIP_FOOTING, FOOTING_BEAM) are a profile extruded along local Z, so Length is local Z and the cross section sits on local X (Width, horizontal) and local Y (Height, vertical). Slab-like footings (PAD_FOOTING, PILE_CAP) have their footprint on local X/Y and their thickness (Height) on local Z. The engine rule set is keyed per IfcFooting and cannot branch on predefined type, so the previous static rule (Height=net_get_z, Length=net_get_max_xy, Width=null) swapped Length and Height for beam-like footings and never emitted Width. Add predefined-type-aware get_footing_length/width/height to the IfcOpenShell and Blender calculators, and point the IfcFooting rule at them in all four IFC4/IFC4X3 ios/Blender rule files. Confirmed by authoring footings through the real Bonsai generators and measuring world-axis orientation: a beam-like footing with a 0.3 wide by 0.6 tall cross section and 6.0 run reports Length 6.0, Width 0.3, Height 0.6, with the 0.3 physically horizontal and 0.6 physically vertical; a 2.0x1.5x0.3 pad reports Length 2.0, Width 1.5, Height 0.3. Co-Authored-By: Claude Opus 4.8 --- .../bonsai/bim/module/qto/calculator.py | 18 ++++- src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json | 6 +- .../ifc5d/IFC4QtoBaseQuantitiesBlender.json | 2 +- src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json | 6 +- .../ifc5d/IFC4X3QtoBaseQuantitiesBlender.json | 6 +- src/ifc5d/ifc5d/qto.py | 68 ++++++++++++++++++- 6 files changed, 93 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/qto/calculator.py b/src/bonsai/bonsai/bim/module/qto/calculator.py index 4921cab262..63b55c0166 100644 --- a/src/bonsai/bonsai/bim/module/qto/calculator.py +++ b/src/bonsai/bonsai/bim/module/qto/calculator.py @@ -104,7 +104,7 @@ def get_footing_length(o: bpy.types.Object) -> float: return get_length(o) if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING": return get_z(o) - elif predefined_type == "PAD_FOOTING": + elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP": return max(get_x(o), get_y(o)) else: return get_length(o) @@ -197,12 +197,26 @@ def get_footing_height(o: bpy.types.Object) -> float: return get_height(o) if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING": return get_y(o) - elif predefined_type == "PAD_FOOTING": + elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP": return get_z(o) else: return get_height(o) +def get_footing_width(o: bpy.types.Object) -> float: + element = tool.Ifc.get_entity(o) + assert element + predefined_type = ifcopenshell.util.element.get_predefined_type(element) + if not predefined_type: + return get_width(o) + if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING": + return get_x(o) + elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP": + return get_width(o) + else: + return get_width(o) + + def get_height(o: bpy.types.Object) -> float: """_summary_: Returns the height of the object bounding box diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json index 8275619ca4..7d35e64b9a 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json @@ -318,12 +318,12 @@ "GrossSurfaceArea": null, "GrossVolume": null, "GrossWeight": "gross_get_weight", - "Height": "net_get_z", - "Length": "net_get_max_xy", + "Height": "net_get_footing_height", + "Length": "net_get_footing_length", "NetVolume": "net_get_volume", "NetWeight": "net_get_weight", "OuterSurfaceArea": null, - "Width": null + "Width": "net_get_footing_width" } }, "IfcHeatExchanger": { diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json index ad09c19493..e60e38827b 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json @@ -323,7 +323,7 @@ "NetVolume": "get_net_volume", "NetWeight": "get_net_weight", "OuterSurfaceArea": "get_outer_surface_area", - "Width": "get_width" + "Width": "get_footing_width" } }, "IfcHeatExchanger": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json index 08ec3e3c27..44806c2ccf 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json @@ -363,12 +363,12 @@ "GrossSurfaceArea": null, "GrossVolume": null, "GrossWeight": "gross_get_weight", - "Height": "net_get_z", - "Length": "net_get_max_xy", + "Height": "net_get_footing_height", + "Length": "net_get_footing_length", "NetVolume": "net_get_volume", "NetWeight": "net_get_weight", "OuterSurfaceArea": null, - "Width": null + "Width": "net_get_footing_width" } }, "IfcGeotechnicalStratum": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json index 6706a8b738..5d899e94fe 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json @@ -363,12 +363,12 @@ "GrossSurfaceArea": "get_gross_surface_area", "GrossVolume": "get_gross_volume", "GrossWeight": "get_gross_weight", - "Height": "get_height", - "Length": "get_length", + "Height": "get_footing_height", + "Length": "get_footing_length", "NetVolume": "get_net_volume", "NetWeight": "get_net_weight", "OuterSurfaceArea": "get_outer_surface_area", - "Width": "get_width" + "Width": "get_footing_width" } }, "IfcGeotechnicalStratum": { diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index 5e119cf989..764c022ff5 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -265,6 +265,27 @@ class IfcOpenShell(QtoCalculator): "Opening Depth", "The depth of an opening (through the voided element), guessing the opening orientation", ), + "get_footing_length": Function( + "IfcLengthMeasure", + "Footing Length", + "The footing length. For beam-like footings (STRIP_FOOTING, FOOTING_BEAM) this is the " + "extruded run along the local Z axis. For slab-like footings (PAD_FOOTING, PILE_CAP) and " + "other predefined types it is the longer footprint side (the larger of local X or Y).", + ), + "get_footing_width": Function( + "IfcLengthMeasure", + "Footing Width", + "The footing width. For beam-like footings (STRIP_FOOTING, FOOTING_BEAM) this is the " + "cross section width along the local X axis. For slab-like footings (PAD_FOOTING, PILE_CAP) " + "and other predefined types it is the shorter footprint side (the smaller of local X or Y).", + ), + "get_footing_height": Function( + "IfcLengthMeasure", + "Footing Height", + "The footing height. For beam-like footings (STRIP_FOOTING, FOOTING_BEAM) this is the " + "cross section height along the local Y axis. For slab-like footings (PAD_FOOTING, PILE_CAP) " + "and other predefined types it is the thickness along the local Z axis.", + ), # IfcAreaMeasure "get_area": Function("IfcAreaMeasure", "Area", "The total surface area of the element"), "get_footprint_area": Function( @@ -312,6 +333,15 @@ class IfcOpenShell(QtoCalculator): functions[f"gross_{k}"] = Function(v.measure, f"Gross {v.name}", v.description) functions[f"net_{k}"] = Function(v.measure, f"Net {v.name}", v.description) + # Predefined-type-aware footing functions. They read the element's predefined type to + # pick the correct local axis, so they receive the element (not just the geometry) and + # cannot live in ifcopenshell.util.shape. + footing_functions = ( + "get_footing_length", + "get_footing_width", + "get_footing_height", + ) + internal_functions = ( "get_segment_length", "get_weight", @@ -319,7 +349,7 @@ class IfcOpenShell(QtoCalculator): "get_opening_height", "get_opening_depth", "get_opening_area", - ) + ) + footing_functions @classmethod def calculate(cls, ifc_file, elements, qtos, results): @@ -386,6 +416,11 @@ class IfcOpenShell(QtoCalculator): elif formula.startswith("get_opening_"): value = cls.get_opening_quantity(geometry, formula) value = cls.unit_converter.convert(value, IfcOpenShell.raw_functions[formula].measure) + elif formula in cls.footing_functions: + value = getattr(cls, formula)(element, geometry) + if value is None: + continue + value = cls.unit_converter.convert(value, cls.raw_functions[formula].measure) else: value = formula_functions[formula](geometry) assert isinstance(value, (float, int)) @@ -469,6 +504,36 @@ class IfcOpenShell(QtoCalculator): z = item.Depth return max([x, y, z]) + # Footings are authored two ways, so a single static axis rule cannot be correct for both. + # Beam-like footings (STRIP_FOOTING, FOOTING_BEAM) are a profile extruded along the local Z + # axis, so the run (Length) is local Z and the cross section sits on local X (Width) and + # local Y (Height). Slab-like footings (PAD_FOOTING, PILE_CAP) have their footprint on the + # local X/Y plane and their thickness (Height) on local Z. These functions branch on the + # predefined type to pick the right axis. This mirrors the Blender calculator's + # get_footing_length / get_footing_width / get_footing_height. + _beam_like_footings = ("STRIP_FOOTING", "FOOTING_BEAM") + + @classmethod + def get_footing_length(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float: + predefined_type = ifcopenshell.util.element.get_predefined_type(element) + if predefined_type in cls._beam_like_footings: + return ifcopenshell.util.shape.get_z(geometry) + return max(ifcopenshell.util.shape.get_x(geometry), ifcopenshell.util.shape.get_y(geometry)) + + @classmethod + def get_footing_width(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float: + predefined_type = ifcopenshell.util.element.get_predefined_type(element) + if predefined_type in cls._beam_like_footings: + return ifcopenshell.util.shape.get_x(geometry) + return min(ifcopenshell.util.shape.get_x(geometry), ifcopenshell.util.shape.get_y(geometry)) + + @classmethod + def get_footing_height(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float: + predefined_type = ifcopenshell.util.element.get_predefined_type(element) + if predefined_type in cls._beam_like_footings: + return ifcopenshell.util.shape.get_y(geometry) + return ifcopenshell.util.shape.get_z(geometry) + @classmethod def get_weight( cls, @@ -544,6 +609,7 @@ class Blender(QtoCalculator): "get_width": Function("IfcLengthMeasure", "Width", ""), "get_footing_height": Function("IfcLengthMeasure", "Height", ""), "get_footing_length": Function("IfcLengthMeasure", "Length", ""), + "get_footing_width": Function("IfcLengthMeasure", "Width", ""), # IfcAreaMeasure "get_covering_gross_area": Function("IfcAreaMeasure", "Covering Gross Area", ""), "get_covering_net_area": Function("IfcAreaMeasure", "Covering Net Area", ""), From e9e2f89649ba228e5ba1ff371e45552999e10d37 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 22 Jul 2026 15:19:16 +0500 Subject: [PATCH 077/142] Revert "Sync ifcopenshell_wrapper.pyi with sync_stub.py" This reverts commit b61f809731cdef9337792d7713cbbaac134937a6. This commit was probably using not updated build, currently latest build is e333c1c and can confirm that it has `logger_or_root` added and `delete_same_facet_edge_pairs` removed. --- src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index 478c45aea9..323200b9fb 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -811,7 +811,6 @@ class context: def __init__(self, *args): ... def add(self, segments): ... def build(self): ... - def delete_same_facet_edge_pairs(self): ... def get_face_pairs(self): ... def merge(self, edge_indices): ... def num_edges(self): ... @@ -1806,6 +1805,7 @@ def kind_to_string(k): ... def less(arg1, arg2): ... def line_segments_to_polygons(s, eps, segments): ... def map_shape(settings, instance): ... +def logger_or_root(logger) -> logger: ... def nary_union(sequence): ... def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ... def open(fn: str, readonly: bool = False, logger=None) -> file: ... From 73387368985993d8782197a8e04ab4e82829ba99 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 22 Jul 2026 17:24:40 +0500 Subject: [PATCH 078/142] ColumnPSetsOfSets.ifc: restore original schema It seems it was switched to ifc2x3 by accident. Related - a7738ee 6c590bf00 --- src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc b/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc index 0ab5ace614..26394a29b3 100644 --- a/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc +++ b/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc @@ -2,7 +2,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]','RevitIdentifiers [ContentGUID: a0df3484-2dab-42c5-b806-8c10d313bee0, VersionGUID: 658c1394-f3a4-43d1-9b3c-eee44a0cd67a, NumberOfSaves: 2]','CoordinateReference [CoordinateBase: Shared Coordinates]'),'2;1'); FILE_NAME('Column_4x3.ifc','2025-03-12T13:53:30+00:00',(''),(''),'ODA SDAI 24.12','Autodesk Revit 25.4.0.32 (ENG) - IFC 25.4.0.32',''); -FILE_SCHEMA(('IFC4')); +FILE_SCHEMA(('IFC4X3_ADD2')); ENDSEC; DATA; #1=IFCORGANIZATION($,'Autodesk Revit 2025 (ENG)',$,$,$); From f0e6cfecc19ad754f99a5ab31a802b26fc07cf7b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 22 Jul 2026 18:31:30 +0500 Subject: [PATCH 079/142] cmake: skip compiled extensions when installing ifcwrap sources --- src/ifcwrap/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ifcwrap/CMakeLists.txt b/src/ifcwrap/CMakeLists.txt index 21c11d3ae0..290ba032e3 100644 --- a/src/ifcwrap/CMakeLists.txt +++ b/src/ifcwrap/CMakeLists.txt @@ -254,6 +254,8 @@ if(Python_Interpreter_FOUND OR PYTHON_MODULE_INSTALL_DIR) else() message(STATUS "Python wrapper will be installed to '${python_package_dir}'.") file(GLOB_RECURSE sourcefiles "${CMAKE_CURRENT_SOURCE_DIR}/../ifcopenshell-python/ifcopenshell/*") + # Exclude compiled extensions - they're part of dev environment and shouldn't leak into installation. + list(FILTER sourcefiles EXCLUDE REGEX "\\.(so|pyd|dylib)$") foreach(file ${sourcefiles}) file(RELATIVE_PATH relative "${CMAKE_CURRENT_SOURCE_DIR}/../ifcopenshell-python/ifcopenshell/" "${file}") get_filename_component(dir "${relative}" DIRECTORY) From 113643c9164b8f498766d4e8e6291d62629d4c77 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 22 Jul 2026 18:04:59 +0500 Subject: [PATCH 080/142] dev_environment.py: add --skip-binaries flag --- src/bonsai/scripts/dev_environment.py | 37 +++++++++++++++++++-------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/bonsai/scripts/dev_environment.py b/src/bonsai/scripts/dev_environment.py index 8fe8464705..a19416b3ab 100755 --- a/src/bonsai/scripts/dev_environment.py +++ b/src/bonsai/scripts/dev_environment.py @@ -15,6 +15,7 @@ Example usage: """ +import argparse import shutil import subprocess import sys @@ -27,6 +28,17 @@ if sys.platform not in available_platforms: print(f"Currently only available on {', '.join(available_platforms)}. Not available on {sys.platform}.") exit(1) +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument( + "--skip-binaries", + action="store_true", + help=( + "Skip copying compiled dependencies (e.g. ifcopenshell_wrapper) to the repo. " + "Useful if you already have the latest binaries in the repo and don't want them to be overridden." + ), +) +args = parser.parse_args() + # --------------------------- # SETTINGS. # --------------------------- @@ -125,17 +137,20 @@ def main() -> None: path.unlink() subprocess.check_call(("git", "checkout", "--", symlinks_glob), cwd=REPO_PATH) - print("Copying compiled dependencies to the repo...") - dest = REPO_PATH / "src" / "ifcopenshell-python" / "ifcopenshell" - for path in PACKAGE_PATH.glob("ifcopenshell/*_wrapper*"): - if path.suffix.lower() == ".pyi": - continue - dest_ = dest / path.name - print(f"Copying {path} -> {dest_}") - try: - shutil.copy(path, dest_) - except shutil.SameFileError: - pass + if args.skip_binaries: + print("Skipping copying compiled dependencies to the repo...") + else: + print("Copying compiled dependencies to the repo...") + dest = REPO_PATH / "src" / "ifcopenshell-python" / "ifcopenshell" + for path in PACKAGE_PATH.glob("ifcopenshell/*_wrapper*"): + if path.suffix.lower() == ".pyi": + continue + dest_ = dest / path.name + print(f"Copying {path} -> {dest_}") + try: + shutil.copy(path, dest_) + except shutil.SameFileError: + pass print("Symlinking extension to the git repo...") # fmt: off From 84b2cf6db047ba24eef1b107a7b5bafddf801d71 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 22 Jul 2026 18:15:00 +0500 Subject: [PATCH 081/142] dev-setup: use Python 3.13 --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4a7d22b0f5..fca2913fb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,7 +156,8 @@ exclude = [ [tool.poe.tasks] dev-setup.sequence = [ - {cmd = "uv sync"}, + # 3.13 is chosen because it's the version used in the latest Bonsai. + {cmd = "uv sync --python 3.13"}, {cmd = "uv pip install -e ./src/bsdd/"}, {cmd = "uv pip install -e ./src/ifcopenshell-python/[advanced,dev]"}, {cmd = "uv pip install -e ./src/ifcedit/"}, From f3cb7aea6115576fdad12787916c816c3998fb0c Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 16 Jul 2026 16:14:25 +0300 Subject: [PATCH 082/142] ifc5d: wire up GrossFootprintArea/NetFootprintArea for IfcWall QTO Root cause: the IfcOpenShell-geometry-engine calculator ruleset (IFC4QtoBaseQuantities.json and IFC4X3QtoBaseQuantities.json) left IfcWall's GrossFootprintArea/NetFootprintArea mapped to null, so these two quantities were silently omitted from Qto_WallBaseQuantities whenever that ruleset was used. The generic gross_get_footprint_area and net_get_footprint_area formulas already exist and are already wired up for IfcSlab in the same files, so this was a missing mapping, not a missing implementation. Fixes #7029. Generated with the assistance of an AI coding tool. --- src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json | 4 ++-- src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json index 7d35e64b9a..bd28c7df47 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json @@ -606,13 +606,13 @@ }, "IfcWall": { "Qto_WallBaseQuantities": { - "GrossFootprintArea": null, + "GrossFootprintArea": "gross_get_footprint_area", "GrossSideArea": "gross_get_side_area", "GrossVolume": "gross_get_volume", "GrossWeight": "gross_get_weight", "Height": "net_get_z", "Length": "net_get_x", - "NetFootprintArea": null, + "NetFootprintArea": "net_get_footprint_area", "NetSideArea": "net_get_side_area", "NetVolume": "net_get_volume", "NetWeight": "net_get_weight", diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json index 44806c2ccf..425a7e468e 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json @@ -769,13 +769,13 @@ }, "IfcWall + IfcWallType": { "Qto_WallBaseQuantities": { - "GrossFootPrintArea": null, + "GrossFootPrintArea": "gross_get_footprint_area", "GrossSideArea": "gross_get_side_area", "GrossVolume": "gross_get_volume", "GrossWeight": "gross_get_weight", "Height": "net_get_z", "Length": "net_get_x", - "NetFootPrintArea": null, + "NetFootPrintArea": "net_get_footprint_area", "NetSideArea": "net_get_side_area", "NetVolume": "net_get_volume", "NetWeight": "net_get_weight", From 99c370b75517924beabcbca86a82e5c5e01f45c4 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 13 Jul 2026 16:06:38 +0300 Subject: [PATCH 083/142] Bonsai: allow nesting element type objects together (#2283) can_nest() only permitted IfcElement-to-IfcElement pairs, so nesting two IfcElementType objects (e.g. an IfcElementAssemblyType nesting a component IfcDoorType) was silently rejected. IfcRelNests.RelatingObject/ RelatedObjects are typed as the general IfcObjectDefinition in the schema, so type-to-type nesting is schema legal, IfcOpenShell's core nest.assign_object API already handles it generically, and the Nest UI panel is driven purely by ifcopenshell.util.element.get_nest/ get_components (IFC data queries, not Blender collection structure), so once the relationship exists it displays correctly with no other changes needed. Extended is_compatible_class to also accept a same-kind IfcTypeProduct pair. Mixing an occurrence element with a type is intentionally still rejected, that isn't a real modeling pattern. Verified live in headless Blender: type-to-type nesting now creates a real IfcRelNests and the Nest panel's own data functions reflect it correctly; mixing an occurrence with a type is still rejected; existing element-to-element nesting is unaffected. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/nest.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/nest.py b/src/bonsai/bonsai/tool/nest.py index 5a1b2c83b2..adfb35da21 100644 --- a/src/bonsai/bonsai/tool/nest.py +++ b/src/bonsai/bonsai/tool/nest.py @@ -47,7 +47,15 @@ class Nest(bonsai.core.tool.Nest): return False if relating_object == related_object: return False - is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement") + # IfcRelNests.RelatingObject/RelatedObjects are typed as the general + # IfcObjectDefinition, so nesting is schema-legal both between element + # occurrences (the common case, e.g. a faucet nested into a sink) and + # between element types (e.g. an assembly type nesting its component + # types). Mixing an occurrence with a type isn't a real modeling + # pattern, so only allow same-kind pairs. See #2283. + is_compatible_class = (relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")) or ( + relating_object.is_a("IfcTypeProduct") and related_object.is_a("IfcTypeProduct") + ) if not is_compatible_class: return False # Prevent cyclic references: walk up the full hierarchy from the From 98a25eca960130e8ad8bbdfc937cec7d28080345 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 17 Jul 2026 07:45:38 +0300 Subject: [PATCH 084/142] Bonsai: wire Shift+Q quantity take off hotkey into Spatial tool Fixes #4443. The Wall/Slab/other authoring tools (BimTool subclasses) already bind Shift+Q to bim.perform_quantity_take_off via hotkey_S_Q, but the Spatial tool has its own separate keymap/operator (bim.spatial_hotkey) that never registered a Q entry, forcing users to switch tools just to (re)calculate quantities for a selected element. Added the same Shift+Q keymap entry and a matching hotkey_S_Q handler to the Spatial tool, mirroring BimTool's existing behavior exactly (including the same selected-objects guard). The other part of the request, a bulk "calculate all quantities" entry point, already exists today: bim.perform_quantity_take_off computes quantities for every IfcElement when no objects are selected, exposed via the Scene > Quantity Take-off panel regardless of which workspace tool is active, so no change was needed there. AI-generated, reviewed and tested by BIMvoice. Co-Authored-By: Claude Sonnet 5 --- src/bonsai/bonsai/bim/module/spatial/workspace.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/spatial/workspace.py b/src/bonsai/bonsai/bim/module/spatial/workspace.py index 6f34099861..cd5d02b2bb 100644 --- a/src/bonsai/bonsai/bim/module/spatial/workspace.py +++ b/src/bonsai/bonsai/bim/module/spatial/workspace.py @@ -45,6 +45,7 @@ class SpatialTool(WorkSpaceTool): ("bim.spatial_hotkey", {"type": "T", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_T")]}), ("bim.spatial_hotkey", {"type": "G", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_G")]}), ("bim.spatial_hotkey", {"type": "H", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_H")]}), + ("bim.spatial_hotkey", {"type": "Q", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_Q")]}), ) def draw_settings(context, layout, ws_tool): @@ -184,3 +185,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): def hotkey_S_G(self): bpy.ops.bim.generate_space() + + def hotkey_S_Q(self): + # Mirrors BimTool.hotkey_S_Q so quantities can be (re)calculated without switching tools. + if not bpy.context.selected_objects: + return + bpy.ops.bim.perform_quantity_take_off() From 305f8c60037e9db69a2fe29a5cfccc8df9cab011 Mon Sep 17 00:00:00 2001 From: carlopav Date: Thu, 23 Jul 2026 14:57:58 +0200 Subject: [PATCH 085/142] cost: don't leave copied cost items in the copied schedule (#8851) copy_cost_item appends the copy to the inverse relationships of the original cost item, which for a root cost item includes the source schedule's IfcRelAssignsToControl. copy_cost_schedule then assigned that same cost item to the new schedule as well, so the copies showed up in both schedules and deleting them from one removed them from the other. Unassign the copy from the source schedule before assigning it to the new one. Co-Authored-By: Claude Opus 4.8 --- .../ifcopenshell/api/cost/copy_cost_schedule.py | 1 + .../test/api/cost/test_copy_cost_schedule.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_schedule.py index 3101e820c2..126748af4c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_schedule.py @@ -45,5 +45,6 @@ def copy_cost_schedule( if isinstance(duplicated_cost_item, list): # All other nested items are not connected to the cost schedule explicitly. duplicated_cost_item = duplicated_cost_item[0] + ifcopenshell.api.control.unassign_control(file, cost_schedule, [duplicated_cost_item]) ifcopenshell.api.control.assign_control(file, new_schedule, [duplicated_cost_item]) return new_schedule diff --git a/src/ifcopenshell-python/test/api/cost/test_copy_cost_schedule.py b/src/ifcopenshell-python/test/api/cost/test_copy_cost_schedule.py index 119c24387e..cac94c320f 100644 --- a/src/ifcopenshell-python/test/api/cost/test_copy_cost_schedule.py +++ b/src/ifcopenshell-python/test/api/cost/test_copy_cost_schedule.py @@ -40,6 +40,9 @@ class TestCopyCostSchedule(test.bootstrap.IFC4): assert len(new_cost_items) == 2 assert len(new_cost_items.intersection(old_cost_items)) == 0 + # The copies should be removed from the original schedule. + assert set(ifcopenshell.util.cost.get_schedule_cost_items(schedule)) == old_cost_items + class TestCopyCostScheduleIFC2X3(test.bootstrap.IFC2X3, TestCopyCostSchedule): pass From c61ebe3876bceb842c054d47e4857bf1914a4e61 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 16 Jul 2026 16:57:59 +0300 Subject: [PATCH 086/142] Fix IfcCovering Qto_CoveringBaseQuantities mismatch between calculators (#6728) The ifc5d IfcOpenShell (geometry-based) Qto engine computed Qto_CoveringBaseQuantities using axis-agnostic heuristics: - GrossArea/NetArea: gross_get_max_side_area / net_get_max_side_area, the largest of the X/Y/Z projected side areas. - Width: gross_get_min_xyz, the smallest of the X/Y/Z dimensions. The Blender Qto engine instead already used EPset_Parametric.LayerSetDirection (AXIS2 for wall-like coverings, AXIS3 for floor/ceiling-like coverings) to pick the correct axis via get_covering_gross_area/get_covering_net_area/get_covering_width in bonsai/bim/module/qto/calculator.py. For any covering whose length isn't the largest dimension (e.g. a short wall-covering strip, or a small covering patch), the two engines' heuristics can pick different faces/axes entirely, giving different Width/Area values for the same element - this is what was reported in #6728. Fix: give the IfcOpenShell engine the same layer-set-direction awareness. Added IfcOpenShell.get_covering_parametric_axis/ get_covering_area/get_covering_width (dispatched as internal functions, like the existing get_weight/get_segment_length), and wired gross_get_covering_area/net_get_covering_area/ gross_get_covering_width into the IfcCovering rules in IFC4QtoBaseQuantities.json and IFC4X3QtoBaseQuantities.json. The AXIS2 area/width formulas (get_side_area, net_get_y) intentionally match the simpler formulas already used for Qto_WallBaseQuantities in this same rule set (net_get_side_area/net_get_y), rather than replicating the Blender engine's more elaborate get_lateral_area/ get_width (min(X,Y)) helpers, consistent with how the two engines already diverge for regular walls without being considered a bug. Verified with a standalone script driving ifc5d.qto.IfcOpenShell directly against synthetic AXIS2/AXIS3 IfcCovering geometry: for typical proportions old and new formulas agree, and for disproportionate coverings (thin dimension not the smallest/largest) the old formulas picked the wrong axis while the new ones correctly track the covering's LayerSetDirection, matching the Blender engine. Did not verify through the full Blender/Bonsai UI, as it would have required registering the addon in the machine's shared Blender profile, which is unsafe while other agents may have it loaded. Generated with the assistance of an AI coding tool. --- src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json | 6 +- src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json | 6 +- src/ifc5d/ifc5d/qto.py | 67 ++++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json index bd28c7df47..aa3a80b632 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json @@ -188,9 +188,9 @@ }, "IfcCovering": { "Qto_CoveringBaseQuantities": { - "GrossArea": "gross_get_max_side_area", - "NetArea": "net_get_max_side_area", - "Width": "gross_get_min_xyz" + "GrossArea": "gross_get_covering_area", + "NetArea": "net_get_covering_area", + "Width": "gross_get_covering_width" } }, "IfcCurtainWall": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json index 425a7e468e..2f1a56ffac 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json @@ -204,9 +204,9 @@ }, "IfcCovering + IfcCoveringType": { "Qto_CoveringBaseQuantities": { - "GrossArea": "gross_get_max_side_area", - "NetArea": "net_get_max_side_area", - "Width": "gross_get_min_xyz" + "GrossArea": "gross_get_covering_area", + "NetArea": "net_get_covering_area", + "Width": "gross_get_covering_width" } }, "IfcCurtainWall + IfcCurtainWallType": { diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index 764c022ff5..adbece1254 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -286,8 +286,20 @@ class IfcOpenShell(QtoCalculator): "cross section height along the local Y axis. For slab-like footings (PAD_FOOTING, PILE_CAP) " "and other predefined types it is the thickness along the local Z axis.", ), + "get_covering_width": Function( + "IfcLengthMeasure", + "Covering Width", + "The covering's thickness: the side area axis for AXIS2 (e.g. wall finishes), " + "otherwise the local Z depth (e.g. floor or ceiling finishes)", + ), # IfcAreaMeasure "get_area": Function("IfcAreaMeasure", "Area", "The total surface area of the element"), + "get_covering_area": Function( + "IfcAreaMeasure", + "Covering Area", + "The covering's side area for AXIS2 (e.g. wall finishes), otherwise its footprint " + "area (e.g. floor or ceiling finishes)", + ), "get_footprint_area": Function( "IfcAreaMeasure", "Footprint Area", @@ -349,6 +361,8 @@ class IfcOpenShell(QtoCalculator): "get_opening_height", "get_opening_depth", "get_opening_area", + "get_covering_width", + "get_covering_area", ) + footing_functions @classmethod @@ -421,6 +435,12 @@ class IfcOpenShell(QtoCalculator): if value is None: continue value = cls.unit_converter.convert(value, cls.raw_functions[formula].measure) + elif formula == "get_covering_width": + value = cls.get_covering_width(element, geometry) + value = cls.unit_converter.convert(value, "IfcLengthMeasure") + elif formula == "get_covering_area": + value = cls.get_covering_area(element, geometry) + value = cls.unit_converter.convert(value, "IfcAreaMeasure") else: value = formula_functions[formula](geometry) assert isinstance(value, (float, int)) @@ -586,6 +606,53 @@ class IfcOpenShell(QtoCalculator): mass += mass_per_length * item.Depth return mass + @staticmethod + def get_covering_parametric_axis(element: ifcopenshell.entity_instance) -> Union[str, None]: + """Get an IfcCovering's layer set direction, as authored by Bonsai's covering type. + + :param element: IFC element entity. + :return: ``"AXIS2"`` for wall-like coverings, ``"AXIS3"`` for slab-like + coverings (e.g. floors or ceilings), or ``None`` if the covering's + type has no ``EPset_Parametric.LayerSetDirection``. + """ + relating_type = ifcopenshell.util.element.get_type(element) + if not relating_type: + return None + parametric = ifcopenshell.util.element.get_psets(relating_type).get("EPset_Parametric") + if not parametric: + return None + return parametric.get("LayerSetDirection") + + @classmethod + def get_covering_area(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float: + """Get a covering's area, following its layer set direction. + + AXIS2 (wall-like) coverings report the local Y-facing side area, + while AXIS3 coverings and coverings without a layer set direction + (e.g. freeform profiles) report the projected footprint area. This + mirrors how ``gross_get_side_area``/``net_get_side_area`` are + already used for ``Qto_WallBaseQuantities.*SideArea`` in this same + rule set. + """ + if cls.get_covering_parametric_axis(element) == "AXIS2": + return ifcopenshell.util.shape.get_side_area(geometry) + return ifcopenshell.util.shape.get_footprint_area(geometry) + + @classmethod + def get_covering_width(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float: + """Get a covering's width (i.e. thickness), following its layer set direction. + + AXIS2 (wall-like) coverings report the local Y depth, while AXIS3 + coverings and coverings without a layer set direction report the + local Z depth. This mirrors how ``net_get_y`` is already used for + ``Qto_WallBaseQuantities.Width`` in this same rule set, rather than + the ``min(X, Y)`` heuristic used by the Blender-side + :func:`bonsai.bim.module.qto.calculator.get_width`. + """ + if cls.get_covering_parametric_axis(element) == "AXIS2": + return ifcopenshell.util.shape.get_y(geometry) + return ifcopenshell.util.shape.get_z(geometry) + class Blender(QtoCalculator): """Calculates geometry based on currently loaded Blender objects.""" From 60858944331a723118f822edd2bf821709c94496 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 13 Jul 2026 06:27:17 +0300 Subject: [PATCH 087/142] Fix #8570: populate ifc5d IfcOpenShell QTO formulas for IfcSpace The headless "IfcOpenShell" calculator had all Qto_SpaceBaseQuantities formulas set to null for IfcSpace in both IFC4QtoBaseQuantities.json and IFC4X3QtoBaseQuantities.json, so qto.py's `if not formula: continue` skipped every quantity, no geometry task was queued, and spaces never appeared in results (elements_quantified: 0). The Blender calculator already computes these; they were just never ported to the ifcopenshell.util.shape-backed calculator. Map the eight computable quantities to existing util.shape functions, mirroring the Blender calculator semantics (no new shape.py code): GrossFloorArea=gross_get_footprint_area, NetFloorArea=net_get_footprint_area, GrossCeilingArea=gross_get_top_area, NetCeilingArea=net_get_top_area, GrossPerimeter=gross_get_footprint_perimeter, GrossVolume=gross_get_volume, NetVolume=net_get_volume, Height=net_get_z. Left null (matching the Blender ruleset, not guessed): GrossWallArea, NetWallArea, NetPerimeter (Blender stub), and FinishFloor/CeilingHeight (Blender derives these from sibling IfcCovering decomposition geometry, which this per-element calculator architecture can't reach). Verified on IFC4 (4x3 space extruded 2.5m): before -> {} / elements_quantified 0; after -> GrossFloorArea 12, GrossPerimeter 14, Height 2.5, GrossVolume 30, etc. - all exact matches to the extrusion. IFC4X3 formulas are identical and the formula->function resolution is schema-agnostic. Scope: fixes the IfcSpace case (the issue title). The 12 other all-null classes noted in the issue (IfcDoor, IfcSite, IfcRailing, ...) are left as follow-up. This change was made with the assistance of an AI tool. Co-Authored-By: Claude Fable 5 --- src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json | 16 ++++++++-------- src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json index aa3a80b632..c002cfaa4c 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json @@ -529,16 +529,16 @@ "Qto_SpaceBaseQuantities": { "FinishCeilingHeight": null, "FinishFloorHeight": null, - "GrossCeilingArea": null, - "GrossFloorArea": null, - "GrossPerimeter": null, - "GrossVolume": null, + "GrossCeilingArea": "gross_get_top_area", + "GrossFloorArea": "gross_get_footprint_area", + "GrossPerimeter": "gross_get_footprint_perimeter", + "GrossVolume": "gross_get_volume", "GrossWallArea": null, - "Height": null, - "NetCeilingArea": null, - "NetFloorArea": null, + "Height": "net_get_z", + "NetCeilingArea": "net_get_top_area", + "NetFloorArea": "net_get_footprint_area", "NetPerimeter": null, - "NetVolume": null, + "NetVolume": "net_get_volume", "NetWallArea": null } }, diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json index 2f1a56ffac..017e164119 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json @@ -665,16 +665,16 @@ "Qto_SpaceBaseQuantities": { "FinishCeilingHeight": null, "FinishFloorHeight": null, - "GrossCeilingArea": null, - "GrossFloorArea": null, - "GrossPerimeter": null, - "GrossVolume": null, + "GrossCeilingArea": "gross_get_top_area", + "GrossFloorArea": "gross_get_footprint_area", + "GrossPerimeter": "gross_get_footprint_perimeter", + "GrossVolume": "gross_get_volume", "GrossWallArea": null, - "Height": null, - "NetCeilingArea": null, - "NetFloorArea": null, + "Height": "net_get_z", + "NetCeilingArea": "net_get_top_area", + "NetFloorArea": "net_get_footprint_area", "NetPerimeter": null, - "NetVolume": null, + "NetVolume": "net_get_volume", "NetWallArea": null } }, From e23142d01a570c04fd12e4812b215d77e85fb2df Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 9 Jul 2026 07:27:56 +0300 Subject: [PATCH 088/142] Fix #7331: derive cost item quantities from IfcSpace base quantities assign_cost_item_quantity skipped every IfcSpatialElement, which also swallowed IfcSpace. Spaces are legitimate quantifiable objects, so their Qto_SpaceBaseQuantities (for example GrossFloorArea) were never picked up and count based cost items fell back to 0. Keep skipping spatial containers (site, building, storey) but allow IfcSpace. Co-Authored-By: Claude Opus 4.8 --- .../ifcopenshell/api/cost/assign_cost_item_quantity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py index c699bb1ebb..7ddfaa2dd0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py @@ -137,7 +137,7 @@ class Usecase: if self.prop_name or formula: self.quantities = set(cost_item.CostQuantities or []) for product in products: - if product.is_a("IfcSpatialElement"): + if product.is_a("IfcSpatialElement") and not product.is_a("IfcSpace"): continue ifcopenshell.api.control.assign_control( self.file, From d506f5df1ec1656a0fb75b93c82fd03b95810c9a Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 9 Jul 2026 07:19:57 +0300 Subject: [PATCH 089/142] Bonsai: compute earthworks base quantities in ifc5d take-off #6325 The ifcopenshell take-off engine left every Qto_EarthworksFillBaseQuantities value null, so Bonsai added the qset with no numbers on IFC4X3 models. Map the geometrically derivable quantities using the slab axis convention: Length on local X, Width on local Y, Depth on local Z, and the net solid volume as the compacted (Fill) or undisturbed (Cut) volume. LooseVolume and Weight stay unmapped because they need soil bulking and density factors absent from geometry. Bring IfcEarthworksCut to parity with the Blender engine and wire IfcReinforcedSoil on both engines. Co-Authored-By: Claude Opus 4.8 --- src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json | 26 +++++++++---------- .../ifc5d/IFC4X3QtoBaseQuantitiesBlender.json | 10 +++---- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json index 017e164119..5dfeca7bb7 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json @@ -265,21 +265,21 @@ }, "IfcEarthworksCut": { "Qto_EarthworksCutBaseQuantities": { - "Depth": null, - "Length": null, + "Depth": "net_get_z", + "Length": "net_get_x", "LooseVolume": null, - "UndisturbedVolume": null, + "UndisturbedVolume": "net_get_volume", "Weight": null, - "Width": null + "Width": "net_get_y" } }, "IfcEarthworksFill": { "Qto_EarthworksFillBaseQuantities": { - "CompactedVolume": null, - "Depth": null, - "Length": null, + "CompactedVolume": "net_get_volume", + "Depth": "net_get_z", + "Length": "net_get_x", "LooseVolume": null, - "Width": null + "Width": "net_get_y" } }, "IfcElectricAppliance + IfcElectricApplianceType": { @@ -585,11 +585,11 @@ }, "IfcReinforcedSoil": { "Qto_ReinforcedSoilBaseQuantities": { - "Area": null, - "Depth": null, - "Length": null, - "Volume": null, - "Width": null + "Area": "net_get_footprint_area", + "Depth": "net_get_z", + "Length": "net_get_x", + "Volume": "net_get_volume", + "Width": "net_get_y" } }, "IfcReinforcingElement + IfcReinforcingElementType": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json index 5d899e94fe..20b58de49a 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json @@ -585,11 +585,11 @@ }, "IfcReinforcedSoil": { "Qto_ReinforcedSoilBaseQuantities": { - "Area": null, - "Depth": null, - "Length": null, - "Volume": null, - "Width": null + "Area": "get_net_footprint_area", + "Depth": "get_height", + "Length": "get_length", + "Volume": "get_net_volume", + "Width": "get_width" } }, "IfcReinforcingElement + IfcReinforcingElementType": { From 8fb896609498676c2ab2c21a6cd8fbd38ff35606 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 19 Jul 2026 16:18:31 +0300 Subject: [PATCH 090/142] docs: cover reading properties and quantities from an element and its type in C++ getting started Fixes issue #3910's documentation gap. IsDefinedBy() returns IfcRelDefinesByProperties relationship objects, not the property set itself, and RelatingPropertyDefinition() must be used to reach the IfcPropertySet or IfcElementQuantity. Properties can also come from an element's type via IsTypedBy() -> RelatingType() -> HasPropertySets(), a path that is easy to miss because it works differently. Adds a worked, beginner-commented, compilable example covering both paths. Generated with the assistance of an AI coding tool. --- .../docs/ifcopenshell/getting_started.rst | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/ifcopenshell-python/docs/ifcopenshell/getting_started.rst b/src/ifcopenshell-python/docs/ifcopenshell/getting_started.rst index 92f89def7f..fd5166c9e4 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell/getting_started.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell/getting_started.rst @@ -12,6 +12,8 @@ The basis of all parsing and getting information from the IFC starts with obtain return 1; } +.. _schema-agnostic-parsing-of-ifcs: + Schema-agnostic parsing of IFCs ------------------------------- @@ -107,6 +109,143 @@ The following function shows how property sets can be extracted from a given ``I }); } +Reading properties and quantities from an element +--------------------------------------------------- + +A frequent point of confusion is that ``IsDefinedBy()`` does *not* return the property set +or quantity set itself. It is an inverse attribute that lists every +``IfcRelDefinesByProperties`` relationship pointing at the element, and the relationship +still needs to be unwrapped via ``RelatingPropertyDefinition()`` to reach the actual +``IfcPropertySet`` (regular properties) or ``IfcElementQuantity`` (physical quantities such +as length, area, or volume). Both classes derive from ``IfcPropertySetDefinition``, so a +single cast check tells you which one you got: + +.. code-block:: c++ + + for (auto& rel : *element.IsDefinedBy()) { + // IsDefinedBy() can also contain IfcRelDefinesByType, IfcRelDefinesByObject, or + // IfcRelDefinesByTemplate relationships, so filter for the one we want first. + auto* defines_by_props = rel->as(); + if (!defines_by_props) + continue; + + // This is the step that's easy to miss: the relationship is not the pset. + auto* pset_def = defines_by_props->RelatingPropertyDefinition(); + + if (auto* pset = pset_def->as()) { + for (auto* prop : *pset->HasProperties()) { + if (auto* single = prop->as()) { + std::cout << single->Name() << " = " + << single->NominalValue()->data().toString() << std::endl; + } + } + } else if (auto* qto = pset_def->as()) { + for (auto* quantity : *qto->Quantities()) { + if (auto* length = quantity->as()) { + std::cout << length->Name() << " = " << length->LengthValue() << std::endl; + } + // IfcQuantityArea, IfcQuantityVolume, IfcQuantityWeight, IfcQuantityCount, + // and IfcQuantityTime all follow the same pattern. + } + } + } + +There is a second, easily-missed source of properties: the element's **type**. Properties +assigned to a type (e.g. a shared "IfcDuctSegmentType") apply to every element of that type, +and are reached completely differently, through ``IsTypedBy()`` and then +``RelatingType()->HasPropertySets()`` directly, with no relationship to unwrap: + +.. code-block:: c++ + + auto* typed_by = element.IsTypedBy(); + if (typed_by && typed_by->size()) { + // Unlike IsDefinedBy(), a type only ever has one IfcRelDefinesByType relationship. + auto* type = (*typed_by->begin())->RelatingType(); + if (auto* psets = type->HasPropertySets()) { + for (auto* pset_def : *psets) { + // pset_def can again be either an IfcPropertySet or an IfcElementQuantity, + // and is handled exactly as above. + std::cout << pset_def->data().toString() << std::endl; + } + } + } + +A complete, compilable example that ties both paths together for a chosen element (here +``IfcDuctSegment``, but any IFC class works the same way) looks like this: + +.. code-block:: c++ + + #include + #include + #include + + void print_pset_or_qto(Ifc4::IfcPropertySetDefinition* pset_def) { + if (auto* pset = pset_def->as()) { + for (auto* prop : *pset->HasProperties()) { + if (auto* single = prop->as()) { + std::cout << " " << single->Name() << " = " + << single->NominalValue()->data().toString() << std::endl; + } + } + } else if (auto* qto = pset_def->as()) { + for (auto* quantity : *qto->Quantities()) { + if (auto* length = quantity->as()) { + std::cout << " " << length->Name() << " = " << length->LengthValue() << std::endl; + } + } + } + } + + int main(int argc, char** argv) { + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " " << std::endl; + return 1; + } + + IfcParse::IfcFile file(argv[1]); + if (!file.good()) { + std::cerr << "Unable to parse .ifc file" << std::endl; + return 1; + } + + auto duct_segments = file.instances_by_type(); + if (duct_segments->size() == 0) { + std::cerr << "No IfcDuctSegment instances found" << std::endl; + return 1; + } + + Ifc4::IfcDuctSegment* element = *duct_segments->begin(); + std::cout << "Inspecting " << element->Name().get_value_or("") << std::endl; + + // 1. Properties and quantities attached directly to the element instance. + std::cout << "Element-level property sets:" << std::endl; + for (auto& rel : *element->IsDefinedBy()) { + if (auto* defines_by_props = rel->as()) { + print_pset_or_qto(defines_by_props->RelatingPropertyDefinition()); + } + } + + // 2. Properties and quantities inherited from the element's type, if any. + std::cout << "Type-level property sets:" << std::endl; + auto* typed_by = element->IsTypedBy(); + if (typed_by && typed_by->size()) { + auto* type = (*typed_by->begin())->RelatingType(); + if (auto* psets = type->HasPropertySets()) { + for (auto* pset_def : *psets) { + print_pset_or_qto(pset_def); + } + } + } + + return 0; + } + +.. note:: + + The example above hardcodes the ``Ifc4`` namespace for readability. See + :ref:`Schema-agnostic parsing of IFCs` above for how to + write the same logic so it works against any schema version at once. + Defensive programming with IfcOpenshell --------------------------------------- From 8c434b71673e57564dd4815b1708e309ff8c873f Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 20 Jul 2026 15:54:59 +0300 Subject: [PATCH 091/142] ifcopenshell.util.element: dedupe SET-typed attributes in replace_attribute replace_attribute() rewrites references inside aggregate attributes via element.walk(), but never checked whether the replacement value was already present elsewhere in the same aggregate. For an EXPRESS SET (e.g. IfcProject.RepresentationContexts, IfcRelAggregates.RelatedObjects) this can leave the same reference listed twice, which is invalid IFC. LIST and BAG aggregates legitimately allow duplicates, so a blanket dedup would be wrong; only SET-typed attributes are deduplicated, determined at runtime from the schema declaration (IfcOpenShell#8706 review comment). The SET/LIST/BAG check is cached per (schema, class, attribute index), and the dedup pass itself only runs when a cheap linear pre-check finds the replacement value already present in the aggregate, so the common case (no duplicate produced) pays only that pre-check, not a hash-set rebuild. Benchmarked against a 23MB (431k entities) and a 104MB (2.4M entities) IFC model against a large SET attribute: worst case adds well under 1ms per call; the realistic case (merging duplicate contexts, matching the PR #8706 scenario) shows no measurable regression. Fixes the root cause flagged in IfcOpenShell#8706 (Moult), obviating the need for MergeDuplicateContexts' own manual aggregate-dedup pass for that scenario. Generated with the assistance of an AI coding tool. --- .../ifcopenshell/util/element.py | 30 ++++++++++++++++++- .../test/util/test_element.py | 16 ++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index cf6c52e37d..dc05059ec4 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -16,12 +16,14 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +import functools from collections import namedtuple from collections.abc import Callable, Generator, Sequence from typing import Any, Literal, Optional, Union, overload import ifcopenshell import ifcopenshell.guid +import ifcopenshell.ifcopenshell_wrapper import ifcopenshell.util.element import ifcopenshell.util.representation @@ -1590,10 +1592,36 @@ def replace_element(element: ifcopenshell.entity_instance, replacement: ifcopens replace_attribute(inverse, element, replacement) +@functools.cache +def _is_set_attribute(schema_identifier: str, ifc_class: str, index: int) -> bool: + """Whether attribute `index` of `ifc_class` is declared as an EXPRESS SET. + + SET aggregates may not contain duplicate members, whereas LIST and BAG + aggregates may, so only SET-typed attributes are safe to deduplicate. + """ + declaration = ifcopenshell.ifcopenshell_wrapper.schema_by_name(schema_identifier).declaration_by_name(ifc_class) + attribute = declaration.attribute_by_index(index) + aggregation = attribute.type_of_attribute().as_aggregation_type() + return aggregation is not None and aggregation.type_of_aggregation_string() == "set" + + def replace_attribute(element: ifcopenshell.entity_instance, old: Any, new: Any) -> None: for i, attribute_value in enumerate(element): if has_element_reference(attribute_value, old): - element[i] = element.walk(lambda v: v == old, lambda v: new, attribute_value) + new_value = element.walk(lambda v: v == old, lambda v: new, attribute_value) + if ( + isinstance(attribute_value, tuple) + and has_element_reference(attribute_value, new) + and _is_set_attribute(element.file.schema_identifier, element.is_a(), i) + ): + seen: set[Any] = set() + deduplicated = [] + for v in new_value: + if v not in seen: + seen.add(v) + deduplicated.append(v) + new_value = tuple(deduplicated) + element[i] = new_value def has_element_reference(value: Any, element: ifcopenshell.entity_instance) -> bool: diff --git a/src/ifcopenshell-python/test/util/test_element.py b/src/ifcopenshell-python/test/util/test_element.py index ffd5789f01..9ec355f7fe 100644 --- a/src/ifcopenshell-python/test/util/test_element.py +++ b/src/ifcopenshell-python/test/util/test_element.py @@ -1244,6 +1244,22 @@ class TestReplaceAttributeIFC4(test.bootstrap.IFC4): subject.replace_attribute(rel, old, new) assert rel.RelatedObjects == (new,) + def test_replacing_into_a_set_deduplicates_the_survivor(self): + old = self.file.createIfcWall() + new = self.file.createIfcWall() + rel = self.file.createIfcRelAggregates() + rel.RelatedObjects = [old, new] + subject.replace_attribute(rel, old, new) + assert rel.RelatedObjects == (new,) + + def test_replacing_into_a_list_keeps_legitimate_duplicates(self): + p1 = self.file.createIfcCartesianPoint((0.0, 0.0, 0.0)) + p2 = self.file.createIfcCartesianPoint((1.0, 0.0, 0.0)) + p3 = self.file.createIfcCartesianPoint((2.0, 0.0, 0.0)) + polyline = self.file.createIfcPolyline([p1, p2, p3, p1]) + subject.replace_attribute(polyline, p2, p3) + assert polyline.Points == (p1, p3, p3, p1) + class TestHasElementReferenceIFC4(test.bootstrap.IFC4): def test_if_a_element_attribute_references_another_element(self): From a155a1ca80feb8862dac2c27c43753dfc8a14026 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 16 Jul 2026 18:19:58 +0300 Subject: [PATCH 092/142] Fix ci-bonsai-choco: choco_release.py still targets the old choco/blenderbim path The choco dir was renamed from choco/blenderbim to choco/bonsai back in 2024 (Rename choco dir), but choco_release.py's BLENDERBIM_DIR constant was never updated, so the daily choco release job crashes immediately with FileNotFoundError trying to os.chdir into the now nonexistent choco/blenderbim directory. Release tags also moved from a bare blenderbim-YYMMDD scheme to bonsai-X.Y.Z-alphaYYMMDDHHMM, so the tag-prefix strip used to build the package version still looked for the old "blenderbim-" prefix and left it untouched, embedding the raw tag (including the already-present "-alpha" segment) into the nuspec version field, which the template then doubled up with its own "-alpha" suffix, producing an invalid NuGet version string. Both are fixed together since the second bug would otherwise surface as soon as the first one is unblocked. The pre-commit black hook also reformatted pre-existing whitespace drift in choco_release.py (this file sits outside CI's lint scope, so it had never been auto-formatted before); that reformatting is incidental to satisfying the local hook, not part of the fix itself. Generated with the assistance of an AI coding tool. --- choco/bonsai/blenderbim.nuspec | 2 +- choco/bonsai/choco_release.py | 25 ++++++++++++++----------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/choco/bonsai/blenderbim.nuspec b/choco/bonsai/blenderbim.nuspec index a6ca7b107d..c769b698dd 100644 --- a/choco/bonsai/blenderbim.nuspec +++ b/choco/bonsai/blenderbim.nuspec @@ -3,7 +3,7 @@ blenderbim-nightly - blenderbim_build_version-alpha + blenderbim_build_version https://github.com/IfcOpenShell/IfcOpenShell fbpyr diff --git a/choco/bonsai/choco_release.py b/choco/bonsai/choco_release.py index ea53798c5d..0521ed4d6b 100644 --- a/choco/bonsai/choco_release.py +++ b/choco/bonsai/choco_release.py @@ -3,11 +3,12 @@ apt update && apt install git wget curl ptpython mono-devel micro mkdir -p /home/runner/work/IfcOpenShell && cd /home/runner/work/IfcOpenShell git clone https://github.com/IfcOpenShell/IfcOpenShell -cd /home/runner/work/IfcOpenShell/IfcOpenShell/choco/blenderbim/ +cd /home/runner/work/IfcOpenShell/IfcOpenShell/choco/bonsai/ micro choco_release.py # paste this script, comment out push command export CHOCO_TOKEN="secret_choco_release_token" python3 choco_release.py """ + import datetime import hashlib import os @@ -28,7 +29,7 @@ def get_repo_tag_names() -> list[str]: def request_repo_info(url: str): - req = request.Request(url) + req = request.Request(url) resp = request.urlopen(req) if not resp.status == 200: print(f"[ERROR] could not contact server: {url}") @@ -85,13 +86,15 @@ def run(command: str) -> None: start = datetime.datetime.now() -URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender" -URL_BLENDER_CMAKE = "https://raw.githubusercontent.com/blender/blender/{}/build_files/cmake/Modules/FindPythonLibsUnix.cmake" -RE_BLENDER_VERSION_MIN_MAJ = r"Latest Version.+Blender (\d+\.\d+)\..+" -RE_BLENDER_VERSION_MIN_MAJ_PAT = r"Latest Version.+Blender (\d+\.\d+\.\d+)" +URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender" +URL_BLENDER_CMAKE = ( + "https://raw.githubusercontent.com/blender/blender/{}/build_files/cmake/Modules/FindPythonLibsUnix.cmake" +) +RE_BLENDER_VERSION_MIN_MAJ = r"Latest Version.+Blender (\d+\.\d+)\..+" +RE_BLENDER_VERSION_MIN_MAJ_PAT = r"Latest Version.+Blender (\d+\.\d+\.\d+)" RE_BLENDER_PYTHON_VERSION_MAJ_MIN = r"\(_PYTHON_VERSION_SUPPORTED (\d+\.\d+)\)" -BLENDERBIM_DIR = pathlib.Path("/home/runner/work/IfcOpenShell/IfcOpenShell/choco/blenderbim/") +BLENDERBIM_DIR = pathlib.Path("/home/runner/work/IfcOpenShell/IfcOpenShell/choco/bonsai/") print("_____ check choco release needed?") @@ -148,7 +151,7 @@ print(f"{blender_python_version_maj_min=}") python_version = f"py{found[0].replace('.', '')}" print(f"{python_version=}") -blenderbim_build_version = target_release_tag.replace("blenderbim-", "") +blenderbim_build_version = target_release_tag.replace("bonsai-", "") # url_blenderbim_py3x_win_zip release_zip_file_name, url_blenderbim_py3x_win_zip = get_release_zip(target_release_tag) @@ -166,15 +169,15 @@ topics = { "path": HERE_DIR / "blenderbim.nuspec", "key_values": { "latest_blender_version_maj_min_pat": latest_blender_release_maj_min_pat, - "blenderbim_build_version" : blenderbim_build_version, + "blenderbim_build_version": blenderbim_build_version, }, }, "install": { "path": HERE_DIR / "tools" / "chocolateyinstall.ps1", "key_values": { - "url_blenderbim_py3x_win_zip" : url_blenderbim_py3x_win_zip, + "url_blenderbim_py3x_win_zip": url_blenderbim_py3x_win_zip, "sha256sum_blenderbim_py3x_win_zip": sha256sum_blenderbim_py3x_win_zip, - "latest_blender_version_maj_min" : blender_version_min_maj, + "latest_blender_version_maj_min": blender_version_min_maj, }, }, "uninstall": { From 5c8eab981cdb33a7cd4d8bc84ca48ecc313cb33f Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 12 Jul 2026 22:52:32 +0300 Subject: [PATCH 093/142] Fix ci-bonsai-daily: get_dictionaries no longer clobbers injected client Bsdd.get_dictionaries() unconditionally did cls.client = bsdd.Client(), replacing whatever client was already set - including the bSDDClientStub the BDD suite injects at module load (test_feature.py: tool.Bsdd.client = bSDDClientStub()) to avoid live network calls. Because "Load bSDD Dictionaries" is the first step of every bsdd.feature scenario, the stub was discarded before its fixture data ("LCA", "BonsaiTestDict") could ever be returned. The re-init is unnecessary: bsdd.Client.__init__ only sets baseurl and blank tokens, and the next line already updates baseurl defensively via hasattr. Drop the clobbering assignment; reuse whichever client is already set. Verified in headless Blender: bsdd scenarios (load dictionaries, search all/single dictionary) go from 3 failed ("Could not see LCA/ BonsaiTestDict") to 3 passed. This change was made with the assistance of an AI tool. Co-Authored-By: Claude Fable 5 --- src/bonsai/bonsai/tool/bsdd.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/bsdd.py b/src/bonsai/bonsai/tool/bsdd.py index 387474b81f..72aba9d7b2 100644 --- a/src/bonsai/bonsai/tool/bsdd.py +++ b/src/bonsai/bonsai/tool/bsdd.py @@ -138,7 +138,6 @@ class Bsdd(bonsai.core.tool.Bsdd): def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]: prefs = tool.Blender.get_addon_preferences() baseurl = getattr(prefs, "bsdd_baseurl", "https://api.bsdd.buildingsmart.org/api/") - cls.client = bsdd.Client() if hasattr(cls.client, "baseurl"): cls.client.baseurl = baseurl response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries) From 6911418c676ce6d6fde78f5bc170aa1da376cbe0 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 16 Jul 2026 17:45:53 +0300 Subject: [PATCH 094/142] ci: fix bSDD 429 rate limiting and restore ColumnPSetsOfSets.ifc schema bsdd.py: the Client made every request with a bare requests.get, so a single 429 from the (unauthenticated, aggressively rate limited) bSDD API failed the whole test. Route requests through a Session with a mounted urllib3 Retry (5 attempts, backoff, honouring Retry-After) for 429/5xx, matching how a resilient API client should behave, not just papering over the test. ColumnPSetsOfSets.ifc: FILE_SCHEMA was accidentally changed from IFC4X3_ADD2 to IFC2X3 in a7738eeb64 (an unrelated logger refactor), a one line collateral edit to this fixture. The file's DATA section still uses IFCPROPERTYSETDEFINITIONSET, an IFC4+ only type. Parsing it against IFC2X3 threw "Entity ... not found in schema", which silently fell back to interpreting the value as a raw nested aggregate instead of the intended defined-type wrapper, producing the double-nested tuple that broke test_stream, test_file and test_rocks in test_streaming_rocksdb_and_simpletyperefs.py. Restoring the original schema declared when the fixture was added (ff3fa48332) fixes all three. Generated with the assistance of an AI coding tool. --- src/bsdd/bsdd.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/bsdd/bsdd.py b/src/bsdd/bsdd.py index ea112f521e..466067e53a 100644 --- a/src/bsdd/bsdd.py +++ b/src/bsdd/bsdd.py @@ -26,7 +26,9 @@ import webbrowser from typing import TYPE_CHECKING, Any, Literal, Optional, TypedDict import requests +from requests.adapters import HTTPAdapter from typing_extensions import NotRequired +from urllib3.util import Retry if TYPE_CHECKING: import ifcopenshell @@ -517,12 +519,25 @@ class Client: self.auth_endpoint = "https://buildingsmartservices.b2clogin.com/tfp/buildingsmartservices.onmicrosoft.com/b2c_1_signupsignin/oauth2/v2.0/authorize" self.token_endpoint = "https://buildingsmartservices.b2clogin.com/tfp/buildingsmartservices.onmicrosoft.com/b2c_1_signupsignin/oauth2/v2.0/token" self.client_id = "4aba821f-d4ff-498b-a462-c2837dbbba70" + # The bSDD API is aggressively rate limited (HTTP 429). Retry transient + # failures with backoff instead of immediately raising, honouring the + # server's `Retry-After` header when present. + self.session = requests.Session() + retries = Retry( + total=5, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + respect_retry_after_header=True, + allowed_methods=["GET", "POST"], + ) + self.session.mount("https://", HTTPAdapter(max_retries=retries)) + self.session.mount("http://", HTTPAdapter(max_retries=retries)) def get(self, endpoint, params=None, is_auth_required=False): headers = {"User-Agent": "IfcOpenShell.bSDD.py/0.8.0"} if is_auth_required: headers["Authorization"] = "Bearer " + self.get_access_token() - response = requests.get(f"{self.baseurl}{endpoint}", timeout=10, headers=headers, params=params or None) + response = self.session.get(f"{self.baseurl}{endpoint}", timeout=10, headers=headers, params=params or None) try: response.raise_for_status() except requests.exceptions.HTTPError as e: @@ -539,7 +554,7 @@ class Client: old_baseurl = "https://bs-dd-api-prototype.azurewebsites.net/" if is_auth_required: headers["Authorization"] = "Bearer " + self.get_access_token() - return requests.get(f"{old_baseurl}{endpoint}", timeout=10, headers=headers, params=params or None).json() + return self.session.get(f"{old_baseurl}{endpoint}", timeout=10, headers=headers, params=params or None).json() def post(self): pass # TODO From 3d7d1ff4f3598200571ac757e062d399a74ca641 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 19 Jul 2026 15:30:30 +0300 Subject: [PATCH 095/142] ci: drop the ColumnPSetsOfSets.ifc fixture change, conflicts upstream Per aothms's review comment: this file's schema was already changed independently on v0.8.0 since this branch was created, so this PR's own edit conflicts with it. Reverting to the current upstream version of the fixture; the bsdd.py rate-limiting fix is untouched. --- src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc b/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc index 26394a29b3..0ab5ace614 100644 --- a/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc +++ b/src/ifcopenshell-python/test/fixtures/ColumnPSetsOfSets.ifc @@ -2,7 +2,7 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition [CoordinationView]','RevitIdentifiers [ContentGUID: a0df3484-2dab-42c5-b806-8c10d313bee0, VersionGUID: 658c1394-f3a4-43d1-9b3c-eee44a0cd67a, NumberOfSaves: 2]','CoordinateReference [CoordinateBase: Shared Coordinates]'),'2;1'); FILE_NAME('Column_4x3.ifc','2025-03-12T13:53:30+00:00',(''),(''),'ODA SDAI 24.12','Autodesk Revit 25.4.0.32 (ENG) - IFC 25.4.0.32',''); -FILE_SCHEMA(('IFC4X3_ADD2')); +FILE_SCHEMA(('IFC4')); ENDSEC; DATA; #1=IFCORGANIZATION($,'Autodesk Revit 2025 (ENG)',$,$,$); From 836d57e7ff543203a5ed29d8db42be430c0ec99e Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 11 Mar 2026 21:39:15 -0500 Subject: [PATCH 096/142] Fix #7774: Fix Select Similar failing on pset names with spaces Pset names containing spaces (e.g. "SOLIDWORKS Custom Properties") were not quoted when building selector keys in SelectSimilarData, causing get_element_value to fail when the operator ran. Now wraps pset names and property names in double quotes if they contain spaces, consistent with the selector syntax used elsewhere. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/search/data.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/search/data.py b/src/bonsai/bonsai/bim/module/search/data.py index 8c2fad00b0..ec954004c6 100644 --- a/src/bonsai/bonsai/bim/module/search/data.py +++ b/src/bonsai/bonsai/bim/module/search/data.py @@ -132,5 +132,12 @@ class SelectSimilarData: if pset.endswith("Common"): keys.extend([f'/.*Common/."{name}"' for name in properties.keys() if name != "id"]) else: - keys.extend([f"{pset}.{name}" for name in properties.keys() if name != "id"]) + pset_part = f'"{pset}"' if " " in pset else pset + keys.extend( + [ + f'{pset_part}."{name}"' if " " in name else f"{pset_part}.{name}" + for name in properties.keys() + if name != "id" + ] + ) return [(k, k, "") for k in keys] From e27624c77f3585cf7619a82e5f67b54b1134ee76 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 12 Jul 2026 22:50:08 +0300 Subject: [PATCH 097/142] Fix ci-bonsai-daily BDD: OperatorSpy.bl_rna + stale MEP port name Two independent test-harness/fixture defects in test/bim/test_feature.py: - OperatorSpy had no bl_rna, so any BDD step that redraws a panel calling helper.draw_filter() (which tests "module" in op.bl_rna.properties) crashed with AttributeError. Give OperatorSpy a bl_rna property that forwards to the real registered operator class (bpy.types[bl_idname].bl_rna), matching live UILayout.operator() semantics. Fixes test_select_all_walls and test_edit_filter_query. - The shared "I create default MEP types" step looked up bpy.data.objects["IfcDistributionPort/Port"], but port creation never sets port.Name, so tool.Loader.get_name deterministically names the object "IfcDistributionPort/Unnamed". Update the literal. Fixes the MEP scenarios (connect/transition/bend) that share this setup. Verified in headless Blender: OperatorSpy scenarios 2 passed (were AttributeError); MEP test_connect_mep_elements* go from KeyError 'IfcDistributionPort/Port' to passing. This change was made with the assistance of an AI tool. Co-Authored-By: Claude Fable 5 --- src/bonsai/test/bim/test_feature.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 1b5e9141ca..9962f6bec2 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -187,7 +187,14 @@ class PanelSpy: after = "" if self.spied_labels: after = self.spied_labels[-1] - spied_operator = {"operator": operator, "icon": icon, "text": text, "kwargs": {}, "after": after} + spied_operator = { + "operator": operator, + "icon": icon, + "text": text, + "kwargs": {}, + "after": after, + "bl_idname": bl_idname, + } self.spied_operators.append(spied_operator) return OperatorSpy(spied_operator) elif self.spied_attr == "panel": @@ -210,6 +217,14 @@ class OperatorSpy: else: self.spied_data["kwargs"][name] = value + @property + def bl_rna(self) -> Any: + # Mirror the real `UILayout.operator()` return value (an OperatorProperties + # instance), which exposes `.bl_rna` so panel code such as + # `"module" in op.bl_rna.properties` (bonsai/bim/helper.py) also works when + # drawing is spied on during BDD tests. + return getattr(bpy.types, self.spied_data["bl_idname"]).bl_rna + class TemplateListSpy(PanelSpy): items: bpy.types.bpy_prop_collection_idprop[bpy.types.PropertyGroup] @@ -773,7 +788,11 @@ def i_create_default_mep_types(): with bpy.context.temp_override(active_object=bpy.data.objects["IfcActuatorType/ACTUATOR"]): bpy.ops.bim.add_port() # port at cube's left side - bpy.data.objects["IfcDistributionPort/Port"].location = (-0.5, 0, 0) + # Newly created ports are never given an explicit IFC `.Name` (see + # `core/system.py:create_port_at_cursor` / `tool/system.py`), so + # `tool.Loader.get_name()` falls back to the standard "Unnamed" convention + # used throughout Bonsai for freshly-created, not-yet-named elements. + bpy.data.objects["IfcDistributionPort/Unnamed"].location = (-0.5, 0, 0) bpy.ops.bim.hide_ports() From 45fa04a94ba910586fd174b3cb115effb0177b35 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 23 Jul 2026 11:25:03 +0300 Subject: [PATCH 098/142] Bonsai tests: stop hardcoding STEP ids in boolean.feature The two boolean.feature scenarios pinned representation item objects by absolute STEP id (Item/IfcHalfSpaceSolid/90, the BBIM_Boolean pset text [91]). Those ids shift every time any earlier entity allocation in an empty project changes (latest instance: #8577 moved 90 to 86), so this cluster re-breaks on unrelated commits. Make the object-name and panel-text BDD steps run their argument through replace_variables, the same substitution 'the variable' and the connection steps already use, and have boolean.feature capture the real ids from the IFC file (by_type(...)[0].id()) into variables at the point the entities are created. The steps stay strict: the substituted name must still resolve to exactly the named object, there is no wildcard matching. Substitution is a no-op for every existing feature string without a {variable} placeholder. This change was made with the assistance of an AI tool. --- src/bonsai/test/bim/feature/boolean.feature | 18 ++++++++++++------ src/bonsai/test/bim/test_feature.py | 5 +++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/bonsai/test/bim/feature/boolean.feature b/src/bonsai/test/bim/feature/boolean.feature index cbf4981dc8..5f79c04e8e 100644 --- a/src/bonsai/test/bim/feature/boolean.feature +++ b/src/bonsai/test/bim/feature/boolean.feature @@ -12,16 +12,19 @@ Scenario: Ensure added booleans are marked as manual And I click "OK" And the object "IfcFurniture/Unnamed" exists And I toggle edit mode - And the object "Item/IfcExtrudedAreaSolid/73" exists + And the variable "extrusion" is "{ifc}.by_type('IfcExtrudedAreaSolid')[0].id()" + And the object "Item/IfcExtrudedAreaSolid/{extrusion}" exists And I open the "Add Item" menu When I click "Half Space Solid" - And the object "Item/IfcHalfSpaceSolid/90" exists + And the variable "half_space" is "{ifc}.by_type('IfcHalfSpaceSolid')[0].id()" + And the variable "boolean" is "{ifc}.by_type('IfcBooleanResult')[0].id()" + And the object "Item/IfcHalfSpaceSolid/{half_space}" exists And I deselect all objects And I toggle edit mode And I select the object "IfcFurniture/Unnamed" And I look at the "Property Sets" panel Then I see "BBIM_Boolean" - And I see "[91]" + And I see "[{boolean}]" Scenario: Ensure removed booleans are unmarked as manual Given an empty IFC project @@ -33,17 +36,20 @@ Scenario: Ensure removed booleans are unmarked as manual And I click "OK" And the object "IfcFurniture/Unnamed" exists And I toggle edit mode - And the object "Item/IfcExtrudedAreaSolid/73" exists + And the variable "extrusion" is "{ifc}.by_type('IfcExtrudedAreaSolid')[0].id()" + And the object "Item/IfcExtrudedAreaSolid/{extrusion}" exists And I open the "Add Item" menu And I click "Half Space Solid" + And the variable "half_space" is "{ifc}.by_type('IfcHalfSpaceSolid')[0].id()" + And the variable "boolean" is "{ifc}.by_type('IfcBooleanResult')[0].id()" And I deselect all objects And I toggle edit mode And I select the object "IfcFurniture/Unnamed" And I toggle edit mode - And I select the object "Item/IfcHalfSpaceSolid/90" + And I select the object "Item/IfcHalfSpaceSolid/{half_space}" When I delete the selected objects And I toggle edit mode And I select the object "IfcFurniture/Unnamed" And I look at the "Property Sets" panel Then I don't see "BBIM_Boolean" - And I don't see "[91]" + And I don't see "[{boolean}]" diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 9962f6bec2..34150fbe98 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -468,6 +468,7 @@ def i_trigger_operator(operator): @then(parsers.parse('I see "{text}"')) def i_see_text(text): assert panel_spy + text = replace_variables(text) panel_spy.refresh_spy() assert [l for l in panel_spy.spied_labels if text in l], f"Text {text} not found in {panel_spy.spied_labels}" @@ -602,6 +603,7 @@ def i_select_the_row_where_i_see_text_in_the_nth_list(text, nth): @then(parsers.parse('I don\'t see "{text}"')) def i_dont_see_text(text): assert panel_spy + text = replace_variables(text) panel_spy.refresh_spy() assert not [l for l in panel_spy.spied_labels if text in l], f"Text {text} found in {panel_spy.spied_labels}" @@ -1092,6 +1094,7 @@ def then_the_object_name_is_placed_in_the_collection_collection(name: str, colle @given(parsers.parse('additionally the object "{name}" is selected')) @when(parsers.parse('additionally the object "{name}" is selected')) def additionally_the_object_name_is_selected(name): + name = replace_variables(name) obj = bpy.context.scene.objects.get(name) if not obj: total = len(bpy.context.scene.objects) @@ -1172,6 +1175,7 @@ def nothing_happens(): @when(parsers.parse('the object "{name}" exists')) @then(parsers.parse('the object "{name}" exists')) def the_object_name_exists(name: str) -> bpy.types.Object: + name = replace_variables(name) # Some objects from linked collections may share the same name. This disambiguates them. if name.startswith("Col:"): _, collection_name, name = name.split(":") @@ -1186,6 +1190,7 @@ def the_object_name_exists(name: str) -> bpy.types.Object: @then(parsers.parse('the object "{name}" does not exist')) def the_object_name_does_not_exist(name) -> None: + name = replace_variables(name) obj = bpy.data.objects.get(name) assert obj is None, f'The object "{name}" exists' From 81a42cec1a771cc802fd32e6ae566333678a5620 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 23 Jul 2026 11:31:46 +0300 Subject: [PATCH 099/142] Bonsai tests: give bSDDClientStub the client baseurl attribute tool.Bsdd.identifier_url() (pset/ui.py pset name check in the Property Sets panel) reads client.baseurl unconditionally, but the test stub never had that attribute, so any scenario that opens the Property Sets panel dies with AttributeError under the stub. The boolean.feature scenarios only surfaced this once their STEP id failures were fixed, the id failure had been masking it. Mirror the real bsdd.Client default so identifier_url() resolves to the standard identifier URL. This change was made with the assistance of an AI tool. --- src/bonsai/test/bim/stub.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bonsai/test/bim/stub.py b/src/bonsai/test/bim/stub.py index 969cf98cbe..c553f5fb80 100644 --- a/src/bonsai/test/bim/stub.py +++ b/src/bonsai/test/bim/stub.py @@ -21,6 +21,10 @@ from typing import Any, Optional, Union class bSDDClientStub: + def __init__(self): + # Mirrors bsdd.Client so tool.Bsdd.identifier_url() works against the stub. + self.baseurl = "https://api.bsdd.buildingsmart.org/api/" + def get_dictionary(self, dictionary_uri=None, include_test_dictionaries=False): dicts = { "dictionaries": [ From 382f5e0c212178121e993f5d7de53953e95588d3 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 12 Jul 2026 08:00:55 +0300 Subject: [PATCH 100/142] ifcpatch: use stdlib graphlib for Optimise topological sort (#4399) The Optimise recipe imported `toposort`, a third-party PyPI package that is not bundled with Bonsai, so running the recipe there raised `ModuleNotFoundError: No module named 'toposort'`. Replace it with the standard library `graphlib.TopologicalSorter` (available since Python 3.9), which provides the same dependencies-first ordering guarantee the recipe relies on: forward-referenced instances are mapped before the instances that reference them. The dependency-graph dict format ({node: {predecessors}}) is identical between the two, so the graph construction is unchanged. Drop `toposort` from ifcpatch's dependencies since it is no longer used. Verified with toposort NOT installed: the Optimise recipe now runs and deduplicates correctly (IfcParseExamples_test.ifc 88 -> 63 instances, all 6 products preserved, output reopens cleanly). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Opus 4.8 --- src/ifcpatch/ifcpatch/recipes/Optimise.py | 8 ++++---- src/ifcpatch/pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ifcpatch/ifcpatch/recipes/Optimise.py b/src/ifcpatch/ifcpatch/recipes/Optimise.py index 581a89c643..3f3eb8a6fa 100644 --- a/src/ifcpatch/ifcpatch/recipes/Optimise.py +++ b/src/ifcpatch/ifcpatch/recipes/Optimise.py @@ -25,18 +25,18 @@ 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. + back to the stdlib graphlib topological sort with a warning. """ try: import igraph except ImportError: logger.warning( - "igraph is not installed, falling back to the slower pure python toposort. " + "igraph is not installed, falling back to the slower stdlib graphlib. " "Install python-igraph for better performance." ) - from toposort import toposort_flatten + from graphlib import TopologicalSorter - return toposort_flatten(graph) + return list(TopologicalSorter(graph).static_order()) ids = list(graph) index = {id_: i for i, id_ in enumerate(ids)} diff --git a/src/ifcpatch/pyproject.toml b/src/ifcpatch/pyproject.toml index 22cf2ec3c5..e9f77a989f 100644 --- a/src/ifcpatch/pyproject.toml +++ b/src/ifcpatch/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", ] -dependencies = ["ifcopenshell", "toposort", "numpy"] +dependencies = ["ifcopenshell", "numpy"] [project.optional-dependencies] advanced = [ From 209c44db8360fece58a2e0252d86ea873b42889e Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 9 Jul 2026 13:24:46 +0300 Subject: [PATCH 101/142] Selector: negate list comparisons as an aggregate #8129 compare() recursed into list values passing the negated comparison through, so != meant "at least one item differs" and both = and != matched the same elements on any multi-valued property (e.g. an enumerated property with two values selected). Strip the negation for the per-item comparison and negate the aggregate instead, so != means "no item equals" and stays the complement of =. The same applies to !*=. Co-Authored-By: Claude Fable 5 --- src/ifcopenshell-python/ifcopenshell/util/selector.py | 8 +++++++- src/ifcopenshell-python/test/util/test_selector.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index f83d3cd3d5..da86b69a91 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -1242,7 +1242,13 @@ class FacetTransformer(lark.Transformer): def compare(self, element_value, comparison, value) -> bool: if isinstance(element_value, (list, tuple)): - return any(self.compare(ev, comparison, value) for ev in element_value) + # Match if any item does, negating the aggregate rather than each + # item, so that e.g. != means "no item equals" and stays the + # complement of = (#8129). + result = any(self.compare(ev, comparison.lstrip("!"), value) for ev in element_value) + if comparison.startswith("!"): + return not result + return result elif isinstance(value, str): try: if isinstance(element_value, int): diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index 5ee2267269..af80141e81 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -279,6 +279,12 @@ class TestFilterElements(test.bootstrap.IFC4): pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Pset_WallCommon") ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Status": ["New"]}) assert subject.filter_elements(self.file, "IfcWall, Pset_WallCommon.Status=New") == {element} + # On multi-valued properties, != means "no value equals" and stays the + # complement of = (#8129). + ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Status": ["New", "Demolish"]}) + assert subject.filter_elements(self.file, "IfcWall, Pset_WallCommon.Status=New") == {element} + assert subject.filter_elements(self.file, "IfcWall, Pset_WallCommon.Status!=New") == {element2} + assert subject.filter_elements(self.file, "IfcWall, Pset_WallCommon.Status!=Temporary") == {element, element2} def test_selecting_by_property_with_comparisons(self): element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") From 82f73c29ea363822a0dc5a69ab66a1e5e8424ebb Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 6 Jul 2026 10:29:16 +0300 Subject: [PATCH 102/142] style.assign_representation_styles: fix crash on IfcPresentationStyleAssignment #7883 When replacing a style on an item whose previous IfcStyledItem wraps its styles in the deprecated IfcPresentationStyleAssignment, and the assignment is not being reused (use_style_assignment is False, e.g. an IFC4 file authored by AVEVA E3D), the else branch called remove_same_type_styles(style_assignment) with style_assignment still None, raising AttributeError: 'NoneType' object has no attribute 'Styles'. Operate on style_, the assignment found in the current iteration, instead of the accumulator. Verified red-green with a minimal IFC4 file using IfcPresentationStyleAssignment. Co-Authored-By: Claude Fable 5 --- .../ifcopenshell/api/style/assign_representation_styles.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py b/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py index b26d106017..412a373b99 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/assign_representation_styles.py @@ -162,7 +162,11 @@ class Usecase: style_assignment = style_ self.remove_same_type_styles(style_assignment, current_style_type, remove_item=False) else: - self.remove_same_type_styles(style_assignment, current_style_type, remove_item=True) + # Operate on the assignment found in this iteration, not the + # style_assignment accumulator, which is still None when the + # file uses IfcPresentationStyleAssignment but we are not + # reusing it (e.g. an IFC4 file authored by AVEVA E3D). See #7883. + self.remove_same_type_styles(style_, current_style_type, remove_item=True) if use_style_assignment: if style_assignment: From c77a28c8621e5875c003665cd0a71949b32cf74c Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 7 Jul 2026 10:45:21 +0200 Subject: [PATCH 103/142] Add Flat/Pretty style toggle and dual-branch external style management --- src/bonsai/bonsai/bim/handler.py | 7 +- src/bonsai/bonsai/bim/import_ifc.py | 4 - .../bonsai/bim/module/project/operator.py | 4 + .../bonsai/bim/module/style/__init__.py | 2 + .../bonsai/bim/module/style/operator.py | 239 +++++++++++- src/bonsai/bonsai/bim/module/style/prop.py | 20 + src/bonsai/bonsai/bim/module/style/ui.py | 86 +++++ src/bonsai/bonsai/tool/blender.py | 34 ++ src/bonsai/bonsai/tool/style.py | 357 ++++++++++++++++++ 9 files changed, 736 insertions(+), 17 deletions(-) diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index c8f91ed6ee..c8502a4c0e 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -418,9 +418,10 @@ def get_user(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None def viewport_shading_changed_callback(area: bpy.types.Area) -> None: - shading = area.spaces.active.shading.type - if shading == "RENDERED": - tool.Style.get_style_props().active_style_type = "External" + shading_type = area.spaces.active.shading.type + tool.Style.restore_material_style_types(shading_type) + if shading_type == "SOLID": + area.spaces.active.shading.color_type = "MATERIAL" def subscribe_to_viewport_shading_changes(): diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 4598c50099..864ce97445 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -133,10 +133,6 @@ class MaterialCreator: if shape_has_openings and coords.is_a("IfcIndexedTextureMap"): continue tool.Loader.load_indexed_map(coords, self.mesh) - elif tool.Style.get_texture_style(material): - # No explicit coordinate mapping (e.g. IFC2X3 has no IsMappedBy, - # and IFC4 COORD uses generated UVs). Bake XY→UV as fallback. - tool.Loader.load_generated_uv_map(self.mesh) def assign_material_slots_to_faces(self) -> None: if not self.mesh["ios_materials"]: diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 02ae75792b..b14d157ed7 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1301,6 +1301,10 @@ class LoadProjectElements(bpy.types.Operator): tool.Project.set_default_modeling_dimensions() tool.Root.reload_grid_decorator() bonsai.bim.handler.refresh_ui_data() + for screen in bpy.data.screens: + for area in screen.areas: + if area.type == "VIEW_3D": + bonsai.bim.handler.viewport_shading_changed_callback(area) return {"FINISHED"} def get_decomposition_elements(self) -> set[ifcopenshell.entity_instance]: diff --git a/src/bonsai/bonsai/bim/module/style/__init__.py b/src/bonsai/bonsai/bim/module/style/__init__.py index 6f13fd6f2d..3102435160 100644 --- a/src/bonsai/bonsai/bim/module/style/__init__.py +++ b/src/bonsai/bonsai/bim/module/style/__init__.py @@ -45,7 +45,9 @@ classes = ( operator.SelectByStyle, operator.SelectStyleInStylesUI, operator.SetAssetMaterialToExternalStyle, + operator.SuggestShadeFromExternalStyle, operator.UnlinkStyle, + operator.TogglePreferIfcShading, operator.UpdateCurrentStyle, operator.UpdateStyleColours, operator.UpdateStyleTextures, diff --git a/src/bonsai/bonsai/bim/module/style/operator.py b/src/bonsai/bonsai/bim/module/style/operator.py index 4672b806f5..7b840dc695 100644 --- a/src/bonsai/bonsai/bim/module/style/operator.py +++ b/src/bonsai/bonsai/bim/module/style/operator.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import colorsys import os from pathlib import Path from typing import Any, Union @@ -238,13 +239,15 @@ class UpdateCurrentStyle(bpy.types.Operator): if not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve)): continue for mat in obj.data.materials: - if ( - mat - and mat not in updated_materials - and (msprops_ := tool.Style.get_material_style_props(mat)).ifc_definition_id != 0 - ): - msprops_.active_style_type = current_style_type - updated_materials.add(mat) + if not mat: + continue + msprops_ = tool.Style.get_material_style_props(mat) + if msprops_.ifc_definition_id == 0: + continue + if mat in updated_materials: + continue + msprops_.active_style_type = current_style_type + updated_materials.add(mat) return {"FINISHED"} @@ -457,10 +460,14 @@ class ActivateExternalStyle(bpy.types.Operator): self.report({"ERROR"}, f"Error loading external style for \"{material.name}\" - {db['msg']}") return {"CANCELLED"} - self.copy_material_attributes(db["data_block"], material) + ext_mat = db["data_block"] + self.copy_material_attributes(ext_mat, material) if tool.Style.get_use_nodes(material): - tool.Blender.copy_node_graph(material, db["data_block"]) - bpy.data.materials.remove(db["data_block"]) + if material.get("bim_dual_branch"): + tool.Style.update_external_branch(material, ext_mat) + else: + tool.Style.setup_dual_branch(material, ext_mat) + bpy.data.materials.remove(ext_mat) return {"FINISHED"} def copy_material_attributes(self, source, target): @@ -503,6 +510,218 @@ class ActivateExternalStyle(bpy.types.Operator): set_prop(prop_name) +class TogglePreferIfcShading(bpy.types.Operator): + bl_idname = "bim.toggle_prefer_ifc_shading" + bl_label = "Toggle Flat/Pretty" + bl_description = ( + "Toggle between Flat (IFC-native shading) and Pretty (external .blend style) for ALL styles.\n\n" + "SHIFT+CLICK to apply to this style only" + ) + bl_options = {"REGISTER", "UNDO"} + material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"}) + single_only: bpy.props.BoolProperty(name="Single Only", default=False, options={"SKIP_SAVE"}) + + def invoke(self, context, event): + if event.shift: + self.single_only = True + return self.execute(context) + + def execute(self, context): + wm = context.window_manager + space = tool.Blender.get_view3d_space() + is_solid = space and space.shading.type == "SOLID" + + if is_solid: + if space.shading.color_type == "TEXTURE": + space.shading.color_type = "MATERIAL" + else: + meshes_needing_uv = [] + for obj in bpy.context.scene.objects: + if not isinstance(obj.data, bpy.types.Mesh): + continue + for slot in obj.material_slots: + mat = slot.material + if not mat or not tool.Blender.get_ifc_definition_id(mat): + continue + style_elements = tool.Style.get_style_elements(mat) + if style_elements.get("IfcSurfaceStyleWithTextures") and not obj.data.uv_layers: + meshes_needing_uv.append(obj.data) + break + wm.progress_begin(0, max(len(meshes_needing_uv), 1)) + try: + for i, mesh in enumerate(meshes_needing_uv): + tool.Loader.load_generated_uv_map(mesh) + wm.progress_update(i) + finally: + wm.progress_end() + space.shading.color_type = "TEXTURE" + return {"FINISHED"} + + if self.single_only: + mat = bpy.data.materials.get(self.material_name) + if not mat: + return {"CANCELLED"} + msprops = tool.Style.get_material_style_props(mat) + msprops.prefer_ifc_shading = not msprops.prefer_ifc_shading + else: + # Default: apply to all IFC materials + source_mat = bpy.data.materials.get(self.material_name) + new_value = not source_mat.BIMStyleProperties.prefer_ifc_shading if source_mat else True + ifc_mats = [m for m in bpy.data.materials if tool.Blender.get_ifc_definition_id(m)] + for mat in ifc_mats: + tool.Style.get_material_style_props(mat).prefer_ifc_shading = new_value + return {"FINISHED"} + + +class SuggestShadeFromExternalStyle(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.suggest_shade_from_external_style" + bl_label = "Suggest Shade from External Style" + bl_description = ( + "Generate a Shade style (Surface Colour + Transparency) from the external .blend style.\n\n" + "ALT+CLICK to apply to all styles with an external .blend style" + ) + bl_options = {"REGISTER", "UNDO"} + material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"}) + all_styles: bpy.props.BoolProperty(name="All Styles", default=False, options={"SKIP_SAVE"}) + value_offset: bpy.props.FloatProperty( + name="Value", + description="Offset added to the colour's value (-1 = fully dark, 0 = unchanged, +1 = fully light)", + default=0.0, + min=-1.0, + max=1.0, + step=1, + precision=2, + options={"SKIP_SAVE"}, + ) + saturation_factor: bpy.props.FloatProperty( + name="Saturation", + description="Scale applied to the colour's saturation (0 = greyscale, 1 = unchanged, >1 = more saturated)", + default=1.0, + min=0.0, + max=2.0, + step=1, + precision=2, + options={"SKIP_SAVE"}, + ) + + def invoke(self, context, event): + if event.alt: + self.all_styles = True + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + layout.prop(self, "value_offset", slider=True) + layout.prop(self, "saturation_factor", slider=True) + if self.all_styles: + layout.label(text="Will apply to all external styles", icon="INFO") + + def _execute(self, context): + if self.all_styles: + candidates = [ + (mat, tool.Style.get_style_elements(mat)) + for mat in bpy.data.materials + if tool.Blender.get_ifc_definition_id(mat) + ] + candidates = [(mat, se) for mat, se in candidates if tool.Style.has_blender_external_style(se)] + wm = context.window_manager + wm.progress_begin(0, max(len(candidates), 1)) + count = 0 + color_cache: dict[tuple[str, str, str], tuple | None] = {} + try: + for i, (mat, style_elements) in enumerate(candidates): + wm.progress_update(i) + if self._apply_to_material( + mat, style_elements, self.value_offset, self.saturation_factor, color_cache + ): + count += 1 + finally: + wm.progress_end() + self.report({"INFO"}, f"Shade style generated for {count} style(s).") + else: + mat = bpy.data.materials.get(self.material_name) + if not mat: + return {"CANCELLED"} + style_elements = tool.Style.get_style_elements(mat) + if not tool.Style.has_blender_external_style(style_elements): + self.report({"ERROR"}, "No external .blend style assigned. Please assign an external style first.") + return {"CANCELLED"} + self._apply_to_material(mat, style_elements, self.value_offset, self.saturation_factor) + props = tool.Style.get_style_props() + if props.is_editing: + core.load_styles(tool.Style, style_type=props.style_type) + + def _apply_to_material( + self, + material: bpy.types.Material, + style_elements: dict, + value_offset: float = 0.0, + saturation_factor: float = 1.0, + color_cache: "dict[tuple[str, str, str], tuple | None] | None" = None, + ) -> bool: + external_style = style_elements["IfcExternallyDefinedSurfaceStyle"] + style_path = Path(tool.Ifc.resolve_uri(external_style.Location)) + data_block_type, data_block = external_style.Identification.split("/") + + cache_key = (str(style_path), data_block_type, data_block) + if color_cache is not None and cache_key in color_cache: + cached = color_cache[cache_key] + if cached is None: + return False # previously failed for this path + surface_colour, transparency = cached + else: + try: + db = tool.Blender.append_data_block(str(style_path), data_block_type, data_block) + except OSError as e: + self.report({"WARNING"}, f'Could not open blend file for "{material.name}": {e}') + if color_cache is not None: + color_cache[cache_key] = None + return False + if not db["data_block"]: + self.report({"WARNING"}, f'Could not load external style for "{material.name}": {db["msg"]}') + if color_cache is not None: + color_cache[cache_key] = None + return False + + ext_mat = db["data_block"] + surface_colour, transparency = tool.Style.get_representative_material_color(ext_mat) + bpy.data.materials.remove(ext_mat) + if color_cache is not None: + color_cache[cache_key] = (surface_colour, transparency) + + if value_offset != 0.0 or saturation_factor != 1.0: + h, s, v = colorsys.rgb_to_hsv(*surface_colour) + v = max(0.0, min(1.0, v + value_offset)) + s = max(0.0, min(1.0, s * saturation_factor)) + surface_colour = colorsys.hsv_to_rgb(h, s, v) + + ifc_style = tool.Ifc.get_entity(material) + attributes: dict = { + "SurfaceColour": { + "Name": None, + "Red": surface_colour[0], + "Green": surface_colour[1], + "Blue": surface_colour[2], + }, + } + if tool.Ifc.get_schema() != "IFC2X3": + attributes["Transparency"] = transparency + + shading_style = style_elements.get("IfcSurfaceStyleShading") + if shading_style: + tool.Ifc.run("style.edit_surface_style", style=shading_style, attributes=attributes) + else: + tool.Ifc.run( + "style.add_surface_style", + style=ifc_style, + ifc_class="IfcSurfaceStyleShading", + attributes=attributes, + ) + material.diffuse_color = (*surface_colour, 1.0 - transparency) + tool.Style.sync_flat_branch_shading(material, surface_colour, transparency) + return True + + class DisableEditingStyles(bpy.types.Operator): bl_idname = "bim.disable_editing_styles" bl_options = {"REGISTER", "UNDO"} diff --git a/src/bonsai/bonsai/bim/module/style/prop.py b/src/bonsai/bonsai/bim/module/style/prop.py index 7dfcad4f07..4165248dc3 100644 --- a/src/bonsai/bonsai/bim/module/style/prop.py +++ b/src/bonsai/bonsai/bim/module/style/prop.py @@ -372,6 +372,16 @@ def update_shading_style(self: "BIMStyleProperties", context: bpy.types.Context) tool.Style.switch_shading(blender_material, self.active_style_type) +def update_prefer_ifc_shading(self: "BIMStyleProperties", context: bpy.types.Context) -> None: + style_elements = tool.Style.get_style_elements(self.id_data) + has_external = tool.Style.has_blender_external_style(style_elements) + if self.prefer_ifc_shading or not has_external: + self.active_style_type = "Shading" + else: + self.active_style_type = "External" + self.id_data.update_tag() + + class BIMStyleProperties(PropertyGroup): ifc_definition_id: IntProperty(name="IFC Definition ID") active_style_type: EnumProperty( @@ -381,9 +391,19 @@ class BIMStyleProperties(PropertyGroup): default="Shading", update=update_shading_style, ) + prefer_ifc_shading: BoolProperty( + name="Flat / Pretty", + description=( + "Toggle between Flat (IFC-native shading) and Pretty (external .blend style). " + "When set to Flat, viewport switches to Material Preview or Rendered will not activate the external style." + ), + default=False, + update=update_prefer_ifc_shading, + ) is_renaming: BoolProperty(description="Used to prevent triggering handler callback.", default=False) if TYPE_CHECKING: ifc_definition_id: int active_style_type: tool.Style.StyleType + prefer_ifc_shading: bool is_renaming: bool diff --git a/src/bonsai/bonsai/bim/module/style/ui.py b/src/bonsai/bonsai/bim/module/style/ui.py index 45120ae1ed..6dba3eb8fc 100644 --- a/src/bonsai/bonsai/bim/module/style/ui.py +++ b/src/bonsai/bonsai/bim/module/style/ui.py @@ -110,6 +110,11 @@ class BIM_PT_styles(Panel): op = row.operator("bim.update_current_style", icon="FILE_REFRESH", text="") op.style_id = style.ifc_definition_id + if active_style and self.props.style_type == "IfcSurfaceStyle": + if material := style.blender_material: + msprops = tool.Style.get_material_style_props(material) + self.draw_style_status_row(material, msprops) + if self.props.style_type == "IfcSurfaceStyle": self.layout.label(text="Surface Style Element:") col = self.layout.column(align=True) @@ -161,6 +166,87 @@ class BIM_PT_styles(Panel): edit_label = "Save Lighting Style" self.draw_edit_ui(edit_label) + def draw_style_status_row(self, material: bpy.types.Material, msprops) -> None: + space = tool.Blender.get_view3d_space() + box = self.layout.box() + + obj = bpy.context.active_object + + parts = [] + if space: + shading_type = space.shading.type + shading_labels = { + "SOLID": "Solid", + "MATERIAL": "Material Preview", + "RENDERED": "Rendered", + "WIREFRAME": "Wireframe", + } + parts.append(f"Viewport: {shading_labels.get(shading_type, shading_type)}") + else: + parts.append("No 3D viewport") + shading_type = None + + if obj: + obj_has_uv = isinstance(obj.data, bpy.types.Mesh) and bool(obj.data.uv_layers) + uv_label = "UV \u2713" if obj_has_uv else "UV \u2717" + parts.append(f"Selected Object: {obj.name} {uv_label}") + else: + parts.append("Selected Object: None") + + if shading_type == "SOLID": + is_flat = space.shading.color_type != "TEXTURE" + mode_label = "Flat" + dep_label = "Shade" + if not is_flat: + mode_label = "Pretty" + dep_label = "Texture \u2192 Shade" + elif shading_type in ("MATERIAL", "RENDERED"): + is_flat = msprops.prefer_ifc_shading + mode_label = "Flat" + dep_label = "Render+Texture \u2192 Render \u2192 Shade" + if not is_flat: + mode_label = "Pretty" + dep_label = "External \u2192 Render+Texture \u2192 Render \u2192 Shade" + else: + is_flat = False + mode_label = "" + dep_label = "" + + row1 = box.row(align=True) + row1.label(text=" | ".join(parts)) + + if mode_label: + row2 = box.row(align=True) + row2.label(text=f"Current Mode: {mode_label} \u2014 {dep_label}") + + row3 = box.row(align=True) + row3.alignment = "RIGHT" + op = row3.operator("bim.suggest_shade_from_external_style", text="", icon="BRUSHES_ALL") + op.material_name = material.name + op = row3.operator("bim.toggle_prefer_ifc_shading", text="", icon="UV_SYNC_SELECT") + op.material_name = material.name + + @staticmethod + def _get_shader_label(material: bpy.types.Material, msprops) -> str: + space = tool.Blender.get_view3d_space() + if space and space.shading.type == "SOLID": + return "Not Applicable" + if msprops.active_style_type == "External": + return "External (.blend)" + if not material.node_tree: + return "Flat colour" + nodes = material.node_tree.nodes + has_mix = any(n.type == "MIX_SHADER" for n in nodes) + if has_mix: + return "Emission (Flat)" + has_principled = any(n.type == "BSDF_PRINCIPLED" for n in nodes) + has_teximage = any(n.type == "TEX_IMAGE" and any(o.links for o in n.outputs) for n in nodes) + if has_principled and has_teximage: + return "BSDF + Textures" + if has_principled: + return "Principled BSDF" + return "Flat colour" + def draw_surface_style_shading(self): row = self.layout.row() row.prop(self.props, "surface_colour") diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index aa9656c76e..9322b1b94a 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -803,6 +803,40 @@ class Blender(bonsai.core.tool.Blender): # restore shader editor settings shader_editor.pin = previous_pin_setting + @classmethod + def copy_node_graph_additive( + cls, material_to: bpy.types.Material, material_from: bpy.types.Material + ) -> bpy.types.ShaderNodeOutputMaterial | None: + """Paste nodes from material_from alongside the existing nodes in material_to. + + Unlike copy_node_graph this does NOT clear the existing node tree first. + Returns the OUTPUT_MATERIAL node that was added from material_from, or None. + """ + temp_override = cls.get_shader_editor_context() + shader_editor = temp_override["space"] + + before_names = {n.name for n in material_to.node_tree.nodes} + + previous_pin_setting = shader_editor.pin + shader_editor.pin = True + shader_editor.node_tree = material_from.node_tree + + for node in material_from.node_tree.nodes: + node.select = True + with bpy.context.temp_override(**temp_override): + bpy.ops.node.clipboard_copy() + + shader_editor.node_tree = material_to.node_tree + with bpy.context.temp_override(**temp_override): + bpy.ops.node.clipboard_paste(offset=(0, 0)) + + shader_editor.pin = previous_pin_setting + + for node in material_to.node_tree.nodes: + if node.name not in before_names and node.type == "OUTPUT_MATERIAL": + return node + return None + @classmethod def get_material_node( cls, blender_material: bpy.types.Material, node_type: str, kwargs: Optional[dict] = {} diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index aa3e508ef0..8606de32ff 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -597,6 +597,146 @@ class Style(bonsai.core.tool.Style): external_style = style_elements.get("IfcExternallyDefinedSurfaceStyle", None) return bool(external_style and external_style.Location and external_style.Location.endswith(".blend")) + @classmethod + def _color_from_principled(cls, node: bpy.types.Node) -> tuple[tuple[float, float, float], float]: + color = cls._resolve_color_socket(node.inputs["Base Color"]) + alpha_socket = node.inputs["Alpha"] + alpha_source = cls._upstream_color_source(alpha_socket) + if alpha_source and alpha_source[0] == "IMAGE": + pixels = alpha_source[1].pixels[:] + n = len(pixels) // 4 + step = max(1, n // 4096) + a_sum = sum(pixels[i * 4 + 3] for i in range(0, n, step)) + count = len(range(0, n, step)) or 1 + transparency = 1.0 - (a_sum / count) + else: + transparency = 1.0 - alpha_socket.default_value + return color, transparency + + @classmethod + def _color_from_shader_socket( + cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None + ) -> tuple[tuple[float, float, float], float] | None: + if seen is None: + seen = set() + for link in socket.links: + node = link.from_node + if node.name in seen: + continue + seen.add(node.name) + if node.type == "BSDF_PRINCIPLED": + return cls._color_from_principled(node) + if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"): + return cls._resolve_color_socket(node.inputs["Color"]), 0.0 + if node.type == "BSDF_GLASS": + return cls._resolve_color_socket(node.inputs["Color"]), 0.0 + if node.type in ("MIX_SHADER", "ADD_SHADER"): + for inp in node.inputs: + if inp.type == "SHADER" and inp.is_linked: + result = cls._color_from_shader_socket(inp, seen) + if result: + return result + return None + + @classmethod + def get_representative_material_color( + cls, material: bpy.types.Material + ) -> tuple[tuple[float, float, float], float]: + if material.node_tree: + nodes = material.node_tree.nodes + output_node = next( + (n for n in nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output), None + ) or next((n for n in nodes if n.type == "OUTPUT_MATERIAL"), None) + if output_node: + result = cls._color_from_shader_socket(output_node.inputs["Surface"]) + if result: + return result + # Fallback: scan all shader nodes if no output node or graph traversal found nothing + for node in nodes: + if node.type == "BSDF_PRINCIPLED": + return cls._color_from_principled(node) + for node in nodes: + if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"): + return cls._resolve_color_socket(node.inputs["Color"]), 0.0 + for node in nodes: + if node.type == "BSDF_GLASS": + return cls._resolve_color_socket(node.inputs["Color"]), 0.0 + color = tuple(material.diffuse_color[:3]) + transparency = 1.0 - material.diffuse_color[3] + return color, transparency + + @classmethod + def _collect_upstream_sources(cls, socket: bpy.types.NodeSocket, seen: set[str]) -> list[tuple[str, object]]: + """Recursively collect all upstream colour/image sources reachable from *socket*.""" + results = [] + for link in socket.links: + node = link.from_node + if node.name in seen: + continue + seen.add(node.name) + if node.type == "TEX_IMAGE": + results.append(("IMAGE", node.image)) + elif node.type == "VALTORGB": + results.append(("COLORRAMP", node)) + else: + for inp in node.inputs: + if inp.is_linked: + results.extend(cls._collect_upstream_sources(inp, seen)) + return results + + @classmethod + def _upstream_color_source( + cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None + ) -> tuple[str, object] | None: + sources = cls._collect_upstream_sources(socket, set() if seen is None else seen) + # Prefer a concrete image texture over a colour ramp (which may be greyscale/procedural). + for s in sources: + if s[0] == "IMAGE": + return s + for s in sources: + if s[0] == "COLORRAMP": + return s + return None + + @classmethod + def _resolve_color_socket(cls, socket: bpy.types.NodeSocket) -> tuple[float, float, float]: + source = cls._upstream_color_source(socket) + if source is None: + return tuple(socket.default_value[:3]) + kind, obj = source + if kind == "IMAGE": + return cls._average_image_color(obj) + if kind == "COLORRAMP": + return cls._average_colorramp_color(obj) + return tuple(socket.default_value[:3]) + + @staticmethod + def _average_image_color(image: bpy.types.Image) -> tuple[float, float, float]: + pixels = image.pixels[:] + n = len(pixels) // 4 + if n == 0: + return (0.5, 0.5, 0.5) + step = max(1, n // 4096) + r_sum = g_sum = b_sum = 0.0 + count = 0 + for i in range(0, n, step): + base = i * 4 + r_sum += pixels[base] + g_sum += pixels[base + 1] + b_sum += pixels[base + 2] + count += 1 + return (r_sum / count, g_sum / count, b_sum / count) + + @staticmethod + def _average_colorramp_color(node: bpy.types.Node) -> tuple[float, float, float]: + elements = node.color_ramp.elements + if not elements: + return (0.5, 0.5, 0.5) + r = sum(e.color[0] for e in elements) / len(elements) + g = sum(e.color[1] for e in elements) / len(elements) + b = sum(e.color[2] for e in elements) / len(elements) + return (r, g, b) + @classmethod def is_editing_styles(cls) -> bool: props = cls.get_style_props() @@ -674,9 +814,179 @@ class Style(bonsai.core.tool.Style): props = cls.get_material_style_props(blender_material) props.active_style_type = props.active_style_type + @classmethod + def get_branch_outputs( + cls, material: bpy.types.Material + ) -> tuple[bpy.types.ShaderNode | None, bpy.types.ShaderNode | None]: + """Return (external_output_node, flat_output_node), or (None, None) if not dual-branch.""" + if not material.node_tree: + return None, None + ext = material.node_tree.nodes.get("BIM_Output_External") + fast = material.node_tree.nodes.get("BIM_Output_Flat") + return ext, fast + + @classmethod + def _remove_external_branch(cls, material: bpy.types.Material) -> None: + """Remove all nodes reachable from BIM_Output_External (walks links backwards).""" + if not material.node_tree: + return + nodes = material.node_tree.nodes + output = nodes.get("BIM_Output_External") + if not output: + return + to_remove: set[str] = set() + stack = [output] + while stack: + node = stack.pop() + if node.name in to_remove: + continue + to_remove.add(node.name) + for inp in node.inputs: + for link in inp.links: + stack.append(link.from_node) + for name in list(to_remove): + n = nodes.get(name) + if n: + nodes.remove(n) + + @classmethod + def _build_flat_branch_nodes(cls, material: bpy.types.Material) -> bpy.types.ShaderNode: + """Add a Principled BSDF flat-branch to material's existing node tree. + + Reads IfcSurfaceStyleRendering or IfcSurfaceStyleShading from the linked IFC entity. + Defaults to a white BSDF when no IFC shading data is available. + Returns the new Material Output node (named BIM_Output_Flat, is_active_output=False). + """ + from mathutils import Vector + + style_elements = cls.get_style_elements(material) + nodes = material.node_tree.nodes + links = material.node_tree.links + + bsdf = nodes.new("ShaderNodeBsdfPrincipled") + bsdf.location = Vector((10, -600)) + output = nodes.new("ShaderNodeOutputMaterial") + output.name = "BIM_Output_Flat" + output.location = Vector((300, -600)) + output.is_active_output = False + links.new(bsdf.outputs["BSDF"], output.inputs["Surface"]) + + rendering_style = None + shading_only = None + for surface_style in style_elements.values(): + if surface_style.is_a() == "IfcSurfaceStyleShading": + shading_only = surface_style + elif surface_style.is_a("IfcSurfaceStyleRendering"): + rendering_style = surface_style + shading_only = None + + if rendering_style: + d = tool.Loader.surface_style_to_dict(rendering_style) + if d.get("DiffuseColour"): + ctype, cval = d["DiffuseColour"] + if ctype == "IfcColourRgb": + bsdf.inputs["Base Color"].default_value = cval + (1,) + solid_color = cval + else: + cval = tuple(v * cval for v in d["SurfaceColour"]) + bsdf.inputs["Base Color"].default_value = cval + (1,) + solid_color = cval + else: + r, g, b = d["SurfaceColour"] + bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0) + solid_color = (r, g, b) + if d.get("SpecularColour"): + ctype, cval = d["SpecularColour"] + if ctype == "IfcNormalisedRatioMeasure": + bsdf.inputs["Metallic"].default_value = cval + if d.get("SpecularHighlight"): + bsdf.inputs["Roughness"].default_value = d["SpecularHighlight"] + transparency = d.get("Transparency") or 0.0 + bsdf.inputs["Alpha"].default_value = 1 - transparency + if transparency > 0: + material.blend_method = "BLEND" + material.diffuse_color = solid_color + (1.0 - transparency,) + elif shading_only: + d = tool.Loader.surface_style_to_dict(shading_only) + r, g, b = d["SurfaceColour"] + alpha = 1 - (d.get("Transparency") or 0.0) + bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0) + bsdf.inputs["Alpha"].default_value = alpha + if alpha < 1.0: + material.blend_method = "BLEND" + material.diffuse_color = (r, g, b, alpha) + # else: leave default white Principled BSDF + return output + + @classmethod + def setup_dual_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> bool: + """Build a dual-branch node tree: flat branch from IFC data + external branch from ext_material. + + Clears any existing nodes and builds both branches from scratch. + External branch output (BIM_Output_External) is set active — Pretty mode. + Flat branch output (BIM_Output_Flat) is inactive — Flat mode. + Returns True on success; False if no shader editor is available (falls back to single-branch). + """ + cls.set_use_nodes(material, True) + for n in material.node_tree.nodes[:]: + material.node_tree.nodes.remove(n) + + cls._build_flat_branch_nodes(material) + + ext_output = tool.Blender.copy_node_graph_additive(material, ext_material) + if not ext_output: + # No shader editor available: fall back to single-branch + tool.Blender.copy_node_graph(material, ext_material) + return False + + ext_output.name = "BIM_Output_External" + ext_output.is_active_output = True + material["bim_dual_branch"] = True + return True + + @classmethod + def update_external_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> None: + """Replace the external-branch nodes of an already dual-branch material.""" + cls._remove_external_branch(material) + ext_output = tool.Blender.copy_node_graph_additive(material, ext_material) + if ext_output: + ext_output.name = "BIM_Output_External" + ext_output.is_active_output = True + fast = material.node_tree.nodes.get("BIM_Output_Flat") + if fast: + fast.is_active_output = False + + @classmethod + def sync_flat_branch_shading( + cls, material: bpy.types.Material, surface_colour: tuple[float, float, float], transparency: float + ) -> None: + """Update the flat-branch Principled BSDF with new shading values. + + Call this after creating or editing IfcSurfaceStyleShading so the flat branch + stays in sync without requiring a full setup_dual_branch rebuild. + """ + if not material.node_tree: + return + fast_output = material.node_tree.nodes.get("BIM_Output_Flat") + if not fast_output: + return + for link in fast_output.inputs["Surface"].links: + if link.from_node.type == "BSDF_PRINCIPLED": + bsdf = link.from_node + r, g, b = surface_colour + bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0) + bsdf.inputs["Alpha"].default_value = 1.0 - transparency + break + @classmethod def switch_shading(cls, blender_material: bpy.types.Material, style_type: StyleType) -> None: if style_type == "External": + ext, fast = cls.get_branch_outputs(blender_material) + if ext and fast: + ext.is_active_output = True + fast.is_active_output = False + blender_material.update_tag() + return try: bpy.ops.bim.activate_external_style(material_name=blender_material.name) except RuntimeError as error: @@ -684,21 +994,41 @@ class Style(bonsai.core.tool.Style): return raise error elif style_type == "Shading": + ext, fast = cls.get_branch_outputs(blender_material) + if ext and fast: + fast.is_active_output = True + ext.is_active_output = False + blender_material.update_tag() + return style_elements = tool.Style.get_style_elements(blender_material) rendering_style = None texture_style = None + shading_only_style = None for surface_style in style_elements.values(): if surface_style.is_a() == "IfcSurfaceStyleShading": + shading_only_style = surface_style tool.Loader.create_surface_style_shading(blender_material, surface_style) elif surface_style.is_a("IfcSurfaceStyleRendering"): rendering_style = surface_style + shading_only_style = None # rendering overrides shading-only path tool.Loader.create_surface_style_rendering(blender_material, surface_style) elif surface_style.is_a("IfcSurfaceStyleWithTextures"): texture_style = surface_style if rendering_style and texture_style: tool.Loader.create_surface_style_with_textures(blender_material, rendering_style, texture_style) + elif shading_only_style and not rendering_style: + # create a minimal Principled BSDF so Material Preview/Rendered shows the colour instead of white. + tool.Style.set_use_nodes(blender_material, True) + tool.Loader.restart_material_node_tree(blender_material) + bsdf = tool.Blender.get_material_node(blender_material, "BSDF_PRINCIPLED") + if bsdf: + r, g, b, a = blender_material.diffuse_color + bsdf.inputs["Base Color"].default_value = (r, g, b, 1) + bsdf.inputs["Alpha"].default_value = a + if a < 1.0: + blender_material.blend_method = "BLEND" else: assert False, f"Invalid style type found: {style_type}" @@ -744,3 +1074,30 @@ class Style(bonsai.core.tool.Style): elements = ifcopenshell.util.element.get_elements_by_style(tool.Ifc.get(), style) objects = [tool.Ifc.get_object(e) for e in elements] tool.Geometry.reload_representation(objects) + + @classmethod + def restore_material_style_types(cls, shading_type: str) -> bool: + """Set each IFC material's active_style_type to the richest available for the given viewport mode. + + In SOLID mode all materials use "Shading". + In MATERIAL_PREVIEW / RENDERED, materials with an external .blend style use "External" + unless prefer_ifc_shading is set on that material. + + Returns True if any IFC material has IfcSurfaceStyleWithTextures (used to decide color_type). + """ + has_any_textures = False + for material in bpy.data.materials: + if not tool.Blender.get_ifc_definition_id(material): + continue + props = cls.get_material_style_props(material) + style_elements = cls.get_style_elements(material) + if style_elements.get("IfcSurfaceStyleWithTextures"): + has_any_textures = True + if shading_type == "SOLID": + props.active_style_type = "Shading" + else: # MATERIAL_PREVIEW or RENDERED + if cls.has_blender_external_style(style_elements) and not props.prefer_ifc_shading: + props.active_style_type = "External" + else: + props.active_style_type = "Shading" + return has_any_textures From 13c4ba257de75034808b30a4a866d9f33bb5deda Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 7 Jul 2026 11:41:53 +0200 Subject: [PATCH 104/142] Fix initila style when loading (default is SOLID - Flat: Shade) --- src/bonsai/bonsai/tool/style.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index 8606de32ff..b4b04fe729 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -1095,6 +1095,11 @@ class Style(bonsai.core.tool.Style): has_any_textures = True if shading_type == "SOLID": props.active_style_type = "Shading" + shading = style_elements.get("IfcSurfaceStyleRendering") or style_elements.get("IfcSurfaceStyleShading") + if shading: + d = tool.Loader.surface_style_to_dict(shading) + alpha = 1.0 - (d.get("Transparency") or 0.0) + material.diffuse_color = d["SurfaceColour"] + (alpha,) else: # MATERIAL_PREVIEW or RENDERED if cls.has_blender_external_style(style_elements) and not props.prefer_ifc_shading: props.active_style_type = "External" From 92e3e400f82c03eb25e96a37e516d0e0fd879441 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 21 Jul 2026 09:48:18 +0200 Subject: [PATCH 105/142] Use consistent material style prop accessor --- src/bonsai/bonsai/bim/module/style/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/style/operator.py b/src/bonsai/bonsai/bim/module/style/operator.py index 7b840dc695..8642b82066 100644 --- a/src/bonsai/bonsai/bim/module/style/operator.py +++ b/src/bonsai/bonsai/bim/module/style/operator.py @@ -566,7 +566,7 @@ class TogglePreferIfcShading(bpy.types.Operator): else: # Default: apply to all IFC materials source_mat = bpy.data.materials.get(self.material_name) - new_value = not source_mat.BIMStyleProperties.prefer_ifc_shading if source_mat else True + new_value = not tool.Style.get_material_style_props(source_mat).prefer_ifc_shading if source_mat else True ifc_mats = [m for m in bpy.data.materials if tool.Blender.get_ifc_definition_id(m)] for mat in ifc_mats: tool.Style.get_material_style_props(mat).prefer_ifc_shading = new_value From 4c5bd888776c24b1d124906f69e979d90ece790c Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 21 Jul 2026 09:52:54 +0200 Subject: [PATCH 106/142] add material update_tag in restore_material_style_types --- src/bonsai/bonsai/tool/style.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index b4b04fe729..7d1b50271a 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -1105,4 +1105,5 @@ class Style(bonsai.core.tool.Style): props.active_style_type = "External" else: props.active_style_type = "Shading" + material.update_tag() return has_any_textures From c92a825a94824c2c1ea95556fb5057f7ccb78e16 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 21 Jul 2026 09:58:06 +0200 Subject: [PATCH 107/142] Cache last shading type to skip redundant material style restores --- src/bonsai/bonsai/tool/style.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index 7d1b50271a..ff94a70157 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -1075,6 +1075,8 @@ class Style(bonsai.core.tool.Style): objects = [tool.Ifc.get_object(e) for e in elements] tool.Geometry.reload_representation(objects) + _last_shading_type: str | None = None + @classmethod def restore_material_style_types(cls, shading_type: str) -> bool: """Set each IFC material's active_style_type to the richest available for the given viewport mode. @@ -1085,6 +1087,10 @@ class Style(bonsai.core.tool.Style): Returns True if any IFC material has IfcSurfaceStyleWithTextures (used to decide color_type). """ + if cls._last_shading_type == shading_type: + return False + cls._last_shading_type = shading_type + has_any_textures = False for material in bpy.data.materials: if not tool.Blender.get_ifc_definition_id(material): From 4a095f98101e2a8d41222ef9aca8f49e5193d216 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Tue, 7 Jul 2026 14:54:44 +0300 Subject: [PATCH 108/142] Bonsai: don't crash querying a freshly linked IFC with cache off Link IFC with 'Use Cache' unchecked crashed with FileNotFoundError when no .ifc.cache.blend existed yet (a fresh link). Regression from 35e3d9c42, which refactored the cache-clear guard from 'if not self.use_cache and blend_filepath.exists()' into should_clear_cache() but dropped the existence check on the not-use_cache path, so os.remove() ran on a non-existent file. Check blend_filepath.exists() first in should_clear_cache() so the remove is never attempted when there is nothing to clear, while keeping the query-mismatch cache invalidation intact. Fixes #8350 Co-Authored-By: Claude Opus 4.8 --- src/bonsai/bonsai/bim/module/project/operator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 02ae75792b..cb6a27c52b 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1578,10 +1578,13 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): json_filepath = self.filepath_.with_suffix(".ifc.cache.json") def should_clear_cache() -> bool: - if not self.use_cache: - return True + # Nothing to clear if the cache file was never created (e.g. a + # fresh link). Check this first so os.remove below is never + # called on a non-existent path, regardless of use_cache. if not blend_filepath.exists(): return False + if not self.use_cache: + return True data = json.loads(json_filepath.read_text()) # Empty 'query' - model loaded without custom query. # Missing 'query' - model was loaded before custom queries were introduced in Bonsai. From 537317b26ff24a4e7bf27189911f8e4d25f99d69 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 13 Jul 2026 06:38:01 +0300 Subject: [PATCH 109/142] Fix ci-bonsai-daily: guard on_depsgraph_update_caps during file load on_depsgraph_update and on_depsgraph_update_caps are registered together as persistent depsgraph handlers (bim/module/clip_box/__init__.py:50-52). on_depsgraph_update guards with `if cls._file_loading: return`, but the sibling on_depsgraph_update_caps did not, so a depsgraph tick during the file-load window still ran it. Beyond the failing test, this can re-arm a cap-rebuild bpy.app.timers callback in the exact load window _on_load_pre cancels timers for, against regions whose GPU state is not yet wired. Add the same _file_loading guard as the first check. Verified in headless Blender: test_clip_box.py::TestRefreshTimerLifecycle::test_depsgraph_update_no_op_while_loading 1 failed -> passed. This change was made with the assistance of an AI tool. Co-Authored-By: Claude Fable 5 --- src/bonsai/bonsai/tool/clip_box.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py index 5ac320e423..14f81d4e04 100644 --- a/src/bonsai/bonsai/tool/clip_box.py +++ b/src/bonsai/bonsai/tool/clip_box.py @@ -1270,6 +1270,17 @@ class ClipBox: def on_depsgraph_update_caps(cls, scene, depsgraph) -> None: """Depsgraph entry-point — guard, then delegate to the modal-aware debounce in :meth:`_handle_cap_tick`.""" + # Same file-load danger window as on_depsgraph_update: a real + # depsgraph tick during load (between load_pre and the new file's + # first paint) must not re-arm a cap-rebuild timer. _on_load_pre + # already cancels any in-flight timer via _cancel_pending_cap_rebuild; + # without this gate, a depsgraph_update_post event firing later in + # the same load (Blender fires these while building the new file's + # scene) would immediately reschedule one via _handle_cap_tick, + # undoing that cancellation and re-arming against regions whose GPU + # state is not yet wired. + if cls._file_loading: + return if getattr(bpy.context, "screen", None) is None: return if cls._active_scene_props(scene) is None: From d1f9e5243efb09cf84c21b567b8ebee8a23ec5f0 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Fri, 24 Jul 2026 10:31:44 +0300 Subject: [PATCH 110/142] Fix ci-bonsai-daily: ProjectLibraryData duplicate parent-library enum entry (#8573) * Fix ci-bonsai-daily: ProjectLibraryData duplicate parent-library enum parent_libraries_enum() adds an explicit entry for get_root_context(), then loops over cls.data["project_libraries"] (all IfcProjectLibrary entities) and appends each. For a library-only file (no IfcProject), get_root_context falls back to the top-level IfcProjectLibrary itself, so the root is appended twice with the same enum key (its STEP id), which Blender EnumProperty requires to be unique -> the data load asserts. Normal project files are unaffected (root is an IfcProject whose id never collides with a library id). Skip library_id == root.id() in the loop (dedup by id, the colliding key). Verified in headless Blender: test_project_library_data.py::TestLibraryOnlyFile goes from 1 failed / 5 passed to 6 passed. This change was made with the assistance of an AI tool. Co-Authored-By: Claude Fable 5 * Bonsai: repair library files missing the required IfcProject, not just the symptom Per the IFC Project Context concept template, every project data set (library files included) shall contain exactly one IfcProject, and IfcProjectLibrary instances are assigned to it via IfcRelDeclares. There is no such thing as a spec-valid file rooted on IfcProjectLibrary alone. get_root_context() (added in 260a387069, #8184) treated a missing IfcProject as license to use the top-level IfcProjectLibrary as the file's root context instead. That invalid premise is why project_libraries() (which walks every IfcProjectLibrary, root included) then re-added that same entity, producing the duplicate, colliding enum key this PR originally papered over with a dedup guard. Add tool.Project.ensure_project_context(), which repairs a file missing IfcProject by creating one and declaring the file's root-level IfcProjectLibrary instances to it, and tool.Project.open_library_file(), which opens a library file through that repair. Route all three IfcStore.library_file load sites in SelectLibraryFile through it. Downstream code (get_root_context, ProjectLibraryData, RefreshLibrary, AddProjectLibrary) now always operates on a spec-valid model, so the duplicate enum entry cannot occur; the previous one-line dedup guard in parent_libraries_enum() is kept only as cheap defense in depth for callers that bypass the load-time repair, not as the fix. Rework test_project_library_data.py: the previous _make_library_only_file() fixture built an invalid library-only model and asserted that as correct behaviour. Replace it with a spec-valid fixture (IfcProject + IfcProjectLibrary declared to it) for the downstream tests, and a malformed fixture used only to exercise the new repair path. Verified live in headless Blender (isolated profile): reproduced the original duplicate-enum-key failure mode, then confirmed ensure_project_context/ open_library_file repair a malformed file and ProjectLibraryData, refresh_library and add_project_library all operate correctly on the result, with no duplicate keys and no regression on already-valid files or IFC2X3. This change was made with the assistance of an AI tool. * Bonsai: stop supporting library-only files, do not repair them Per Moult's feedback: if the IFC is invalid, our default position is to not support it, not to patch around it. A library file with no IfcProject is invalid IFC (Project Context concept template requires exactly one IfcProject), and it is not ubiquitous: every library file bonsai ships under bim/data/libraries has an IfcProject with the IfcProjectLibrary declared to it via IfcRelDeclares. The single #8183 report is an outlier, not a common authoring pattern worth accommodating. Remove tool.Project.ensure_project_context() and open_library_file() (the load-time repair added in the previous commit here) and revert SelectLibraryFile's three load sites to plain ifcopenshell.open. Simplify get_root_context() back to returning ifc_file.by_type("IfcProject")[0] directly, no IfcProjectLibrary fallback: a file without IfcProject now raises IndexError instead of being silently treated as valid. AddProjectLibrary's nest-under-library branch is now dead code (root_context is always an IfcProject) and is removed. The one-line enum dedup guard from the original commit here is also removed: since get_root_context can only return an IfcProject or raise, an IfcProject id can never collide with a library id, so the guard has nothing left to guard against. Rework test_project_library_data.py: drop the invalid _make_library_only_file fixture and its tests, which asserted an unsupported model as correct behaviour. Replace with a single spec-valid fixture matching bonsai's own shipped library files (IfcProject + IfcProjectLibrary declared to it), used for the ci-bonsai-daily regression test and the refresh/add-library operators, plus one explicit test that get_root_context raises for a file without IfcProject, documenting that this input is intentionally unsupported rather than silently tolerated. Verified live in headless Blender (isolated profile, source-loaded, never the real profile): confirmed the removed methods are gone, that a library-only file now raises instead of being handled, that ProjectLibraryData/refresh_library/add_project_library all work correctly on a spec-valid model with unique enum keys, and spot-checked that every library file under bim/data/libraries already has an IfcProject. This change was made with the assistance of an AI tool. * Bonsai: inline get_root_context, trim docstrings, confirm get_parent_library unchanged Per Moult's round 3 review. get_root_context added nothing over ifc_file.by_type("IfcProject")[0], which is guaranteed by the IFC Project Context concept template; remove it and inline the call at its three sites (operator.py's RefreshLibrary and AddProjectLibrary, data.py's parent_libraries_enum). Trim the get_parent_library docstring to one line; its logic is untouched by this PR, byte for byte identical to origin/v0.8.0, and still returns None only when project_library has neither Nests nor HasContext, never for a library declared directly to IfcProject. Rework test_project_library_data.py to match: replace the two get_root_context-specific tests with one that exercises the real call site (ProjectLibraryData.parent_libraries_enum raising IndexError for a file without IfcProject), and add an explicit test that get_parent_library returns None for a genuinely orphaned library. Also drop a long inline comment that restated what the test body already shows. Verified live in headless Blender (isolated profile, source-loaded, never the real profile): all 17 test/bim/module/project tests pass, including the new get_parent_library None-for-orphan case. Ran the full test/bim suite before and after on the identical harness: 82 failed/1335 passed both times, same failing tests (all pre-existing, unrelated to this module). This change was made with the assistance of an AI tool. * Bonsai: fix EditProjectLibrary leaving stale declarations after reparenting Per Moult's round 4 review. The assertion change (get_parent_library(root) now returns the IfcProject instead of None) is correct: in the old library-only test model a top-level library had neither IfcRelNests nor IfcRelDeclares, so None meant "top level". In the new spec-valid model a top-level library is always declared to the guaranteed IfcProject via IfcRelDeclares, so get_parent_library correctly resolves it through the HasContext branch instead of falling through to None. get_project_hierarchy already keys top-level libraries under the project for exactly this reason, so the library tree still renders correctly. Auditing every caller found one real bug in EditProjectLibrary, which Gorgious56 originally wrote for the library-only model. Its move-library logic assumed a top-level library (previous_parent_library is None) needed no cleanup before nesting it under a new parent, and that unnesting a library back to the project needed no new relationship because it was "already assigned by default". Both assumptions relied on a top-level library never actually holding a IfcRelDeclares, which is no longer true. Reproduced live: moving a project-declared library under another library left its old IfcRelDeclares dangling alongside the new IfcRelNests (an invalid double parentage), and moving a nested library back to the project left it with neither relationship, orphaning it out of the tree entirely. Fixed by tearing down whichever of IfcRelDeclares/IfcRelNests the library previously had before establishing whichever one the new parent requires, instead of assuming which prior state applies. Added tests: get_parent_library resolving a nested sub-library to its library parent (the third contract case alongside project-declared and orphaned), and both EditProjectLibrary reparenting directions, which fail without the operator.py fix and pass with it. Verified live in headless Blender (isolated profile, source-loaded, never the real profile): all 20 test/bim/module/project tests pass. Ran the full test/bim suite before and after on the identical harness: 123 failed/1294 passed before, 123 failed/1297 passed after, identical failing test names in both runs (diffed), the extra 3 passes are the new tests above. This change was made with the assistance of an AI tool. --------- Co-authored-by: Claude Fable 5 --- src/bonsai/bonsai/bim/module/project/data.py | 2 +- .../bonsai/bim/module/project/operator.py | 32 ++-- src/bonsai/bonsai/tool/project.py | 19 +-- .../project/test_project_library_data.py | 139 ++++++++++++++---- 4 files changed, 127 insertions(+), 65 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/data.py b/src/bonsai/bonsai/bim/module/project/data.py index db64041a89..8ad010bc25 100644 --- a/src/bonsai/bonsai/bim/module/project/data.py +++ b/src/bonsai/bonsai/bim/module/project/data.py @@ -162,7 +162,7 @@ class ProjectLibraryData: library_file = IfcStore.library_file if library_file is None or library_file.schema == "IFC2X3": return results - root = tool.Project.get_root_context(library_file) + root = library_file.by_type("IfcProject")[0] results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or "")) for library_id, data in cls.data["project_libraries"].items(): results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or "")) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index cb6a27c52b..efe36b0c78 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -281,7 +281,7 @@ class RefreshLibrary(bpy.types.Operator): elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)} self.props.add_library_project_library("Unassigned", len(elements), 0, False) - root_context = tool.Project.get_root_context(library_file) + root_context = library_file.by_type("IfcProject")[0] hierarchy = tool.Project.get_project_hierarchy(library_file) tool.Project.load_project_libraries_to_ui(root_context, hierarchy) return {"FINISHED"} @@ -761,21 +761,22 @@ class EditProjectLibrary(bpy.types.Operator): attributes = bonsai.bim.helper.export_attributes(props.project_library_attributes) ifcopenshell.api.attribute.edit_attributes(library_file, project_library, attributes) - # Update parent library. + # Update parent library. Tear down the old IfcRelDeclares/IfcRelNests before + # creating the new one; a library must have exactly one of the two, never both. previous_parent_library = tool.Project.get_parent_library(project_library) new_parent_library = library_file.by_id(int(props.parent_library)) if previous_parent_library != new_parent_library: - if previous_parent_library is None: - # Edited library was a root in a library-only file; nest it under the new parent. + if previous_parent_library is not None: + if previous_parent_library.is_a("IfcProject"): + ifcopenshell.api.project.unassign_declaration( + library_file, [project_library], previous_parent_library + ) + else: + ifcopenshell.api.nest.unassign_object(library_file, [project_library]) + if new_parent_library.is_a("IfcProject"): + ifcopenshell.api.project.assign_declaration(library_file, [project_library], new_parent_library) + else: ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library) - elif previous_parent_library.is_a("IfcProject"): - # Then new one is IfcProjectLibrary. - ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library) - else: # Previous is IfcProjectLibrary. - ifcopenshell.api.nest.unassign_object(library_file, [project_library]) - # If new one is IfcProject, then it's already assigned by default. - if new_parent_library.is_a("IfcProjectLibrary"): - ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library) props.is_editing_project_library = False bpy.ops.bim.refresh_library() @@ -809,12 +810,9 @@ class AddProjectLibrary(bpy.types.Operator): props = tool.Project.get_project_props() library_file = IfcStore.library_file assert library_file - root_context = tool.Project.get_root_context(library_file) + root_context = library_file.by_type("IfcProject")[0] project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary") - if root_context.is_a("IfcProject"): - ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context) - else: - ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context) + ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context) ProjectLibraryData.load() # Update enum. props.selected_project_library = str(project_library.id()) props.is_editing_project_library = True diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 1d7a2dc2e0..f65ddd1519 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -391,29 +391,14 @@ class Project(bonsai.core.tool.Project): def get_parent_library( cls, project_library: ifcopenshell.entity_instance ) -> Union[ifcopenshell.entity_instance, None]: - """Return the IfcContext that declares or nests ``project_library``. - - Returns ``None`` when ``project_library`` is itself the root of a - library-only file (no IfcRelNests, no IfcRelDeclares). - """ + """Return the IfcContext that declares or nests ``project_library``, or ``None`` + if neither relationship is present.""" if nests := project_library.Nests: return nests[0].RelatingObject if has_context := project_library.HasContext: return has_context[0].RelatingContext return None - @classmethod - def get_root_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance: - """Return the file's root IfcContext. - - Prefers IfcProject if present, otherwise falls back to IfcProjectLibrary — - library-only files are valid per IFC4+ and contain no IfcProject. Caller is - responsible for the IFC2X3 guard; IfcContext does not exist in that schema. - """ - if projects := ifc_file.by_type("IfcProject"): - return projects[0] - return ifc_file.by_type("IfcProjectLibrary")[0] - @classmethod def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict: """Get project hierarchy in the following form: diff --git a/src/bonsai/test/bim/module/project/test_project_library_data.py b/src/bonsai/test/bim/module/project/test_project_library_data.py index 08f5573b00..df9192aa6d 100644 --- a/src/bonsai/test/bim/module/project/test_project_library_data.py +++ b/src/bonsai/test/bim/module/project/test_project_library_data.py @@ -32,64 +32,91 @@ from test.bim.bootstrap import NewIfc pytestmark = pytest.mark.project -def _make_library_only_file(*, with_child: bool = False) -> ifcopenshell.file: - """Build a minimal IFC4 file containing only an IfcProjectLibrary (no IfcProject). +def _make_library_file(*, with_child: bool = False) -> ifcopenshell.file: + """Build a spec-valid IFC4 library file: IfcProject + IfcProjectLibrary declared to it. - Per IFC4+, a file must contain at least one IfcContext; IfcProjectLibrary is a - valid root on its own. ``with_child=True`` nests a sub-library under the root via - IfcRelNests, mirroring real authored library files. + Per the IFC Project Context concept template, every project data set (library + files included) shall contain exactly one IfcProject, and IfcProjectLibrary + instances are assigned to it via IfcRelDeclares. This matches how every library + file shipped in bonsai/bim/data/libraries is actually authored. ``with_child=True`` + also nests a sub-library under the root via IfcRelNests. """ library_file = ifcopenshell.api.project.create_file(version="IFC4") + project = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProject", name="Demo Project") root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib") + ifcopenshell.api.project.assign_declaration(library_file, definitions=[root], relating_context=project) if with_child: child = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="ChildLib") ifcopenshell.api.nest.assign_object(library_file, [child], root) return library_file -class TestLibraryOnlyFile(NewIfc): - def test_get_root_context_returns_project_library_when_no_project(self): - library_file = _make_library_only_file() - assert not library_file.by_type("IfcProject") +class TestLibraryFile(NewIfc): + """Project-library UI code operating on a spec-valid model (IfcProject root). - root = tool.Project.get_root_context(library_file) + A file containing only IfcProjectLibrary and no IfcProject is not valid IFC and + is not supported; see test_parent_libraries_enum_raises_for_a_file_without_a_project. + """ - assert root.is_a("IfcProjectLibrary") - assert root.Name == "RootLib" + def test_parent_libraries_enum_raises_for_a_file_without_a_project(self): + library_file = ifcopenshell.api.project.create_file(version="IFC4") + ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib") + IfcStore.library_file = library_file + try: + with pytest.raises(IndexError): + ProjectLibraryData.parent_libraries_enum() + finally: + IfcStore.library_file = None - def test_get_parent_library_returns_none_for_root_library(self): - library_file = _make_library_only_file() + def test_get_parent_library_returns_project_for_declared_root_library(self): + library_file = _make_library_file() + project = library_file.by_type("IfcProject")[0] root = library_file.by_type("IfcProjectLibrary")[0] - assert tool.Project.get_parent_library(root) is None + assert tool.Project.get_parent_library(root) == project - def test_get_project_hierarchy_skips_root_library(self): - library_file = _make_library_only_file(with_child=True) + def test_get_parent_library_returns_the_library_for_a_nested_sub_library(self): + library_file = _make_library_file(with_child=True) + root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib") + child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib") + + assert tool.Project.get_parent_library(child) == root + + def test_get_parent_library_returns_none_for_an_orphaned_library(self): + library_file = ifcopenshell.api.project.create_file(version="IFC4") + orphan = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="Orphan") + + assert tool.Project.get_parent_library(orphan) is None + + def test_get_project_hierarchy_roots_libraries_under_the_project(self): + library_file = _make_library_file(with_child=True) + project = library_file.by_type("IfcProject")[0] root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib") child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib") hierarchy = tool.Project.get_project_hierarchy(library_file) - assert root in hierarchy + assert root in hierarchy[project] assert child in hierarchy[root] - def test_project_library_data_loads_without_crash(self): - IfcStore.library_file = _make_library_only_file() + def test_project_library_data_loads_with_unique_enum_keys(self): + IfcStore.library_file = _make_library_file(with_child=True) try: ProjectLibraryData.is_loaded = False ProjectLibraryData.load() assert ProjectLibraryData.is_loaded enum = ProjectLibraryData.data["parent_libraries_enum"] - assert len(enum) == 1 - assert enum[0][1].startswith("IfcProjectLibrary ") + keys = [entry[0] for entry in enum] + assert len(keys) == len(set(keys)) + assert enum[0][1].startswith("IfcProject ") finally: IfcStore.library_file = None ProjectLibraryData.is_loaded = False - def test_refresh_library_succeeds_on_library_only_file(self): + def test_refresh_library_succeeds(self): import bpy - IfcStore.library_file = _make_library_only_file(with_child=True) + IfcStore.library_file = _make_library_file(with_child=True) try: result = bpy.ops.bim.refresh_library() assert result == {"FINISHED"} @@ -97,13 +124,65 @@ class TestLibraryOnlyFile(NewIfc): IfcStore.library_file = None ProjectLibraryData.is_loaded = False - def test_add_project_library_nests_under_root_when_no_project(self): + def test_edit_project_library_moves_a_project_declared_library_under_another_library(self): import bpy - IfcStore.library_file = _make_library_only_file() + library_file = _make_library_file() + project = library_file.by_type("IfcProject")[0] + root = library_file.by_type("IfcProjectLibrary")[0] + target = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="TargetLib") + ifcopenshell.api.project.assign_declaration(library_file, definitions=[target], relating_context=project) + IfcStore.library_file = library_file + try: + props = tool.Project.get_project_props() + props.selected_project_library = str(root.id()) + props.is_editing_project_library = True + props.parent_library = str(target.id()) + + result = bpy.ops.bim.edit_project_library() + + assert result == {"FINISHED"} + assert tool.Project.get_parent_library(root) == target + assert root.Nests and root.Nests[0].RelatingObject == target + assert not root.HasContext + finally: + if props.is_editing_project_library: + props.is_editing_project_library = False + IfcStore.library_file = None + ProjectLibraryData.is_loaded = False + + def test_edit_project_library_moves_a_nested_library_back_under_the_project(self): + import bpy + + library_file = _make_library_file(with_child=True) + project = library_file.by_type("IfcProject")[0] + child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib") + IfcStore.library_file = library_file + try: + props = tool.Project.get_project_props() + props.selected_project_library = str(child.id()) + props.is_editing_project_library = True + props.parent_library = str(project.id()) + + result = bpy.ops.bim.edit_project_library() + + assert result == {"FINISHED"} + assert tool.Project.get_parent_library(child) == project + assert child.HasContext and child.HasContext[0].RelatingContext == project + assert not child.Nests + finally: + if props.is_editing_project_library: + props.is_editing_project_library = False + IfcStore.library_file = None + ProjectLibraryData.is_loaded = False + + def test_add_project_library_declares_new_library_under_the_project_root(self): + import bpy + + IfcStore.library_file = _make_library_file() library_file = IfcStore.library_file try: - root = library_file.by_type("IfcProjectLibrary")[0] + project = library_file.by_type("IfcProject")[0] before = set(library_file.by_type("IfcProjectLibrary")) result = bpy.ops.bim.add_project_library() @@ -113,9 +192,9 @@ class TestLibraryOnlyFile(NewIfc): new_libraries = after - before assert len(new_libraries) == 1 new_library = next(iter(new_libraries)) - assert new_library.Nests - assert new_library.Nests[0].RelatingObject == root - assert not new_library.HasContext + assert new_library.HasContext + assert new_library.HasContext[0].RelatingContext == project + assert not new_library.Nests finally: IfcStore.library_file = None ProjectLibraryData.is_loaded = False From e9b619e3fba90cadadd4158b5e8f90809214a67c Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 24 Jul 2026 09:41:23 +0200 Subject: [PATCH 111/142] Remove unused _get_shader_label helper method --- src/bonsai/bonsai/bim/module/style/ui.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/style/ui.py b/src/bonsai/bonsai/bim/module/style/ui.py index 6dba3eb8fc..99f901f195 100644 --- a/src/bonsai/bonsai/bim/module/style/ui.py +++ b/src/bonsai/bonsai/bim/module/style/ui.py @@ -226,27 +226,6 @@ class BIM_PT_styles(Panel): op = row3.operator("bim.toggle_prefer_ifc_shading", text="", icon="UV_SYNC_SELECT") op.material_name = material.name - @staticmethod - def _get_shader_label(material: bpy.types.Material, msprops) -> str: - space = tool.Blender.get_view3d_space() - if space and space.shading.type == "SOLID": - return "Not Applicable" - if msprops.active_style_type == "External": - return "External (.blend)" - if not material.node_tree: - return "Flat colour" - nodes = material.node_tree.nodes - has_mix = any(n.type == "MIX_SHADER" for n in nodes) - if has_mix: - return "Emission (Flat)" - has_principled = any(n.type == "BSDF_PRINCIPLED" for n in nodes) - has_teximage = any(n.type == "TEX_IMAGE" and any(o.links for o in n.outputs) for n in nodes) - if has_principled and has_teximage: - return "BSDF + Textures" - if has_principled: - return "Principled BSDF" - return "Flat colour" - def draw_surface_style_shading(self): row = self.layout.row() row.prop(self.props, "surface_colour") From f2d8f17f88b12e02e2f8e03d742a2375659555c5 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 24 Jul 2026 09:47:16 +0200 Subject: [PATCH 112/142] Remove unused `has_any_textures` return from restore_material_style_types --- src/bonsai/bonsai/tool/style.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index ff94a70157..8a5eef0d75 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -1078,27 +1078,22 @@ class Style(bonsai.core.tool.Style): _last_shading_type: str | None = None @classmethod - def restore_material_style_types(cls, shading_type: str) -> bool: + def restore_material_style_types(cls, shading_type: str) -> None: """Set each IFC material's active_style_type to the richest available for the given viewport mode. In SOLID mode all materials use "Shading". In MATERIAL_PREVIEW / RENDERED, materials with an external .blend style use "External" unless prefer_ifc_shading is set on that material. - - Returns True if any IFC material has IfcSurfaceStyleWithTextures (used to decide color_type). """ if cls._last_shading_type == shading_type: - return False + return cls._last_shading_type = shading_type - has_any_textures = False for material in bpy.data.materials: if not tool.Blender.get_ifc_definition_id(material): continue props = cls.get_material_style_props(material) style_elements = cls.get_style_elements(material) - if style_elements.get("IfcSurfaceStyleWithTextures"): - has_any_textures = True if shading_type == "SOLID": props.active_style_type = "Shading" shading = style_elements.get("IfcSurfaceStyleRendering") or style_elements.get("IfcSurfaceStyleShading") @@ -1112,4 +1107,3 @@ class Style(bonsai.core.tool.Style): else: props.active_style_type = "Shading" material.update_tag() - return has_any_textures From 21122c0d282d67d90d5607490c61e9c15ec2dbe3 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 12 Jul 2026 13:21:32 +0300 Subject: [PATCH 113/142] Resolve nested complex quantity paths in the selector (#2041) get_element_value could not reach the members of an IfcPhysicalComplexQuantity (or IfcComplexProperty) by their natural path. util.element expands a complex quantity into a dict whose nested members live under a "properties" sub-dict, but the selector's dict navigation only looked at the top level, so "Qto_Custom.Layer1.Width" returned None and IfcCsv exported nothing for it. Only the internal "Qto_Custom.Layer1.properties.Width" path worked. When a key is not a direct member of the value dict, descend into its "properties" sub-dict so nested quantities/properties resolve with the natural "Set.Complex.Nested" path. Direct keys still take priority, so the explicit ".properties." path stays backward compatible and the regex branch is untouched. Verified: Qto_Custom.Layer1.Width -> 0.1 and Layer1.Height -> 2.5 (were None), the sibling simple NetArea still resolves, the legacy .properties. path still works, and IfcCsv now exports the nested value. test_selector.py: 38 passed (adds test_selecting_a_nested_complex_quantity). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Opus 4.8 --- .../ifcopenshell/util/selector.py | 9 +++++- .../test/util/test_selector.py | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index da86b69a91..40edf5199b 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -540,8 +540,15 @@ def _get_element_value(element: ifcopenshell.entity_instance, keys: list[str]) - value = results or None if value and len(value) == 1: value = value[0] + elif key in value: + value = value[key] else: - value = value.get(key, None) + # A nested complex quantity/property (IfcPhysicalComplexQuantity / + # IfcComplexProperty) is represented as a dict whose nested members + # live under a "properties" sub-dict. Descend into it so that nested + # values are reachable with the natural "Qto.Complex.Nested" path. + subprops = value.get("properties") + value = subprops.get(key, None) if isinstance(subprops, dict) else None elif isinstance(value, (list, tuple, set)): # If we use regex if isinstance(key, str) and key.isnumeric(): try: diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index af80141e81..c4c7a6ce17 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -195,6 +195,37 @@ class TestGetElementValue(test.bootstrap.IFC4): assert subject.get_element_value(element, "/Pset_.*Common/.Status") == ["New"] assert subject.get_element_value(element, "/Pset_.*Common/.Status.0") == "New" + def test_selecting_a_nested_complex_quantity(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + complex_quantity = self.file.create_entity( + "IfcPhysicalComplexQuantity", + Name="Layer1", + Discrimination="layer", + HasQuantities=[ + self.file.create_entity("IfcQuantityLength", Name="Width", LengthValue=0.1), + self.file.create_entity("IfcQuantityLength", Name="Height", LengthValue=2.5), + ], + ) + quantity = self.file.create_entity( + "IfcElementQuantity", + GlobalId=ifcopenshell.guid.new(), + Name="Qto_Custom", + Quantities=[complex_quantity, self.file.create_entity("IfcQuantityArea", Name="NetArea", AreaValue=5.0)], + ) + self.file.create_entity( + "IfcRelDefinesByProperties", + GlobalId=ifcopenshell.guid.new(), + RelatedObjects=[element], + RelatingPropertyDefinition=quantity, + ) + # A simple quantity in the same set still resolves normally. + assert subject.get_element_value(element, "Qto_Custom.NetArea") == 5.0 + # Nested quantities of a complex quantity are reachable with the natural path. + assert subject.get_element_value(element, "Qto_Custom.Layer1.Width") == 0.1 + assert subject.get_element_value(element, "Qto_Custom.Layer1.Height") == 2.5 + # The explicit "properties" path is preserved for backwards compatibility. + assert subject.get_element_value(element, "Qto_Custom.Layer1.properties.Width") == 0.1 + class TestFilterElements(test.bootstrap.IFC4): def test_selecting_by_globalid(self): From 98c28a1f3034ddbab5b7e337d3cbac69cd47e905 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 5 Jul 2026 21:40:58 +0300 Subject: [PATCH 114/142] ifc5d: professional grade ODS/XLSX cost schedule export #6251 Three defects reported against the Costing tab export: 1. XLSX export crashed with ModuleNotFoundError: xlsxwriter was never bundled with Bonsai. Port the writer to openpyxl, which ifccsv already uses and Bonsai already ships, so it works out of the box. 2. Every ODS cell was written as a string (numbers as text), and the formula branch was dead code: it compared against 'Total Price' / 'Rate Subtotal' while the headers are 'TotalPrice' / 'RateSubtotal'. Numeric columns are now typed float cells and TotalPrice becomes a real formula: Quantity*RateSubtotal on leaf items, SUM over the direct children's TotalPrice cells on sum items. 3. Internal bookkeeping columns (Id, ItemIsASum, Hierarchy, Index, Quantities) leaked into the presentation formats. ODS/XLSX now hide them; CSV keeps them since csv2ifc consumes them for the round trip. Co-Authored-By: Claude Fable 5 --- src/ifc5d/ifc5d/ifc5Dspreadsheet.py | 126 ++++++++++++++++++++-------- src/ifc5d/pyproject.toml | 12 ++- 2 files changed, 97 insertions(+), 41 deletions(-) diff --git a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py index a015f4206b..9db4a45385 100644 --- a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py +++ b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py @@ -386,12 +386,62 @@ class Ifc5Dwriter: "PredefinedType": cost_schedule.PredefinedType, } + # Bookkeeping columns needed for the .csv round trip (csv2ifc) but noise + # in presentation formats (.ods / .xlsx). + INTERNAL_COLUMNS = ("Id", "ItemIsASum", "Hierarchy", "Index", "Quantities") + def multiply_cells(self, cell1, cell2): return "={}*{}".format(cell1, cell2) def sum_cells(self, list_of_cells): return "=SUM({})".format(",".join(list_of_cells)) + def get_visible_headers(self, schedule_id: int) -> list[str]: + """Headers for presentation formats, without the internal bookkeeping columns.""" + return [h for h in self.sheet_data[schedule_id]["headers"] if h not in self.INTERNAL_COLUMNS] + + def is_numeric_column(self, column: str) -> bool: + return column in ("Quantity", "RateSubtotal", "TotalPrice") or column.endswith(" Cost") + + def get_total_price_formula( + self, schedule_id: int, cost_item_index: int, first_data_row: int + ) -> Union[str, None]: + """Spreadsheet formula for the TotalPrice cell of a cost item, or None for a plain value. + + Sum items get ``=SUM(...)`` over the TotalPrice cells of their direct + children, leaf items with a quantity and a rate get ``=Quantity*RateSubtotal``. + Assumes one cost item per row, in ``cost_items`` order, starting at + ``first_data_row`` (1-based). + """ + items = self.sheet_data[schedule_id]["cost_items"] + headers = self.get_visible_headers(schedule_id) + if "TotalPrice" not in headers: + return None + item = items[cost_item_index] + col = lambda name: self.column_indexes[headers.index(name)] + if item["ItemIsASum"]: + prefix = item["Hierarchy"] + "." + child_rows = [ + first_data_row + i + for i, other in enumerate(items) + if other["Hierarchy"].startswith(prefix) and "." not in other["Hierarchy"][len(prefix) :] + ] + if child_rows: + total_col = col("TotalPrice") + return self.sum_cells(["{}{}".format(total_col, r) for r in child_rows]) + return None + if ( + "Quantity" in headers + and "RateSubtotal" in headers + and item.get("Quantity") + and item.get("RateSubtotal") + ): + row = first_data_row + cost_item_index + return self.multiply_cells( + "{}{}".format(col("Quantity"), row), "{}{}".format(col("RateSubtotal"), row) + ) + return None + def get_cell_position(self, schedule_id, attribute): def get_position_in_list(item, item_list): try: @@ -482,32 +532,25 @@ class Ifc5DOdsWriter(Ifc5Dwriter): assert False, type row.addElement(cell) - def add_cost_item_rows(table, cost_data): + first_data_row = 6 # 3 metadata rows, 1 blank row, 1 header row. + + def add_cost_item_rows(table, cost_data, cost_item_index): row = TableRow() self.row_count += 1 + style = self.colours.get(cost_data["Index"]) - for i, column in enumerate(self.sheet_data[cost_schedule.id()]["headers"]): - if column == "Total Price" and cost_data["Quantity"] != 0 and cost_data["Rate Subtotal"]: - cell_quantity = self.get_cell_position(cost_schedule.id(), "Quantity") - cell_subtotal_rate = self.get_cell_position(cost_schedule.id(), "Rate Subtotal") - value = self.multiply_cells(cell_quantity, cell_subtotal_rate) - cell = TableCell(formula=value, stylename=self.colours.get(cost_data["Index"])) + for column in self.get_visible_headers(cost_schedule.id()): + value = cost_data.get(column, "") + formula = None + if column == "TotalPrice": + formula = self.get_total_price_formula(cost_schedule.id(), cost_item_index, first_data_row) + if formula: + cell = TableCell(formula=formula, stylename=style) + elif self.is_numeric_column(column) and isinstance(value, (int, float)): + cell = TableCell(valuetype="float", value=value, stylename=style) else: - value = cost_data.get(column, "") - cell = TableCell(valuetype="string", stylename=self.colours.get(cost_data["Index"])) + cell = TableCell(valuetype="string", stylename=style) cell.addElement(P(text=value)) - # TODO:FIX QUANTITY AND COST TO SHOW AS NUMBERS AND CURRENCIES - # elif "Cost" in column or "Rate" in column: - # value = cost_data.get(column, "") - # cell = TableCell(valuetype="string", stylename=self.colours.get(cost_data["Index"])) - # cell.addElement(P(text=value)) - # # cell.addElement(P(text=u"${}".format(value))) # The current displayed value - # print("Should add rate ", "${}".format(value)) - # elif "Quantity" in column: - # value = cost_data.get(column, "") - # cell = TableCell(valuetype="float", stylename=self.colours.get(cost_data["Index"])) - # print("Should add quantity",value) - # cell.addElement(P(text=value)) row.addElement(cell) table.addElement(row) @@ -534,20 +577,22 @@ class Ifc5DOdsWriter(Ifc5Dwriter): table.addElement(new) header_row = TableRow() - for header in self.sheet_data[cost_schedule.id()]["headers"]: + for header in self.get_visible_headers(cost_schedule.id()): add_cell(type="text", value=header, row=header_row, style="fed8b1") table.addElement(header_row) self.row_count = 5 - for cost_item_data in self.sheet_data[cost_schedule.id()]["cost_items"]: - add_cost_item_rows(table, cost_item_data) + for i, cost_item_data in enumerate(self.sheet_data[cost_schedule.id()]["cost_items"]): + add_cost_item_rows(table, cost_item_data, i) self.doc.spreadsheet.addElement(table) class Ifc5DXlsxWriter(Ifc5Dwriter): def write(self) -> None: - import xlsxwriter + # openpyxl rather than xlsxwriter: it is what ifccsv already uses and + # what ships with Bonsai, so XLSX export works out of the box there. + import openpyxl super().write() os.makedirs(self.output, exist_ok=True) @@ -558,24 +603,31 @@ class Ifc5DXlsxWriter(Ifc5Dwriter): else: file_name += cost_schedule.Name or "" self.file_path = os.path.join(self.output, "{}.xlsx".format(file_name)) - self.workbook = xlsxwriter.Workbook(self.file_path) + self.workbook = openpyxl.Workbook() + self.workbook.remove(self.workbook.active) for cost_schedule in self.cost_schedules: self.write_table(cost_schedule) - self.workbook.close() + self.workbook.save(self.file_path) def write_table(self, cost_schedule): - worksheet = self.workbook.add_worksheet(self.sheet_data[cost_schedule.id()]["Name"]) - headers = self.sheet_data[cost_schedule.id()]["headers"] - for i, header in enumerate(headers): - worksheet.write(0, i, header) + import re - row = 1 - for cost_item_data in self.sheet_data[cost_schedule.id()]["cost_items"]: - col = 0 + sheet_id = cost_schedule.id() + title = re.sub(r"[\[\]:*?/\\]", "_", self.sheet_data[sheet_id]["Name"])[:31] + worksheet = self.workbook.create_sheet(title) + headers = self.get_visible_headers(sheet_id) + worksheet.append(headers) + + first_data_row = 2 # Row 1 is the header. + for i, cost_item_data in enumerate(self.sheet_data[sheet_id]["cost_items"]): + row = [] for header in headers: - worksheet.write(row, col, cost_item_data.get(header, "")) - col += 1 - row += 1 + formula = None + if header == "TotalPrice": + formula = self.get_total_price_formula(sheet_id, i, first_data_row) + # openpyxl treats strings starting with "=" as formulas. + row.append(formula if formula else cost_item_data.get(header, None)) + worksheet.append(row) class Ifc5DPdfWriter(Ifc5Dwriter): diff --git a/src/ifc5d/pyproject.toml b/src/ifc5d/pyproject.toml index eee7b5f07d..4a378de663 100644 --- a/src/ifc5d/pyproject.toml +++ b/src/ifc5d/pyproject.toml @@ -20,10 +20,14 @@ dependencies = [ "typing_extensions", ] - [project.optional-dependencies] - advanced = [ - "typst", - ] + [project.optional-dependencies] + advanced = [ + "typst", + ] + spreadsheet = [ + "odfpy", + "openpyxl", + ] [project.urls] Homepage = "http://ifcopenshell.org" From 1df738d968339b581e9419d6c1b5da1dafc2ea29 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 23 Jul 2026 09:24:36 +0300 Subject: [PATCH 115/142] ifc5d: match cost schedule export columns to the Bonsai cost panel (#6251) Stefano's final ask on #6251 was specific: the ODS/XLSX export should show exactly what the cost panel shows, ID (Identification), Name, Quantity, Value, Total Cost, no more, no less. The previous fix in this PR removed the internal bookkeeping columns but still exported Description, Unit and a per-category cost breakdown (Labor Cost, Material Cost, etc), none of which appear in the panel. Presentation formats (.ods/.xlsx) now use an explicit allow-list of columns instead of a block-list of internal ones, and relabel headers to match the panel's own wording (ID / Value / Total Cost). The .csv format is unchanged: csv2ifc still reads back the extra bookkeeping columns for the import round trip, which is why it keeps them. Also add a "Download CSV" button to the browser costing view (Generate spreadsheet browser), which previously only offered a clipboard-based Copy Selected. It reuses the already-rendered table (respecting the user's column visibility settings) and triggers a real file download, dropping only the UI-only Actions column. AI-generated with Claude Code; reviewed and tested by Petru Conduraru. --- .../data/webui/static/js/utilities/costui.js | 83 +++++++++++++++++++ src/ifc5d/ifc5d/ifc5Dspreadsheet.py | 47 ++++++----- src/ifc5d/test/test_csv2ifc.py | 28 +++++++ 3 files changed, 139 insertions(+), 19 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js index 7c9f5ed1a1..3c10101488 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js @@ -250,6 +250,14 @@ export class CostUI { }, }); + CostUI.addRibbonButton({ + text: "Download CSV", + icon: "fa-solid fa-file-csv", + callback: () => { + CostUI.downloadCsv(); + }, + }); + CostUI.addRibbonButton({ text: "Hide Schedules", icon: "fa-regular fa-eye-slash", @@ -523,6 +531,81 @@ export class CostUI { } } + static downloadCsv() { + const tables = document.querySelectorAll("table[id^='cost-items-']"); + if (tables.length === 0) { + alert("No cost schedule loaded to export!"); + return; + } + tables.forEach((table) => { + const scheduleId = table.id.split("-").pop(); + const csv = CostUI.tableToCsv(table); + if (csv === null) { + return; + } + const nameEl = document.querySelector( + "#cost-schedule-container-" + scheduleId + " .form-header span" + ); + const scheduleName = nameEl + ? nameEl.textContent + : "cost_schedule_" + scheduleId; + CostUI.triggerCsvDownload(csv, scheduleName + ".csv"); + }); + } + + static tableToCsv(table) { + const escapeCsvCell = (value) => { + const text = (value === null || value === undefined ? "" : value) + .toString() + .trim(); + if (/[",\n]/.test(text)) { + return '"' + text.replace(/"/g, '""') + '"'; + } + return text; + }; + + const cellText = (cell) => { + const input = cell.querySelector("input"); + return input ? input.value : cell.innerText; + }; + + // The Actions column only holds buttons (edit/delete/etc), not data. + const isDataColumn = (column) => column && column !== "Actions"; + + const headerCells = Array.from(table.querySelectorAll("thead th")).filter( + (th) => isDataColumn(th.getAttribute("data-column")) + ); + if (headerCells.length === 0) { + return null; + } + + const rows = [headerCells.map((th) => escapeCsvCell(th.textContent)).join(",")]; + + table.querySelectorAll("tbody tr").forEach((row) => { + const cells = Array.from(row.children).filter((cell) => + isDataColumn(cell.getAttribute("data-column")) + ); + if (cells.length === 0) { + return; // e.g. the "No cost items found" placeholder row. + } + rows.push(cells.map((cell) => escapeCsvCell(cellText(cell))).join(",")); + }); + + return rows.join("\n"); + } + + static triggerCsvDownload(csvContent, filename) { + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + static createCostTable({ costSchedule, currency, callbacks }) { const preferences = CostUI.getColumnPreferences(); const isScheduleOfRates = costSchedule.PredefinedType === "SCHEDULEOFRATES"; diff --git a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py index 9db4a45385..622fefa211 100644 --- a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py +++ b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py @@ -386,9 +386,22 @@ class Ifc5Dwriter: "PredefinedType": cost_schedule.PredefinedType, } - # Bookkeeping columns needed for the .csv round trip (csv2ifc) but noise - # in presentation formats (.ods / .xlsx). - INTERNAL_COLUMNS = ("Id", "ItemIsASum", "Hierarchy", "Index", "Quantities") + # Presentation formats (.ods / .xlsx) mirror exactly what the Bonsai cost + # panel shows for a cost item: ID (Identification), Name, Quantity, + # Value (RateSubtotal) and the calculated Total Cost. Everything else + # (internal bookkeeping columns, Description, Unit, per-category cost + # breakdowns) is bonsai/csv2ifc round-trip plumbing and stays out of the + # presentation formats. The .csv format keeps the full column set since + # csv2ifc reads those extra columns back in on import. + PRESENTATION_COLUMNS = ("Identification", "Name", "Quantity", "RateSubtotal", "TotalPrice") + + # Header text as shown in presentation formats, matching the Bonsai cost + # panel's own column labels (see BIM_UL_cost_items_trait.draw_header). + PRESENTATION_LABELS = { + "Identification": "ID", + "RateSubtotal": "Value", + "TotalPrice": "Total Cost", + } def multiply_cells(self, cell1, cell2): return "={}*{}".format(cell1, cell2) @@ -397,15 +410,18 @@ class Ifc5Dwriter: return "=SUM({})".format(",".join(list_of_cells)) def get_visible_headers(self, schedule_id: int) -> list[str]: - """Headers for presentation formats, without the internal bookkeeping columns.""" - return [h for h in self.sheet_data[schedule_id]["headers"] if h not in self.INTERNAL_COLUMNS] + """Internal column keys shown in presentation formats, in panel order.""" + headers = self.sheet_data[schedule_id]["headers"] + return [h for h in self.PRESENTATION_COLUMNS if h in headers] + + def get_display_label(self, column: str) -> str: + """Header text to write for a column in presentation formats.""" + return self.PRESENTATION_LABELS.get(column, column) def is_numeric_column(self, column: str) -> bool: return column in ("Quantity", "RateSubtotal", "TotalPrice") or column.endswith(" Cost") - def get_total_price_formula( - self, schedule_id: int, cost_item_index: int, first_data_row: int - ) -> Union[str, None]: + def get_total_price_formula(self, schedule_id: int, cost_item_index: int, first_data_row: int) -> Union[str, None]: """Spreadsheet formula for the TotalPrice cell of a cost item, or None for a plain value. Sum items get ``=SUM(...)`` over the TotalPrice cells of their direct @@ -430,16 +446,9 @@ class Ifc5Dwriter: total_col = col("TotalPrice") return self.sum_cells(["{}{}".format(total_col, r) for r in child_rows]) return None - if ( - "Quantity" in headers - and "RateSubtotal" in headers - and item.get("Quantity") - and item.get("RateSubtotal") - ): + if "Quantity" in headers and "RateSubtotal" in headers and item.get("Quantity") and item.get("RateSubtotal"): row = first_data_row + cost_item_index - return self.multiply_cells( - "{}{}".format(col("Quantity"), row), "{}{}".format(col("RateSubtotal"), row) - ) + return self.multiply_cells("{}{}".format(col("Quantity"), row), "{}{}".format(col("RateSubtotal"), row)) return None def get_cell_position(self, schedule_id, attribute): @@ -578,7 +587,7 @@ class Ifc5DOdsWriter(Ifc5Dwriter): header_row = TableRow() for header in self.get_visible_headers(cost_schedule.id()): - add_cell(type="text", value=header, row=header_row, style="fed8b1") + add_cell(type="text", value=self.get_display_label(header), row=header_row, style="fed8b1") table.addElement(header_row) self.row_count = 5 @@ -616,7 +625,7 @@ class Ifc5DXlsxWriter(Ifc5Dwriter): title = re.sub(r"[\[\]:*?/\\]", "_", self.sheet_data[sheet_id]["Name"])[:31] worksheet = self.workbook.create_sheet(title) headers = self.get_visible_headers(sheet_id) - worksheet.append(headers) + worksheet.append([self.get_display_label(h) for h in headers]) first_data_row = 2 # Row 1 is the header. for i, cost_item_data in enumerate(self.sheet_data[sheet_id]["cost_items"]): diff --git a/src/ifc5d/test/test_csv2ifc.py b/src/ifc5d/test/test_csv2ifc.py index 01b4a85ee6..7f6a8ebfd3 100644 --- a/src/ifc5d/test/test_csv2ifc.py +++ b/src/ifc5d/test/test_csv2ifc.py @@ -120,6 +120,34 @@ class TestCsv2Ifc: assert len(list(Path(temp_csv_dir).glob("*.ods"))) == 1 assert len(list(Path(temp_csv_dir).glob("*.xlsx"))) == 1 + def test_xlsx_columns_match_cost_panel(self): + """ODS/XLSX are presentation formats: they must show exactly what the + Bonsai cost panel shows (ID, Name, Quantity, Value, Total Cost), no + internal bookkeeping columns, no Description/Unit, no per-category + cost breakdown. See #6251.""" + import openpyxl + + ifc_file = self.setup_ifc_file() + csv_filepath = Path(__file__).parent.parent / "sample_cost_schedule_house_FR.csv" + ifc5d.csv2ifc.Csv2Ifc(str(csv_filepath), ifc_file).execute() + + with tempfile.TemporaryDirectory("w") as temp_dir: + writer = ifc5d.ifc5Dspreadsheet.Ifc5DXlsxWriter(ifc_file, temp_dir) + writer.write() + workbook = openpyxl.load_workbook(next(Path(temp_dir).glob("*.xlsx"))) + worksheet = workbook.active + + headers = [cell.value for cell in next(worksheet.iter_rows())] + assert headers == ["ID", "Name", "Quantity", "Value", "Total Cost"] + + # A leaf item (has quantity and value) gets Quantity * Value. + leaf_row = next(row for row in worksheet.iter_rows(min_row=2) if row[0].value == "DB.1.1") + assert leaf_row[4].value == "=C{}*D{}".format(leaf_row[0].row, leaf_row[0].row) + + # A parent/sum item gets the sum of its direct children's Total Cost. + parent_row = next(row for row in worksheet.iter_rows(min_row=2) if row[0].value == "DB.1") + assert parent_row[4].value.startswith("=SUM(") + class TestSerialiseCostQuantities: def test_quantity_name_with_special_characters_round_trips_as_json(self): From e7591356082736d0a5d9a15e9a60ba529841d67e Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 23 Jul 2026 10:29:53 +0300 Subject: [PATCH 116/142] Bonsai: cache-bust webui static assets so shipped JS/CSS changes reach users Browsers were caching /static/js and /static/css for the standalone webui (costing, gantt, drawings, index, demo pages) indefinitely, so a shipped JS fix (e.g. the Download CSV button) would only reach a user after a manual hard refresh. Two changes, applied consistently across all five webui pages. 1. Every locally served link/script tag in the pystache templates now carries a ?v= query string, falling back to a static asset mtime hash when BONSAI_VERSION isn't set (e.g. running sioserver.py standalone). Since get_bonsai_version() includes the build's commit hash, the token changes on every shipped update. 2. Responses under /static/ and /jsgantt/ now carry Cache-Control: no-cache, must-revalidate. This covers what query stamping alone can't reach: cost.js and gantt.js statically import utilities/costui.js by a fixed relative path with no query string, so that nested module still needed server side revalidation to pick up changes. Verified against a live aiohttp instance of sioserver.py: rendered HTML for all five routes shows the stamped URLs, and the token changes when BONSAI_VERSION changes between two server runs. A conditional GET against a static file with a stale If-Modified-Since header confirms the cheap 304 revalidation path still works. Also used this instance plus a real headless Chromium (Playwright) to click test the previously untested Download CSV button on the costing page. The ribbon renders it correctly, and clicking it (with a synthetic cost-items table injected into the DOM to stand in for a connected Blender's data) triggers a real Blob download with the correct filename and CSV content. No bug found, the button works as intended. AI-generated with Claude Code. --- src/bonsai/bonsai/bim/data/webui/sioserver.py | 58 +++++++++++++++++-- .../bim/data/webui/templates/costing.html | 6 +- .../bonsai/bim/data/webui/templates/demo.html | 6 +- .../bim/data/webui/templates/drawings.html | 6 +- .../bim/data/webui/templates/gantt.html | 10 ++-- .../bim/data/webui/templates/index.html | 6 +- 6 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/webui/sioserver.py b/src/bonsai/bonsai/bim/data/webui/sioserver.py index 91c3c4e8eb..4098778fac 100644 --- a/src/bonsai/bonsai/bim/data/webui/sioserver.py +++ b/src/bonsai/bonsai/bim/data/webui/sioserver.py @@ -10,6 +10,7 @@ if bonsai_lib_path: import argparse import base64 import json +import urllib.parse import xml.etree.ElementTree as ET import pystache @@ -18,8 +19,53 @@ from aiohttp import web sio_port = 8080 # default port + +def get_asset_version() -> str: + """A cache-busting token appended to locally served static asset URLs. + + Browsers otherwise keep serving a stale cached copy of static/js and + static/css after Bonsai ships a code change, until the user does a hard + refresh. Using the Bonsai version (which includes the build's commit + hash) means the token changes on every shipped update. + """ + if bonsai_version: + return urllib.parse.quote(bonsai_version, safe="") + # Fallback for standalone runs without BONSAI_VERSION set (e.g. running + # sioserver.py directly outside of Blender): derive a token from the + # newest mtime among the static assets, so it still changes whenever the + # shipped files change. + static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") + latest_mtime = 0 + for root, _dirs, files in os.walk(static_dir): + for name in files: + latest_mtime = max(latest_mtime, int(os.path.getmtime(os.path.join(root, name)))) + return f"dev-{latest_mtime}" + + +asset_version = get_asset_version() + + +@web.middleware +async def no_cache_static_middleware(request: web.Request, handler): + """Force revalidation of locally served static assets. + + Query-string version stamping (see `asset_version`) busts the cache for + the HTML-referenced entry points, but JS files that statically import + other local modules (e.g. cost.js/gantt.js importing utilities/costui.js) + reference those modules by an un-stamped relative path. Marking all + /static/ and /jsgantt/ responses as no-cache makes browsers always + revalidate with the server (a cheap conditional GET / 304 when nothing + changed), so nested imports also pick up shipped changes without + requiring a hard refresh. + """ + response = await handler(request) + if request.path.startswith("/static/") or request.path.startswith("/jsgantt/"): + response.headers["Cache-Control"] = "no-cache, must-revalidate" + return response + + sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="aiohttp", max_http_buffer_size=10000000) -app = web.Application() +app = web.Application(middlewares=[no_cache_static_middleware]) sio.attach(app) @@ -199,28 +245,28 @@ class BlenderNamespace(socketio.AsyncNamespace): async def schedules(request): with open("templates/index.html", "r") as f: template = f.read() - html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version}) return web.Response(text=html_content, content_type="text/html") async def costing(request): with open("templates/costing.html", "r") as f: template = f.read() - html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version}) return web.Response(text=html_content, content_type="text/html") async def sequencing(request): with open("templates/gantt.html", "r") as f: template = f.read() - html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version}) return web.Response(text=html_content, content_type="text/html") async def documentation(request): with open("templates/drawings.html", "r") as f: template = f.read() - html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version}) return web.Response(text=html_content, content_type="text/html") @@ -229,7 +275,7 @@ async def documentation(request): async def demo(request): with open("templates/demo.html", "r") as f: template = f.read() - html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version}) return web.Response(text=html_content, content_type="text/html") diff --git a/src/bonsai/bonsai/bim/data/webui/templates/costing.html b/src/bonsai/bonsai/bim/data/webui/templates/costing.html index 6e076203c3..948ba4a00e 100644 --- a/src/bonsai/bonsai/bim/data/webui/templates/costing.html +++ b/src/bonsai/bonsai/bim/data/webui/templates/costing.html @@ -7,7 +7,7 @@ - +