From fb5071a0ee76c55bde4bbf8fe056e76cc822d874 Mon Sep 17 00:00:00 2001 From: Geert Hesselink <54070862+Ghesselink@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:07:06 +0000 Subject: [PATCH 1/2] Add experimental BCF 2.1 export of validation outcomes --- .github/workflows/ci.yml | 6 +- .github/workflows/ci_cd.yml | 4 + backend/apps/ifc_validation/api/v1/urls.py | 3 +- backend/apps/ifc_validation/api/v1/views.py | 54 ++++ backend/apps/ifc_validation/bcf_export.py | 300 ++++++++++++++++++ backend/apps/ifc_validation/test_settings.py | 1 + .../ifc_validation/tests/tests_bcf_export.py | 170 ++++++++++ backend/apps/ifc_validation_bff/urls.py | 3 +- .../apps/ifc_validation_bff/views_legacy.py | 39 +++ backend/requirements.txt | 2 + docker/backend/Dockerfile | 3 + frontend/src/DashboardTable.js | 12 + frontend/src/Report.js | 16 +- frontend/src/bcf-icon.png | Bin 0 -> 3329 bytes 14 files changed, 609 insertions(+), 4 deletions(-) create mode 100644 backend/apps/ifc_validation/bcf_export.py create mode 100644 backend/apps/ifc_validation/tests/tests_bcf_export.py create mode 100644 frontend/src/bcf-icon.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13002bf6..7632eb56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,9 @@ jobs: wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.6-8b5b400-linux64.zip" mkdir -p venv/lib/python3.11/site-packages unzip -d venv/lib/python3.11/site-packages /tmp/ifcopenshell_python.zip + # BCF export - --no-deps so pip cannot pull PyPI ifcopenshell over the pinned build + pip install --no-deps bcf-client==0.8.5 + pip install xsdata==26.2 - name: Check Django config run: | @@ -116,4 +119,5 @@ jobs: MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_syntax_validation_task --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_schema_validation_task --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_status_combine --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 - MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_management_commands --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 \ No newline at end of file + MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_management_commands --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 + MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_bcf_export --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 \ No newline at end of file diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 287b993b..9974889e 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -100,6 +100,9 @@ jobs: wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.6-8b5b400-linux64.zip" mkdir -p venv/lib/python3.11/site-packages unzip -d venv/lib/python3.11/site-packages /tmp/ifcopenshell_python.zip + # BCF export - --no-deps so pip cannot pull PyPI ifcopenshell over the pinned build + pip install --no-deps bcf-client==0.8.5 + pip install xsdata==26.2 - name: Check Django config run: | @@ -117,6 +120,7 @@ jobs: MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_syntax_validation_task --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_schema_validation_task --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_management_commands --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 + MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_bcf_export --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 deploy: diff --git a/backend/apps/ifc_validation/api/v1/urls.py b/backend/apps/ifc_validation/api/v1/urls.py index 80abe02e..73c60e36 100644 --- a/backend/apps/ifc_validation/api/v1/urls.py +++ b/backend/apps/ifc_validation/api/v1/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from .views import ValidationRequestListAPIView, ValidationRequestDetailAPIView +from .views import ValidationRequestListAPIView, ValidationRequestDetailAPIView, ValidationRequestBcfAPIView from .views import ValidationTaskListAPIView, ValidationTaskDetailAPIView from .views import ValidationOutcomeListAPIView, ValidationOutcomeDetailAPIView from .views import ModelListAPIView, ModelDetailAPIView @@ -11,6 +11,7 @@ # REST API # using re_path to make trailing slashes optional re_path(r'validationrequest/?$', ValidationRequestListAPIView.as_view()), + re_path(r'validationrequest/(?P[\w-]+)/bcf/?$', ValidationRequestBcfAPIView.as_view()), re_path(r'validationrequest/(?P[\w-]+)/?$', ValidationRequestDetailAPIView.as_view()), re_path(r'validationtask/?$', ValidationTaskListAPIView.as_view()), re_path(r'validationtask/(?P[\w-]+)/?$', ValidationTaskDetailAPIView.as_view()), diff --git a/backend/apps/ifc_validation/api/v1/views.py b/backend/apps/ifc_validation/api/v1/views.py index 838f09e0..3a9a4fd5 100644 --- a/backend/apps/ifc_validation/api/v1/views.py +++ b/backend/apps/ifc_validation/api/v1/views.py @@ -1,9 +1,12 @@ import traceback import sys import logging +import os import re from django.db import transaction +from django.http import HttpResponse +from django.utils.http import content_disposition_header from core.utils import get_client_ip_address from core.settings import MAX_FILES_PER_UPLOAD @@ -18,6 +21,7 @@ from rest_framework.throttling import ScopedRateThrottle from rest_framework.decorators import throttle_classes from drf_spectacular.utils import extend_schema +from drf_spectacular.types import OpenApiTypes from apps.ifc_validation_models.models import set_user_context from apps.ifc_validation_models.models import ValidationRequest @@ -94,6 +98,56 @@ def delete(self, request, id, *args, **kwargs): return Response(data, status=status.HTTP_404_NOT_FOUND) +@extend_schema(tags=['Validation Request']) +class ValidationRequestBcfAPIView(APIView): + + queryset = ValidationRequest.objects.all() + permission_classes = [IsAuthenticated] + serializer_class = ValidationRequestSerializer + throttle_classes = [UserRateThrottle] + + @extend_schema( + operation_id='validationrequest_bcf', + responses={ + (200, 'application/zip'): OpenApiTypes.BINARY, + 404: None, + 501: None, + } + ) + def get(self, request, id, *args, **kwargs): + + """ + Downloads the Validation Outcomes of a Validation Request as a BCF 2.1 file (experimental). + Only outcomes with severity Error or Warning are included, capped per rule like the report UI. + """ + + logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH'))) + + instance = ValidationRequest.objects.filter(created_by__id=request.user.id, deleted=False, id=ValidationRequest.to_private_id(id)).first() + if not instance: + data = {'detail': f"Validation Request with public_id={id} does not exist for user with id={request.user.id}."} + return Response(data, status=status.HTTP_404_NOT_FOUND) + + try: + import bcf # noqa: F401 - fail early with a clear message when not installed + from apps.ifc_validation.bcf_export import generate_bcf_download + except ImportError: + data = {'detail': "BCF export is not available on this server."} + return Response(data, status=status.HTTP_501_NOT_IMPLEMENTED) + + try: + content, _ = generate_bcf_download(instance) + except Exception: + logger.exception(f'BCF generation failed for request {id}') + data = {'detail': "BCF generation failed."} + return Response(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + file_name = os.path.splitext(instance.file_name)[0] + response = HttpResponse(content, content_type='application/zip') + response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=f'{file_name}.bcf') + return response + + @extend_schema(tags=['Validation Request']) class ValidationRequestListAPIView(ListCreateAPIView): permission_classes = [IsAuthenticated] diff --git a/backend/apps/ifc_validation/bcf_export.py b/backend/apps/ifc_validation/bcf_export.py new file mode 100644 index 00000000..9179f660 --- /dev/null +++ b/backend/apps/ifc_validation/bcf_export.py @@ -0,0 +1,300 @@ +""" +Experimental export of Validation Outcomes to BCF 2.1 (BIM Collaboration Format). + +One BCF topic is created per error/warning outcome, mirroring what the report UI +shows: outcomes are grouped per rule/constraint and capped at MAX_OUTCOMES_PER_RULE +per group (with the total count mentioned in the topic description when capped). + +Where the offending entity has an IFC GlobalId (stored in ModelInstance.fields by +the instance completion task), the topic gets a viewpoint selecting that element. +For non-rooted entities (e.g. IfcPolyline) the nearest parent IfcProduct is looked +up in the IFC file, when it is still available on disk. + +Requires the 'bcf-client' package (https://pypi.org/project/bcf-client/). +""" +import json +import logging +import os +import re +import struct +import tempfile +import zlib +from collections import defaultdict + +from django.conf import settings + +from apps.ifc_validation_models.models import ValidationOutcome, ValidationRequest, ValidationTask +from apps.ifc_validation_models.models import calculate_whitelist + +logger = logging.getLogger(__name__) + +BCF_AUTHOR = "validate@buildingsmart.org" + +MAX_TITLE_LENGTH = 100 + +GHERKIN_TASK_TYPES = ( + ValidationTask.Type.NORMATIVE_IA, + ValidationTask.Type.NORMATIVE_IP, + ValidationTask.Type.PREREQUISITES, + ValidationTask.Type.INDUSTRY_PRACTICES, +) + +RELEVANT_TASK_TYPES = ( + ValidationTask.Type.SCHEMA, + ValidationTask.Type.SYNTAX, + ValidationTask.Type.HEADER_SYNTAX, + ValidationTask.Type.HEADER, +) + GHERKIN_TASK_TYPES + +FILE_LEVEL_LABELS = { + ValidationTask.Type.SYNTAX: "Syntax error", + ValidationTask.Type.HEADER_SYNTAX: "Header syntax error", + ValidationTask.Type.HEADER: "Header policy", +} + +SEVERITY_TO_TOPIC_TYPE = { + ValidationOutcome.OutcomeSeverity.ERROR: "Error", + ValidationOutcome.OutcomeSeverity.WARNING: "Warning", +} + + +def _placeholder_snapshot_png(width=320, height=240, rgb=(226, 232, 240)) -> bytes: + # Sommige viewers (o.a. BIMcollab ZOOM) activeren een viewpoint alleen via de + # snapshot-thumbnail; zonder PNG geldt het viewpoint daar als afwezig. + raw = b"".join(b"\x00" + bytes(rgb) * width for _ in range(height)) + + def chunk(tag, data): + payload = tag + data + return struct.pack(">I", len(data)) + payload + struct.pack(">I", zlib.crc32(payload)) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) + + +_SNAPSHOT_PNG = _placeholder_snapshot_png() + +# XML 1.0 does not allow most C0 control characters, even escaped; lone surrogates +# crash on save and ￾/￿ are non-characters — bcf-client does not guard these +INVALID_XML_CHARS = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff￾￿]") + +# IFC GlobalIds are 22 chars from a base64 alphabet including $ and _ +IFC_GUID_PATTERN = re.compile(r"[0-9A-Za-z_$]{22}") + + +def _sanitize(value: str) -> str: + return INVALID_XML_CHARS.sub("", value) + + +def _is_valid_ifc_guid(guid) -> bool: + return isinstance(guid, str) and IFC_GUID_PATTERN.fullmatch(guid) is not None + + +def _format_value(value) -> str: + """Formats an expected/observed JSON value (str, dict, list, number) for display.""" + if value is None: + return "" + if isinstance(value, str): + return value + try: + return json.dumps(value, default=str) + except (TypeError, ValueError): + return str(value) + + +def _group_key(outcome, task_type): + """Grouping key per rule/constraint, identical to the titles used in the report UI.""" + if task_type == ValidationTask.Type.SCHEMA: + try: + parsed = json.loads(outcome.feature) if outcome.feature else {} + except (json.JSONDecodeError, TypeError): + parsed = {} + attribute = parsed.get("attribute") or "Uncategorized" + constraint_type = parsed.get("type") or "Uncategorized" + return f"{constraint_type.replace('_', ' ').capitalize()} - {attribute}" + if task_type in FILE_LEVEL_LABELS: + return FILE_LEVEL_LABELS[task_type] + return outcome.feature or str(outcome.outcome_code) + + +def _walk_up_to_product(ifc_file, entity, max_depth=8, max_visited=200): + """Finds the nearest parent IfcProduct via inverse relationships (BFS), or None.""" + seen, queue = {entity.id()}, [(entity, 0)] + while queue: + current, depth = queue.pop(0) + if depth >= max_depth or len(seen) > max_visited: + break + for inverse in ifc_file.get_inverse(current): + if inverse.id() in seen: + continue + seen.add(inverse.id()) + if inverse.is_a("IfcProduct"): + return inverse + queue.append((inverse, depth + 1)) + return None + + +def _open_ifc_file(ifc_path): + if not ifc_path: + return None + try: + import ifcopenshell + return ifcopenshell.open(ifc_path) + except Exception as err: + # file may be removed by retention, or not parseable (syntax errors) + logger.info(f"BCF export continues without parent-element lookup: {err}") + return None + + +def generate_bcf(request: ValidationRequest, output_path: str, ifc_path: str = None) -> dict: + """ + Generates a BCF 2.1 file for all error/warning outcomes of a Validation Request. + + Returns a stats dict: {'topics', 'with_viewpoint', 'via_parent', 'truncated_groups', 'skipped'}. + """ + import numpy as np + from bcf.v2.bcfxml import BcfXml + from bcf.v2.visinfo import VisualizationInfoHandler + + # open the IFC lazily: only needed when a capped outcome lacks a usable GlobalId + ifc_state = {"loaded": False, "file": None} + + def get_ifc_file(): + if not ifc_state["loaded"]: + ifc_state["loaded"] = True + ifc_state["file"] = _open_ifc_file(ifc_path) + return ifc_state["file"] + + tasks = [ + task for task_type in RELEVANT_TASK_TYPES + if (task := ValidationTask.objects.filter(request_id=request.id, type=task_type).last()) + ] + + # allowlist upgrades (to PASSED) are applied in SQL, not per row + wl_annotations, effective_severity = calculate_whitelist(include_whitelist=True) + + # cap per group while iterating; only capped outcomes are kept in memory + grouped = defaultdict(list) + totals = defaultdict(int) + for task in tasks: + outcomes = ( + task.outcomes + .annotate(**wl_annotations) + .annotate(effective_severity=effective_severity) + .filter(effective_severity__in=( + ValidationOutcome.OutcomeSeverity.ERROR, + ValidationOutcome.OutcomeSeverity.WARNING, + )) + .order_by("-severity_in_db", "id") # errors fill the cap before warnings, like the report + .select_related("instance") + ) + for outcome in outcomes.iterator(): + key = (task.type, _group_key(outcome, task.type)) + totals[key] += 1 + if len(grouped[key]) < settings.MAX_OUTCOMES_PER_RULE: + grouped[key].append(outcome) + + bcfxml = BcfXml.create_new(project_name=f"Validation report {_sanitize(request.file_name)}") + + stats = {"topics": 0, "with_viewpoint": 0, "via_parent": 0, "truncated_groups": 0, "skipped": 0} + + # deterministic order: errors before warnings, then per group title + def sort_key(item): + (_, group_title), outcomes = item + return (-max(outcome.effective_severity for outcome in outcomes), group_title) + + for (task_type, group_title), capped in sorted(grouped.items(), key=sort_key): + total = totals[(task_type, group_title)] + if total > len(capped): + stats["truncated_groups"] += 1 + stats["skipped"] += total - len(capped) + + for outcome in capped: + severity_label = SEVERITY_TO_TOPIC_TYPE[outcome.effective_severity] + instance = outcome.instance + guid = (instance.fields or {}).get("GlobalId") if instance else None + if not _is_valid_ifc_guid(guid): + guid = None + + parent_note = None + if instance and not guid and (ifc_file := get_ifc_file()) is not None: + try: + parent = _walk_up_to_product(ifc_file, ifc_file[instance.stepfile_id]) + except Exception: + parent = None + if parent is not None: + guid = parent.GlobalId + parent_note = ( + f"The viewpoint selects the parent element {parent.is_a()} " + f"(#{parent.id()}, GlobalId {guid}) that contains the reported entity." + ) + stats["via_parent"] += 1 + + description_parts = [] + observed = _format_value(outcome.observed) + if observed: + description_parts.append(observed) + expected = _format_value(outcome.expected) + if expected: + description_parts.append(f"Expected: {expected}") + if instance: + instance_guid = (instance.fields or {}).get("GlobalId") + entity = f"Entity: {instance.ifc_type} (#{instance.stepfile_id}" + entity += f", GlobalId {instance_guid})" if instance_guid else ")" + description_parts.append(entity) + if parent_note: + description_parts.append(parent_note) + if total > len(capped): + description_parts.append( + f"Note: this issue occurs {total} times in the model; " + f"the first {len(capped)} occurrences are included in this BCF." + ) + description_parts.append( + f"Reported by the buildingSMART Validation Service " + f"(report {request.public_id}, outcome {outcome.public_id}, {outcome.outcome_code})." + ) + + title = f"[{severity_label}] {group_title}" + if len(title) > MAX_TITLE_LENGTH: + title = title[: MAX_TITLE_LENGTH - 1] + "…" + + topic = bcfxml.add_topic( + title=_sanitize(title), + description=_sanitize("\n\n".join(description_parts)), + author=BCF_AUTHOR, + topic_type=severity_label, + topic_status="Active", + ) + if _is_valid_ifc_guid(guid): + vi_handler = VisualizationInfoHandler.create_from_point_and_guids(np.zeros(3), guid) + vi_handler.snapshot = _SNAPSHOT_PNG + topic.add_visinfo_handler(vi_handler, f"{vi_handler.guid}.png") + stats["with_viewpoint"] += 1 + stats["topics"] += 1 + + # save atomically: a failing bcf-client save() clobbers an existing target file + temp_path = output_path + ".tmp" + bcfxml.save(temp_path) + os.replace(temp_path, output_path) + logger.info(f"BCF export for request {request.public_id}: {stats}") + return stats + + +def generate_bcf_download(validation_request: ValidationRequest) -> tuple: + """ + Generates the BCF file for a Validation Request and returns (file bytes, stats). + + Resolves the uploaded IFC file on disk when still available (it may be removed + or archived by file retention), enabling parent-element lookup for viewpoints. + """ + ifc_path = None + if validation_request.file: + candidate = os.path.join(settings.MEDIA_ROOT, validation_request.file.name) + if os.path.exists(candidate): + ifc_path = candidate + + with tempfile.TemporaryDirectory() as temp_dir: + bcf_path = os.path.join(temp_dir, "report.bcf") + stats = generate_bcf(validation_request, bcf_path, ifc_path=ifc_path) + with open(bcf_path, "rb") as bcf_file: + return bcf_file.read(), stats diff --git a/backend/apps/ifc_validation/test_settings.py b/backend/apps/ifc_validation/test_settings.py index bf666971..ecce70dc 100644 --- a/backend/apps/ifc_validation/test_settings.py +++ b/backend/apps/ifc_validation/test_settings.py @@ -23,4 +23,5 @@ DATABASES = {"default": DATABASES_ALL[os.environ.get("TEST_DJANGO_DB", DB_SQLITE)]} MEDIA_ROOT = "./apps/ifc_validation/fixtures" +MAX_OUTCOMES_PER_RULE = 10 USE_TZ = True \ No newline at end of file diff --git a/backend/apps/ifc_validation/tests/tests_bcf_export.py b/backend/apps/ifc_validation/tests/tests_bcf_export.py new file mode 100644 index 00000000..690ebca1 --- /dev/null +++ b/backend/apps/ifc_validation/tests/tests_bcf_export.py @@ -0,0 +1,170 @@ +import io +import json +import unittest +import zipfile + +from django.test import TransactionTestCase +from django.contrib.auth.models import User + +from apps.ifc_validation_models.models import * + +try: + import bcf # noqa: F401 + HAS_BCF = True +except ImportError: + HAS_BCF = False + +WALL_GUID = '1kTvXnbbzCWw8lcMd1dR4o' +ALIGNMENT_GUID = '2O2Fr$t4X7Zf8NOew3FLKr' + + +@unittest.skipUnless(HAS_BCF, "bcf-client is not installed") +class BcfExportTestCase(TransactionTestCase): + + @staticmethod + def set_user_context(): + user, _ = User.objects.get_or_create(id=1, defaults={'username': 'SYSTEM', 'is_active': True}) + set_user_context(user) + return user + + def create_request_with_outcomes(self, user): + + # file name deliberately does not exist in MEDIA_ROOT; parent-element lookup is skipped + request = ValidationRequest.objects.create( + file_name='bcf_export_test.ifc', + file='bcf_export_test.ifc', + size=1024 + ) + model = Model.objects.create(file_name=request.file_name, file=request.file_name, size=1024, schema='IFC4', uploaded_by=user) + request.model = model + request.save() + + wall = ModelInstance.objects.create( + model=model, stepfile_id=254, ifc_type='IfcWall', + fields={'GlobalId': WALL_GUID, 'Name': 'Basic Wall:200mm'}) + alignment = ModelInstance.objects.create( + model=model, stepfile_id=11, ifc_type='IfcAlignment', + fields={'GlobalId': ALIGNMENT_GUID}) + point = ModelInstance.objects.create( + model=model, stepfile_id=999, ifc_type='IfcCartesianPoint', + fields={'Coordinates': [0.0, 0.0]}) # no GlobalId (non-rooted entity) + + task_schema = ValidationTask.objects.create(request=request, type=ValidationTask.Type.SCHEMA) + task_normative = ValidationTask.objects.create(request=request, type=ValidationTask.Type.NORMATIVE_IA) + task_syntax = ValidationTask.objects.create(request=request, type=ValidationTask.Type.SYNTAX) + + # more outcomes in a single group than MAX_OUTCOMES_PER_RULE (10) + for sequence_number in range(14): + ValidationOutcome.objects.create( + validation_task=task_schema, instance=wall, + feature=json.dumps({'attribute': 'IfcWall.Name', 'type': 'schema'}), + outcome_code=ValidationOutcome.ValidationOutcomeCode.SCHEMA_ERROR, + severity=ValidationOutcome.OutcomeSeverity.ERROR, + observed=f'Invalid value for Name attribute (occurrence {sequence_number + 1})') + + # entity without a GlobalId + ValidationOutcome.objects.create( + validation_task=task_schema, instance=point, + feature=json.dumps({'attribute': 'IfcCartesianPoint.Coordinates', 'type': 'entity_rule'}), + outcome_code=ValidationOutcome.ValidationOutcomeCode.SCHEMA_ERROR, + severity=ValidationOutcome.OutcomeSeverity.ERROR, + observed='Coordinates dimensionality mismatch') + + # gherkin rule error with control characters in the message (must be sanitized) + ValidationOutcome.objects.create( + validation_task=task_normative, instance=alignment, + feature='ALB002 - Alignment referents', feature_version=1, + outcome_code=ValidationOutcome.ValidationOutcomeCode.VALUE_ERROR, + severity=ValidationOutcome.OutcomeSeverity.ERROR, + expected='IfcReferent nested in IfcAlignment', + observed='No nested IfcReferent found \x00\x0b\x1f') + + # warning + ValidationOutcome.objects.create( + validation_task=task_normative, instance=wall, + feature='SPS001 - Spatial containment', feature_version=1, + outcome_code=ValidationOutcome.ValidationOutcomeCode.WARNING, + severity=ValidationOutcome.OutcomeSeverity.WARNING, + observed='Wall not contained in any storey') + + # passed outcome, must not become a topic + ValidationOutcome.objects.create( + validation_task=task_normative, instance=None, + feature='GRF001 - Georeferencing', feature_version=1, + outcome_code=ValidationOutcome.ValidationOutcomeCode.PASSED, + severity=ValidationOutcome.OutcomeSeverity.PASSED) + + # syntax error without instance + ValidationOutcome.objects.create( + validation_task=task_syntax, instance=None, + outcome_code=ValidationOutcome.ValidationOutcomeCode.SYNTAX_ERROR, + severity=ValidationOutcome.OutcomeSeverity.ERROR, + observed='On line 42 column 7: unexpected token') + + return request + + def test_bcf_export_creates_expected_topics(self): + + user = BcfExportTestCase.set_user_context() + request = self.create_request_with_outcomes(user) + + from apps.ifc_validation.bcf_export import generate_bcf_download + content, stats = generate_bcf_download(request) + + # 10 capped wall errors + 1 point error + 1 gherkin error + 1 warning + 1 syntax error + self.assertEqual(stats['topics'], 14) + self.assertEqual(stats['with_viewpoint'], 12) # not: point (no GlobalId), syntax (no instance) + self.assertEqual(stats['truncated_groups'], 1) + self.assertEqual(stats['skipped'], 4) + + def test_bcf_export_produces_valid_loadable_bcf(self): + + user = BcfExportTestCase.set_user_context() + request = self.create_request_with_outcomes(user) + + from apps.ifc_validation.bcf_export import generate_bcf_download + import tempfile, os + from bcf.v2.bcfxml import BcfXml + + content, _ = generate_bcf_download(request) + + # well-formed zip with BCF 2.1 marker + zip_file = zipfile.ZipFile(io.BytesIO(content)) + self.assertIn('bcf.version', zip_file.namelist()) + self.assertIn('VersionId="2.1"', zip_file.read('bcf.version').decode()) + + # re-loadable by bcf-client (fails on unescaped control characters) + with tempfile.TemporaryDirectory() as temp_dir: + bcf_path = os.path.join(temp_dir, 'roundtrip.bcf') + with open(bcf_path, 'wb') as file: + file.write(content) + with BcfXml.load(bcf_path) as bcfxml: + topics = list(bcfxml.topics.values()) + self.assertEqual(len(topics), 14) + titles = [handler.topic.title for handler in topics] + self.assertTrue(any('ALB002' in title for title in titles)) + self.assertFalse(any('GRF001' in title for title in titles)) # passed is excluded + + # viewpoint selects the element by its IFC GlobalId + alignment_handler = next(h for h in topics if 'ALB002' in h.topic.title) + viewpoints = list(alignment_handler.viewpoints.values()) + self.assertEqual(len(viewpoints), 1) + components = viewpoints[0].visualization_info.components.selection.component + self.assertEqual(components[0].ifc_guid, ALIGNMENT_GUID) + + def test_bcf_export_without_error_outcomes_is_empty_but_valid(self): + + user = BcfExportTestCase.set_user_context() + request = ValidationRequest.objects.create(file_name='bcf_export_empty.ifc', file='bcf_export_empty.ifc', size=100) + task = ValidationTask.objects.create(request=request, type=ValidationTask.Type.SCHEMA) + ValidationOutcome.objects.create( + validation_task=task, instance=None, + outcome_code=ValidationOutcome.ValidationOutcomeCode.PASSED, + severity=ValidationOutcome.OutcomeSeverity.PASSED) + + from apps.ifc_validation.bcf_export import generate_bcf_download + content, stats = generate_bcf_download(request) + + self.assertEqual(stats['topics'], 0) + zip_file = zipfile.ZipFile(io.BytesIO(content)) + self.assertIn('bcf.version', zip_file.namelist()) diff --git a/backend/apps/ifc_validation_bff/urls.py b/backend/apps/ifc_validation_bff/urls.py index 6ac34324..7b063245 100644 --- a/backend/apps/ifc_validation_bff/urls.py +++ b/backend/apps/ifc_validation_bff/urls.py @@ -1,7 +1,7 @@ from django.urls import path from .views_legacy import get_allowlist, me, logout_view, models_paginated, upload, delete -from .views_legacy import report, report_error +from .views_legacy import report, report_bcf, report_error urlpatterns = [ @@ -12,6 +12,7 @@ path('api/', upload), path('api/delete/', delete), path('api/report/', report), + path('api/report//bcf', report_bcf), path('api/report_error', report_error), path('api/allowlist', get_allowlist), diff --git a/backend/apps/ifc_validation_bff/views_legacy.py b/backend/apps/ifc_validation_bff/views_legacy.py index 00cdfbab..72e7aa94 100644 --- a/backend/apps/ifc_validation_bff/views_legacy.py +++ b/backend/apps/ifc_validation_bff/views_legacy.py @@ -14,6 +14,7 @@ from django.db import transaction from django.db.models import Count from django.http import JsonResponse, HttpResponse, FileResponse, HttpResponseNotFound, HttpResponseNotAllowed +from django.utils.http import content_disposition_header from django.contrib.auth.models import User from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect @@ -663,6 +664,44 @@ def report(request, id: str): return response +@ensure_csrf_cookie +def report_bcf(request, id: str): + + if request.method != "GET": + logger.error(f'Received invalid request: {request}') + return HttpResponseNotAllowed(['GET']) + + # fetch current user + user = get_current_user(request) + if not user: + return create_redirect_response(login=True) + + # return 404-NotFound if report is not for current user or if it is deleted + validation_request = ValidationRequest.objects.filter(created_by__id=user.id, deleted=False, id=ValidationRequest.to_private_id(id)).first() + if not validation_request: + return HttpResponseNotFound() + + try: + import bcf # noqa: F401 - fail early with a clear message when not installed + from apps.ifc_validation.bcf_export import generate_bcf_download + except ImportError: + logger.error('BCF export requested but the bcf-client package is not installed.') + return HttpResponse(status=501, content='BCF export is not available on this server.') + + logger.info(f'Generating BCF for request {id}...') + try: + content, _ = generate_bcf_download(validation_request) + except Exception: + logger.exception(f'BCF generation failed for request {id}') + return HttpResponse(status=500, content='BCF generation failed.') + logger.info('BCF done.') + + file_name = os.path.splitext(validation_request.file_name)[0] + response = HttpResponse(content, content_type='application/zip') + response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=f'{file_name}.bcf') + return response + + @ensure_csrf_cookie @csrf_protect def report_error(request): diff --git a/backend/requirements.txt b/backend/requirements.txt index 0bb367ef..f69e3e80 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -47,6 +47,8 @@ python-ranges==1.2.2 pyproj==3.7.1 python-dateutil==2.9.0.post0 filetype==1.2.0 +# NB: bcf-client (BCF export) is installed with --no-deps in the Dockerfile and CI workflows, +# because pip would otherwise pull PyPI ifcopenshell over the pinned S3 build # dev django-debug-toolbar==6.0.0 diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 80ec1c1e..4d00306c 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -40,6 +40,9 @@ RUN --mount=type=cache,target=/root/.cache \ wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.6-8b5b400-linux64.zip" && \ mkdir -p /opt/venv/lib/python3.11/site-packages && \ unzip -d /opt/venv/lib/python3.11/site-packages /tmp/ifcopenshell_python.zip && \ + # BCF export - install with --no-deps so pip doesn't install ifcopenshell over the build above + pip install --no-cache-dir --no-deps bcf-client==0.8.5 && \ + pip install --no-cache-dir xsdata==26.2 && \ # some cleanup find / -type d -name setuptools -prune -exec rm -rf {} \; && \ find / -type d -name pip -prune -exec rm -rf {} \; && \ diff --git a/frontend/src/DashboardTable.js b/frontend/src/DashboardTable.js index ff812453..269501cb 100644 --- a/frontend/src/DashboardTable.js +++ b/frontend/src/DashboardTable.js @@ -24,6 +24,7 @@ import BrowserNotSupportedIcon from '@mui/icons-material/BrowserNotSupported'; import WarningIcon from '@mui/icons-material/Warning'; import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; import BlockIcon from '@mui/icons-material/Block'; +import bcfIcon from './bcf-icon.png'; import Link from '@mui/material/Link'; import Button from '@mui/material/Button'; @@ -413,6 +414,17 @@ export default function DashboardTable({ models }) { {row.status_signatures === 'i' && } + {!context.sandboxId && row.progress >= 100 && + + event.stopPropagation()} + > + Download BCF + + } {wrap_status(row.status_syntax, context.sandboxId ? `/sandbox/report_syntax/${context.sandboxId}/${row.code}` : `/report_syntax/${row.code}`)} diff --git a/frontend/src/Report.js b/frontend/src/Report.js index 05d92a74..54edc418 100644 --- a/frontend/src/Report.js +++ b/frontend/src/Report.js @@ -14,6 +14,8 @@ import FeedbackWidget from './FeedbackWidget'; import SelfDeclarationDialog from './SelfDeclarationDialog'; import SearchOffOutlinedIcon from '@mui/icons-material/SearchOffOutlined'; +import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'; +import Button from '@mui/material/Button'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; @@ -168,7 +170,19 @@ function Report({ kind }) { - {(kind === "syntax") && } + href={`${FETCH_PATH}/api/report/${modelCode}/bcf`} + sx={{ + alignSelf: 'flex-start', + textTransform: 'none', + color: 'primary.main', + '&:hover': { textDecoration: 'underline', backgroundColor: 'transparent' } + }} + >Download BCF (experimental)} + + {(kind === "syntax") && } diff --git a/frontend/src/bcf-icon.png b/frontend/src/bcf-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ecef547e9e05494af25812dc7e05f4bc3540f30a GIT binary patch literal 3329 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 LNkvXXu0mjfYvl?% literal 0 HcmV?d00001 From a006045f8c3375fc13b03ba9e30517e1c1c37ae5 Mon Sep 17 00:00:00 2001 From: bSI Validation Service CI/CD Date: Thu, 23 Jul 2026 20:15:51 +0000 Subject: [PATCH 2/2] update docstrings comments --- backend/apps/ifc_validation/bcf_export.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/backend/apps/ifc_validation/bcf_export.py b/backend/apps/ifc_validation/bcf_export.py index 9179f660..7df22ecd 100644 --- a/backend/apps/ifc_validation/bcf_export.py +++ b/backend/apps/ifc_validation/bcf_export.py @@ -1,16 +1,13 @@ """ -Experimental export of Validation Outcomes to BCF 2.1 (BIM Collaboration Format). +Export of utcomes to BCF 2.1 One BCF topic is created per error/warning outcome, mirroring what the report UI shows: outcomes are grouped per rule/constraint and capped at MAX_OUTCOMES_PER_RULE per group (with the total count mentioned in the topic description when capped). -Where the offending entity has an IFC GlobalId (stored in ModelInstance.fields by -the instance completion task), the topic gets a viewpoint selecting that element. +Where the offending entity has an guid, the topic gets a viewpoint selecting that element. For non-rooted entities (e.g. IfcPolyline) the nearest parent IfcProduct is looked up in the IFC file, when it is still available on disk. - -Requires the 'bcf-client' package (https://pypi.org/project/bcf-client/). """ import json import logging @@ -148,7 +145,7 @@ def _open_ifc_file(ifc_path): def generate_bcf(request: ValidationRequest, output_path: str, ifc_path: str = None) -> dict: """ - Generates a BCF 2.1 file for all error/warning outcomes of a Validation Request. + Generates a BCF (2.1) file for all error/warning outcomes of a Request. Returns a stats dict: {'topics', 'with_viewpoint', 'via_parent', 'truncated_groups', 'skipped'}. """