diff --git a/.github/workflows/ci-bcf.yml b/.github/workflows/ci-bcf.yml index 8d689cc2fd..2d82036975 100644 --- a/.github/workflows/ci-bcf.yml +++ b/.github/workflows/ci-bcf.yml @@ -19,20 +19,19 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: - python-version: '3.x' + python-version: '3.10' - name: Build package run: | cd src/bcf pip install build python -m build + - name: Test + run: | + cd src/bcf + make test - name: Publish package uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} packages_dir: src/bcf/dist - - - - - diff --git a/.gitignore b/.gitignore index b1783fad92..7c5d7a2e19 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,7 @@ _build/ # IDS Docs src/ifcopenshell-python/test/build + +# tox cache +.tox/ +*.egg-info/ diff --git a/src/bcf/.gitignore b/src/bcf/.gitignore new file mode 100644 index 0000000000..172bf5786d --- /dev/null +++ b/src/bcf/.gitignore @@ -0,0 +1 @@ +.tox diff --git a/src/bcf/Makefile b/src/bcf/Makefile new file mode 100644 index 0000000000..2baaaac259 --- /dev/null +++ b/src/bcf/Makefile @@ -0,0 +1,37 @@ +.PHONY: test +test: + tox + +.PHONY: qa +qa: + black . + isort . + pylint ./src --output-format=colorized + +.PHONY: typecheck +typecheck: + mypy src/bcf + +.PHONY: license +license: + copyright-header --license LGPL3 --copyright-holder "Andrea Ghensi " --copyright-year "2022" --copyright-software "IfcOpenShell" --copyright-software-description "BCF XML file handling" -a ./ -o ./ + +.PHONY: coverage +coverage: + coverage run --source bcf -m pytest tests + coverage html + xdg-open htmlcov/index.html + +.PHONY: clean +clean: + rm -rf htmlcov + +# TODO: make this based on xsd file presence +.PHONY: models +models: + cd src && xsdata generate -p bcf.v2.model --unnest-classes --kw-only --slots -ds Google bcf/v2/xsd + cd src && xsdata generate -p bcf.v3.model --unnest-classes --kw-only --slots -ds Google bcf/v3/xsd + +# .PHONY +# api: +# openapi-python-client generate --url https://api.swaggerhub.com/apis/buildingSMART/BCF/3.0 diff --git a/src/bcf/README.md b/src/bcf/README.md index 812c0d6f04..5aeade120f 100644 --- a/src/bcf/README.md +++ b/src/bcf/README.md @@ -1,64 +1,54 @@ # bcf -A simple Python implementation of BCF. The data model is described in `data.py`. +A simple Python implementation of BCF. Manipulation of BCF-XML is available via `bcfxml.py` and manipulation of BCF-API is available via `bcfapi.py`. -- BCF-XML version 2.1: Fully supported -- BCF-API version 2.1: Not supported, will probably tackle this after BCF-API v3.0 -- BCF-XML version 3.0: Almost fully supported, except for the documents module -- BCF-API version 3.0: Almost fully supported, except for two requests. +It tries to support BCF-XML version 2.1 and 3.0, and BCF-API 3.0. ## bcfxml -The `bcfxml` module lets you interact with the BCF-XML standard. +The `bcfxml.load` function lets you read a BCF-XML file. +It takes care of using the right version based on the "bcf.version" file contained in the BCF package. + +The BCF files are extracted and parsed on-demand, and edits are stored in memory until you call the `save` method. ```python -from bcf import bcfxml - +from bcf.bcfxml import load # Load a project -bcfxml = bcfxml.load("/path/to/file.bcf") +with load("/path/to/file.bcf") as bcfxml: + project = bcfxml.project + print(project.name) + # To edit a project, just modify the object directly + bcfxml.project.name = "New name" -# The project is also stored in the module -# project == bcfxml.project -project=bcfxml.get_project() -print(project.name) + # Get a dictionary of topics + topics = bcfxml.topics -# To edit a project, just modify the object directly -bcfxml.project.name = "New name" -bcfxml.edit_project() + for topic_handler in bcfxml.topics: + topic = topic_handler.topic + print("Topic guid is", topic.guid) + print("Topic title is", topic.title) -# The BCF file is extracted to this temporary directory -print(bcfxml.filepath) + # Fetch extra data about a topic + header = topic_handler.header + comments = topic_handler.comments + viewpoints = topic_handler.viewpoints -# Get a dictionary of topics -topics = bcfxml.get_topics() + for comment in comments: + print(comment.guid) + print(comment.comment) + print(comment.author) -# Note: topics == bcfxml.topics -for guid, topic in bcfxml.topics.items(): - print("Topic guid is", guid) - print("Topic guid is", topic.guid) - print("Topic title is", topic.title) + # Get a particular topic + topic = bcfxml.get_topic(guid) - # Fetch extra data about a topic - header = bcfxml.get_header(guid) - comments = bcfxml.get_comments(guid) - viewpoints = bcfxml.get_viewpoints(guid) + # Modify a topic + topic.title = "New title" - # Note: comments == topic.comments, and so on - for comment_guid, comment in comments.items(): - print(comment_guid) - print(comment.comment) - print(comment.author) - -# Get a particular topic -topic = bcfxml.get_topic(guid) - -# Modify a topic -topic.title = "New title" -bcfxml.edit_topic(topic) + bcfxml.save() ``` ## bcfapi @@ -92,10 +82,3 @@ print(data) data = bcf_client.get_extensions(project_id) print(data) ``` - -## Todo List - -The remaining work that needs to be completed in `bcfxml.py` and `bcfapi.py`. - -- For `bcfxml.py` two xsds support is remaining namely 'documents.xsd`and`extensions.xsd`. -- For `bcfapi.py` two requests that are `get_topics` and `get_comments` are remaining. diff --git a/src/bcf/environment.yml b/src/bcf/environment.yml new file mode 100644 index 0000000000..01fe5b7a8c --- /dev/null +++ b/src/bcf/environment.yml @@ -0,0 +1,7 @@ +name: bcf-client +channels: + - conda-forge +dependencies: + - ifcopenshell + - xsdata + - numpy diff --git a/src/bcf/pyproject.toml b/src/bcf/pyproject.toml index dadda3ed02..49ce8c4ecb 100644 --- a/src/bcf/pyproject.toml +++ b/src/bcf/pyproject.toml @@ -1,12 +1,126 @@ [build-system] requires = [ - "setuptools>=42", + "setuptools>=61", "wheel" ] build-backend = "setuptools.build_meta" +[project] +name = "bcf-client" +# author = "IfcOpenShell" +description = "BCF-XML file handler." +readme = "README.md" +requires-python = ">=3.8" +keywords = ["IFC", "BCF", "BIM", "eingineering"] +dependencies = [ + "xsdata", + "numpy", +] +version = "0.0.1" +classifiers = [ + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering", + "Topic :: Utilities", +] + +[project.urls] +Source = "https://github.com/IfcOpenShell/IfcOpenShell" +Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues" + [tool.black] line-length = 120 +extend-exclude = "model" [tool.isort] -profile = "black" \ No newline at end of file +profile = "black" +extend_skip_glob = ["src/bcf/*/model/*"] + +[tool.coverage.paths] +source = ["src"] + +[tool.coverage.run] +branch = true +source = ["bcf"] +omit = ["*/model/*"] + +[tool.coverage.report] +show_missing = true +fail_under = 65 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING", + "if __name__ == .__main__.:", + "Protocol", +] + +[tool.tox] +legacy_tox_ini = """ +[tox] +envlist = py310 +isolated_build = true +skip_missing_interpreters = true +requires = tox-conda + +[testenv] +conda_deps = + pytest + pytest-cov + coverage +conda_channels = + conda-forge +conda_env = environment.yml +commands = pytest --cov --cov-report=term tests +""" + +[tool.mypy] +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_defs = true +no_implicit_optional = true +#no_implicit_reexport = true +show_column_numbers = true +show_error_codes = true +show_error_context = true +strict_equality = true +strict_optional = true +warn_redundant_casts = true +#warn_return_any = true +warn_unreachable = true +warn_unused_configs = true +warn_unused_ignores = true +exclude= "src/bcf/v(2|3)/model" +plugins = "numpy.typing.mypy_plugin" + +[[tool.mypy.overrides]] +module = "tests" +disallow_untyped_decorators = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = [ + "pytest", + "pytest_mock", + "ifcopenshell", + "ifcopenshell.*", +] +ignore_missing_imports = true + +[tool.pylint.main] +ignore = ["model"] +ignored-modules = ["bcf.v2.model", "bcf.v3.model", "xsdata"] +jobs = 0 +disable="all" +enable="E" # B,B9,BLK,C,D,E,F,I,N,S,W + +[tool.pylint.design] +max-args = 10 +max-attributes = 10 + +[tool.pylint.format] +expected-line-ending-format = "LF" +max-line-length = 120 diff --git a/src/bcf/requirements-dev.txt b/src/bcf/requirements-dev.txt new file mode 100644 index 0000000000..12033556c7 --- /dev/null +++ b/src/bcf/requirements-dev.txt @@ -0,0 +1,7 @@ +black +mypy +pylint +isort +xsdata +tox +tox-conda diff --git a/src/bcf/requirements.txt b/src/bcf/requirements.txt deleted file mode 100644 index 648a39d330..0000000000 --- a/src/bcf/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -xmlschema \ No newline at end of file diff --git a/src/bcf/setup.cfg b/src/bcf/setup.cfg deleted file mode 100644 index 3eba915750..0000000000 --- a/src/bcf/setup.cfg +++ /dev/null @@ -1,37 +0,0 @@ -[metadata] -name=bcf-client -version=0.0.1 -author = Ifcopenshell -description = A simple Python implementation of BCF -url = https://github.com/IfcOpenShell/IfcOpenShell -project_urls = - Code=https://github.com/IfcOpenShell/IfcOpenShell - Issues=https://github.com/IfcOpenShell/IfcOpenShell/issues -long_description = file: README.md -long_description_content_type = text/markdown -classifiers = - License :: OSI Approved :: GNU General Public License v3 (GPLv3) - Operating System :: OS Independent - Programming Language :: Python :: 3 - Topic :: Scientific/Engineering - Topic :: Utilities -keywords = - Python - file formats - engineering - -[options] -package_dir = - = src -packages = find: -python_requires = >=3 -install_requires= - xmlschema -include_package_data = True - -[options.packages.find] -where = src - -[flake8] -max-line-length = 120 -ignore = E24, E121, E123, E126, E203, E226, E704, E741, W503, W504 diff --git a/src/bcf/setup.py b/src/bcf/setup.py new file mode 100644 index 0000000000..606849326a --- /dev/null +++ b/src/bcf/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/src/bcf/src/bcf/bcfxml.py b/src/bcf/src/bcf/bcfxml.py index e9bcde349f..00a2871e99 100644 --- a/src/bcf/src/bcf/bcfxml.py +++ b/src/bcf/src/bcf/bcfxml.py @@ -1,61 +1,69 @@ -# BCF - BCF Python library -# Copyright (C) 2021 Prabhat Singh -# -# This file is part of BCF. -# -# BCF 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. -# -# BCF 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 BCF. If not, see . +""" +BCF - BCF Python library +Copyright (C) 2021 Prabhat Singh +Copyright (C) 2022 Andrea Ghensi +This file is part of BCF. -import os.path +BCF 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. + +BCF 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 BCF. If not, see . +""" import zipfile -import tempfile -from xml.dom import minidom +from pathlib import Path +from typing import Optional, Union + +from bcf.v2.bcfxml import BcfXml as BcfXml2 +from bcf.v3.bcfxml import BcfXml as BcfXml3 +from bcf.v3.model import Version +from bcf.xml_parser import AbstractXmlParserSerializer, XmlParserSerializer -def load(filepath): - filepath = extract_project(filepath) - if os.path.isfile(os.path.join(filepath, "bcf.version")): - version_path = os.path.join(filepath, "bcf.version") - version_id = get_version(version_path) - # TODO: we actually coded it for 2.1, let's check the difference between 2.0 and 2.1 - if version_id == "2.1" or version_id == "2.0": - from bcf.v2.bcfxml import BcfXml +def load( + filepath: Path, xml_handler: Optional[AbstractXmlParserSerializer] = None +) -> Optional[Union[BcfXml2, BcfXml3]]: + """ + Load a BCF file. - bcfxml = BcfXml() - bcfxml.filepath = filepath - return bcfxml - elif version_id == "3.0": - from bcf.v3.bcfxml import BcfXml + Args: + filepath: The path to the BCF file. - bcfxml = BcfXml() - bcfxml.filepath = filepath - return bcfxml - else: - raise Exception(f"Version {version_id} not supported.") + Returns: + The loaded BCF file. + + Raises: + ValueError: If the BCF version is not supported. + """ + xml_handler = xml_handler or XmlParserSerializer() + version_id = _get_version(filepath, xml_handler) + if version_id in {"2.1", "2.0"}: + return BcfXml2.load(filepath, xml_handler) + if version_id == "3.0": + return BcfXml3.load(filepath, xml_handler) + raise ValueError(f"Version {version_id} not supported.") -def get_version(version_path): - xmlparse = minidom.parse(version_path) - version_el = xmlparse.getElementsByTagName("Version")[0] - version = version_el.getAttribute("VersionId") - return version +def _get_version(filepath: Union[str, Path], xml_handler: Optional[AbstractXmlParserSerializer] = None) -> str: + """ + Returns the version of the BCF file. + Args: + filepath: The path to the BCF file. + xml_handler: The XML handler. If none is given, XmlParserSerializer is used. -def extract_project(filepath): - if not filepath: - return - zip_file = zipfile.ZipFile(filepath) - filepath = tempfile.mkdtemp() - zip_file.extractall(filepath) - return filepath + Returns: + The version of the BCF file. + """ + xml_handler = xml_handler or XmlParserSerializer() + with zipfile.ZipFile(filepath) as bcf_zip: + version = xml_handler.parse(bcf_zip.read("bcf.version"), Version) + return version.version_id diff --git a/src/bcf/src/bcf/geometry.py b/src/bcf/src/bcf/geometry.py new file mode 100644 index 0000000000..130befcdb7 --- /dev/null +++ b/src/bcf/src/bcf/geometry.py @@ -0,0 +1,44 @@ +import numpy as np +from numpy.typing import NDArray + + +def calc_camera_vectors( + elem_placement: NDArray[np.float_], +) -> tuple[NDArray[np.float_], NDArray[np.float_], NDArray[np.float_]]: + """ + Calculate the vectors of a camera pointing to an element. + + Args: + elem_placement: Placement matrix of an element. + + Returns: + Camera position, direction and up vectors + """ + target_position = elem_placement[:3, 3] + camera_position = target_position + np.array((5, 5, 5)) + camera_direction = unit_vector(camera_position - target_position) + camera_right = unit_vector(np.cross(np.array([0.0, 0.0, 1.0]), camera_direction)) + camera_up = unit_vector(np.cross(camera_direction, camera_right)) + rotation_transform = np.eye(4) + rotation_transform[0, :3] = camera_right + rotation_transform[1, :3] = camera_up + rotation_transform[2, :3] = camera_direction + translation_transform = np.eye(4) + translation_transform[:3, -1] = -camera_position + look_at_transform = np.matmul(rotation_transform, translation_transform) + mat = np.linalg.inv(look_at_transform) + return camera_position, -mat[:3, 2], mat[:3, 1] + + +def unit_vector(v: NDArray[np.float_]) -> NDArray[np.float_]: + """ + Return the unit vector of a vector. + + Args: + v: vector + + Returns: + unit vector. + """ + norm = np.linalg.norm(v) + return v if norm == 0 else v / norm diff --git a/src/bcf/src/bcf/inmemory_zipfile.py b/src/bcf/src/bcf/inmemory_zipfile.py new file mode 100644 index 0000000000..c78e9f9a74 --- /dev/null +++ b/src/bcf/src/bcf/inmemory_zipfile.py @@ -0,0 +1,55 @@ +""" +In Memory Zip File management, taken from ruamel.std.zipfile + +Copyright (c) 2017-2020 Anthon van der Neut, Ruamel bvba + +original idea from https://stackoverflow.com/a/19722365/1307905 +""" +import zipfile +from io import BytesIO +from os import PathLike +from pathlib import Path +from typing import Any, Optional, Protocol + + +class ZipFileInterface(Protocol): + def writestr(self, filename_in_zip: str | zipfile.ZipInfo, file_contents: bytes | str) -> None: + ... + + +class InMemoryZipFile: + def __init__( + self, file_name: Optional[str | Path] = None, compression: int = zipfile.ZIP_DEFLATED, debug: int = 0 + ) -> None: + # Create the in-memory file-like object + self._file_name: Optional[str | Path] = str(file_name) if hasattr(file_name, "_from_parts") else file_name + self.in_memory_data = BytesIO() + # Create the in-memory zipfile + self.in_memory_zip = zipfile.ZipFile(self.in_memory_data, "w", compression, False) + self.in_memory_zip.debug = debug + + def writestr(self, filename_in_zip: str | zipfile.ZipInfo, file_contents: bytes | str) -> None: + """Appends a file with name filename_in_zip and contents of + file_contents to the in-memory zip.""" + self.in_memory_zip.writestr(filename_in_zip, file_contents) + + def write_to_file(self, filename: str | bytes | PathLike[str] | PathLike[bytes] | int) -> None: + """Writes the in-memory zip to a file.""" + # Mark the files as having been created on Windows so that + # Unix permissions are not inferred as 0000 + for zfile in self.in_memory_zip.filelist: + zfile.create_system = 0 + self.in_memory_zip.close() + with open(filename, "wb") as f: + f.write(self.data) + + @property + def data(self) -> bytes: + return self.in_memory_data.getvalue() + + def __enter__(self) -> "InMemoryZipFile": + return self + + def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + if self._file_name: + self.write_to_file(self._file_name) diff --git a/src/bcf/src/bcf/py.typed b/src/bcf/src/bcf/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bcf/src/bcf/v2/__init__.py b/src/bcf/src/bcf/v2/__init__.py index adb79b4034..d4e76a1eb8 100644 --- a/src/bcf/src/bcf/v2/__init__.py +++ b/src/bcf/src/bcf/v2/__init__.py @@ -1,4 +1,3 @@ - # BCF - BCF Python library # Copyright (C) 2020, 2021 Dion Moult # @@ -16,4 +15,3 @@ # # You should have received a copy of the GNU Lesser General Public License # along with BCF. If not, see . - diff --git a/src/bcf/src/bcf/v2/bcfxml.py b/src/bcf/src/bcf/v2/bcfxml.py index 61a91d0267..4702526379 100644 --- a/src/bcf/src/bcf/v2/bcfxml.py +++ b/src/bcf/src/bcf/v2/bcfxml.py @@ -1,790 +1,273 @@ - -# BCF - BCF Python library -# Copyright (C) 2020, 2021 Dion Moult -# -# This file is part of BCF. -# -# BCF 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. -# -# BCF 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 BCF. If not, see . - -import os +"""BCF XML V2 handler.""" import uuid -import shutil +import warnings import zipfile -import logging -import tempfile -import bcf.v2.data -from datetime import datetime -from xml.dom import minidom -from xmlschema import XMLSchema -from contextlib import contextmanager -from shutil import copyfile +from pathlib import Path +from typing import Any, Optional, TypeVar +import bcf.v2.model as mdl +from bcf.inmemory_zipfile import InMemoryZipFile, ZipFileInterface +from bcf.v2.topic import TopicHandler +from bcf.xml_parser import AbstractXmlParserSerializer, XmlParserSerializer -cwd = os.path.dirname(os.path.realpath(__file__)) - - -@contextmanager -def cd(newdir): - prevdir = os.getcwd() - os.chdir(os.path.expanduser(newdir)) - try: - yield - finally: - os.chdir(prevdir) +T = TypeVar("T") class BcfXml: - def __init__(self): - self.filepath = None - self.logger = logging.getLogger("bcfxml") - self.author = "john@doe.com" - self.project = bcf.v2.data.Project() - self.version = "2.1" - self.topics = {} + """BCF XML handler.""" - def new_project(self): - self.project.project_id = str(uuid.uuid4()) - self.project.name = "New Project" - self.topics = {} - if self.filepath: - self.close_project() - self.filepath = tempfile.mkdtemp() - self.edit_project() - self.edit_version() + def __init__( + self, filename: Optional[Path] = None, xml_handler: Optional[AbstractXmlParserSerializer] = None + ) -> None: + self._filename = filename + self._xml_handler = xml_handler or XmlParserSerializer() + self._version: Optional[mdl.Version] = None + self._project_info: Optional[mdl.ProjectExtension] = None + self._topics: dict[str, TopicHandler] = {} + self._extension_schema: Optional[bytes] = None + self._zip_file = self._load_zip_file() - def get_project(self, filepath=None): - if not filepath: - return self.project - if os.path.isfile(os.path.join(self.filepath, "project.bcfp")): - data = self._read_xml("project.bcfp", "project.xsd") - self.project.extension_schema = data["ExtensionSchema"] - if "Project" in data: - self.project.project_id = data["Project"]["@ProjectId"] - self.project.name = data["Project"].get("Name") + def __enter__(self) -> "BcfXml": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def __del__(self) -> None: + self.close() + + def close(self) -> None: + if self._zip_file: + self._zip_file.close() + + def _load_zip_file(self) -> Optional[zipfile.ZipFile]: + return zipfile.ZipFile(self._filename) if self._filename else None + + @property + def version(self) -> mdl.Version: + """Bcf Version.""" + if not self._version: + self._version = ( + self._xml_handler.parse(self._zip_file.read("bcf.version"), mdl.Version) + if self._zip_file + else mdl.Version(version_id="3.0") + ) + return self._version + + @version.setter + def version(self, value: mdl.Version) -> None: + self._version = value + + @property + def project_info(self) -> Optional[mdl.ProjectExtension]: + """BCF project information.""" + if not self._project_info and self._zip_file and zipfile.Path(self._zip_file, "project.bcfp").exists(): + self._project_info = self._xml_handler.parse(self._zip_file.read("project.bcfp"), mdl.ProjectExtension) + return self._project_info + + @project_info.setter + def project_info(self, value: Optional[mdl.ProjectExtension]) -> None: + self._project_info = value + + @property + def project(self) -> Optional[mdl.Project]: + """BCF project.""" + return self.project_info.project if self.project_info else None + + @property + def extension_schema(self) -> Optional[bytes]: + if not self._extension_schema and self._zip_file and self.project_info and self.project_info.extension_schema: + self._extension_schema = self._zip_file.read(self.project_info.extension_schema) + return self._extension_schema + + @extension_schema.setter + def extension_schema(self, value: bytes) -> None: + self._extension_schema = value + + @property + def topics(self) -> dict[str, TopicHandler]: + """BCF topics.""" + if not self._topics and self._zip_file: + self._topics = self._load_topics(self._zip_file, self._xml_handler) + return self._topics + + def _load_topics( + self, zip_file: zipfile.ZipFile, xml_handler: AbstractXmlParserSerializer + ) -> dict[str, TopicHandler]: + topics = {} + for topic_dir in zipfile.Path(zip_file).iterdir(): + if not topic_dir.is_dir(): + continue + markup_path = topic_dir.joinpath("markup.bcf") + if not markup_path.exists(): + continue + topics[topic_dir.name] = TopicHandler(topic_dir, xml_handler) + return topics + + @classmethod + def load(cls, filename: Path, xml_handler: Optional[AbstractXmlParserSerializer] = None) -> Optional["BcfXml"]: + """ + Create a BcfXml object from a file. + + Args: + filename: Path to the file. + xml_handler: XML parser and serializer. + + Returns: + A BcfXml object with the file contents. + + Raises: + ValueError: If the file name is null or empty + """ + if not filename: + raise ValueError("filename is required") + xml_handler = xml_handler or XmlParserSerializer() + return cls(xml_handler=xml_handler, filename=filename) + + @classmethod + def create_new( + cls, + project_name: Optional[str] = None, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> "BcfXml": + """ + Create a new BcfXml object. + + Args: + project_name: The name of the project. + xml_handler: XML parser and serializer. + + Returns: + A new BcfXml object. + """ + instance = cls(xml_handler=xml_handler or XmlParserSerializer()) + instance.project_info = mdl.ProjectExtension( + project=mdl.Project(name=project_name, project_id=str(uuid.uuid4())), extension_schema="" + ) + return instance + + def save(self, filename: Optional[Path] = None, keep_open: bool = False) -> None: + """Save the BCF file to the given filename.""" + if not filename and not self._filename: + raise ValueError("No file name specified, cannot save BCF file.") + if filename: + self._filename = filename + with InMemoryZipFile(self._filename) as bcf_zip: + self._save_project(bcf_zip) + self._save_version(bcf_zip) + self._save_topics(bcf_zip) + if keep_open: + self._zip_file = self._load_zip_file() + + def _save_project(self, destination_zip: ZipFileInterface) -> None: + self._smart_save_xml(destination_zip, self._project_info, "project.bcfp") + if self.extension_schema and self.project_info: + destination_zip.writestr(self.project_info.extension_schema, self.extension_schema) + + def _save_version(self, destination_zip: ZipFileInterface) -> None: + if not self._version and self._zip_file: + destination_zip.writestr("bcf.version", self._zip_file.read("bcf.version")) + else: + self._save_xml(destination_zip, "bcf.version", self.version) + + def _smart_save_xml(self, destination_zip: ZipFileInterface, item: Any, target: str) -> None: + if item: + self._save_xml(destination_zip, target, item) + elif self._zip_file and zipfile.Path(self._zip_file, target).exists(): + destination_zip.writestr(target, self._zip_file.read(target)) + + def _save_xml(self, destination_zip: ZipFileInterface, inner_file: str, xml_obj: Any) -> None: + destination_zip.writestr(inner_file, self._xml_handler.serialize(xml_obj)) + + def _save_topics(self, destination_zip: ZipFileInterface) -> None: + for topic_handler in self.topics.values(): + topic_handler.save(destination_zip) + + def add_topic( + self, title: str, description: str, author: str, topic_type: str = "", topic_status: str = "" + ) -> TopicHandler: + """ + Add a new topic to the BCF. + + Args: + title: The title of the topic. + description: The description of the topic. + author: The author of the topic. + topic_type: The type of the topic. + topic_status: The status of the topic. + + Returns: + The newly created topic wrapped inside a TopicHandler object. + """ + topic_handler = TopicHandler.create_new( + title, + description, + author, + topic_type=topic_type, + topic_status=topic_status, + xml_handler=self._xml_handler, + ) + self.topics[topic_handler.guid] = topic_handler + return topic_handler + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BcfXml): + raise TypeError("Equality needs a BcfXml object.") + return self.version == other.version and self.project_info == other.project_info + + # region Deprecated methods + def new_project(self) -> "BcfXml": + """Deprecated method.""" + warnings.warn("new_project is deprecated, use create_new instead.", DeprecationWarning) + return self.create_new() + + def get_project(self, _filepath: Optional[str] = None) -> Optional[mdl.Project]: + """Deprecated method.""" + warnings.warn("get_project is deprecated, use project_info.project instead.", DeprecationWarning) return self.project - def edit_project(self): - self.document = minidom.Document() - root = self._create_element(self.document, "ProjectExtension") - project = self._create_element(root, "Project", {"ProjectId": self.project.project_id}) - self._create_element(project, "Name", text=self.project.name) - self._create_element(root, "ExtensionSchema", text="extensions.xsd") - with open(os.path.join(self.filepath, "project.bcfp"), "wb") as f: - f.write(self.document.toprettyxml(encoding="utf-8")) + def edit_project(self) -> None: + """Deprecated method.""" + warnings.warn("edit_project is deprecated, there's no need to use it.", DeprecationWarning) - def save_project(self, filepath): - with cd(self.filepath): - zip_file = zipfile.ZipFile(filepath, "w", zipfile.ZIP_DEFLATED) - for root, dirs, files in os.walk("./"): - for file in files: - zip_file.write(os.path.join(root, file)) - zip_file.close() + def save_project(self, filepath: Path) -> None: + """Deprecated method.""" + warnings.warn("save_project is deprecated, use save instead.", DeprecationWarning) + self.save(filepath) - def get_version(self): - data = self._read_xml("bcf.version", "version.xsd") - self.version = data["@VersionId"] - return self.version + def get_version(self) -> Optional[str]: + warnings.warn("get_version is deprecated, use version.version_id instead.", DeprecationWarning) + return self.version.version_id - def edit_version(self): - self.document = minidom.Document() - root = self._create_element(self.document, "Version", {"VersionId": self.version}) - version = self._create_element(root, "DetailedVersion", text=self.version) - with open(os.path.join(self.filepath, "bcf.version"), "wb") as f: - f.write(self.document.toprettyxml(encoding="utf-8")) + def edit_version(self) -> None: + """Deprecated method.""" + warnings.warn("edit_version is deprecated, there's no need to use it.", DeprecationWarning) - def get_topics(self): - self.topics = {} - topics = [] - subdirs = [] - for (dirpath, dirnames, filenames) in os.walk(self.filepath): - subdirs = dirnames - break - for subdir in subdirs: - try: - uuid.UUID(subdir) - except ValueError: - continue - if not os.path.exists(os.path.join(self.filepath, subdir, "markup.bcf")): - continue - self.topics[subdir] = self.get_topic(subdir) + def get_topics(self) -> dict[str, TopicHandler]: + """Deprecated method.""" + warnings.warn("get_topics is deprecated, use topics instead.", DeprecationWarning) return self.topics - def get_header(self, guid): - data = self._read_xml(os.path.join(guid, "markup.bcf"), "markup.xsd") - if "Header" not in data: - return - header = bcf.v2.data.Header() - for item in data["Header"]["File"]: - header_file = bcf.v2.data.HeaderFile() - optional_keys = { - "filename": "Filename", - "date": "Date", - "reference": "Reference", - "ifc_project": "@IfcProject", - "ifc_spatial_structure_element": "@IfcSpatialStructureElement", - "is_external": "@isExternal", - } - for key, value in optional_keys.items(): - if value in item: - setattr(header_file, key, item[value]) - header.files.append(header_file) - self.topics[guid].header = header - return header + def get_topic(self, guid: str) -> TopicHandler: + """Return a topic by its GUID.""" + warnings.warn("get_topic is deprecated, use topics[guid] instead", DeprecationWarning) + return self.topics[guid] - def get_topic(self, guid): - if guid in self.topics: - return self.topics[guid] - data = self._read_xml(os.path.join(guid, "markup.bcf"), "markup.xsd") - topic = bcf.v2.data.Topic() - self.topics[guid] = topic + def get_header(self, guid: str) -> Optional[mdl.Header]: + """Return the header of a Topic by its GUID.""" + return self.topics[guid].header - mandatory_keys = { - "guid": "@Guid", - "title": "Title", - "creation_date": "CreationDate", - "creation_author": "CreationAuthor", - } - for key, value in mandatory_keys.items(): - setattr(topic, key, data["Topic"][value]) + def edit_topic(self) -> None: + """Deprecated method.""" + warnings.warn("edit_topic is deprecated, there's no need to use it.", DeprecationWarning) - optional_keys = { - "priority": "Priority", - "index": "Index", - "labels": "Labels", - "reference_links": "ReferenceLink", - "modified_date": "ModifiedDate", - "modified_author": "ModifiedAuthor", - "due_date": "DueDate", - "assigned_to": "AssignedTo", - "stage": "Stage", - "description": "Description", - "topic_status": "@TopicStatus", - "topic_type": "@TopicType", - } - for key, value in optional_keys.items(): - if value in data["Topic"]: - setattr(topic, key, data["Topic"][value]) + def add_comment(self, _topic: mdl.Topic, _comment: Optional[mdl.Comment] = None) -> None: + """Deprecated method.""" + warnings.warn("add_comment is deprecated, use topics methods instead.", DeprecationWarning) - if "BimSnippet" in data["Topic"]: - bim_snippet = bcf.v2.data.BimSnippet() - keys = { - "snippet_type": "@SnippetType", - "is_external": "@IsExternal", - "reference": "Reference", - "reference_schema": "ReferenceSchema", - } - for key, value in keys.items(): - if value in data["Topic"]["BimSnippet"]: - setattr(bim_snippet, key, data["Topic"]["BimSnippet"][value]) - topic.bim_snippet = bim_snippet + def edit_comment(self) -> None: + """Deprecated method.""" + warnings.warn("edit_comment is deprecated, there's no need to use it.", DeprecationWarning) - if "DocumentReference" in data["Topic"]: - for item in data["Topic"]["DocumentReference"]: - document_reference = bcf.v2.data.DocumentReference() - keys = { - "referenced_document": "ReferencedDocument", - "is_external": "@IsExternal", - "guid": "@Guid", - "description": "Description", - } - for key, value in keys.items(): - if value in item: - setattr(document_reference, key, item[value]) - topic.document_references.append(document_reference) - - if "RelatedTopic" in data["Topic"]: - for item in data["Topic"]["RelatedTopic"]: - related_topic = bcf.v2.data.RelatedTopic() - related_topic.guid = item["@Guid"] - topic.related_topics.append(related_topic) - return topic - - def add_topic(self, topic=None): - if topic is None: - topic = bcf.v2.data.Topic() - if not topic.guid: - topic.guid = str(uuid.uuid4()) - if not topic.title: - topic.title = "New Topic" - os.mkdir(os.path.join(self.filepath, topic.guid)) - self.edit_topic(topic) - return topic - - def edit_topic(self, topic): - if not topic.creation_date: - topic.creation_date = datetime.utcnow().replace(microsecond=0).isoformat() - topic.creation_author = self.author - else: - topic.modified_date = datetime.utcnow().replace(microsecond=0).isoformat() - topic.modified_author = self.author - - self.document = minidom.Document() - root = self._create_element(self.document, "Markup") - - self.write_header(topic.header, root) - - topic_el = self._create_element( - root, - "Topic", - { - "Guid": topic.guid, - "TopicType": topic.topic_type, - "TopicStatus": topic.topic_status, - }, - ) - - for reference_link in topic.reference_links: - self._create_element(topic_el, "ReferenceLink", text=reference_link) - - text_map = { - "Title": topic.title, - "Priority": topic.priority, - "Index": topic.index, - } - for key, value in text_map.items(): - if value: - self._create_element(topic_el, key, text=value) - - for label in topic.labels: - self._create_element(topic_el, "Labels", text=label) - - text_map = { - "CreationDate": topic.creation_date, - "CreationAuthor": topic.creation_author, - "ModifiedDate": topic.modified_date, - "ModifiedAuthor": topic.modified_author, - "DueDate": topic.due_date, - "AssignedTo": topic.assigned_to, - "Stage": topic.stage, - "Description": topic.description, - } - for key, value in text_map.items(): - if value: - self._create_element(topic_el, key, text=value) - - if topic.bim_snippet: - bim_snippet = self._create_element( - topic_el, - "BimSnippet", - {"SnippetType": topic.bim_snippet.snippet_type, "isExternal": topic.bim_snippet.is_external}, - ) - self._create_element(bim_snippet, "Reference", text=topic.bim_snippet.reference) - self._create_element(bim_snippet, "ReferenceSchema", text=topic.bim_snippet.reference_schema) - for reference in topic.document_references: - reference_el = self._create_element( - topic_el, "DocumentReference", {"Guid": reference.guid, "isExternal": reference.is_external} - ) - self._create_element(reference_el, "ReferencedDocument", text=reference.referenced_document) - self._create_element(reference_el, "Description", text=reference.description) - for related_topic in topic.related_topics: - self._create_element(topic_el, "RelatedTopic", {"Guid": related_topic.guid}) - - self.write_comments(topic.comments, root) - self.write_viewpoints(topic.viewpoints, root, topic) - - with open(os.path.join(self.filepath, topic.guid, "markup.bcf"), "wb") as f: - f.write(self.document.toprettyxml(encoding="utf-8")) - - def write_header(self, header, root): - if not header or not header.files: - return - header_el = self._create_element(root, "Header") - for f in header.files: - file_el = self._create_element( - header_el, - "File", - { - "IfcProject": f.ifc_project, - "IfcSpatialStructureElement": f.ifc_spatial_structure_element, - "isExternal": f.is_external, - }, - ) - if f.filename: - self._create_element(file_el, "Filename", text=f.filename) - if f.date: - self._create_element(file_el, "Date", text=f.date) - if f.reference: - self._create_element(file_el, "Reference", text=f.reference) - - def write_comments(self, comments, root): - for comment in comments.values(): - comment_el = self._create_element(root, "Comment", {"Guid": comment.guid}) - text_map = { - "Date": comment.date, - "Author": comment.author, - "Comment": comment.comment, - "ModifiedDate": comment.modified_date, - "ModifiedAuthor": comment.modified_author, - } - for key, value in text_map.items(): - if value: - self._create_element(comment_el, key, text=value) - if comment.viewpoint: - self._create_element(comment_el, "Viewpoint", {"Guid": comment.viewpoint.guid}) - - def add_comment(self, topic, comment=None): - if comment is None: - comment = bcf.v2.data.Comment() - if not comment.guid: - comment.guid = str(uuid.uuid4()) - if not comment.comment: - comment.comment = "'Free software' is a matter of liberty, not price. To understand the concept, you should think of 'free' as in 'free speech,' not as in 'free beer'." - topic.comments[comment.guid] = comment - self.edit_comment(comment, topic) - - def edit_comment(self, comment, topic): - if not comment.date: - comment.date = datetime.utcnow().replace(microsecond=0).isoformat() - comment.author = self.author - else: - comment.modified_date = datetime.utcnow().replace(microsecond=0).isoformat() - comment.modified_author = self.author - self.edit_topic(topic) - - def delete_comment(self, guid, topic): - if guid in topic.comments: - del topic.comments[guid] - self.edit_topic(topic) - - def delete_topic(self, guid): - if guid in self.topics: - del self.topics[guid] - shutil.rmtree(os.path.join(self.filepath, guid)) - - def write_viewpoints(self, viewpoints, root, topic): - for viewpoint in viewpoints.values(): - viewpoint_el = self._create_element(root, "Viewpoints", {"Guid": viewpoint.guid}) - text_map = {"Viewpoint": viewpoint.viewpoint, "Snapshot": viewpoint.snapshot, "Index": viewpoint.index} - for key, value in text_map.items(): - if value: - self._create_element(viewpoint_el, key, text=value) - self.write_viewpoint(viewpoint, topic) - - def write_viewpoint(self, viewpoint, topic): - document = minidom.Document() - root = self._create_element(document, "VisualizationInfo", {"Guid": viewpoint.guid}) - self.write_viewpoint_components(viewpoint, root) - self.write_viewpoint_orthogonal_camera(viewpoint, root) - self.write_viewpoint_perspective_camera(viewpoint, root) - self.write_viewpoint_lines(viewpoint, root) - self.write_viewpoint_clipping_planes(viewpoint, root) - self.write_viewpoint_bitmaps(viewpoint, root) - with open(os.path.join(self.filepath, topic.guid, viewpoint.viewpoint), "wb") as f: - f.write(document.toprettyxml(encoding="utf-8")) - - def write_viewpoint_components(self, viewpoint, parent): - if not viewpoint.components: - return - components_el = self._create_element(parent, "Components") - if viewpoint.components.view_setup_hints: - view_setup_hints = self._create_element( - components_el, - "ViewSetupHints", - { - "SpacesVisible": viewpoint.components.view_setup_hints.spaces_visible, - "SpaceBoundariesVisible": viewpoint.components.view_setup_hints.space_boundaries_visible, - "OpeningsVisible": viewpoint.components.view_setup_hints.openings_visible, - }, - ) - if viewpoint.components.selection: - selection_el = self._create_element(components_el, "Selection") - for selection in viewpoint.components.selection: - self.write_component(selection, selection_el) - visibility = self._create_element( - components_el, "Visibility", {"DefaultVisibility": viewpoint.components.visibility.default_visibility} - ) - if viewpoint.components.visibility.exceptions: - exceptions_el = self._create_element(visibility, "Exceptions") - for exception in viewpoint.components.visibility.exceptions: - self.write_component(exception, exceptions_el) - if viewpoint.components.coloring: - coloring_el = self._create_element(components_el, "Coloring") - for color in viewpoint.components.coloring: - color_el = self._create_element(coloring_el, "Color", {"Color": color.color}) - for component in color.components: - self.write_component(component, color_el) - - def write_viewpoint_orthogonal_camera(self, viewpoint, parent): - if not viewpoint.orthogonal_camera: - return - camera = viewpoint.orthogonal_camera - camera_el = self._create_element(parent, "OrthogonalCamera") - camera_view_point = self._create_element(camera_el, "CameraViewPoint") - self.write_vector(camera_view_point, camera.camera_view_point) - camera_direction = self._create_element(camera_el, "CameraDirection") - self.write_vector(camera_direction, camera.camera_direction) - camera_up_vector = self._create_element(camera_el, "CameraUpVector") - self.write_vector(camera_up_vector, camera.camera_up_vector) - self._create_element(camera_el, "ViewToWorldScale", text=camera.view_to_world_scale) - - def write_viewpoint_perspective_camera(self, viewpoint, parent): - if not viewpoint.perspective_camera: - return - camera = viewpoint.perspective_camera - camera_el = self._create_element(parent, "PerspectiveCamera") - camera_view_point = self._create_element(camera_el, "CameraViewPoint") - self.write_vector(camera_view_point, camera.camera_view_point) - camera_direction = self._create_element(camera_el, "CameraDirection") - self.write_vector(camera_direction, camera.camera_direction) - camera_up_vector = self._create_element(camera_el, "CameraUpVector") - self.write_vector(camera_up_vector, camera.camera_up_vector) - self._create_element(camera_el, "FieldOfView", text=camera.field_of_view) - - def write_viewpoint_lines(self, viewpoint, parent): - if not viewpoint.lines: - return - lines_el = self._create_element(parent, "Lines") - for line in viewpoint.lines: - line_el = self._create_element(lines_el, "Line") - start_point_el = self._create_element(line_el, "StartPoint") - self.write_vector(start_point_el, line.start_point) - end_point_el = self._create_element(line_el, "EndPoint") - self.write_vector(end_point_el, line.end_point) - - def write_viewpoint_clipping_planes(self, viewpoint, parent): - if not viewpoint.clipping_planes: - return - planes_el = self._create_element(parent, "ClippingPlanes") - for plane in viewpoint.clipping_planes: - plane_el = self._create_element(planes_el, "ClippingPlane") - location_el = self._create_element(plane_el, "Location") - self.write_vector(location_el, plane.location) - direction_el = self._create_element(plane_el, "Direction") - self.write_vector(direction_el, plane.direction) - - def write_viewpoint_bitmaps(self, viewpoint, parent): - if not viewpoint.bitmaps: - return - for bitmap in viewpoint.bitmaps: - bitmap_el = self._create_element(parent, "Bitmap") - - text_map = {"Bitmap": bitmap.bitmap_format, "Reference": bitmap.reference} - for key, value in text_map.items(): - self._create_element(bitmap_el, key, text=value) - - location_el = self._create_element(bitmap_el, "Location") - self.write_vector(location_el, bitmap.location) - normal_el = self._create_element(bitmap_el, "Normal") - self.write_vector(normal_el, bitmap.normal) - up_el = self._create_element(bitmap_el, "Up") - self.write_vector(up_el, bitmap.up) - - self._create_element(bitmap_el, "Height", text=bitmap.height) - - def write_vector(self, parent, from_obj): - self._create_element(parent, "X", text=from_obj.x) - self._create_element(parent, "Y", text=from_obj.y) - self._create_element(parent, "Z", text=from_obj.z) - - def write_component(self, data, parent): - component_el = self._create_element(parent, "Component", {"IfcGuid": data.ifc_guid}) - text_map = {"OriginatingSystem": data.originating_system, "AuthoringToolId": data.authoring_tool_id} - for key, value in text_map.items(): - if value: - self._create_element(component_el, key, text=value) - - def add_viewpoint(self, topic, viewpoint=None): - if not viewpoint: - viewpoint = bcf.v2.data.Viewpoint() - if not viewpoint.guid: - viewpoint.guid = str(uuid.uuid4()) - if not viewpoint.viewpoint: - viewpoint.viewpoint = f"{viewpoint.guid}.bcfv" - if viewpoint.snapshot: - topic_filepath = os.path.join(self.filepath, topic.guid) - filepath = os.path.join(topic_filepath, viewpoint.snapshot) - if not os.path.exists(filepath): - filename = viewpoint.guid + os.path.splitext(viewpoint.snapshot)[-1] - copyfile(viewpoint.snapshot, os.path.join(topic_filepath, filename)) - viewpoint.snapshot = filename - topic.viewpoints[viewpoint.guid] = viewpoint - self.edit_topic(topic) - - def delete_viewpoint(self, guid, topic): - if guid not in topic.viewpoints: - return - viewpoint = topic.viewpoints[guid] - if viewpoint.snapshot: - filepath = os.path.join(self.filepath, topic.guid, viewpoint.snapshot) - if os.path.exists(filepath): - os.remove(filepath) - if viewpoint.viewpoint: - filepath = os.path.join(self.filepath, topic.guid, viewpoint.viewpoint) - if os.path.exists(filepath): - os.remove(filepath) - for bitmap in viewpoint.bitmaps: - if not bitmap.reference: - continue - filepath = os.path.join(self.filepath, topic.guid, bitmap.reference) - if os.path.exists(filepath): - os.remove(filepath) - del topic.viewpoints[guid] - self.edit_topic(topic) - - def delete_file(self, topic, index): - if not topic.header: - return - f = topic.header.files.pop(index) - filepath = os.path.join(self.filepath, topic.guid, f.reference) - if not f.is_external and os.path.exists(filepath): - os.remove(filepath) - self.edit_topic(topic) - - def delete_bim_snippet(self, topic): - if not topic.bim_snippet: - return - if topic.bim_snippet.reference and not topic.bim_snippet.is_external: - filepath = os.path.join(self.filepath, topic.guid, topic.bim_snippet.reference) - if os.path.exists(filepath): - os.remove(filepath) - topic.bim_snippet = None - self.edit_topic(topic) - - def delete_document_reference(self, topic, index): - document_reference = topic.document_references[index] - if document_reference.referenced_document and not document_reference.is_external: - filepath = os.path.join(self.filepath, topic.guid, document_reference.referenced_document) - if os.path.exists(filepath): - os.remove(filepath) - del topic.document_references[index] - self.edit_topic(topic) - - def add_document_reference(self, topic, document_reference): - if os.path.exists(document_reference.referenced_document): - topic_filepath = os.path.join(self.filepath, topic.guid) - filename = os.path.basename(document_reference.referenced_document) - copyfile(document_reference.referenced_document, os.path.join(topic_filepath, filename)) - document_reference.referenced_document = filename - document_reference.is_external = False - else: - document_reference.is_external = True - if not document_reference.guid: - document_reference.guid = str(uuid.uuid4()) - topic.document_references.append(document_reference) - self.edit_topic(topic) - - def add_bim_snippet(self, topic, bim_snippet): - if topic.bim_snippet: - self.delete_bim_snippet(topic) - if os.path.exists(bim_snippet.reference): - topic_filepath = os.path.join(self.filepath, topic.guid) - filename = os.path.basename(bim_snippet.reference) - copyfile(bim_snippet.reference, os.path.join(topic_filepath, filename)) - bim_snippet.reference = filename - bim_snippet.is_external = False - else: - bim_snippet.is_external = True - topic.bim_snippet = bim_snippet - self.edit_topic(topic) - - def add_file(self, topic, header_file): - if os.path.exists(header_file.reference): - topic_filepath = os.path.join(self.filepath, topic.guid) - header_file.filename = os.path.basename(header_file.reference) - copyfile(header_file.reference, os.path.join(topic_filepath, header_file.filename)) - header_file.reference = header_file.filename - header_file.is_external = False - header_file.date = datetime.utcnow().replace(microsecond=0).isoformat() - if not topic.header: - topic.header = bcf.v2.data.Header() - topic.header.files.append(header_file) - self.edit_topic(topic) - - def get_comments(self, guid): - comments = {} - data = self._read_xml(os.path.join(guid, "markup.bcf"), "markup.xsd") - if "Comment" not in data: - return comments - for item in data["Comment"]: - comment = bcf.v2.data.Comment() - mandatory_keys = {"guid": "@Guid", "date": "Date", "author": "Author", "comment": "Comment"} - for key, value in mandatory_keys.items(): - setattr(comment, key, item[value]) - optional_keys = {"modified_date": "ModifiedDate", "modified_author": "ModifiedAuthor"} - for key, value in optional_keys.items(): - if value in item: - setattr(comment, key, item[value]) - if "Viewpoint" in item: - viewpoint = bcf.v2.data.Viewpoint() - viewpoint.guid = item["Viewpoint"]["@Guid"] - comment.viewpoint = viewpoint - comments[comment.guid] = comment - self.topics[guid].comments = comments - return comments - - def get_viewpoints(self, guid): - viewpoints = {} - data = self._read_xml(os.path.join(guid, "markup.bcf"), "markup.xsd") - if "Viewpoints" not in data: - return viewpoints - for item in data["Viewpoints"]: - viewpoint = self.get_viewpoint(item, guid) - viewpoints[viewpoint.guid] = viewpoint - self.topics[guid].viewpoints = viewpoints - return viewpoints - - def get_viewpoint(self, data, topic_guid): - viewpoint = bcf.v2.data.Viewpoint() - viewpoint.guid = data["@Guid"] - optional_keys = {"viewpoint": "Viewpoint", "snapshot": "Snapshot", "index": "Index"} - for key, value in optional_keys.items(): - if value in data: - setattr(viewpoint, key, data[value]) - visinfo = self._read_xml(os.path.join(topic_guid, viewpoint.viewpoint), "visinfo.xsd") - viewpoint.components = self.get_viewpoint_components(visinfo) - viewpoint.orthogonal_camera = self.get_viewpoint_orthogonal_camera(visinfo) - viewpoint.perspective_camera = self.get_viewpoint_perspective_camera(visinfo) - viewpoint.lines = self.get_viewpoint_lines(visinfo) - viewpoint.clipping_planes = self.get_viewpoint_clipping_planes(visinfo) - viewpoint.bitmaps = self.get_viewpoint_bitmaps(visinfo) - return viewpoint - - def get_viewpoint_components(self, visinfo): - if "Components" not in visinfo: - return None - components = bcf.v2.data.Components() - data = visinfo["Components"] - if "ViewSetupHints" in data: - view_setup_hints = bcf.v2.data.ViewSetupHints() - optional_keys = { - "spaces_visible": "@SpacesVisible", - "space_boundaries_visible": "@SpaceBoundariesVisible", - "openings_visible": "@OpeningsVisible", - } - for key, value in optional_keys.items(): - if value in data["ViewSetupHints"]: - setattr(view_setup_hints, key, data["ViewSetupHints"][value]) - components.view_setup_hints = view_setup_hints - if "Selection" in data and "Component" in data["Selection"]: - for item in data["Selection"]["Component"]: - components.selection.append(self.get_component(item)) - if "Visibility" in data: - component_visibility = bcf.v2.data.ComponentVisibility() - if "@DefaultVisibility" in data["Visibility"]: - component_visibility.default_visibility = data["Visibility"]["@DefaultVisibility"] - if "Exceptions" in data["Visibility"] and "Component" in data["Visibility"]["Exceptions"]: - for item in data["Visibility"]["Exceptions"]["Component"]: - component_visibility.exceptions.append(self.get_component(item)) - components.visibility = component_visibility - if "Coloring" in data and "Color" in data["Coloring"]: - for item in data["Coloring"]["Color"]: - color = bcf.v2.data.Color() - color.color = item["@Color"] - for item2 in item["Component"]: - color.components.append(self.get_component(item2)) - components.coloring.append(color) - return components - - def get_viewpoint_orthogonal_camera(self, visinfo): - if "OrthogonalCamera" not in visinfo: - return None - camera = bcf.v2.data.OrthogonalCamera() - data = visinfo["OrthogonalCamera"] - self.set_vector(camera.camera_view_point, data["CameraViewPoint"]) - self.set_vector(camera.camera_direction, data["CameraDirection"]) - self.set_vector(camera.camera_up_vector, data["CameraUpVector"]) - camera.view_to_world_scale = data["ViewToWorldScale"] - return camera - - def get_viewpoint_perspective_camera(self, visinfo): - if "PerspectiveCamera" not in visinfo: - return None - camera = bcf.v2.data.PerspectiveCamera() - data = visinfo["PerspectiveCamera"] - self.set_vector(camera.camera_view_point, data["CameraViewPoint"]) - self.set_vector(camera.camera_direction, data["CameraDirection"]) - self.set_vector(camera.camera_up_vector, data["CameraUpVector"]) - camera.field_of_view = data["FieldOfView"] - return camera - - def get_viewpoint_lines(self, visinfo): - if "Lines" not in visinfo: - return [] - lines = [] - for item in visinfo["Lines"]["Line"]: - line = bcf.v2.data.Line() - self.set_vector(line.start_point, item["StartPoint"]) - self.set_vector(line.end_point, item["EndPoint"]) - lines.append(line) - return lines - - def get_viewpoint_clipping_planes(self, visinfo): - if "ClippingPlanes" not in visinfo: - return [] - planes = [] - for item in visinfo["ClippingPlanes"]["ClippingPlane"]: - plane = bcf.v2.data.ClippingPlane() - self.set_vector(plane.location, item["Location"]) - self.set_vector(plane.direction, item["Direction"]) - planes.append(plane) - return planes - - def get_viewpoint_bitmaps(self, visinfo): - if "Bitmap" not in visinfo: - return [] - bitmaps = [] - for item in visinfo["Bitmap"]: - bitmap = bcf.v2.data.Bitmap() - bitmap.reference = item["Reference"] - bitmap.bitmap_format = item["Bitmap"].upper() - self.set_vector(bitmap.location, item["Location"]) - self.set_vector(bitmap.normal, item["Normal"]) - self.set_vector(bitmap.up, item["Up"]) - bitmap.height = item["Height"] - bitmaps.append(bitmap) - return bitmaps - - def set_vector(self, to_obj, from_xml): - to_obj.x = from_xml["X"] - to_obj.y = from_xml["Y"] - to_obj.z = from_xml["Z"] - - def get_component(self, data): - component = bcf.v2.data.Component() - optional_keys = { - "originating_system": "OriginatingSystem", - "authoring_tool_id": "AuthoringToolId", - "ifc_guid": "@IfcGuid", - } - for key, value in optional_keys.items(): - if value in data: - setattr(component, key, data[value]) - return component - - def close_project(self): - shutil.rmtree(self.filepath) - - def _read_xml(self, filename, xsd): - schema = XMLSchema(os.path.join(cwd, "xsd", xsd)) - filepath = os.path.join(self.filepath, filename) - (data, errors) = schema.to_dict(filepath, validation="lax") - for error in errors: - self.logger.error(error) - return data - - def _create_element(self, parent, name, attributes={}, text=None): - element = self.document.createElement(name) - for key, value in attributes.items(): - if isinstance(value, bool): - element.setAttribute(key, str(value).lower()) - elif value: - element.setAttribute(key, value) - if text is not None: - text = self.document.createTextNode(str(text)) - element.appendChild(text) - parent.appendChild(element) - return element - - def __del__(self): - self.close_project() + # TODO: deprecate other methods + # endregion diff --git a/src/bcf/src/bcf/v2/data.py b/src/bcf/src/bcf/v2/data.py deleted file mode 100644 index e79352dbd0..0000000000 --- a/src/bcf/src/bcf/v2/data.py +++ /dev/null @@ -1,198 +0,0 @@ - -# BCF - BCF Python library -# Copyright (C) 2020, 2021 Dion Moult -# -# This file is part of BCF. -# -# BCF 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. -# -# BCF 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 BCF. If not, see . - -class Project: - def __init__(self): - self.project_id = "" - self.name = "" - self.extension_schema = "" - - -class BimSnippet: - def __init__(self): - self.snippet_type = None - self.is_external = False - self.reference = None - self.reference_schema = None - - -class DocumentReference: - def __init__(self): - self.referenced_document = None - self.description = None - self.guid = None - self.is_external = False - - -class RelatedTopic: - def __init__(self): - self.guid = None - - -class HeaderFile: - def __init__(self): - self.filename = None - self.date = None - self.reference = None - self.ifc_project = None - self.ifc_spatial_structure_element = None - self.is_external = True - - -class Header: - def __init__(self): - self.files = [] - - -class Topic: - def __init__(self): - self.reference_links = [] - self.title = "" - self.priority = None - self.index = None # Deprecated, stored, but ignored - self.labels = [] - self.creation_date = None - self.creation_author = None - self.modified_date = None - self.modified_author = None - self.due_date = None - self.assigned_to = None - self.stage = None - self.description = None - self.bim_snippet = None - self.document_references = [] - self.related_topics = [] - self.topic_status = None - self.topic_type = None - self.guid = None - - self.header = None - self.comments = {} - self.viewpoints = {} - - -class Comment: - def __init__(self): - self.guid = None - self.date = None - self.author = None - self.comment = None - self.viewpoint = None - self.modified_date = None - self.modified_author = None - self.topic_guid = None # Part of BCF-API - - -class ViewSetupHints: - def __init__(self): - self.spaces_visible = False - self.space_boundaries_visible = False - self.openings_visible = False - - -class Component: - def __init__(self): - self.originating_system = None - self.authoring_tool_id = None - self.ifc_guid = None - - -class ComponentVisibility: - def __init__(self): - self.exceptions = [] - self.default_visibility = False - - -class Color: - def __init__(self): - self.color = None - self.components = [] - - -class Components: - def __init__(self): - self.view_setup_hints = None - self.selection = [] - self.visibility = None - self.coloring = [] - - -class Point: - def __init__(self): - self.x = 0 - self.y = 0 - self.z = 0 - - -class Direction(Point): - pass - - -class OrthogonalCamera: - def __init__(self): - self.camera_view_point = Point() - self.camera_direction = Direction() - self.camera_up_vector = Direction() - self.view_to_world_scale = 1.0 - - -class PerspectiveCamera: - def __init__(self): - self.camera_view_point = Point() - self.camera_direction = Direction() - self.camera_up_vector = Direction() - self.field_of_view = 60.0 - - -class Line: - def __init__(self): - self.start_point = Point() - self.end_point = Point() - - -class ClippingPlane: - def __init__(self): - self.location = Point() - self.direction = Direction() - - -class Bitmap: - def __init__(self): - self.reference = "" # Only in BCF-XML - self.bitmap_data = None # Only in BCF-API - self.bitmap_format = "PNG" # Enum of png or jpg - self.location = Point() - self.normal = Direction() - self.up = Direction() - self.height = 1.0 - - -class Viewpoint: - def __init__(self): - self.guid = None - self.viewpoint = None - self.snapshot = None - self.index = None - - self.components = None # It's not a list, despite the plural name - self.orthogonal_camera = None - self.perspective_camera = None - self.lines = [] - self.clipping_planes = [] - self.bitmaps = [] diff --git a/src/bcf/src/bcf/v2/model/__init__.py b/src/bcf/src/bcf/v2/model/__init__.py new file mode 100644 index 0000000000..3bb55fd922 --- /dev/null +++ b/src/bcf/src/bcf/v2/model/__init__.py @@ -0,0 +1,70 @@ +from bcf.v2.model.markup import ( + BimSnippet, + Comment, + CommentViewpoint, + Header, + HeaderFile, + Markup, + Topic, + TopicDocumentReference, + TopicRelatedTopic, + ViewPoint, +) +from bcf.v2.model.project import Project, ProjectExtension +from bcf.v2.model.version import Version +from bcf.v2.model.visinfo import ( + BitmapFormat, + ClippingPlane, + Component, + ComponentColoring, + ComponentColoringColor, + Components, + ComponentSelection, + ComponentVisibility, + ComponentVisibilityExceptions, + Direction, + Line, + OrthogonalCamera, + PerspectiveCamera, + Point, + ViewSetupHints, + VisualizationInfo, + VisualizationInfoBitmap, + VisualizationInfoClippingPlanes, + VisualizationInfoLines, +) + +__all__ = [ + "BimSnippet", + "Comment", + "CommentViewpoint", + "Header", + "HeaderFile", + "Markup", + "Topic", + "TopicDocumentReference", + "TopicRelatedTopic", + "ViewPoint", + "Project", + "ProjectExtension", + "Version", + "BitmapFormat", + "ClippingPlane", + "Component", + "ComponentColoring", + "ComponentColoringColor", + "ComponentSelection", + "ComponentVisibility", + "ComponentVisibilityExceptions", + "Components", + "Direction", + "Line", + "OrthogonalCamera", + "PerspectiveCamera", + "Point", + "ViewSetupHints", + "VisualizationInfo", + "VisualizationInfoBitmap", + "VisualizationInfoClippingPlanes", + "VisualizationInfoLines", +] diff --git a/src/bcf/src/bcf/v2/model/markup.py b/src/bcf/src/bcf/v2/model/markup.py new file mode 100644 index 0000000000..88ddd3e3a4 --- /dev/null +++ b/src/bcf/src/bcf/v2/model/markup.py @@ -0,0 +1,461 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDateTime + + +@dataclass(slots=True, kw_only=True) +class BimSnippet: + reference: str = field( + metadata={ + "name": "Reference", + "type": "Element", + "namespace": "", + "required": True, + } + ) + reference_schema: str = field( + metadata={ + "name": "ReferenceSchema", + "type": "Element", + "namespace": "", + "required": True, + } + ) + snippet_type: str = field( + metadata={ + "name": "SnippetType", + "type": "Attribute", + "required": True, + } + ) + is_external: bool = field( + default=False, + metadata={ + "name": "isExternal", + "type": "Attribute", + } + ) + + +@dataclass(slots=True, kw_only=True) +class CommentViewpoint: + class Meta: + global_type = False + + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", + } + ) + + +@dataclass(slots=True, kw_only=True) +class HeaderFile: + class Meta: + global_type = False + + filename: Optional[str] = field( + default=None, + metadata={ + "name": "Filename", + "type": "Element", + "namespace": "", + } + ) + date: Optional[XmlDateTime] = field( + default=None, + metadata={ + "name": "Date", + "type": "Element", + "namespace": "", + } + ) + reference: Optional[str] = field( + default=None, + metadata={ + "name": "Reference", + "type": "Element", + "namespace": "", + } + ) + ifc_project: Optional[str] = field( + default=None, + metadata={ + "name": "IfcProject", + "type": "Attribute", + "length": 22, + "pattern": r"[0-9,A-Z,a-z,_$]*", + } + ) + ifc_spatial_structure_element: Optional[str] = field( + default=None, + metadata={ + "name": "IfcSpatialStructureElement", + "type": "Attribute", + "length": 22, + "pattern": r"[0-9,A-Z,a-z,_$]*", + } + ) + is_external: bool = field( + default=True, + metadata={ + "name": "isExternal", + "type": "Attribute", + } + ) + + +@dataclass(slots=True, kw_only=True) +class TopicDocumentReference: + class Meta: + global_type = False + + referenced_document: Optional[str] = field( + default=None, + metadata={ + "name": "ReferencedDocument", + "type": "Element", + "namespace": "", + } + ) + description: Optional[str] = field( + default=None, + metadata={ + "name": "Description", + "type": "Element", + "namespace": "", + } + ) + guid: Optional[str] = field( + default=None, + metadata={ + "name": "Guid", + "type": "Attribute", + "pattern": r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", + } + ) + is_external: bool = field( + default=False, + metadata={ + "name": "isExternal", + "type": "Attribute", + } + ) + + +@dataclass(slots=True, kw_only=True) +class TopicRelatedTopic: + class Meta: + global_type = False + + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ViewPoint: + viewpoint: Optional[str] = field( + default=None, + metadata={ + "name": "Viewpoint", + "type": "Element", + "namespace": "", + } + ) + snapshot: Optional[str] = field( + default=None, + metadata={ + "name": "Snapshot", + "type": "Element", + "namespace": "", + } + ) + index: Optional[int] = field( + default=None, + metadata={ + "name": "Index", + "type": "Element", + "namespace": "", + } + ) + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Comment: + date: XmlDateTime = field( + metadata={ + "name": "Date", + "type": "Element", + "namespace": "", + "required": True, + } + ) + author: str = field( + metadata={ + "name": "Author", + "type": "Element", + "namespace": "", + "required": True, + } + ) + comment: str = field( + metadata={ + "name": "Comment", + "type": "Element", + "namespace": "", + "required": True, + } + ) + viewpoint: Optional[CommentViewpoint] = field( + default=None, + metadata={ + "name": "Viewpoint", + "type": "Element", + "namespace": "", + } + ) + modified_date: Optional[XmlDateTime] = field( + default=None, + metadata={ + "name": "ModifiedDate", + "type": "Element", + "namespace": "", + } + ) + modified_author: Optional[str] = field( + default=None, + metadata={ + "name": "ModifiedAuthor", + "type": "Element", + "namespace": "", + } + ) + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Header: + file: List[HeaderFile] = field( + default_factory=list, + metadata={ + "name": "File", + "type": "Element", + "namespace": "", + "min_occurs": 1, + } + ) + + +@dataclass(slots=True, kw_only=True) +class Topic: + reference_link: List[str] = field( + default_factory=list, + metadata={ + "name": "ReferenceLink", + "type": "Element", + "namespace": "", + } + ) + title: str = field( + metadata={ + "name": "Title", + "type": "Element", + "namespace": "", + "required": True, + } + ) + priority: Optional[str] = field( + default=None, + metadata={ + "name": "Priority", + "type": "Element", + "namespace": "", + } + ) + index: Optional[int] = field( + default=None, + metadata={ + "name": "Index", + "type": "Element", + "namespace": "", + } + ) + labels: List[str] = field( + default_factory=list, + metadata={ + "name": "Labels", + "type": "Element", + "namespace": "", + } + ) + creation_date: XmlDateTime = field( + metadata={ + "name": "CreationDate", + "type": "Element", + "namespace": "", + "required": True, + } + ) + creation_author: str = field( + metadata={ + "name": "CreationAuthor", + "type": "Element", + "namespace": "", + "required": True, + } + ) + modified_date: Optional[XmlDateTime] = field( + default=None, + metadata={ + "name": "ModifiedDate", + "type": "Element", + "namespace": "", + } + ) + modified_author: Optional[str] = field( + default=None, + metadata={ + "name": "ModifiedAuthor", + "type": "Element", + "namespace": "", + } + ) + due_date: Optional[XmlDateTime] = field( + default=None, + metadata={ + "name": "DueDate", + "type": "Element", + "namespace": "", + } + ) + assigned_to: Optional[str] = field( + default=None, + metadata={ + "name": "AssignedTo", + "type": "Element", + "namespace": "", + } + ) + stage: Optional[str] = field( + default=None, + metadata={ + "name": "Stage", + "type": "Element", + "namespace": "", + } + ) + description: Optional[str] = field( + default=None, + metadata={ + "name": "Description", + "type": "Element", + "namespace": "", + } + ) + bim_snippet: Optional[BimSnippet] = field( + default=None, + metadata={ + "name": "BimSnippet", + "type": "Element", + "namespace": "", + } + ) + document_reference: List[TopicDocumentReference] = field( + default_factory=list, + metadata={ + "name": "DocumentReference", + "type": "Element", + "namespace": "", + } + ) + related_topic: List[TopicRelatedTopic] = field( + default_factory=list, + metadata={ + "name": "RelatedTopic", + "type": "Element", + "namespace": "", + } + ) + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", + } + ) + topic_type: Optional[str] = field( + default=None, + metadata={ + "name": "TopicType", + "type": "Attribute", + } + ) + topic_status: Optional[str] = field( + default=None, + metadata={ + "name": "TopicStatus", + "type": "Attribute", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Markup: + header: Optional[Header] = field( + default=None, + metadata={ + "name": "Header", + "type": "Element", + "namespace": "", + } + ) + topic: Topic = field( + metadata={ + "name": "Topic", + "type": "Element", + "namespace": "", + "required": True, + } + ) + comment: List[Comment] = field( + default_factory=list, + metadata={ + "name": "Comment", + "type": "Element", + "namespace": "", + } + ) + viewpoints: List[ViewPoint] = field( + default_factory=list, + metadata={ + "name": "Viewpoints", + "type": "Element", + "namespace": "", + } + ) diff --git a/src/bcf/src/bcf/v2/model/project.py b/src/bcf/src/bcf/v2/model/project.py new file mode 100644 index 0000000000..fea44c6f50 --- /dev/null +++ b/src/bcf/src/bcf/v2/model/project.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass(slots=True, kw_only=True) +class Project: + name: Optional[str] = field( + default=None, + metadata={ + "name": "Name", + "type": "Element", + "namespace": "", + } + ) + project_id: str = field( + metadata={ + "name": "ProjectId", + "type": "Attribute", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ProjectExtension: + project: Optional[Project] = field( + default=None, + metadata={ + "name": "Project", + "type": "Element", + "namespace": "", + } + ) + extension_schema: str = field( + metadata={ + "name": "ExtensionSchema", + "type": "Element", + "namespace": "", + "required": True, + } + ) diff --git a/src/bcf/src/bcf/v2/model/version.py b/src/bcf/src/bcf/v2/model/version.py new file mode 100644 index 0000000000..777572c6ae --- /dev/null +++ b/src/bcf/src/bcf/v2/model/version.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass(slots=True, kw_only=True) +class Version: + detailed_version: Optional[str] = field( + default=None, + metadata={ + "name": "DetailedVersion", + "type": "Element", + "namespace": "", + } + ) + version_id: Optional[str] = field( + default=None, + metadata={ + "name": "VersionId", + "type": "Attribute", + } + ) diff --git a/src/bcf/src/bcf/v2/model/visinfo.py b/src/bcf/src/bcf/v2/model/visinfo.py new file mode 100644 index 0000000000..f9976e1c78 --- /dev/null +++ b/src/bcf/src/bcf/v2/model/visinfo.py @@ -0,0 +1,476 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Optional + + +class BitmapFormat(Enum): + PNG = "PNG" + JPG = "JPG" + + +@dataclass(slots=True, kw_only=True) +class Component: + originating_system: Optional[str] = field( + default=None, + metadata={ + "name": "OriginatingSystem", + "type": "Element", + } + ) + authoring_tool_id: Optional[str] = field( + default=None, + metadata={ + "name": "AuthoringToolId", + "type": "Element", + } + ) + ifc_guid: Optional[str] = field( + default=None, + metadata={ + "name": "IfcGuid", + "type": "Attribute", + "length": 22, + "pattern": r"[0-9,A-Z,a-z,_$]*", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Direction: + x: float = field( + metadata={ + "name": "X", + "type": "Element", + "required": True, + } + ) + y: float = field( + metadata={ + "name": "Y", + "type": "Element", + "required": True, + } + ) + z: float = field( + metadata={ + "name": "Z", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class Point: + x: float = field( + metadata={ + "name": "X", + "type": "Element", + "required": True, + } + ) + y: float = field( + metadata={ + "name": "Y", + "type": "Element", + "required": True, + } + ) + z: float = field( + metadata={ + "name": "Z", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ViewSetupHints: + spaces_visible: Optional[bool] = field( + default=None, + metadata={ + "name": "SpacesVisible", + "type": "Attribute", + } + ) + space_boundaries_visible: Optional[bool] = field( + default=None, + metadata={ + "name": "SpaceBoundariesVisible", + "type": "Attribute", + } + ) + openings_visible: Optional[bool] = field( + default=None, + metadata={ + "name": "OpeningsVisible", + "type": "Attribute", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ClippingPlane: + location: Point = field( + metadata={ + "name": "Location", + "type": "Element", + "required": True, + } + ) + direction: Direction = field( + metadata={ + "name": "Direction", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentColoringColor: + class Meta: + global_type = False + + component: List[Component] = field( + default_factory=list, + metadata={ + "name": "Component", + "type": "Element", + "min_occurs": 1, + } + ) + color: Optional[str] = field( + default=None, + metadata={ + "name": "Color", + "type": "Attribute", + "pattern": r"[0-9,a-f,A-F]{6}([0-9,a-f,A-F]{2})?", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentSelection: + component: List[Component] = field( + default_factory=list, + metadata={ + "name": "Component", + "type": "Element", + "min_occurs": 1, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentVisibilityExceptions: + class Meta: + global_type = False + + component: List[Component] = field( + default_factory=list, + metadata={ + "name": "Component", + "type": "Element", + "min_occurs": 1, + } + ) + + +@dataclass(slots=True, kw_only=True) +class Line: + start_point: Point = field( + metadata={ + "name": "StartPoint", + "type": "Element", + "required": True, + } + ) + end_point: Point = field( + metadata={ + "name": "EndPoint", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class OrthogonalCamera: + """ + Attributes + camera_view_point: + camera_direction: + camera_up_vector: + view_to_world_scale: view's visible size in meters + """ + camera_view_point: Point = field( + metadata={ + "name": "CameraViewPoint", + "type": "Element", + "required": True, + } + ) + camera_direction: Direction = field( + metadata={ + "name": "CameraDirection", + "type": "Element", + "required": True, + } + ) + camera_up_vector: Direction = field( + metadata={ + "name": "CameraUpVector", + "type": "Element", + "required": True, + } + ) + view_to_world_scale: float = field( + metadata={ + "name": "ViewToWorldScale", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class PerspectiveCamera: + """ + Attributes + camera_view_point: + camera_direction: + camera_up_vector: + field_of_view: It is currently limited to a value between 45 and + 60 degrees. This limitation will be dropped in the next + release and viewers should be expect values outside this + range in current implementations. + """ + camera_view_point: Point = field( + metadata={ + "name": "CameraViewPoint", + "type": "Element", + "required": True, + } + ) + camera_direction: Direction = field( + metadata={ + "name": "CameraDirection", + "type": "Element", + "required": True, + } + ) + camera_up_vector: Direction = field( + metadata={ + "name": "CameraUpVector", + "type": "Element", + "required": True, + } + ) + field_of_view: float = field( + metadata={ + "name": "FieldOfView", + "type": "Element", + "required": True, + "min_inclusive": 1.0, + "max_inclusive": 170.0, + } + ) + + +@dataclass(slots=True, kw_only=True) +class VisualizationInfoBitmap: + class Meta: + global_type = False + + bitmap: BitmapFormat = field( + metadata={ + "name": "Bitmap", + "type": "Element", + "required": True, + } + ) + reference: str = field( + metadata={ + "name": "Reference", + "type": "Element", + "required": True, + } + ) + location: Point = field( + metadata={ + "name": "Location", + "type": "Element", + "required": True, + } + ) + normal: Direction = field( + metadata={ + "name": "Normal", + "type": "Element", + "required": True, + } + ) + up: Direction = field( + metadata={ + "name": "Up", + "type": "Element", + "required": True, + } + ) + height: float = field( + metadata={ + "name": "Height", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentColoring: + color: List[ComponentColoringColor] = field( + default_factory=list, + metadata={ + "name": "Color", + "type": "Element", + "min_occurs": 1, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentVisibility: + exceptions: Optional[ComponentVisibilityExceptions] = field( + default=None, + metadata={ + "name": "Exceptions", + "type": "Element", + } + ) + default_visibility: Optional[bool] = field( + default=None, + metadata={ + "name": "DefaultVisibility", + "type": "Attribute", + } + ) + + +@dataclass(slots=True, kw_only=True) +class VisualizationInfoClippingPlanes: + class Meta: + global_type = False + + clipping_plane: List[ClippingPlane] = field( + default_factory=list, + metadata={ + "name": "ClippingPlane", + "type": "Element", + } + ) + + +@dataclass(slots=True, kw_only=True) +class VisualizationInfoLines: + class Meta: + global_type = False + + line: List[Line] = field( + default_factory=list, + metadata={ + "name": "Line", + "type": "Element", + "min_occurs": 1, + } + ) + + +@dataclass(slots=True, kw_only=True) +class Components: + view_setup_hints: Optional[ViewSetupHints] = field( + default=None, + metadata={ + "name": "ViewSetupHints", + "type": "Element", + } + ) + selection: Optional[ComponentSelection] = field( + default=None, + metadata={ + "name": "Selection", + "type": "Element", + } + ) + visibility: ComponentVisibility = field( + metadata={ + "name": "Visibility", + "type": "Element", + "required": True, + } + ) + coloring: Optional[ComponentColoring] = field( + default=None, + metadata={ + "name": "Coloring", + "type": "Element", + } + ) + + +@dataclass(slots=True, kw_only=True) +class VisualizationInfo: + """ + VisualizationInfo documentation. + """ + components: Optional[Components] = field( + default=None, + metadata={ + "name": "Components", + "type": "Element", + } + ) + orthogonal_camera: Optional[OrthogonalCamera] = field( + default=None, + metadata={ + "name": "OrthogonalCamera", + "type": "Element", + } + ) + perspective_camera: Optional[PerspectiveCamera] = field( + default=None, + metadata={ + "name": "PerspectiveCamera", + "type": "Element", + } + ) + lines: Optional[VisualizationInfoLines] = field( + default=None, + metadata={ + "name": "Lines", + "type": "Element", + } + ) + clipping_planes: Optional[VisualizationInfoClippingPlanes] = field( + default=None, + metadata={ + "name": "ClippingPlanes", + "type": "Element", + } + ) + bitmap: List[VisualizationInfoBitmap] = field( + default_factory=list, + metadata={ + "name": "Bitmap", + "type": "Element", + } + ) + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", + } + ) diff --git a/src/bcf/src/bcf/v2/topic.py b/src/bcf/src/bcf/v2/topic.py new file mode 100644 index 0000000000..0180427350 --- /dev/null +++ b/src/bcf/src/bcf/v2/topic.py @@ -0,0 +1,240 @@ +"""BCF XML V2 Topic handler.""" +import datetime +import uuid +import zipfile +from pathlib import Path +from typing import Any, Optional + +from ifcopenshell import entity_instance +from xsdata.models.datatype import XmlDateTime + +import bcf.v2.model as mdl +from bcf.inmemory_zipfile import ZipFileInterface +from bcf.v2.visinfo import VisualizationInfoHandler +from bcf.xml_parser import AbstractXmlParserSerializer, XmlParserSerializer + + +class TopicHandler: + """BCF Topic and related objects handler.""" + + def __init__( + self, + topic_dir: Optional[zipfile.Path] = None, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> None: + self._markup: Optional[mdl.Markup] = None + self._viewpoints: dict[str, VisualizationInfoHandler] = {} + self._reference_files: dict[str, bytes] = {} + self._document_references: dict[str, bytes] = {} + self._bim_snippet: Optional[bytes] = None + self._xml_handler = xml_handler or XmlParserSerializer() + self._topic_dir = topic_dir + + @property + def markup(self) -> Optional[mdl.Markup]: + if not self._markup: + markup_path = self._topic_dir.joinpath("markup.bcf") + if markup_path.exists(): + self._markup = self._xml_handler.parse(markup_path.read_bytes(), mdl.Markup) + return self._markup + + @markup.setter + def markup(self, value: mdl.Markup) -> None: + self._markup = value + + @property + def topic(self) -> mdl.Topic: + """Return the Topic object.""" + return self.markup.topic + + @property + def guid(self) -> str: + """Return the GUID of the topic.""" + if self._markup: + return self.topic.guid + return self._topic_dir.name if self._topic_dir else "" + + @property + def header(self) -> Optional[mdl.Header]: + """Return the header of the topic.""" + return self.markup.header if self.markup else None + + @property + def comments(self) -> list[mdl.Comment]: + """Return the comments of the topic.""" + return self.markup.comment if self.markup else [] + + @property + def bim_snippet(self) -> Optional[bytes]: + if not self._bim_snippet and self._topic_dir: + self._bim_snippet = self._load_bim_snippet() + return self._bim_snippet + + @bim_snippet.setter + def bim_snippet(self, value: bytes) -> None: + self._bim_snippet = value + + @property + def viewpoints(self) -> dict[str, VisualizationInfoHandler]: + if not self._viewpoints and self._topic_dir: + self._viewpoints = self._load_viewpoints() + return self._viewpoints + + @property + def reference_files(self) -> dict[str, bytes]: + if self._reference_files or not self.header: + return self._reference_files + for ref in self.header.file: + if ref.is_external: + continue + real_path = self._topic_dir + for path_part in ref.reference.split("/"): + real_path = real_path.parent if path_part == ".." else real_path.joinpath(path_part) + self._reference_files[ref.reference] = real_path.read_bytes() + return self._reference_files + + @property + def document_references(self) -> dict[str, bytes]: + if self._document_references or not self.topic: + return self._document_references + for doc in self.topic.document_reference: + if doc.is_external or not doc.referenced_document: + continue + real_path = self._topic_dir + for path_part in doc.referenced_document.split("/"): + real_path = real_path.parent if path_part == ".." else real_path.joinpath(path_part) + self._document_references[doc.referenced_document] = real_path.read_bytes() + return self._document_references + + def _load_bim_snippet(self) -> Optional[bytes]: + bim_snippet_obj = self.topic.bim_snippet + if bim_snippet_obj and not bim_snippet_obj.is_external: + bim_snippet_path = self._topic_dir.joinpath(bim_snippet_obj.reference) + if bim_snippet_path.exists(): + return bim_snippet_path.read_bytes() + return None + + def _load_viewpoints(self) -> dict[str, VisualizationInfoHandler]: + if self.markup and (viewpoints := self.markup.viewpoints): + return VisualizationInfoHandler.from_topic_viewpoints(self._topic_dir, viewpoints) + return {} + + @classmethod + def create_new( + cls, + title: str, + description: str, + author: str, + topic_type: str = "", + topic_status: str = "", + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> "TopicHandler": + """ + Create a new BCF topic. + + Args: + title: The title of the topic. + description: The description of the topic. + author: The author of the topic. + topic_type: The type of the topic. + topic_status: The status of the topic. + xml_handler: The XML parser/serializer to use. + + Returns: + The BCF topic definition. + """ + creation_date = XmlDateTime.from_datetime(datetime.datetime.now()) + guid = str(uuid.uuid4()) + topic = mdl.Topic( + title=title, + description=description, + creation_author=author, + creation_date=creation_date, + guid=guid, + topic_type=topic_type, + topic_status=topic_status, + ) + markup = mdl.Markup(topic=topic) + obj = cls(topic_dir=Path(guid), xml_handler=xml_handler or XmlParserSerializer()) + obj.markup = markup + return obj + + def save(self, destination_zip: ZipFileInterface) -> None: + """ + Save the topic to a BCF zip file. + + Args: + bcf_zip: The BCF zip file to save to. + """ + topic_dir = self.guid + self._save_xml(destination_zip, self._markup, "markup.bcf") + self._save_viewpoints(destination_zip, topic_dir) + self._save_bim_snippet(destination_zip) + self._save_reference_files(destination_zip) + self._save_document_references(destination_zip) + + def _save_viewpoints(self, destination_zip: ZipFileInterface, topic_dir: str) -> None: + if not self.markup or not (viewpoints := self.markup.viewpoints): + return + for vpt in viewpoints: + if vpt.viewpoint: + self.viewpoints[vpt.viewpoint].save(destination_zip, topic_dir, vpt) + + def _save_xml(self, destination_zip: ZipFileInterface, item: Any, target: str) -> None: + if self._topic_dir is None: + return + to_write = self._xml_handler.serialize(item) if item else self._topic_dir.joinpath(target).read_bytes() + destination_zip.writestr(f"{self._topic_dir.name}/{target}", to_write) + + def _save_bim_snippet(self, destination_zip: ZipFileInterface) -> None: + snippet = self.topic.bim_snippet + if not snippet or snippet.is_external: + return + ref_filename = Path(snippet.reference).name + if self.bim_snippet: + destination_zip.writestr(f"{self.topic.guid}/{ref_filename}", self.bim_snippet) + + def _save_reference_files(self, destination_zip: ZipFileInterface) -> None: + if not self.header: + return + for ref in self.header.file: + if ref.is_external or not ref.reference: + continue + real_path = self._topic_dir + for path_part in ref.reference.split("/"): + real_path = real_path.parent if path_part == ".." else real_path.joinpath(path_part) + destination_zip.writestr(real_path.at, self.reference_files[ref.reference]) + + def _save_document_references(self, destination_zip: ZipFileInterface) -> None: + if not self.topic: + return + for doc in self.topic.document_reference: + if doc.is_external or not doc.referenced_document: + continue + real_path = self._topic_dir + for path_part in doc.referenced_document.split("/"): + real_path = real_path.parent if path_part == ".." else real_path.joinpath(path_part) + destination_zip.writestr(real_path.at, self.document_references[doc.referenced_document]) + + def add_viewpoint(self, element: entity_instance) -> None: + """ + Add a viewpoint tergeting an IFC element to the topic. + + Args: + element: The IFC element. + """ + new_viewpoint = VisualizationInfoHandler.create_new(element, self._xml_handler) + self.add_visinfo_handler(new_viewpoint) + + def add_visinfo_handler(self, new_viewpoint: VisualizationInfoHandler) -> None: + self.viewpoints[new_viewpoint.guid] = new_viewpoint + self.markup.viewpoints.append(mdl.ViewPoint(viewpoint=new_viewpoint.guid, guid=new_viewpoint.guid)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, TopicHandler): + raise TypeError("Equality needs a BcfXml object.") + return ( + self.markup == other.markup + and self.viewpoints == other.viewpoints + and self.bim_snippet == other.bim_snippet + ) diff --git a/src/bcf/src/bcf/v2/visinfo.py b/src/bcf/src/bcf/v2/visinfo.py new file mode 100644 index 0000000000..1a15086b99 --- /dev/null +++ b/src/bcf/src/bcf/v2/visinfo.py @@ -0,0 +1,246 @@ +import uuid +import zipfile +from functools import lru_cache +from typing import Any, Iterable, Optional + +import numpy as np +from ifcopenshell import entity_instance +from ifcopenshell.util import placement +from numpy.typing import NDArray + +import bcf.v2.model as mdl +from bcf.geometry import calc_camera_vectors +from bcf.inmemory_zipfile import ZipFileInterface +from bcf.xml_parser import AbstractXmlParserSerializer, XmlParserSerializer + + +class VisualizationInfoHandler: + """Handle the VisualizationInfo and related objects.""" + + def __init__( + self, + visualization_info: mdl.VisualizationInfo, + snapshot: Optional[bytes] = None, + bitmaps: Optional[dict[str, bytes]] = None, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> None: + self.visualization_info = visualization_info + self.snapshot = snapshot + self.bitmaps = bitmaps or {} + self._xml_handler = xml_handler or XmlParserSerializer() + + @property + def guid(self) -> str: + """Return the GUID of the visualization info.""" + return self.visualization_info.guid + + @classmethod + def from_topic_viewpoints( + cls, + topic_dir: zipfile.Path, + vps: Iterable[mdl.ViewPoint], + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> dict[str, "VisualizationInfoHandler"]: + """Create VisualizationInfoHandler objects of a Topic's ViewPoints.""" + viewpoints = {} + for vpt in vps: + visinfo = cls.load(topic_dir, vpt, xml_handler) + if visinfo and vpt.viewpoint: + viewpoints[vpt.viewpoint] = visinfo + return viewpoints + + @classmethod + def load( + cls, + topic_dir: zipfile.Path, + vpt: mdl.ViewPoint, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> Optional["VisualizationInfoHandler"]: + """ + Load the VisualizationInfo and related objects from a BCF zip file. + + Args: + topic_dir: The directory in the BCF zip file to load from. + vpt: The ViewPoint to load. + xml_handler: The XML handler to use to parse the VisualizationInfo. + + Returns: + The VisualizationInfoHandler object. + """ + visinfo = cls._load_visinfo(topic_dir, vpt.viewpoint, xml_handler) + if not visinfo: + return None + snapshot = cls._load_snapshot(topic_dir, vpt.snapshot) + bitmaps = cls._load_bitmaps(topic_dir, visinfo) + return cls(visinfo, snapshot, bitmaps, xml_handler) + + @staticmethod + def _load_visinfo( + topic_dir: zipfile.Path, + vp_name: Optional[str], + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> Optional[mdl.VisualizationInfo]: + if not vp_name: + return None + vp_path = topic_dir.joinpath(vp_name) + if vp_path.exists(): + xml_handler = xml_handler or XmlParserSerializer() + return xml_handler.parse(vp_path.read_bytes(), mdl.VisualizationInfo) + return None + + @staticmethod + def _load_snapshot(topic_dir: zipfile.Path, vp_snapshot: Optional[str]) -> Optional[bytes]: + if vp_snapshot: + snapshot_path = topic_dir.joinpath(vp_snapshot) + if snapshot_path.exists(): + return snapshot_path.read_bytes() + return None + + @staticmethod + def _load_bitmaps(topic_dir: zipfile.Path, visinfo: Optional[mdl.VisualizationInfo]) -> dict[str, bytes]: + if not visinfo or not (bitmaps := visinfo.bitmap): + return {} + bitmaps_dict = {} + for bitmap in bitmaps: + if not bitmap.reference: + continue + bitmap_path = topic_dir.joinpath(bitmap.reference) + if bitmap_path.exists(): + bitmaps_dict[bitmap.reference] = bitmap_path.read_bytes() + return bitmaps_dict + + def save( + self, + bcf_zip: ZipFileInterface, + topic_dir: str, + vpt: mdl.ViewPoint, + ) -> None: + """ + Save the VisualizationInfo and related objects to a BCF zip file. + + Args: + bcf_zip: The BCF zip file to save to. + topic_dir: The directory in the BCF zip file to save to. + vpt: The ViewPoint to save. + """ + if not (vp_name := vpt.viewpoint): + return + self._save_visinfo(bcf_zip, topic_dir, vp_name) + self._save_snapshot(bcf_zip, topic_dir, vpt.snapshot) + self._save_bitmaps(bcf_zip, topic_dir) + + def _save_snapshot(self, bcf_zip: ZipFileInterface, topic_dir: str, filename: Optional[str]) -> None: + if self.snapshot and filename: + bcf_zip.writestr(f"{topic_dir}/{filename}", self.snapshot) + + def _save_visinfo(self, bcf_zip: ZipFileInterface, topic_dir: str, vp_name: str) -> None: + bcf_zip.writestr( + f"{topic_dir}/{vp_name}", + self._xml_handler.serialize(self.visualization_info), + ) + + def _save_bitmaps(self, bcf_zip: ZipFileInterface, topic_dir: str) -> None: + if not self.bitmaps: + return + if not (bitmaps_defs := self.visualization_info.bitmap): + return + for bitmap_def in bitmaps_defs: + if not (bitmap_name := bitmap_def.reference): + continue + if bitmap_name in self.bitmaps: + bcf_zip.writestr(f"{topic_dir}/{bitmap_name}", self.bitmaps[bitmap_name]) + + @classmethod + def create_new( + cls, + element: entity_instance, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> "VisualizationInfoHandler": + """ + Create a new VisualizationInfoHandler object from an IFC element. + + Args: + element: The IFC element to point at. + xml_handler: The XML handler to use. + + Returns: + The VisualizationInfoHandler object. + """ + xml_handler = xml_handler or XmlParserSerializer() + return cls(visualization_info=build_viewpoint(element), xml_handler=xml_handler) + + +@lru_cache(maxsize=None) +def build_viewpoint(element: entity_instance) -> mdl.VisualizationInfo: + """ + Return a BCF viewpoint of an IFC element. + + This function is cached to speedudp the creation of multiple BCF topics regarding the same element. + + Args: + element: The IFC element to point at. + + Returns: + The BCF viewpoint definition. + """ + elem_placement = placement.get_local_placement(element.ObjectPlacement) + + return mdl.VisualizationInfo( + guid=str(uuid.uuid4()), + components=build_components(element.GlobalId), + perspective_camera=build_camera(elem_placement), + ) + + +def build_components(guid: str) -> mdl.Components: + """ + Return the BCF components from an IFC element GUID. + + Args: + guid: The IFC element GUID. + + Returns: + The BCF components definition. + """ + return mdl.Components( + selection=mdl.ComponentSelection(component=[mdl.Component(ifc_guid=guid)]), + visibility=mdl.ComponentVisibility(default_visibility=True), + ) + + +def build_camera(elem_placement: NDArray[np.float_]) -> mdl.PerspectiveCamera: + """ + Return a BCF camera for an IFC element placement matrix. + + Args: + elem_placement: The IFC element placement as a rototranslation matrix. + + Returns: + The BCF camera definition. + """ + return build_camera_from_vectors(*calc_camera_vectors(elem_placement)) + + +def build_camera_from_vectors( + camera_position: NDArray[np.float_], camera_dir: NDArray[np.float_], camera_up: NDArray[np.float_] +) -> mdl.PerspectiveCamera: + """ + Return a BCF camera for an IFC element placement matrix. + + Args: + camera_position: camera position array + camera_dir: camera direction versor + camera_up_vector: camera up versor + + Returns: + The BCF camera definition. + """ + camera_viewpoint = mdl.Point(x=camera_position[0], y=camera_position[1], z=camera_position[2]) + camera_direction = mdl.Direction(x=camera_dir[0], y=camera_dir[1], z=camera_dir[2]) + camera_up_vector = mdl.Direction(x=camera_up[0], y=camera_up[1], z=camera_up[2]) + return mdl.PerspectiveCamera( + camera_view_point=camera_viewpoint, + camera_direction=camera_direction, + camera_up_vector=camera_up_vector, + field_of_view=60.0, + ) diff --git a/src/bcf/src/bcf/v3/__init__.py b/src/bcf/src/bcf/v3/__init__.py index 5828265521..32d3e3f1c3 100644 --- a/src/bcf/src/bcf/v3/__init__.py +++ b/src/bcf/src/bcf/v3/__init__.py @@ -1,19 +1 @@ - -# BCF - BCF Python library -# Copyright (C) 2021 Prabhat Singh -# -# This file is part of BCF. -# -# BCF 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. -# -# BCF 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 BCF. If not, see . - +"""BCF XML v3 handler.""" diff --git a/src/bcf/src/bcf/v3/bcfapi.py b/src/bcf/src/bcf/v3/bcfapi.py index 308f323eb8..7303ed8d7e 100644 --- a/src/bcf/src/bcf/v3/bcfapi.py +++ b/src/bcf/src/bcf/v3/bcfapi.py @@ -1,4 +1,3 @@ - # BCF - BCF Python library # Copyright (C) 2021 Prabhat Singh # @@ -17,25 +16,27 @@ # You should have received a copy of the GNU Lesser General Public License # along with BCF. If not, see . -import uuid -import time -import json -import urllib -import requests -import webbrowser -import http.server import base64 -import tempfile +import http.server import os +import tempfile +import time +import urllib +import uuid +import webbrowser +from re import A +from typing import Any, Optional, Tuple + +import requests client_id, client_secret = "", "" class OAuthReceiver(http.server.BaseHTTPRequestHandler): - def do_GET(self): + def do_GET(self) -> None: query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) - self.server.auth_code = query.get("code", [""])[0] - self.server.auth_state = query.get("state", [""])[0] + self.server.auth_code = query.get("code", [""])[0] # type:ignore + self.server.auth_state = query.get("state", [""])[0] # type:ignore self.send_response(200) self.send_header("Content-type", "text/plain") self.end_headers() @@ -43,20 +44,21 @@ class OAuthReceiver(http.server.BaseHTTPRequestHandler): class FoundationClient: - def __init__(self, client_id, client_secret, base_url=None, redirect_subdir=None): + def __init__( + self, client_id: str, client_secret: str, base_url: Optional[str] = None, redirect_subdir: Optional[str] = None + ) -> None: self.baseurl = base_url self.access_token = "" self.refresh_token = "" self.access_token_expires_on = time.time() self.refresh_token_expires_on = float("inf") - self.auth_endpoint = None - self.token_endpoint = None + self.token_endpoint = "" self.client_id = client_id self.client_secret = client_secret - self.auth_method = None + self.auth_method: Optional[str] = None self.redirect_subdir = redirect_subdir - def get_access_token(self): + def get_access_token(self) -> str: if self.access_token and self.access_token_expires_on > time.time(): return self.access_token elif self.refresh_token and self.refresh_token_expires_on > time.time(): @@ -65,18 +67,18 @@ class FoundationClient: self.login() return self.access_token - def get_auth_methods(self): + def get_auth_methods(self) -> list[Any]: resp = requests.get(f"{self.baseurl}foundation/1.0/auth") return resp.json()["supported_oauth2_flows"] - def get_versions(self): + def get_versions(self) -> list[Any]: resp = requests.get(f"{self.baseurl}foundation/versions") return resp.json()["versions"] - def login(self): + def login(self) -> None: resp = requests.get(f"{self.baseurl}foundation/1.0/auth") values = resp.json() - self.auth_endpoint = values["oauth2_auth_url"] + auth_endpoint = values["oauth2_auth_url"] self.token_endpoint = values["oauth2_token_url"] with http.server.HTTPServer(("", 8080), OAuthReceiver) as server: @@ -89,25 +91,22 @@ class FoundationClient: "redirect_uri": f"http://localhost:{server.server_address[1]}/{self.redirect_subdir}", } ) - if "?" in self.auth_endpoint: - webbrowser.open(f"{self.auth_endpoint}&{query}") + if "?" in auth_endpoint: + webbrowser.open(f"{auth_endpoint}&{query}") else: - webbrowser.open(f"{self.auth_endpoint}?{query}") + webbrowser.open(f"{auth_endpoint}?{query}") server.timeout = 100 - server.state = state server.handle_request() - if server.auth_code and server.auth_state == state: + if server.auth_code and server.auth_state == state: # pylint: disable=E1101 data = { "grant_type": "authorization_code", - "code": server.auth_code, + "code": server.auth_code, # pylint: disable=E1101 type:ignore "redirect_uri": f"http://localhost:{server.server_address[1]}/{self.redirect_subdir}", } - auth_string = f"{self.client_id}:{self.client_secret}" - header_string = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8") - headers = {"Authorization": f"Basic {header_string}"} + headers = self._get_access_token_headers() self.set_tokens_from_response(requests.post(self.token_endpoint, data=data, headers=headers)) - def get_refresh_token(self): + def get_refresh_token(self) -> None: self.set_tokens_from_response( requests.post( self.token_endpoint, @@ -118,10 +117,8 @@ class FoundationClient: ).json() ) - def get_new_access_token(self): - auth_string = f"{self.client_id}:{self.client_secret}" - header_string = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8") - headers = {"Authorization": f"Basic {header_string}"} + def get_new_access_token(self) -> None: + headers = self._get_access_token_headers() self.set_tokens_from_response( requests.post( self.token_endpoint, @@ -133,35 +130,41 @@ class FoundationClient: ).json() ) - def set_auth_method(self, method="authorization_code_grant"): + def _get_access_token_headers(self) -> dict[str, str]: + auth_string = f"{self.client_id}:{self.client_secret}" + header_string = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8") + return {"Authorization": f"Basic {header_string}"} + + def set_auth_method(self, method: str = "authorization_code_grant") -> None: if method != "authorization_code_grant": raise NotImplementedError(f"{method} not supported") else: self.auth_method = method - def set_tokens_from_response(self, response): - response = response.json() - self.access_token = response["access_token"] - self.refresh_token = response["refresh_token"] - self.access_token_expires_on = time.time() + response["expires_in"] - if "refresh_token_expires_in" in response: - self.refresh_token_expires_on = time.time() + response["refresh_token_expires_in"] + def set_tokens_from_response(self, response: requests.Response) -> None: + response_dict = response.json() + self.access_token = response_dict["access_token"] + self.refresh_token = response_dict["refresh_token"] + self.access_token_expires_on = time.time() + response_dict["expires_in"] + if "refresh_token_expires_in" in response_dict: + self.refresh_token_expires_on = time.time() + response_dict["refresh_token_expires_in"] class BcfClient: - def __init__(self, foundation_client): + def __init__(self, foundation_client: FoundationClient) -> None: self.foundation_client = foundation_client - self.version_id = None - self.baseurl = None + self.version_id: Optional[str] = None + self.baseurl: Optional[str] = None self.filepath = tempfile.mkdtemp() - def set_version(self, version): + def set_version(self, version: dict[str, str]) -> None: self.version_id = version["version_id"] self.baseurl = version["api_base_url"] - def get(self, endpoint, params=None, is_auth_required=False): + def get(self, endpoint: str, params: Any = None, is_auth_required: bool = False) -> Any: # TODO: handle error http status codes and raise exception. Follow error.json standard. - headers = {"Authorization": "Bearer " + self.foundation_client.get_access_token()} + headers = {"Authorization": f"Bearer {self.foundation_client.get_access_token()}"} + response = requests.get(f"{self.baseurl}{endpoint}", headers=headers, params=params or None) try: response = requests.get(f"{self.baseurl}{endpoint}", headers=headers, params=params or None) @@ -171,68 +174,62 @@ class BcfClient: except requests.exceptions.HTTPError as e: print(f"message: {response.reason}' '{response.status_code}' '{ e }") - def post(self, endpoint, data=None, params=None): + def post(self, endpoint: str, data: Any = None, params: Any = None) -> Tuple[int, str]: headers = { - "Authorization": "Bearer " + self.foundation_client.get_access_token(), + "Authorization": f"Bearer {self.foundation_client.get_access_token()}", "Content-type": "application/json", } + try: response = requests.post( - f"{self.baseurl}{endpoint}", - headers=headers, - params=params or None, - data=data or None, + f"{self.baseurl}{endpoint}", headers=headers, params=params or None, data=data or None ) - if response.status_code == 201: - return response.status_code, response.text - response.raise_for_status() + + if response.status_code != 201: + response.raise_for_status() + return response.status_code, response.text except requests.exceptions.HTTPError as errh: print(f"message: {response.reason}' '{response.status_code}, {errh}") + return response.status_code, response.reason - def put(self, endpoint, data=None, params=None): + def put(self, endpoint: str, data: Any = None, params: Any = None) -> Tuple[int, str]: headers = { - "Authorization": "Bearer " + self.foundation_client.get_access_token(), + "Authorization": f"Bearer {self.foundation_client.get_access_token()}", "Content-type": "application/json", } + try: response = requests.put( - f"{self.baseurl}{endpoint}", - headers=headers, - params=params or None, - data=data or None, + f"{self.baseurl}{endpoint}", headers=headers, params=params or None, data=data or None ) - if response.status_code == 200: - return response.status_code, response.text - response.raise_for_status() + + if response.status_code != 200: + response.raise_for_status() + return response.status_code, response.text except requests.exceptions.HTTPError as errh: print(f"message: {response.reason}' '{response.status_code}, {errh}") + return response.status_code, response.reason - def delete(self, endpoint, params=None): + def delete(self, endpoint: str, params: Any = None) -> Tuple[int, str]: headers = { - "Authorization": "Bearer " + self.foundation_client.get_access_token(), + "Authorization": f"Bearer {self.foundation_client.get_access_token()}", "Content-type": "application/json", } + try: - response = requests.delete( - f"{self.baseurl}{endpoint}", - headers=headers, - params=params or None, - ) - if response.status_code == 200: - return response.status_code, response.text - response.raise_for_status() + response = requests.delete(f"{self.baseurl}{endpoint}", headers=headers, params=params or None) + + if response.status_code != 200: + response.raise_for_status() + return response.status_code, response.text except requests.exceptions.HTTPError as errh: print(f"message: {response.reason}' '{response.status_code}, {errh}") + return response.status_code, response.reason - def get_projects(self) -> list: - return self.get( - f"/projects", - ) + def get_projects(self) -> list[Any]: + return self.get("/projects") - def get_project( - self, - project_id="", - ) -> dict: + def get_project(self, project_id: str = "") -> dict[str, Any]: return self.get( f"/projects/{project_id}", { @@ -240,16 +237,13 @@ class BcfClient: }, ) - def update_project(self, project_id="", data=None) -> dict: + def update_project(self, project_id: str = "", data: Any = None) -> Tuple[int, str]: url = f"{self.baseurl}/projects/{project_id}" - headers = {"Authorization": "Bearer " + self.foundation_client.get_access_token()} + headers = {"Authorization": f"Bearer {self.foundation_client.get_access_token()}"} resp = requests.put(url, headers=headers, data=data) return resp.status_code, resp.text - def get_extensions( - self, - project_id="", - ) -> dict: + def get_extensions(self, project_id: str = "") -> dict[str, Any]: return self.get( f"/projects/{project_id}/extensions", { @@ -259,10 +253,10 @@ class BcfClient: def get_topics( self, - project_id="", - topics="", - query_string=None, - ) -> list: + project_id: str = "", + topics: str = "", + query_string: Optional[str] = None, + ) -> list[Any]: # return self.get( # f"/projects/{project_id}/topics", # { @@ -273,7 +267,7 @@ class BcfClient: # ) pass - def get_topic(self, project_id="", topic_id="") -> dict: + def get_topic(self, project_id: str = "", topic_id: str = "") -> dict[str, Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}", { @@ -282,42 +276,40 @@ class BcfClient: }, ) - def create_topic(self, project_id="", data=None): + def create_topic(self, project_id: str = "", data: Any = None) -> Tuple[int, str]: return self.post(f"/projects/{project_id}/topics", data=data) - def update_topic(self, project_id="", topic_id="", data=None) -> dict: + def update_topic(self, project_id: str = "", topic_id: str = "", data: Any = None) -> Tuple[int, str]: return self.put(f"/projects/{project_id}/topics/{topic_id}", data=data) - def delete_topic(self, project_id="", topic_id=""): + def delete_topic(self, project_id: str = "", topic_id: str = "") -> Tuple[int, str]: return self.delete(f"/projects/{project_id}/topics/{topic_id}") - def get_snippet(self, project_id="", topic_id="") -> str: + def get_snippet(self, project_id: str = "", topic_id: str = "") -> Tuple[int, str]: headers = { - "Authorization": "Bearer " + self.foundation_client.get_access_token(), + "Authorization": f"Bearer {self.foundation_client.get_access_token()}", "Content-type": "application/octet-stream", } - response = requests.get( - f"{self.baseurl}/projects/{project_id}/topics/{topic_id}/snippet", - headers=headers, - ) - # TODO: write to tmpdir - with open(os.path.join(self.filepath, f"{project_id}_{topic_id}_snippet.txt"), "wb") as f: - f.write(response.content.decode("utf-8")) - return response.status_code, response.content - def update_snippet(self, project_id="", topic_id="", files=None, data=None): + response = requests.get(f"{self.baseurl}/projects/{project_id}/topics/{topic_id}/snippet", headers=headers) + content = response.content.decode("utf-8") + with open(os.path.join(self.filepath, f"{project_id}_{topic_id}_snippet.txt"), "w") as f: + f.write(content) + return response.status_code, content + + def update_snippet(self, project_id: str = "", topic_id: str = "", files: Any = None, data: Any = None) -> int: headers = { - "Authorization": "Bearer " + self.foundation_client.get_access_token(), + "Authorization": f"Bearer {self.foundation_client.get_access_token()}", "Content-type": "application/octet-stream", } + response = requests.put( - f"{self.baseurl}/projects/{project_id}/topics/{topic_id}/snippet", - headers=headers, - files=files, + f"{self.baseurl}/projects/{project_id}/topics/{topic_id}/snippet", headers=headers, files=files ) + return response.status_code - def get_files_information(self, project_id="") -> list: + def get_files_information(self, project_id: str = "") -> list[Any]: return self.get( f"/projects/{project_id}/files_information", { @@ -325,7 +317,7 @@ class BcfClient: }, ) - def get_files(self, project_id="", topic_id="") -> list: + def get_files(self, project_id: str = "", topic_id: str = "") -> list[Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/files", { @@ -336,32 +328,32 @@ class BcfClient: def update_files( self, - project_id="", - topic_id="", - data=None, - params=None, - ): + project_id: str = "", + topic_id: str = "", + data: Any = None, + params: Any = None, + ) -> Tuple[int, str]: return self.put( f"/projects/{project_id}/topics/{topic_id}/files", data=data, ) - def get_comments(self, project_id="", topic_id="") -> list: + def get_comments(self, project_id: str = "", topic_id: str = "") -> None: pass def create_comments( self, - project_id="", - topic_id="", - data=None, - params=None, - ): + project_id: str = "", + topic_id: str = "", + data: Any = None, + params: Any = None, + ) -> Tuple[int, str]: return self.post( f"/projects/{project_id}/topics/{topic_id}/comments", data=data, ) - def get_comment(self, project_id="", topic_id="", comment_id="") -> dict: + def get_comment(self, project_id: str = "", topic_id: str = "", comment_id: str = "") -> dict[str, Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/comments/{comment_id}", { @@ -371,22 +363,22 @@ class BcfClient: }, ) - def delete_comment(self, project_id="", topic_id="", comment_id=""): + def delete_comment(self, project_id: str = "", topic_id: str = "", comment_id: str = "") -> Tuple[int, str]: return self.delete(f"/projects/{project_id}/topics/{topic_id}/comments/{comment_id}") def update_comment( self, - project_id="", - topic_id="", - comment_id="", - data=None, - ): + project_id: str = "", + topic_id: str = "", + comment_id: str = "", + data: Any = None, + ) -> Tuple[int, str]: return self.put( f"/projects/{project_id}/topics/{topic_id}/comments/{comment_id}", data=data, ) - def get_viewpoints(self, project_id="", topic_id="") -> list: + def get_viewpoints(self, project_id: str = "", topic_id: str = "") -> list[Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/viewpoints", { @@ -395,13 +387,13 @@ class BcfClient: }, ) - def create_viewpoints(self, project_id="", topic_id="", data=None): + def create_viewpoints(self, project_id: str = "", topic_id: str = "", data: Any = None) -> Tuple[int, str]: return self.post( f"/projects/{project_id}/topics/{topic_id}/viewpoints", data=data, ) - def get_viewpoint(self, project_id="", topic_id="", viewpoint_id="") -> dict: + def get_viewpoint(self, project_id: str = "", topic_id: str = "", viewpoint_id: str = "") -> dict[str, Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/viewpoints/{viewpoint_id}", { @@ -413,15 +405,15 @@ class BcfClient: def delete_viewpoint( self, - project_id="", - topic_id="", - viewpoint_id="", - ): + project_id: str = "", + topic_id: str = "", + viewpoint_id: str = "", + ) -> Tuple[int, str]: return self.delete( f"/projects/{project_id}/topics/{topic_id}/viewpoints/{viewpoint_id}", ) - def get_snapshot(self, project_id="", topic_id="", viewpoint_id="") -> str: + def get_snapshot(self, project_id: str = "", topic_id: str = "", viewpoint_id: str = "") -> str: return self.get( f"/projects/{project_id}/topics/{topic_id}/viewpoints/{viewpoint_id}/snapshot", { @@ -431,7 +423,7 @@ class BcfClient: }, ) - def get_bitmap(self, project_id="", topic_id="", viewpoint_id="", bitmap_id="") -> str: + def get_bitmap(self, project_id: str = "", topic_id: str = "", viewpoint_id: str = "", bitmap_id: str = "") -> str: return self.get( f"/projects/{project_id}/topics/{topic_id}/viewpoints/{viewpoint_id}/bitmaps/{bitmap_id}", { @@ -442,7 +434,7 @@ class BcfClient: }, ) - def get_selection(self, project_id="", topic_id="", viewpoint_id="") -> dict: + def get_selection(self, project_id: str = "", topic_id: str = "", viewpoint_id: str = "") -> dict[str, Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/viewpoints/{viewpoint_id}/selection", { @@ -452,7 +444,7 @@ class BcfClient: }, ) - def get_coloring(self, project_id="", topic_id="", viewpoint_id="") -> dict: + def get_coloring(self, project_id: str = "", topic_id: str = "", viewpoint_id: str = "") -> dict[str, Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/viewpoints/{viewpoint_id}/coloring", { @@ -462,7 +454,7 @@ class BcfClient: }, ) - def get_visibility(self, project_id="", topic_id="", viewpoint_id="") -> dict: + def get_visibility(self, project_id: str = "", topic_id: str = "", viewpoint_id: str = "") -> dict[str, Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/viewpoints/{viewpoint_id}/visibility", { @@ -472,7 +464,7 @@ class BcfClient: }, ) - def get_related_topics(self, project_id="", topic_id="") -> list: + def get_related_topics(self, project_id: str = "", topic_id: str = "") -> list[Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/related_topics", { @@ -483,16 +475,16 @@ class BcfClient: def update_related_topics( self, - project_id="", - topic_id="", - data=None, - ): + project_id: str = "", + topic_id: str = "", + data: Any = None, + ) -> Tuple[int, str]: return self.put( f"/projects/{project_id}/topics/{topic_id}/related_topics", data=data, ) - def get_document_references(self, project_id="", topic_id="") -> list: + def get_document_references(self, project_id: str = "", topic_id: str = "") -> list[Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/document_references", { @@ -503,10 +495,10 @@ class BcfClient: def create_document_reference( self, - project_id="", - topic_id="", - data=None, - ): + project_id: str = "", + topic_id: str = "", + data: Any = None, + ) -> Tuple[int, str]: return self.post( f"/projects/{project_id}/topics/{topic_id}/document_references", data=data, @@ -514,17 +506,17 @@ class BcfClient: def update_document_references( self, - project_id="", - topic_id="", - document_reference_id="", - data=None, - ): + project_id: str = "", + topic_id: str = "", + document_reference_id: str = "", + data: Any = None, + ) -> Tuple[int, str]: return self.put( f"/projects/{project_id}/topics/{topic_id}/document_references/{document_reference_id}", data=data, ) - def get_documents(self, project_id="", topic_id="") -> list: + def get_documents(self, project_id: str = "", topic_id: str = "") -> list[Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/documents", { @@ -534,40 +526,36 @@ class BcfClient: ) def create_document( - self, - project_id="", - topic_id="", - guid=None, - files=None, - data=None, - ): + self, project_id: str = "", topic_id: str = "", guid: Optional[str] = None, files: Any = None, data: Any = None + ) -> int: headers = { - "Authorization": "Bearer " + self.foundation_client.get_access_token(), + "Authorization": f"Bearer {self.foundation_client.get_access_token()}", "Content-type": "application/octet-stream", } + response = requests.post( f"/projects/{project_id}/topics/{topic_id}/documents", data=data, - params={guid}, + params={"guid": guid}, files=files, headers=headers, ) + return response.status_code - def get_document(self, project_id="", topic_id="", document_id="") -> str: + def get_document(self, project_id: str = "", topic_id: str = "", document_id: str = "") -> Tuple[int, str]: headers = { - "Authorization": "Bearer " + self.foundation_client.get_access_token(), + "Authorization": f"Bearer {self.foundation_client.get_access_token()}", "Content-type": "application/octet-stream", } - response = requests.get( - f"{self.baseurl}/projects/{project_id}/topics/documents/{document_id}", - headers=headers, - ) - with open(os.path.join(self.filepath, f"{project_id}_{topic_id}_{document_id}_document.txt"), "wb") as f: - f.write(response.content.decode("utf-8")) - return response.status_code, response.content - def get_topics_events(self, project_id="") -> list: + response = requests.get(f"{self.baseurl}/projects/{project_id}/topics/documents/{document_id}", headers=headers) + content = response.content.decode("utf-8") + with open(os.path.join(self.filepath, f"{project_id}_{topic_id}_{document_id}_document.txt"), "w") as f: + f.write(content) + return response.status_code, content + + def get_topics_events(self, project_id: str = "") -> list[Any]: return self.get( f"/projects/{project_id}/topics/events", { @@ -575,7 +563,7 @@ class BcfClient: }, ) - def get_topic_events(self, project_id="", topic_id="") -> list: + def get_topic_events(self, project_id: str = "", topic_id: str = "") -> list[Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/events", { @@ -584,7 +572,7 @@ class BcfClient: }, ) - def get_comments_events(self, project_id="") -> list: + def get_comments_events(self, project_id: str = "") -> list[Any]: return self.get( f"/projects/{project_id}/topics/comments/events", { @@ -592,7 +580,7 @@ class BcfClient: }, ) - def get_comment_events(self, project_id="", topic_id="", comment_id="") -> list: + def get_comment_events(self, project_id: str = "", topic_id: str = "", comment_id: str = "") -> list[Any]: return self.get( f"/projects/{project_id}/topics/{topic_id}/comments/{comment_id}/events", { diff --git a/src/bcf/src/bcf/v3/bcfxml.py b/src/bcf/src/bcf/v3/bcfxml.py index 0aa5925bdf..a2a81199ec 100644 --- a/src/bcf/src/bcf/v3/bcfxml.py +++ b/src/bcf/src/bcf/v3/bcfxml.py @@ -1,847 +1,291 @@ - -# BCF - BCF Python library -# Copyright (C) 2021 Prabhat Singh -# -# This file is part of BCF. -# -# BCF 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. -# -# BCF 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 BCF. If not, see . - -import os +"""BCF XML V3 handlers.""" import uuid -import shutil +import warnings import zipfile -import logging -import tempfile -import bcf.v3.data -from datetime import datetime -from xml.dom import minidom -from xmlschema import XMLSchema -from contextlib import contextmanager -from shutil import copyfile +from pathlib import Path +from typing import Any, Optional, TypeVar -cwd = os.path.dirname(os.path.realpath(__file__)) +import bcf.v3.model as mdl +from bcf.inmemory_zipfile import InMemoryZipFile, ZipFileInterface +from bcf.v3.document import DocumentsHandler +from bcf.v3.topic import TopicHandler +from bcf.xml_parser import AbstractXmlParserSerializer, XmlParserSerializer - -@contextmanager -def cd(newdir): - prevdir = os.getcwd() - os.chdir(os.path.expanduser(newdir)) - try: - yield - finally: - os.chdir(prevdir) +T = TypeVar("T") class BcfXml: - def __init__(self): - self.filepath = None - self.logger = logging.getLogger("bcfxml") - self.author = "john@doe.com" - self.project = bcf.v3.data.Project() - self.version = "3.0" - self.topics = {} + """BCF XML handler.""" - def new_project(self): - self.project.project_id = str(uuid.uuid4()) - self.project.name = "New Project" - self.topics = {} - if self.filepath: - self.close_project() - self.filepath = tempfile.mkdtemp() - self.edit_project() - self.edit_version() + def __init__( + self, filename: Optional[Path] = None, xml_handler: Optional[AbstractXmlParserSerializer] = None + ) -> None: + self._filename = filename + self._xml_handler = xml_handler or XmlParserSerializer() + self._version: Optional[mdl.Version] = None + self._project_info: Optional[mdl.ProjectInfo] = None + self._extensions: Optional[mdl.Extensions] = None + self._topics: dict[str, TopicHandler] = {} + self._documents: Optional[DocumentsHandler] = None + self._zip_file = self._load_zip_file() - def get_project(self, filepath=None): - if os.path.isfile(os.path.join(self.filepath, "project.bcfp")): - data = self._read_xml("project.bcfp", "project.xsd") - self.project.project_id = data["Project"]["@ProjectId"] - self.project.name = data["Project"].get("Name") + def __enter__(self) -> "BcfXml": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def __del__(self) -> None: + self.close() + + def close(self) -> None: + if self._zip_file: + self._zip_file.close() + + def _load_zip_file(self) -> Optional[zipfile.ZipFile]: + return zipfile.ZipFile(self._filename) if self._filename else None + + @property + def version(self) -> mdl.Version: + """Bcf Version.""" + if not self._version: + self._version = ( + self._xml_handler.parse(self._zip_file.read("bcf.version"), mdl.Version) + if self._zip_file + else mdl.Version(version_id="3.0") + ) + return self._version + + @version.setter + def version(self, value: mdl.Version) -> None: + self._version = value + + @property + def project_info(self) -> Optional[mdl.ProjectInfo]: + """BCF project information.""" + if not self._project_info and self._zip_file and zipfile.Path(self._zip_file, "project.bcfp").exists(): + self._project_info = self._xml_handler.parse(self._zip_file.read("project.bcfp"), mdl.ProjectInfo) + return self._project_info + + @project_info.setter + def project_info(self, value: Optional[mdl.ProjectInfo]) -> None: + self._project_info = value + + @property + def project(self) -> Optional[mdl.Project]: + """BCF project.""" + return self.project_info.project if self.project_info else None + + @property + def extensions(self) -> Optional[mdl.Extensions]: + """BCF extensions.""" + if not self._extensions and self._zip_file: + self._extensions = self._xml_handler.parse(self._zip_file.read("extensions.xml"), mdl.Extensions) + return self._extensions + + @extensions.setter + def extensions(self, value: Optional[mdl.Extensions]) -> None: + self._extensions = value + + @property + def topics(self) -> dict[str, TopicHandler]: + """BCF topics.""" + if not self._topics and self._zip_file: + self._load_topics() + return self._topics + + def _load_topics(self) -> None: + for topic_dir in zipfile.Path(self._zip_file).iterdir(): + if not topic_dir.is_dir(): + continue + markup_path = topic_dir.joinpath("markup.bcf") + if not markup_path.exists(): + continue + self._topics[topic_dir.name] = TopicHandler(topic_dir, self._xml_handler) + + @property + def documents(self) -> Optional[DocumentsHandler]: + """Documents stored in the BCF file.""" + if not self._documents and self._zip_file: + self._documents = DocumentsHandler.load(self._zip_file, self._xml_handler) + return self._documents + + @classmethod + def load(cls, filename: Path, xml_handler: Optional[AbstractXmlParserSerializer] = None) -> Optional["BcfXml"]: + """ + Create a BcfXml object from a file. + + Args: + filename: Path to the file. + xml_handler: XML parser and serializer. + + Returns: + A BcfXml object with the file contents. + + Raises: + ValueError: If the file name is null or empty + """ + if not filename: + raise ValueError("filename is required") + xml_handler = xml_handler or XmlParserSerializer() + return cls(xml_handler=xml_handler, filename=filename) + + @classmethod + def create_new( + cls, + project_name: Optional[str] = None, + extensions: Optional[mdl.Extensions] = None, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> "BcfXml": + """ + Create a new BcfXml object. + + Args: + project_name: The name of the project. + extensions: The Extension XML object. Defaults to an empty one. + xml_handler: XML parser and serializer. + + Returns: + A new BcfXml object. + """ + instance = cls(xml_handler=xml_handler or XmlParserSerializer()) + instance.project_info = mdl.ProjectInfo(project=mdl.Project(name=project_name, project_id=str(uuid.uuid4()))) + instance.extensions = extensions or mdl.Extensions() + return instance + + def save(self, filename: Optional[Path] = None, keep_open: bool = False) -> None: + """Save the BCF file to the given filename.""" + if not filename and not self._filename: + raise ValueError("No file name specified, cannot save BCF file.") + if filename: + self._filename = filename + with InMemoryZipFile(self._filename) as bcf_zip: + self._save_project(bcf_zip) + self._save_version(bcf_zip) + self._save_extensions(bcf_zip) + self._save_documents(bcf_zip) + self._save_topics(bcf_zip) + if keep_open: + self._zip_file = self._load_zip_file() + + def _save_project(self, destination_zip: ZipFileInterface) -> None: + self._smart_save_xml(destination_zip, self._project_info, "project.bcfp") + + def _save_version(self, destination_zip: ZipFileInterface) -> None: + if not self._version and self._zip_file: + destination_zip.writestr("bcf.version", self._zip_file.read("bcf.version")) + else: + self._save_xml(destination_zip, "bcf.version", self.version) + + def _save_extensions(self, destination_zip: ZipFileInterface) -> None: + self._smart_save_xml(destination_zip, self._extensions, "extensions.xml") + + def _smart_save_xml(self, destination_zip: ZipFileInterface, item: Any, target: str) -> None: + if item: + self._save_xml(destination_zip, target, item) + elif self._zip_file and zipfile.Path(self._zip_file, target).exists(): + destination_zip.writestr(target, self._zip_file.read(target)) + + def _save_xml(self, destination_zip: ZipFileInterface, inner_file: str, xml_obj: Any) -> None: + destination_zip.writestr(inner_file, self._xml_handler.serialize(xml_obj)) + + def _save_documents(self, bcf_zip: ZipFileInterface) -> None: + if self.documents: + self.documents.save(bcf_zip) + + def _save_topics(self, destination_zip: ZipFileInterface) -> None: + for topic_handler in self.topics.values(): + topic_handler.save(destination_zip) + + def add_topic( + self, title: str, description: str, author: str, topic_type: str = "", topic_status: str = "" + ) -> TopicHandler: + """ + Add a new topic to the BCF. + + Args: + title: The title of the topic. + description: The description of the topic. + author: The author of the topic. + topic_type: The type of the topic. + topic_status: The status of the topic. + + Returns: + The newly created topic wrapped inside a TopicHandler object. + """ + topic_handler = TopicHandler.create_new( + title, + description, + author, + topic_type=topic_type, + topic_status=topic_status, + xml_handler=self._xml_handler, + ) + self.topics[topic_handler.guid] = topic_handler + return topic_handler + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BcfXml): + raise TypeError("Equality needs a BcfXml object.") + return ( + self.version == other.version + and self.project_info == other.project_info + and self.extensions == other.extensions + ) + + # region Deprecated methods + def new_project(self) -> "BcfXml": + """Deprecated method.""" + warnings.warn("new_project is deprecated, use create_new instead.", DeprecationWarning) + return self.create_new() + + def get_project(self, _filepath: Optional[str] = None) -> Optional[mdl.Project]: + """Deprecated method.""" + warnings.warn("get_project is deprecated, use project_info.project instead.", DeprecationWarning) return self.project - def edit_project(self): - self.document = minidom.Document() - root = self._create_element(self.document, "ProjectInfo") - project = self._create_element(root, "Project", {"ProjectId": self.project.project_id}) - if self.project.name: - self._create_element(project, "Name", text=self.project.name) - with open(os.path.join(self.filepath, "project.bcfp"), "wb") as f: - f.write(self.document.toprettyxml(encoding="utf-8")) + def edit_project(self) -> None: + """Deprecated method.""" + warnings.warn("edit_project is deprecated, there's no need to use it.", DeprecationWarning) - def save_project(self, filepath): - with cd(self.filepath): - zip_file = zipfile.ZipFile(filepath, "w", zipfile.ZIP_DEFLATED) - for root, dirs, files in os.walk("./"): - for file in files: - zip_file.write(os.path.join(root, file)) - zip_file.close() + def save_project(self, filepath: Path) -> None: + """Deprecated method.""" + warnings.warn("save_project is deprecated, use save instead.", DeprecationWarning) + self.save(filepath) - def get_version(self): - data = self._read_xml("bcf.version", "version.xsd") - self.version = data["@VersionId"] - return self.version + def get_version(self) -> str: + warnings.warn("get_version is deprecated, use version.version_id instead.", DeprecationWarning) + return self.version.version_id - def edit_version(self): - self.document = minidom.Document() - root = self._create_element(self.document, "Version", {"VersionId": self.version}) - with open(os.path.join(self.filepath, "bcf.version"), "wb") as f: - f.write(self.document.toprettyxml(encoding="utf-8")) + def edit_version(self) -> None: + """Deprecated method.""" + warnings.warn("edit_version is deprecated, there's no need to use it.", DeprecationWarning) - def get_topics(self): - self.topics = {} - topics = [] - subdirs = [] - for (dirpath, dirnames, filenames) in os.walk(self.filepath): - subdirs = dirnames - break - for subdir in subdirs: - try: - uuid.UUID(subdir) - except ValueError: - continue - if not os.path.exists(os.path.join(self.filepath, subdir, "markup.bcf")): - continue - self.topics[subdir] = self.get_topic(subdir) + def get_topics(self) -> dict[str, TopicHandler]: + """Deprecated method.""" + warnings.warn("get_topics is deprecated, use topics instead.", DeprecationWarning) return self.topics - def get_header(self, guid): - data = self._read_xml(os.path.join(guid, "markup.bcf"), "markup.xsd") - if "Header" not in data: - return - header = bcf.v3.data.Header() - if data["Header"].get("Files"): - for item in data["Header"]["Files"].get("File", []): - header_file = bcf.v3.data.HeaderFile() - optional_keys = { - "filename": "Filename", - "date": "Date", - "reference": "Reference", - "ifc_project": "@IfcProject", - "ifc_spatial_structure_element": "@IfcSpatialStructureElement", - "is_external": "@IsExternal", - } - for key, value in optional_keys.items(): - if value in item: - setattr(header_file, key, item[value]) - header.files.append(header_file) - self.topics[guid].header = header - return header + def get_topic(self, guid: str) -> TopicHandler: + """Return a topic by its GUID.""" + warnings.warn("get_topic is deprecated, use topics[guid] instead", DeprecationWarning) + return self.topics[guid] - def get_topic(self, guid): - if guid in self.topics: - return self.topics[guid] - data = self._read_xml(os.path.join(guid, "markup.bcf"), "markup.xsd") - topic = bcf.v3.data.Topic() - self.topics[guid] = topic + def get_header(self, guid: str) -> Optional[mdl.Header]: + """Return the header of a Topic by its GUID.""" + return self.topics[guid].header - mandatory_keys = { - "guid": "@Guid", - "title": "Title", - "creation_date": "CreationDate", - "creation_author": "CreationAuthor", - "topic_status": "@TopicStatus", - "topic_type": "@TopicType", - } - for key, value in mandatory_keys.items(): - setattr(topic, key, data["Topic"][value]) + def edit_topic(self) -> None: + """Deprecated method.""" + warnings.warn("edit_topic is deprecated, there's no need to use it.", DeprecationWarning) - optional_keys = { - "priority": "Priority", - "index": "Index", - "modified_date": "ModifiedDate", - "modified_author": "ModifiedAuthor", - "due_date": "DueDate", - "assigned_to": "AssignedTo", - "stage": "Stage", - "description": "Description", - "server_assigned_id": "@ServerAssignedId", - } - for key, value in optional_keys.items(): - if value in data["Topic"]: - setattr(topic, key, data["Topic"][value]) + def add_comment(self, _topic: mdl.Topic, _comment: Optional[mdl.Comment] = None) -> None: + """Deprecated method.""" + warnings.warn("add_comment is deprecated, use topics methods instead.", DeprecationWarning) - if data["Topic"].get("ReferenceLinks"): - topic.reference_links.extend(data["Topic"]["ReferenceLinks"].get("ReferenceLink", [])) + def edit_comment(self) -> None: + """Deprecated method.""" + warnings.warn("edit_comment is deprecated, there's no need to use it.", DeprecationWarning) - if data["Topic"].get("Labels"): - topic.labels.extend(data["Topic"]["Labels"].get("Label", [])) - - if "BimSnippet" in data["Topic"]: - bim_snippet = bcf.v3.data.BimSnippet() - keys = { - "snippet_type": "@SnippetType", - "is_external": "@IsExternal", - "reference": "Reference", - "reference_schema": "ReferenceSchema", - } - for key, value in keys.items(): - if value in data["Topic"]["BimSnippet"]: - setattr(bim_snippet, key, data["Topic"]["BimSnippet"][value]) - topic.bim_snippet = bim_snippet - - if data["Topic"].get("DocumentReferences"): - for item in data["Topic"]["DocumentReferences"].get("DocumentReference", []): - document_reference = bcf.v3.data.DocumentReference() - keys = { - "document_guid": "DocumentGuid", - "url": "Url", - "guid": "@Guid", - "description": "Description", - } - for key, value in keys.items(): - if value in item: - setattr(document_reference, key, item[value]) - topic.document_references.append(document_reference) - - if data["Topic"].get("RelatedTopics"): - for item in data["Topic"]["RelatedTopics"].get("RelatedTopic", []): - related_topic = bcf.v3.data.RelatedTopic() - related_topic.guid = item["@Guid"] - topic.related_topics.append(related_topic) - return topic - - def add_topic(self, topic=None): - if topic is None: - topic = bcf.v3.data.Topic() - if not topic.guid: - topic.guid = str(uuid.uuid4()) - if not topic.title: - topic.title = "New Topic" - os.mkdir(os.path.join(self.filepath, topic.guid)) - self.edit_topic(topic) - return topic - - def edit_topic(self, topic): - if not topic.creation_date: - topic.creation_date = datetime.utcnow().isoformat() - topic.creation_author = self.author - else: - topic.modified_date = datetime.utcnow().isoformat() - topic.modified_author = self.author - - self.document = minidom.Document() - root = self._create_element(self.document, "Markup") - if topic.header: - self.write_header(topic.header, root) - - topic_el = self._create_element( - root, - "Topic", - { - "Guid": topic.guid, - "ServerAssignedId": topic.server_assigned_id, - "TopicType": topic.topic_type, - "TopicStatus": topic.topic_status, - }, - ) - if topic.reference_links: - reference_Links_el = self._create_element(topic_el, "ReferenceLinks") - for reference_link in topic.reference_links: - self._create_element(reference_Links_el, "ReferenceLink", text=reference_link) - - text_map = { - "Title": topic.title, - "Priority": topic.priority, - "Index": topic.index, - } - for key, value in text_map.items(): - if value: - self._create_element(topic_el, key, text=value) - if topic.labels: - label_el = self._create_element(topic_el, "Labels") - for label in topic.labels: - self._create_element(label_el, "Label", text=label) - - text_map = { - "CreationDate": topic.creation_date, - "CreationAuthor": topic.creation_author, - "ModifiedDate": topic.modified_date, - "ModifiedAuthor": topic.modified_author, - "DueDate": topic.due_date, - "AssignedTo": topic.assigned_to, - "Stage": topic.stage, - "Description": topic.description, - } - for key, value in text_map.items(): - if value: - self._create_element(topic_el, key, text=value) - - if topic.bim_snippet: - bim_snippet = self._create_element( - topic_el, - "BimSnippet", - { - "SnippetType": topic.bim_snippet.snippet_type, - "IsExternal": topic.bim_snippet.is_external, - }, - ) - self._create_element(bim_snippet, "Reference", text=topic.bim_snippet.reference) - self._create_element( - bim_snippet, - "ReferenceSchema", - text=topic.bim_snippet.reference_schema, - ) - if topic.document_references: - reference_el = self._create_element(topic_el, "DocumentReferences") - self.write_document_references(topic.document_references, reference_el) - if topic.related_topics: - related_topic_el = self._create_element(topic_el, "RelatedTopics") - for related_topic in topic.related_topics: - self._create_element(related_topic_el, "RelatedTopic", {"Guid": related_topic.guid}) - if topic.comments: - comment_el = self._create_element(topic_el, "Comments") - self.write_comments(topic.comments, comment_el) - if topic.viewpoints: - viewpoint_el = self._create_element(topic_el, "Viewpoints") - self.write_viewpoints(topic.viewpoints, viewpoint_el, topic) - with open(os.path.join(self.filepath, topic.guid, "markup.bcf"), "wb") as f: - f.write(self.document.toprettyxml(encoding="utf-8")) - - def write_document_references(self, references, root): - for reference in references: - document_reference_el = self._create_element(root, "DocumentReference", {"Guid": reference.guid}) - if reference.document_guid: - self._create_element(document_reference_el, "DocumentGuid", text=reference.document_guid) - elif reference.url: - self._create_element(document_reference_el, "Url", text=reference.url) - if reference.description: - self._create_element(document_reference_el, "Description", text=reference.description) - - def write_header(self, header, root): - if not header or not header.files: - return - header_el = self._create_element(root, "Header") - files_el = self._create_element(header_el, "Files") - for f in header.files: - file_el = self._create_element( - files_el, - "File", - { - "IfcProject": f.ifc_project, - "IfcSpatialStructureElement": f.ifc_spatial_structure_element, - "IsExternal": f.is_external, - }, - ) - if f.filename: - self._create_element(file_el, "Filename", text=f.filename) - if f.date: - self._create_element(file_el, "Date", text=f.date) - if f.reference: - self._create_element(file_el, "Reference", text=f.reference) - - def write_comments(self, comments, root): - for comment in comments.values(): - comment_el = self._create_element(root, "Comment", {"Guid": comment.guid}) - text_map = { - "Date": comment.date, - "Author": comment.author, - "Comment": comment.comment, - "ModifiedDate": comment.modified_date, - "ModifiedAuthor": comment.modified_author, - } - for key, value in text_map.items(): - if value: - self._create_element(comment_el, key, text=value) - if comment.viewpoint: - self._create_element(comment_el, "Viewpoint", {"Guid": comment.viewpoint.guid}) - - def add_comment(self, topic, comment=None): - if comment is None: - comment = bcf.v3.data.Comment() - if not comment.guid: - comment.guid = str(uuid.uuid4()) - if not comment.comment: - comment.comment = "'Free software' is a matter of liberty, not price. To understand the concept, you should think of 'free' as in 'free speech,' not as in 'free beer'." - topic.comments[comment.guid] = comment - self.edit_comment(comment, topic) - - def edit_comment(self, comment, topic): - if not comment.date: - comment.date = datetime.utcnow().isoformat() - comment.author = self.author - else: - comment.modified_date = datetime.utcnow().isoformat() - comment.modified_author = self.author - self.edit_topic(topic) - - def delete_comment(self, guid, topic): - if guid in topic.comments: - del topic.comments[guid] - self.edit_topic(topic) - - def delete_topic(self, guid): - if guid in self.topics: - del self.topics[guid] - shutil.rmtree(os.path.join(self.filepath, guid)) - - def write_viewpoints(self, viewpoints, root, topic): - for viewpoint in viewpoints.values(): - viewpoint_el = self._create_element(root, "ViewPoint", {"Guid": viewpoint.guid}) - text_map = { - "Viewpoint": viewpoint.viewpoint, - "Snapshot": viewpoint.snapshot, - "Index": viewpoint.index, - } - for key, value in text_map.items(): - if value: - self._create_element(viewpoint_el, key, text=value) - self.write_viewpoint(viewpoint, topic) - - def write_viewpoint(self, viewpoint, topic): - document = minidom.Document() - root = self._create_element(document, "VisualizationInfo", {"Guid": viewpoint.guid}) - self.write_viewpoint_components(viewpoint, root) - self.write_viewpoint_orthogonal_camera(viewpoint, root) - self.write_viewpoint_perspective_camera(viewpoint, root) - self.write_viewpoint_lines(viewpoint, root) - self.write_viewpoint_clipping_planes(viewpoint, root) - self.write_viewpoint_bitmaps(viewpoint, root) - with open(os.path.join(self.filepath, topic.guid, viewpoint.viewpoint), "wb") as f: - f.write(document.toprettyxml(encoding="utf-8")) - - def write_viewpoint_components(self, viewpoint, parent): - if not viewpoint.components: - return - components_el = self._create_element(parent, "Components") - if viewpoint.components.selection: - selection_el = self._create_element(components_el, "Selection") - for selection in viewpoint.components.selection: - self.write_component(selection, selection_el) - if viewpoint.components.visibility: - visibility = self._create_element( - components_el, - "Visibility", - {"DefaultVisibility": viewpoint.components.visibility.default_visibility}, - ) - if viewpoint.components.visibility.view_setup_hints: - view_setup_hints = self._create_element( - visibility, - "ViewSetupHints", - { - "SpacesVisible": viewpoint.components.visibility.view_setup_hints.spaces_visible, - "SpaceBoundariesVisible": viewpoint.components.visibility.view_setup_hints.space_boundaries_visible, - "OpeningsVisible": viewpoint.components.visibility.view_setup_hints.openings_visible, - }, - ) - if viewpoint.components.visibility.exceptions: - exceptions_el = self._create_element(visibility, "Exceptions") - for exception in viewpoint.components.visibility.exceptions: - self.write_component(exception, exceptions_el) - if viewpoint.components.coloring: - coloring_el = self._create_element(components_el, "Coloring") - for color in viewpoint.components.coloring: - color_el = self._create_element(coloring_el, "Color", {"Color": color.color}) - component_el = self._create_element(color_el, "Components") - for component in color.components: - self.write_component(component, component_el) - - def write_viewpoint_orthogonal_camera(self, viewpoint, parent): - if not viewpoint.orthogonal_camera: - return - camera = viewpoint.orthogonal_camera - camera_el = self._create_element(parent, "OrthogonalCamera") - camera_view_point = self._create_element(camera_el, "CameraViewPoint") - self.write_vector(camera_view_point, camera.camera_view_point) - camera_direction = self._create_element(camera_el, "CameraDirection") - self.write_vector(camera_direction, camera.camera_direction) - camera_up_vector = self._create_element(camera_el, "CameraUpVector") - self.write_vector(camera_up_vector, camera.camera_up_vector) - self._create_element(camera_el, "ViewToWorldScale", text=camera.view_to_world_scale) - self._create_element(camera_el, "AspectRatio", text=camera.aspect_ratio) - - def write_viewpoint_perspective_camera(self, viewpoint, parent): - if not viewpoint.perspective_camera: - return - camera = viewpoint.perspective_camera - camera_el = self._create_element(parent, "PerspectiveCamera") - camera_view_point = self._create_element(camera_el, "CameraViewPoint") - self.write_vector(camera_view_point, camera.camera_view_point) - camera_direction = self._create_element(camera_el, "CameraDirection") - self.write_vector(camera_direction, camera.camera_direction) - camera_up_vector = self._create_element(camera_el, "CameraUpVector") - self.write_vector(camera_up_vector, camera.camera_up_vector) - self._create_element(camera_el, "FieldOfView", text=camera.field_of_view) - self._create_element(camera_el, "AspectRatio", text=camera.aspect_ratio) - - def write_viewpoint_lines(self, viewpoint, parent): - if not viewpoint.lines: - return - lines_el = self._create_element(parent, "Lines") - for line in viewpoint.lines: - line_el = self._create_element(lines_el, "Line") - start_point_el = self._create_element(line_el, "StartPoint") - self.write_vector(start_point_el, line.start_point) - end_point_el = self._create_element(line_el, "EndPoint") - self.write_vector(end_point_el, line.end_point) - - def write_viewpoint_clipping_planes(self, viewpoint, parent): - if not viewpoint.clipping_planes: - return - planes_el = self._create_element(parent, "ClippingPlanes") - for plane in viewpoint.clipping_planes: - plane_el = self._create_element(planes_el, "ClippingPlane") - location_el = self._create_element(plane_el, "Location") - self.write_vector(location_el, plane.location) - direction_el = self._create_element(plane_el, "Direction") - self.write_vector(direction_el, plane.direction) - - def write_viewpoint_bitmaps(self, viewpoint, parent): - if not viewpoint.bitmaps: - return - bitmaps_el = self._create_element(parent, "Bitmaps") - for bitmap in viewpoint.bitmaps: - bitmap_el = self._create_element(bitmaps_el, "Bitmap") - - text_map = {"Format": bitmap.bitmap_format, "Reference": bitmap.reference} - for key, value in text_map.items(): - self._create_element(bitmap_el, key, text=value) - - location_el = self._create_element(bitmap_el, "Location") - self.write_vector(location_el, bitmap.location) - normal_el = self._create_element(bitmap_el, "Normal") - self.write_vector(normal_el, bitmap.normal) - up_el = self._create_element(bitmap_el, "Up") - self.write_vector(up_el, bitmap.up) - - self._create_element(bitmap_el, "Height", text=bitmap.height) - - def write_vector(self, parent, from_obj): - self._create_element(parent, "X", text=from_obj.x) - self._create_element(parent, "Y", text=from_obj.y) - self._create_element(parent, "Z", text=from_obj.z) - - def write_component(self, data, parent): - component_el = self._create_element(parent, "Component", {"IfcGuid": data.ifc_guid}) - text_map = { - "OriginatingSystem": data.originating_system, - "AuthoringToolId": data.authoring_tool_id, - } - for key, value in text_map.items(): - if value: - self._create_element(component_el, key, text=value) - - def add_viewpoint(self, topic, viewpoint=None): - if not viewpoint: - viewpoint = bcf.v3.data.Viewpoint() - if not viewpoint.guid: - viewpoint.guid = str(uuid.uuid4()) - if not viewpoint.viewpoint: - viewpoint.viewpoint = f"{viewpoint.guid}.bcfv" - if viewpoint.snapshot: - topic_filepath = os.path.join(self.filepath, topic.guid) - filepath = os.path.join(topic_filepath, viewpoint.snapshot) - if not os.path.exists(filepath): - filename = viewpoint.guid + os.path.splitext(viewpoint.snapshot)[-1] - copyfile(viewpoint.snapshot, os.path.join(topic_filepath, filename)) - viewpoint.snapshot = filename - topic.viewpoints[viewpoint.guid] = viewpoint - self.edit_topic(topic) - - def delete_viewpoint(self, guid, topic): - if guid not in topic.viewpoints: - return - viewpoint = topic.viewpoints[guid] - if viewpoint.snapshot: - filepath = os.path.join(self.filepath, topic.guid, viewpoint.snapshot) - if os.path.exists(filepath): - os.remove(filepath) - if viewpoint.viewpoint: - filepath = os.path.join(self.filepath, topic.guid, viewpoint.viewpoint) - if os.path.exists(filepath): - os.remove(filepath) - for bitmap in viewpoint.bitmaps: - if not bitmap.reference: - continue - filepath = os.path.join(self.filepath, topic.guid, bitmap.reference) - if os.path.exists(filepath): - os.remove(filepath) - del topic.viewpoints[guid] - self.edit_topic(topic) - - def delete_file(self, topic, index): - if not topic.header: - return - f = topic.header.files.pop(index) - filepath = os.path.join(self.filepath, topic.guid, f.reference) - if not f.is_external and os.path.exists(filepath): - os.remove(filepath) - self.edit_topic(topic) - - def delete_bim_snippet(self, topic): - if not topic.bim_snippet: - return - if topic.bim_snippet.reference and not topic.bim_snippet.is_external: - filepath = os.path.join(self.filepath, topic.guid, topic.bim_snippet.reference) - if os.path.exists(filepath): - os.remove(filepath) - topic.bim_snippet = None - self.edit_topic(topic) - - def delete_document_reference(self, topic, index): - document_reference = topic.document_references[index] - if document_reference.referenced_document and not document_reference.is_external: - filepath = os.path.join(self.filepath, topic.guid, document_reference.referenced_document) - if os.path.exists(filepath): - os.remove(filepath) - del topic.document_references[index] - self.edit_topic(topic) - - def add_document_reference(self, topic, document_reference): - if os.path.exists(document_reference.referenced_document): - topic_filepath = os.path.join(self.filepath, topic.guid) - filename = os.path.basename(document_reference.referenced_document) - copyfile( - document_reference.referenced_document, - os.path.join(topic_filepath, filename), - ) - document_reference.referenced_document = filename - document_reference.is_external = False - else: - document_reference.is_external = True - if not document_reference.guid: - document_reference.guid = str(uuid.uuid4()) - topic.document_references.append(document_reference) - self.edit_topic(topic) - - def add_bim_snippet(self, topic, bim_snippet): - if topic.bim_snippet: - self.delete_bim_snippet(topic) - if os.path.exists(bim_snippet.reference): - topic_filepath = os.path.join(self.filepath, topic.guid) - filename = os.path.basename(bim_snippet.reference) - copyfile(bim_snippet.reference, os.path.join(topic_filepath, filename)) - bim_snippet.reference = filename - bim_snippet.is_external = False - else: - bim_snippet.is_external = True - topic.bim_snippet = bim_snippet - self.edit_topic(topic) - - def add_file(self, topic, header_file): - if os.path.exists(header_file.reference): - topic_filepath = os.path.join(self.filepath, topic.guid) - header_file.filename = os.path.basename(header_file.reference) - copyfile( - header_file.reference, - os.path.join(topic_filepath, header_file.filename), - ) - header_file.reference = header_file.filename - header_file.is_external = False - header_file.date = datetime.utcnow().isoformat() - if not topic.header: - topic.header = bcf.v3.data.Header() - topic.header.files.append(header_file) - self.edit_topic(topic) - - def get_comments(self, guid): - comments = {} - data = self._read_xml(os.path.join(guid, "markup.bcf"), "markup.xsd") - if "Comments" not in data["Topic"]: - return comments - - for item in data["Topic"]["Comments"].get("Comment", []): - comment = bcf.v3.data.Comment() - mandatory_keys = { - "guid": "@Guid", - "date": "Date", - "author": "Author", - } - for key, value in mandatory_keys.items(): - setattr(comment, key, item[value]) - optional_keys = { - "comment": "Comment", - "modified_date": "ModifiedDate", - "modified_author": "ModifiedAuthor", - } - for key, value in optional_keys.items(): - if value in item: - setattr(comment, key, item[value]) - if "Viewpoint" in item: - viewpoint = bcf.v3.data.Viewpoint() - viewpoint.guid = item["Viewpoint"]["@Guid"] - comment.viewpoint = viewpoint - comments[comment.guid] = comment - self.topics[guid].comments = comments - return comments - - def get_viewpoints(self, guid): - viewpoints = {} - data = self._read_xml(os.path.join(guid, "markup.bcf"), "markup.xsd") - if "Viewpoints" not in data["Topic"]: - return viewpoints - for item in data["Topic"]["Viewpoints"]: - viewpoint = self.get_viewpoint(item, guid) - viewpoints[viewpoint.guid] = viewpoint - self.topics[guid].viewpoints = viewpoints - return viewpoints - - def get_viewpoint(self, data, topic_guid): - viewpoint = bcf.v3.data.Viewpoint() - viewpoint.guid = data["@Guid"] - optional_keys = { - "viewpoint": "Viewpoint", - "snapshot": "Snapshot", - "index": "Index", - } - for key, value in optional_keys.items(): - if value in data: - setattr(viewpoint, key, data[value]) - visinfo = self._read_xml(os.path.join(topic_guid, viewpoint.viewpoint), "visinfo.xsd") - viewpoint.components = self.get_viewpoint_components(visinfo) - viewpoint.orthogonal_camera = self.get_viewpoint_orthogonal_camera(visinfo) - viewpoint.perspective_camera = self.get_viewpoint_perspective_camera(visinfo) - viewpoint.lines = self.get_viewpoint_lines(visinfo) - viewpoint.clipping_planes = self.get_viewpoint_clipping_planes(visinfo) - viewpoint.bitmaps = self.get_viewpoint_bitmaps(visinfo) - return viewpoint - - def get_viewpoint_components(self, visinfo): - if "Components" not in visinfo: - return None - components = bcf.v3.data.Components() - data = visinfo["Components"] - if "Selection" in data and "Component" in data["Selection"]: - for item in data["Selection"].get("Component", []): - components.selection.append(self.get_component(item)) - if "Visibility" in data: - component_visibility = bcf.v3.data.ComponentVisibility() - if "@DefaultVisibility" in data["Visibility"]: - component_visibility.default_visibility = data["Visibility"]["@DefaultVisibility"] - if "Exceptions" in data["Visibility"] and "Component" in data["Visibility"]["Exceptions"]: - for item in data["Visibility"]["Exceptions"]["Component"]: - component_visibility.exceptions.append(self.get_component(item)) - if "ViewSetupHints" in data["Visibility"]: - view_setup_hints = bcf.v3.data.ViewSetupHints() - optional_keys = { - "spaces_visible": "@SpacesVisible", - "space_boundaries_visible": "@SpaceBoundariesVisible", - "openings_visible": "@OpeningsVisible", - } - for key, value in optional_keys.items(): - if value in data["Visibility"]["ViewSetupHints"]: - setattr(view_setup_hints, key, data["Visibility"]["ViewSetupHints"][value]) - component_visibility.view_setup_hints = view_setup_hints - components.visibility = component_visibility - if "Coloring" in data and "Color" in data["Coloring"]: - for item in data["Coloring"]["Color"]: - color = bcf.v3.data.Color() - color.color = item["@Color"] - for item2 in item["Components"]["Component"]: - color.components.append(self.get_component(item2)) - components.coloring.append(color) - return components - - def get_viewpoint_orthogonal_camera(self, visinfo): - if "OrthogonalCamera" not in visinfo: - return None - camera = bcf.v3.data.OrthogonalCamera() - data = visinfo["OrthogonalCamera"] - self.set_vector(camera.camera_view_point, data["CameraViewPoint"]) - self.set_vector(camera.camera_direction, data["CameraDirection"]) - self.set_vector(camera.camera_up_vector, data["CameraUpVector"]) - camera.view_to_world_scale = data["ViewToWorldScale"] - camera.aspect_ratio = data["AspectRatio"] - return camera - - def get_viewpoint_perspective_camera(self, visinfo): - if "PerspectiveCamera" not in visinfo: - return None - camera = bcf.v3.data.PerspectiveCamera() - data = visinfo["PerspectiveCamera"] - self.set_vector(camera.camera_view_point, data["CameraViewPoint"]) - self.set_vector(camera.camera_direction, data["CameraDirection"]) - self.set_vector(camera.camera_up_vector, data["CameraUpVector"]) - camera.field_of_view = data["FieldOfView"] - camera.aspect_ratio = data["AspectRatio"] - return camera - - def get_viewpoint_lines(self, visinfo): - if "Lines" not in visinfo: - return [] - lines = [] - for item in visinfo["Lines"].get("Line", []): - line = bcf.v3.data.Line() - self.set_vector(line.start_point, item["StartPoint"]) - self.set_vector(line.end_point, item["EndPoint"]) - lines.append(line) - return lines - - def get_viewpoint_clipping_planes(self, visinfo): - if "ClippingPlanes" not in visinfo: - return [] - planes = [] - for item in visinfo["ClippingPlanes"]["ClippingPlane"]: - plane = bcf.v3.data.ClippingPlane() - self.set_vector(plane.location, item["Location"]) - self.set_vector(plane.direction, item["Direction"]) - planes.append(plane) - return planes - - def get_viewpoint_bitmaps(self, visinfo): - if "Bitmaps" not in visinfo: - return [] - bitmaps = [] - for item in visinfo["Bitmaps"].get("Bitmap"): - bitmap = bcf.v3.data.Bitmap() - bitmap.reference = item["Reference"] - bitmap.bitmap_format = item["Format"].upper() - self.set_vector(bitmap.location, item["Location"]) - self.set_vector(bitmap.normal, item["Normal"]) - self.set_vector(bitmap.up, item["Up"]) - bitmap.height = item["Height"] - bitmaps.append(bitmap) - return bitmaps - - def set_vector(self, to_obj, from_xml): - to_obj.x = from_xml["X"] - to_obj.y = from_xml["Y"] - to_obj.z = from_xml["Z"] - - def get_component(self, data): - component = bcf.v3.data.Component() - optional_keys = { - "originating_system": "OriginatingSystem", - "authoring_tool_id": "AuthoringToolId", - "ifc_guid": "@IfcGuid", - } - for key, value in optional_keys.items(): - if value in data: - setattr(component, key, data[value]) - return component - - def close_project(self): - shutil.rmtree(self.filepath) - - def _read_xml(self, filename, xsd): - schema = XMLSchema(os.path.join(cwd, "xsd", xsd)) - filepath = os.path.join(self.filepath, filename) - (data, errors) = schema.to_dict(filepath, validation="lax") - for error in errors: - self.logger.error(error) - return data - - def _create_element(self, parent, name, attributes={}, text=None): - element = self.document.createElement(name) - for key, value in attributes.items(): - if isinstance(value, bool): - element.setAttribute(key, str(value).lower()) - elif value: - element.setAttribute(key, value) - if text is not None: - text = self.document.createTextNode(str(text)) - element.appendChild(text) - parent.appendChild(element) - return element - - def __del__(self): - self.close_project() + # TODO: deprecate other methods + # endregion diff --git a/src/bcf/src/bcf/v3/data.py b/src/bcf/src/bcf/v3/data.py deleted file mode 100644 index c98efc42fd..0000000000 --- a/src/bcf/src/bcf/v3/data.py +++ /dev/null @@ -1,200 +0,0 @@ - -# BCF - BCF Python library -# Copyright (C) 2021 Prabhat Singh -# -# This file is part of BCF. -# -# BCF 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. -# -# BCF 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 BCF. If not, see . - -class Project: - def __init__(self): - self.project_id = "" - self.name = "" - - -class BimSnippet: - def __init__(self): - self.snippet_type = None - self.is_external = False - self.reference = None - self.reference_schema = None - - -class DocumentReference: - def __init__(self): - self.description = None - self.document_guid = None - self.url = None - self.guid = None - - -class RelatedTopic: - def __init__(self): - self.guid = None - - -class HeaderFile: - def __init__(self): - self.filename = "" - self.date = None - self.reference = "" - self.ifc_project = None - self.ifc_spatial_structure_element = None - self.is_external = True - - -class Header: - def __init__(self): - self.files = [] - - -class Topic: - def __init__(self): - self.reference_links = [] - self.title = "" - self.priority = None - self.index = None # Deprecated, stored, but ignored - self.labels = [] - self.creation_date = None - self.creation_author = None - self.modified_date = None - self.modified_author = None - self.due_date = None - self.assigned_to = None - self.stage = None - self.description = None - self.bim_snippet = None - self.document_references = [] - self.related_topics = [] - self.topic_status = None - self.topic_type = None - self.guid = None - - self.header = None - self.comments = {} - self.viewpoints = {} - self.server_assigned_id = "" - - -class Comment: - def __init__(self): - self.guid = None - self.date = None - self.author = "" - self.comment = "" - self.viewpoint = None - self.modified_date = None - self.modified_author = "" - - -class ViewSetupHints: - def __init__(self): - self.spaces_visible = False - self.space_boundaries_visible = False - self.openings_visible = False - - -class Component: - def __init__(self): - self.originating_system = None - self.authoring_tool_id = None - self.ifc_guid = None - - -class ComponentVisibility: - def __init__(self): - self.exceptions = [] - self.default_visibility = False - self.view_setup_hints = None - - -class Color: - def __init__(self): - self.color = None - self.components = [] - - -class Components: - def __init__(self): - - self.selection = [] - self.visibility = None - self.coloring = [] - - -class Point: - def __init__(self): - self.x = 0 - self.y = 0 - self.z = 0 - - -class Direction(Point): - pass - - -class OrthogonalCamera: - def __init__(self): - self.camera_view_point = Point() - self.camera_direction = Direction() - self.camera_up_vector = Direction() - self.view_to_world_scale = 1.0 - self.aspect_ratio = 1.0 - - -class PerspectiveCamera: - def __init__(self): - self.camera_view_point = Point() - self.camera_direction = Direction() - self.camera_up_vector = Direction() - self.field_of_view = 60.0 - self.aspect_ratio = 1.0 - - -class Line: - def __init__(self): - self.start_point = Point() - self.end_point = Point() - - -class ClippingPlane: - def __init__(self): - self.location = Point() - self.direction = Direction() - - -class Bitmap: - def __init__(self): - self.reference = "" # Only in BCF-XML - self.bitmap_data = None # Only in BCF-API - self.bitmap_format = "PNG" # Enum of png or jpg - self.location = Point() - self.normal = Direction() - self.up = Direction() - self.height = 1.0 - - -class Viewpoint: - def __init__(self): - self.guid = None - self.viewpoint = None - self.snapshot = None - self.index = None - - self.components = None # It's not a list, despite the plural name - self.orthogonal_camera = None - self.perspective_camera = None - self.lines = [] - self.clipping_planes = [] - self.bitmaps = [] diff --git a/src/bcf/src/bcf/v3/document.py b/src/bcf/src/bcf/v3/document.py new file mode 100644 index 0000000000..56a15da42d --- /dev/null +++ b/src/bcf/src/bcf/v3/document.py @@ -0,0 +1,61 @@ +"""BCF XML V3 Documents handler.""" +import zipfile +from typing import Any, Optional + +import bcf.v3.model as mdl +from bcf.inmemory_zipfile import ZipFileInterface +from bcf.xml_parser import AbstractXmlParserSerializer, XmlParserSerializer + + +class DocumentsHandler: + """BCF documents handler.""" + + def __init__( + self, + definition: mdl.DocumentInfo, + documents: Optional[dict[str, bytes]] = None, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> None: + self.definition = definition + self.documents = documents or {} + self._xml_handler = xml_handler or XmlParserSerializer() + + @classmethod + def load( + cls, + zip_file: zipfile.ZipFile, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> Optional["DocumentsHandler"]: + """ + Loads the documents from the given zip file directory. + + Args: + zip_path: The directory path inside the zip file. + xml_handler: The xml parser/serializer to use. + + Returns: + The documents handler. + """ + xml_handler = xml_handler or XmlParserSerializer() + file_to_open = zipfile.Path(zip_file, "documents.xml") + if not file_to_open.exists(): + return None + definition = xml_handler.parse(file_to_open.read_bytes(), mdl.DocumentInfo) + documents = {} + if def_docs := definition.documents: + for document in def_docs.document: + document_path = zipfile.Path(zip_file, f"documents/{document.guid}") + if document_path.exists(): + documents[document.filename] = document_path.read_bytes() + return cls(definition, documents=documents) + + def save(self, bcf_zip: ZipFileInterface) -> None: + """Save the documents to the zip file.""" + bcf_zip.writestr("documents.xml", self._xml_handler.serialize(self.definition)) + if documents := self.definition.documents: + for doc in documents.document: + if doc.filename in self.documents: + bcf_zip.writestr( + f"documents/{doc.guid}", + self.documents[doc.filename], + ) diff --git a/src/bcf/src/bcf/v3/model/__init__.py b/src/bcf/src/bcf/v3/model/__init__.py new file mode 100644 index 0000000000..ed74e66dcc --- /dev/null +++ b/src/bcf/src/bcf/v3/model/__init__.py @@ -0,0 +1,110 @@ +from bcf.v3.model.documents import Document, DocumentInfo, DocumentInfoDocuments +from bcf.v3.model.extensions import ( + Extensions, + ExtensionsPriorities, + ExtensionsSnippetTypes, + ExtensionsStages, + ExtensionsTopicLabels, + ExtensionsTopicStatuses, + ExtensionsTopicTypes, + ExtensionsUsers, +) +from bcf.v3.model.markup import ( + BimSnippet, + Comment, + CommentViewpoint, + DocumentReference, + File, + Header, + HeaderFiles, + Markup, + Topic, + TopicComments, + TopicDocumentReferences, + TopicLabels, + TopicReferenceLinks, + TopicRelatedTopics, + TopicRelatedTopicsRelatedTopic, + TopicViewpoints, + ViewPoint, +) +from bcf.v3.model.project import Project, ProjectInfo +from bcf.v3.model.version import Version +from bcf.v3.model.visinfo import ( + Bitmap, + BitmapFormat, + ClippingPlane, + Component, + ComponentColoring, + ComponentColoringColor, + ComponentColoringColorComponents, + Components, + ComponentSelection, + ComponentVisibility, + ComponentVisibilityExceptions, + Direction, + Line, + OrthogonalCamera, + PerspectiveCamera, + Point, + ViewSetupHints, + VisualizationInfo, + VisualizationInfoBitmaps, + VisualizationInfoClippingPlanes, + VisualizationInfoLines, +) + +__all__ = [ + "Document", + "DocumentInfo", + "DocumentInfoDocuments", + "Extensions", + "ExtensionsPriorities", + "ExtensionsSnippetTypes", + "ExtensionsStages", + "ExtensionsTopicLabels", + "ExtensionsTopicStatuses", + "ExtensionsTopicTypes", + "ExtensionsUsers", + "BimSnippet", + "Comment", + "CommentViewpoint", + "DocumentReference", + "File", + "Header", + "HeaderFiles", + "Markup", + "Topic", + "TopicComments", + "TopicDocumentReferences", + "TopicLabels", + "TopicReferenceLinks", + "TopicRelatedTopics", + "TopicRelatedTopicsRelatedTopic", + "TopicViewpoints", + "ViewPoint", + "Project", + "ProjectInfo", + "Version", + "Bitmap", + "BitmapFormat", + "ClippingPlane", + "Component", + "ComponentColoring", + "ComponentColoringColor", + "ComponentColoringColorComponents", + "ComponentSelection", + "ComponentVisibility", + "ComponentVisibilityExceptions", + "Components", + "Direction", + "Line", + "OrthogonalCamera", + "PerspectiveCamera", + "Point", + "ViewSetupHints", + "VisualizationInfo", + "VisualizationInfoBitmaps", + "VisualizationInfoClippingPlanes", + "VisualizationInfoLines", +] diff --git a/src/bcf/src/bcf/v3/model/documents.py b/src/bcf/src/bcf/v3/model/documents.py new file mode 100644 index 0000000000..c9a1b43c9c --- /dev/null +++ b/src/bcf/src/bcf/v3/model/documents.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass(slots=True, kw_only=True) +class Document: + filename: str = field( + metadata={ + "name": "Filename", + "type": "Element", + "namespace": "", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + description: Optional[str] = field( + default=None, + metadata={ + "name": "Description", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", + } + ) + + +@dataclass(slots=True, kw_only=True) +class DocumentInfoDocuments: + class Meta: + global_type = False + + document: List[Document] = field( + default_factory=list, + metadata={ + "name": "Document", + "type": "Element", + "namespace": "", + } + ) + + +@dataclass(slots=True, kw_only=True) +class DocumentInfo: + documents: Optional[DocumentInfoDocuments] = field( + default=None, + metadata={ + "name": "Documents", + "type": "Element", + "namespace": "", + } + ) diff --git a/src/bcf/src/bcf/v3/model/extensions.py b/src/bcf/src/bcf/v3/model/extensions.py new file mode 100644 index 0000000000..cb0af500f6 --- /dev/null +++ b/src/bcf/src/bcf/v3/model/extensions.py @@ -0,0 +1,181 @@ +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass(slots=True, kw_only=True) +class ExtensionsPriorities: + class Meta: + global_type = False + + priority: List[str] = field( + default_factory=list, + metadata={ + "name": "Priority", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsSnippetTypes: + class Meta: + global_type = False + + snippet_type: List[str] = field( + default_factory=list, + metadata={ + "name": "SnippetType", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsStages: + class Meta: + global_type = False + + stage: List[str] = field( + default_factory=list, + metadata={ + "name": "Stage", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsTopicLabels: + class Meta: + global_type = False + + topic_label: List[str] = field( + default_factory=list, + metadata={ + "name": "TopicLabel", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsTopicStatuses: + class Meta: + global_type = False + + topic_status: List[str] = field( + default_factory=list, + metadata={ + "name": "TopicStatus", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsTopicTypes: + class Meta: + global_type = False + + topic_type: List[str] = field( + default_factory=list, + metadata={ + "name": "TopicType", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ExtensionsUsers: + class Meta: + global_type = False + + user: List[str] = field( + default_factory=list, + metadata={ + "name": "User", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Extensions: + topic_types: Optional[ExtensionsTopicTypes] = field( + default=None, + metadata={ + "name": "TopicTypes", + "type": "Element", + "namespace": "", + } + ) + topic_statuses: Optional[ExtensionsTopicStatuses] = field( + default=None, + metadata={ + "name": "TopicStatuses", + "type": "Element", + "namespace": "", + } + ) + priorities: Optional[ExtensionsPriorities] = field( + default=None, + metadata={ + "name": "Priorities", + "type": "Element", + "namespace": "", + } + ) + topic_labels: Optional[ExtensionsTopicLabels] = field( + default=None, + metadata={ + "name": "TopicLabels", + "type": "Element", + "namespace": "", + } + ) + users: Optional[ExtensionsUsers] = field( + default=None, + metadata={ + "name": "Users", + "type": "Element", + "namespace": "", + } + ) + snippet_types: Optional[ExtensionsSnippetTypes] = field( + default=None, + metadata={ + "name": "SnippetTypes", + "type": "Element", + "namespace": "", + } + ) + stages: Optional[ExtensionsStages] = field( + default=None, + metadata={ + "name": "Stages", + "type": "Element", + "namespace": "", + } + ) diff --git a/src/bcf/src/bcf/v3/model/markup.py b/src/bcf/src/bcf/v3/model/markup.py new file mode 100644 index 0000000000..f212b9fc7e --- /dev/null +++ b/src/bcf/src/bcf/v3/model/markup.py @@ -0,0 +1,616 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDateTime + + +@dataclass(slots=True, kw_only=True) +class BimSnippet: + reference: str = field( + metadata={ + "name": "Reference", + "type": "Element", + "namespace": "", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + reference_schema: str = field( + metadata={ + "name": "ReferenceSchema", + "type": "Element", + "namespace": "", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + snippet_type: str = field( + metadata={ + "name": "SnippetType", + "type": "Attribute", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + is_external: bool = field( + default=False, + metadata={ + "name": "IsExternal", + "type": "Attribute", + } + ) + + +@dataclass(slots=True, kw_only=True) +class CommentViewpoint: + class Meta: + global_type = False + + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", + } + ) + + +@dataclass(slots=True, kw_only=True) +class DocumentReference: + document_guid: Optional[str] = field( + default=None, + metadata={ + "name": "DocumentGuid", + "type": "Element", + "namespace": "", + "pattern": r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", + } + ) + url: Optional[str] = field( + default=None, + metadata={ + "name": "Url", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + description: Optional[str] = field( + default=None, + metadata={ + "name": "Description", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", + } + ) + + +@dataclass(slots=True, kw_only=True) +class File: + filename: Optional[str] = field( + default=None, + metadata={ + "name": "Filename", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + date: Optional[XmlDateTime] = field( + default=None, + metadata={ + "name": "Date", + "type": "Element", + "namespace": "", + } + ) + reference: Optional[str] = field( + default=None, + metadata={ + "name": "Reference", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + ifc_project: Optional[str] = field( + default=None, + metadata={ + "name": "IfcProject", + "type": "Attribute", + "length": 22, + "pattern": r"[0-9A-Za-z_$]*", + } + ) + ifc_spatial_structure_element: Optional[str] = field( + default=None, + metadata={ + "name": "IfcSpatialStructureElement", + "type": "Attribute", + "length": 22, + "pattern": r"[0-9A-Za-z_$]*", + } + ) + is_external: bool = field( + default=True, + metadata={ + "name": "IsExternal", + "type": "Attribute", + } + ) + + +@dataclass(slots=True, kw_only=True) +class TopicLabels: + class Meta: + global_type = False + + label: List[str] = field( + default_factory=list, + metadata={ + "name": "Label", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class TopicReferenceLinks: + class Meta: + global_type = False + + reference_link: List[str] = field( + default_factory=list, + metadata={ + "name": "ReferenceLink", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class TopicRelatedTopicsRelatedTopic: + class Meta: + global_type = False + + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ViewPoint: + viewpoint: Optional[str] = field( + default=None, + metadata={ + "name": "Viewpoint", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + snapshot: Optional[str] = field( + default=None, + metadata={ + "name": "Snapshot", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + index: Optional[int] = field( + default=None, + metadata={ + "name": "Index", + "type": "Element", + "namespace": "", + } + ) + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Comment: + date: XmlDateTime = field( + metadata={ + "name": "Date", + "type": "Element", + "namespace": "", + "required": True, + } + ) + author: str = field( + metadata={ + "name": "Author", + "type": "Element", + "namespace": "", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + comment: Optional[str] = field( + default=None, + metadata={ + "name": "Comment", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + viewpoint: Optional[CommentViewpoint] = field( + default=None, + metadata={ + "name": "Viewpoint", + "type": "Element", + "namespace": "", + } + ) + modified_date: Optional[XmlDateTime] = field( + default=None, + metadata={ + "name": "ModifiedDate", + "type": "Element", + "namespace": "", + } + ) + modified_author: Optional[str] = field( + default=None, + metadata={ + "name": "ModifiedAuthor", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", + } + ) + + +@dataclass(slots=True, kw_only=True) +class HeaderFiles: + class Meta: + global_type = False + + file: List[File] = field( + default_factory=list, + metadata={ + "name": "File", + "type": "Element", + "namespace": "", + } + ) + + +@dataclass(slots=True, kw_only=True) +class TopicDocumentReferences: + class Meta: + global_type = False + + document_reference: List[DocumentReference] = field( + default_factory=list, + metadata={ + "name": "DocumentReference", + "type": "Element", + "namespace": "", + } + ) + + +@dataclass(slots=True, kw_only=True) +class TopicRelatedTopics: + class Meta: + global_type = False + + related_topic: List[TopicRelatedTopicsRelatedTopic] = field( + default_factory=list, + metadata={ + "name": "RelatedTopic", + "type": "Element", + "namespace": "", + } + ) + + +@dataclass(slots=True, kw_only=True) +class TopicViewpoints: + class Meta: + global_type = False + + view_point: List[ViewPoint] = field( + default_factory=list, + metadata={ + "name": "ViewPoint", + "type": "Element", + "namespace": "", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Header: + files: Optional[HeaderFiles] = field( + default=None, + metadata={ + "name": "Files", + "type": "Element", + "namespace": "", + } + ) + + +@dataclass(slots=True, kw_only=True) +class TopicComments: + class Meta: + global_type = False + + comment: List[Comment] = field( + default_factory=list, + metadata={ + "name": "Comment", + "type": "Element", + "namespace": "", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Topic: + reference_links: Optional[TopicReferenceLinks] = field( + default=None, + metadata={ + "name": "ReferenceLinks", + "type": "Element", + "namespace": "", + } + ) + title: str = field( + metadata={ + "name": "Title", + "type": "Element", + "namespace": "", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + priority: Optional[str] = field( + default=None, + metadata={ + "name": "Priority", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + index: Optional[int] = field( + default=None, + metadata={ + "name": "Index", + "type": "Element", + "namespace": "", + } + ) + labels: Optional[TopicLabels] = field( + default=None, + metadata={ + "name": "Labels", + "type": "Element", + "namespace": "", + } + ) + creation_date: XmlDateTime = field( + metadata={ + "name": "CreationDate", + "type": "Element", + "namespace": "", + "required": True, + } + ) + creation_author: str = field( + metadata={ + "name": "CreationAuthor", + "type": "Element", + "namespace": "", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + modified_date: Optional[XmlDateTime] = field( + default=None, + metadata={ + "name": "ModifiedDate", + "type": "Element", + "namespace": "", + } + ) + modified_author: Optional[str] = field( + default=None, + metadata={ + "name": "ModifiedAuthor", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + due_date: Optional[XmlDateTime] = field( + default=None, + metadata={ + "name": "DueDate", + "type": "Element", + "namespace": "", + } + ) + assigned_to: Optional[str] = field( + default=None, + metadata={ + "name": "AssignedTo", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + stage: Optional[str] = field( + default=None, + metadata={ + "name": "Stage", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + description: Optional[str] = field( + default=None, + metadata={ + "name": "Description", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + bim_snippet: Optional[BimSnippet] = field( + default=None, + metadata={ + "name": "BimSnippet", + "type": "Element", + "namespace": "", + } + ) + document_references: Optional[TopicDocumentReferences] = field( + default=None, + metadata={ + "name": "DocumentReferences", + "type": "Element", + "namespace": "", + } + ) + related_topics: Optional[TopicRelatedTopics] = field( + default=None, + metadata={ + "name": "RelatedTopics", + "type": "Element", + "namespace": "", + } + ) + comments: Optional[TopicComments] = field( + default=None, + metadata={ + "name": "Comments", + "type": "Element", + "namespace": "", + } + ) + viewpoints: Optional[TopicViewpoints] = field( + default=None, + metadata={ + "name": "Viewpoints", + "type": "Element", + "namespace": "", + } + ) + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", + } + ) + server_assigned_id: Optional[str] = field( + default=None, + metadata={ + "name": "ServerAssignedId", + "type": "Attribute", + "min_length": 1, + "white_space": "collapse", + } + ) + topic_type: str = field( + metadata={ + "name": "TopicType", + "type": "Attribute", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + topic_status: str = field( + metadata={ + "name": "TopicStatus", + "type": "Attribute", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Markup: + header: Optional[Header] = field( + default=None, + metadata={ + "name": "Header", + "type": "Element", + "namespace": "", + } + ) + topic: Topic = field( + metadata={ + "name": "Topic", + "type": "Element", + "namespace": "", + "required": True, + } + ) diff --git a/src/bcf/src/bcf/v3/model/project.py b/src/bcf/src/bcf/v3/model/project.py new file mode 100644 index 0000000000..b6962213d6 --- /dev/null +++ b/src/bcf/src/bcf/v3/model/project.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass(slots=True, kw_only=True) +class Project: + name: Optional[str] = field( + default=None, + metadata={ + "name": "Name", + "type": "Element", + "namespace": "", + "min_length": 1, + "white_space": "collapse", + } + ) + project_id: str = field( + metadata={ + "name": "ProjectId", + "type": "Attribute", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ProjectInfo: + project: Project = field( + metadata={ + "name": "Project", + "type": "Element", + "namespace": "", + "required": True, + } + ) diff --git a/src/bcf/src/bcf/v3/model/version.py b/src/bcf/src/bcf/v3/model/version.py new file mode 100644 index 0000000000..f3a44d2ab3 --- /dev/null +++ b/src/bcf/src/bcf/v3/model/version.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass, field + + +@dataclass(slots=True, kw_only=True) +class Version: + version_id: str = field( + metadata={ + "name": "VersionId", + "type": "Attribute", + "required": True, + } + ) diff --git a/src/bcf/src/bcf/v3/model/visinfo.py b/src/bcf/src/bcf/v3/model/visinfo.py new file mode 100644 index 0000000000..fa541945ea --- /dev/null +++ b/src/bcf/src/bcf/v3/model/visinfo.py @@ -0,0 +1,524 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Optional + + +class BitmapFormat(Enum): + PNG = "png" + JPG = "jpg" + + +@dataclass(slots=True, kw_only=True) +class Component: + originating_system: Optional[str] = field( + default=None, + metadata={ + "name": "OriginatingSystem", + "type": "Element", + "min_length": 1, + "white_space": "collapse", + } + ) + authoring_tool_id: Optional[str] = field( + default=None, + metadata={ + "name": "AuthoringToolId", + "type": "Element", + "min_length": 1, + "white_space": "collapse", + } + ) + ifc_guid: Optional[str] = field( + default=None, + metadata={ + "name": "IfcGuid", + "type": "Attribute", + "length": 22, + "pattern": r"[0-9A-Za-z_$]*", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Direction: + x: float = field( + metadata={ + "name": "X", + "type": "Element", + "required": True, + } + ) + y: float = field( + metadata={ + "name": "Y", + "type": "Element", + "required": True, + } + ) + z: float = field( + metadata={ + "name": "Z", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class Point: + x: float = field( + metadata={ + "name": "X", + "type": "Element", + "required": True, + } + ) + y: float = field( + metadata={ + "name": "Y", + "type": "Element", + "required": True, + } + ) + z: float = field( + metadata={ + "name": "Z", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ViewSetupHints: + spaces_visible: bool = field( + default=False, + metadata={ + "name": "SpacesVisible", + "type": "Attribute", + } + ) + space_boundaries_visible: bool = field( + default=False, + metadata={ + "name": "SpaceBoundariesVisible", + "type": "Attribute", + } + ) + openings_visible: bool = field( + default=False, + metadata={ + "name": "OpeningsVisible", + "type": "Attribute", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Bitmap: + format: BitmapFormat = field( + metadata={ + "name": "Format", + "type": "Element", + "required": True, + } + ) + reference: str = field( + metadata={ + "name": "Reference", + "type": "Element", + "required": True, + "min_length": 1, + "white_space": "collapse", + } + ) + location: Point = field( + metadata={ + "name": "Location", + "type": "Element", + "required": True, + } + ) + normal: Direction = field( + metadata={ + "name": "Normal", + "type": "Element", + "required": True, + } + ) + up: Direction = field( + metadata={ + "name": "Up", + "type": "Element", + "required": True, + } + ) + height: float = field( + metadata={ + "name": "Height", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ClippingPlane: + location: Point = field( + metadata={ + "name": "Location", + "type": "Element", + "required": True, + } + ) + direction: Direction = field( + metadata={ + "name": "Direction", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentColoringColorComponents: + class Meta: + global_type = False + + component: List[Component] = field( + default_factory=list, + metadata={ + "name": "Component", + "type": "Element", + "min_occurs": 1, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentSelection: + component: List[Component] = field( + default_factory=list, + metadata={ + "name": "Component", + "type": "Element", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentVisibilityExceptions: + class Meta: + global_type = False + + component: List[Component] = field( + default_factory=list, + metadata={ + "name": "Component", + "type": "Element", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Line: + start_point: Point = field( + metadata={ + "name": "StartPoint", + "type": "Element", + "required": True, + } + ) + end_point: Point = field( + metadata={ + "name": "EndPoint", + "type": "Element", + "required": True, + } + ) + + +@dataclass(slots=True, kw_only=True) +class OrthogonalCamera: + """ + Attributes + camera_view_point: + camera_direction: + camera_up_vector: + view_to_world_scale: view's visible vertical size in meters + aspect_ratio: Proportional relationship between the width and + the height of the view (w/h). + """ + camera_view_point: Point = field( + metadata={ + "name": "CameraViewPoint", + "type": "Element", + "required": True, + } + ) + camera_direction: Direction = field( + metadata={ + "name": "CameraDirection", + "type": "Element", + "required": True, + } + ) + camera_up_vector: Direction = field( + metadata={ + "name": "CameraUpVector", + "type": "Element", + "required": True, + } + ) + view_to_world_scale: float = field( + metadata={ + "name": "ViewToWorldScale", + "type": "Element", + "required": True, + } + ) + aspect_ratio: float = field( + metadata={ + "name": "AspectRatio", + "type": "Element", + "required": True, + "min_exclusive": 0.0, + } + ) + + +@dataclass(slots=True, kw_only=True) +class PerspectiveCamera: + """ + Attributes + camera_view_point: + camera_direction: + camera_up_vector: + field_of_view: Vertical field of view, in degrees. It is + currently limited to a value between 45 and 60 degrees. This + limitation will be dropped in the next release and viewers + should be expect values outside this range in current + implementations. + aspect_ratio: Proportional relationship between the width and + the height of the view (w/h). + """ + camera_view_point: Point = field( + metadata={ + "name": "CameraViewPoint", + "type": "Element", + "required": True, + } + ) + camera_direction: Direction = field( + metadata={ + "name": "CameraDirection", + "type": "Element", + "required": True, + } + ) + camera_up_vector: Direction = field( + metadata={ + "name": "CameraUpVector", + "type": "Element", + "required": True, + } + ) + field_of_view: float = field( + metadata={ + "name": "FieldOfView", + "type": "Element", + "required": True, + "min_exclusive": 0.0, + "max_exclusive": 180.0, + } + ) + aspect_ratio: float = field( + metadata={ + "name": "AspectRatio", + "type": "Element", + "required": True, + "min_exclusive": 0.0, + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentColoringColor: + class Meta: + global_type = False + + components: ComponentColoringColorComponents = field( + metadata={ + "name": "Components", + "type": "Element", + "required": True, + } + ) + color: str = field( + metadata={ + "name": "Color", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentVisibility: + view_setup_hints: Optional[ViewSetupHints] = field( + default=None, + metadata={ + "name": "ViewSetupHints", + "type": "Element", + } + ) + exceptions: Optional[ComponentVisibilityExceptions] = field( + default=None, + metadata={ + "name": "Exceptions", + "type": "Element", + } + ) + default_visibility: bool = field( + default=False, + metadata={ + "name": "DefaultVisibility", + "type": "Attribute", + } + ) + + +@dataclass(slots=True, kw_only=True) +class VisualizationInfoBitmaps: + class Meta: + global_type = False + + bitmap: List[Bitmap] = field( + default_factory=list, + metadata={ + "name": "Bitmap", + "type": "Element", + } + ) + + +@dataclass(slots=True, kw_only=True) +class VisualizationInfoClippingPlanes: + class Meta: + global_type = False + + clipping_plane: List[ClippingPlane] = field( + default_factory=list, + metadata={ + "name": "ClippingPlane", + "type": "Element", + } + ) + + +@dataclass(slots=True, kw_only=True) +class VisualizationInfoLines: + class Meta: + global_type = False + + line: List[Line] = field( + default_factory=list, + metadata={ + "name": "Line", + "type": "Element", + } + ) + + +@dataclass(slots=True, kw_only=True) +class ComponentColoring: + color: List[ComponentColoringColor] = field( + default_factory=list, + metadata={ + "name": "Color", + "type": "Element", + } + ) + + +@dataclass(slots=True, kw_only=True) +class Components: + selection: Optional[ComponentSelection] = field( + default=None, + metadata={ + "name": "Selection", + "type": "Element", + } + ) + visibility: Optional[ComponentVisibility] = field( + default=None, + metadata={ + "name": "Visibility", + "type": "Element", + } + ) + coloring: Optional[ComponentColoring] = field( + default=None, + metadata={ + "name": "Coloring", + "type": "Element", + } + ) + + +@dataclass(slots=True, kw_only=True) +class VisualizationInfo: + """ + VisualizationInfo documentation. + """ + components: Optional[Components] = field( + default=None, + metadata={ + "name": "Components", + "type": "Element", + } + ) + orthogonal_camera: Optional[OrthogonalCamera] = field( + default=None, + metadata={ + "name": "OrthogonalCamera", + "type": "Element", + } + ) + perspective_camera: Optional[PerspectiveCamera] = field( + default=None, + metadata={ + "name": "PerspectiveCamera", + "type": "Element", + } + ) + lines: Optional[VisualizationInfoLines] = field( + default=None, + metadata={ + "name": "Lines", + "type": "Element", + } + ) + clipping_planes: Optional[VisualizationInfoClippingPlanes] = field( + default=None, + metadata={ + "name": "ClippingPlanes", + "type": "Element", + } + ) + bitmaps: Optional[VisualizationInfoBitmaps] = field( + default=None, + metadata={ + "name": "Bitmaps", + "type": "Element", + } + ) + guid: str = field( + metadata={ + "name": "Guid", + "type": "Attribute", + "required": True, + "pattern": r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", + } + ) diff --git a/src/bcf/src/bcf/v3/topic.py b/src/bcf/src/bcf/v3/topic.py new file mode 100644 index 0000000000..c3a90f7a1b --- /dev/null +++ b/src/bcf/src/bcf/v3/topic.py @@ -0,0 +1,188 @@ +"""BCF XML V3 Topic handler.""" +import datetime +import uuid +import zipfile +from pathlib import Path +from typing import Any, Optional + +from ifcopenshell import entity_instance +from xsdata.models.datatype import XmlDateTime + +import bcf.v3.model as mdl +from bcf.inmemory_zipfile import ZipFileInterface +from bcf.v3.visinfo import VisualizationInfoHandler +from bcf.xml_parser import AbstractXmlParserSerializer, XmlParserSerializer + + +class TopicHandler: + """BCF Topic and related objects handler.""" + + def __init__( + self, + topic_dir: Optional[zipfile.Path] = None, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> None: + self._markup: Optional[mdl.Markup] = None + self._viewpoints: dict[str, VisualizationInfoHandler] = {} + self._bim_snippet: Optional[bytes] = None + self._xml_handler = xml_handler or XmlParserSerializer() + self._topic_dir = topic_dir + + @property + def markup(self) -> Optional[mdl.Markup]: + if not self._markup and self._topic_dir: + markup_path = self._topic_dir.joinpath("markup.bcf") + if markup_path.exists(): + self._markup = self._xml_handler.parse(markup_path.read_bytes(), mdl.Markup) + return self._markup + + @markup.setter + def markup(self, value: mdl.Markup) -> None: + self._markup = value + + @property + def topic(self) -> mdl.Topic: + """Return the Topic object.""" + return self.markup.topic + + @property + def guid(self) -> Optional[str]: + """Return the GUID of the topic.""" + if self._markup: + return self.topic.guid + return self._topic_dir.name if self._topic_dir else None + + @property + def header(self) -> Optional[mdl.Header]: + """Return the header of the topic.""" + return self.markup.header + + @property + def comments(self) -> list[mdl.Comment]: + """Return the comments of the topic.""" + return self.topic.comments.comment if self.topic.comments else [] + + @property + def bim_snippet(self) -> Optional[bytes]: + if not self._bim_snippet and self._topic_dir: + self._bim_snippet = self._load_bim_snippet() + return self._bim_snippet + + @bim_snippet.setter + def bim_snippet(self, value: bytes) -> None: + self._bim_snippet = value + + @property + def viewpoints(self) -> Optional[VisualizationInfoHandler]: + if ( + not self._viewpoints + and self._topic_dir + and self.topic.viewpoints + and (viewpoints := self.topic.viewpoints.view_point) + ): + self._viewpoints = VisualizationInfoHandler.from_topic_viewpoints(self._topic_dir, viewpoints) + return self._viewpoints + + def _load_bim_snippet(self) -> Optional[bytes]: + bim_snippet_obj = self.topic.bim_snippet + if bim_snippet_obj and not bim_snippet_obj.is_external and self._topic_dir: + bim_snippet_path = self._topic_dir.joinpath(bim_snippet_obj.reference) + if bim_snippet_path.exists(): + return bim_snippet_path.read_bytes() + return None + + @classmethod + def create_new( + cls, + title: str, + description: str, + author: str, + topic_type: str = "", + topic_status: str = "", + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> "TopicHandler": + """ + Create a new BCF topic. + + Args: + title: The title of the topic. + description: The description of the topic. + author: The author of the topic. + topic_type: The type of the topic. + topic_status: The status of the topic. + xml_handler: The XML parser/serializer to use. + + Returns: + The BCF topic definition. + """ + creation_date = XmlDateTime.from_datetime(datetime.datetime.now()) + guid = str(uuid.uuid4()) + topic = mdl.Topic( + title=title, + description=description, + creation_author=author, + creation_date=creation_date, + guid=guid, + topic_type=topic_type, + topic_status=topic_status, + ) + markup = mdl.Markup(topic=topic) + obj = cls(topic_dir=Path(guid), xml_handler=xml_handler or XmlParserSerializer()) + obj.markup = markup + return obj + + def save(self, destination_zip: ZipFileInterface) -> None: + """ + Save the topic to a BCF zip file. + + Args: + bcf_zip: The BCF zip file to save to. + """ + topic_dir = self.guid + self._save_xml(destination_zip, self._markup, "markup.bcf") + self._save_viewpoints(destination_zip, topic_dir) + self._save_bim_snippet(destination_zip) + + def _save_viewpoints(self, destination_zip, topic_dir) -> None: + if not self.topic.viewpoints or not (viewpoints := self.topic.viewpoints.view_point): + return + for vpt in viewpoints: + if vpt.viewpoint: + self.viewpoints[vpt.viewpoint].save(destination_zip, topic_dir, vpt) + + def _save_xml(self, destination_zip: ZipFileInterface, item: Any, target: str) -> None: + to_write = self._xml_handler.serialize(item) if item else self._topic_dir.joinpath(target).read_bytes() + destination_zip.writestr(f"{self._topic_dir.name}/{target}", to_write) + + def _save_bim_snippet(self, destination_zip: ZipFileInterface) -> None: + snippet = self.topic.bim_snippet + if not snippet or snippet.is_external: + return + ref_filename = Path(snippet.reference).name + if self.bim_snippet: + destination_zip.writestr(f"{self.topic.guid}/{ref_filename}", self.bim_snippet) + + def add_viewpoint(self, element: entity_instance) -> None: + """ + Add a viewpoint tergeting an IFC element to the topic. + + Args: + element: The IFC element. + """ + new_viewpoint = VisualizationInfoHandler.create_new(element, self._xml_handler) + self.add_visinfo_handler(new_viewpoint) + + def add_visinfo_handler(self, new_viewpoint: VisualizationInfoHandler) -> None: + self.viewpoints[new_viewpoint.guid] = new_viewpoint + if self.topic.viewpoints is None: + self.topic.viewpoints = mdl.TopicViewpoints() + self.topic.viewpoints.view_point.append(mdl.ViewPoint(viewpoint=new_viewpoint.guid, guid=new_viewpoint.guid)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, TopicHandler): + raise TypeError("Equality needs a BcfXml object.") + return ( + self.markup == other.markup + and self.viewpoints == other.viewpoints + and self.bim_snippet == other.bim_snippet + ) diff --git a/src/bcf/src/bcf/v3/visinfo.py b/src/bcf/src/bcf/v3/visinfo.py new file mode 100644 index 0000000000..6c1f79de27 --- /dev/null +++ b/src/bcf/src/bcf/v3/visinfo.py @@ -0,0 +1,247 @@ +import uuid +import zipfile +from functools import lru_cache +from typing import Any, Iterable, Optional + +import numpy as np +from ifcopenshell import entity_instance +from ifcopenshell.util import placement +from numpy.typing import NDArray + +import bcf.v3.model as mdl +from bcf.geometry import calc_camera_vectors +from bcf.inmemory_zipfile import ZipFileInterface +from bcf.xml_parser import AbstractXmlParserSerializer, XmlParserSerializer + + +class VisualizationInfoHandler: + """Handle the VisualizationInfo and related objects.""" + + def __init__( + self, + visualization_info: mdl.VisualizationInfo, + snapshot: Optional[bytes] = None, + bitmaps: Optional[dict[str, bytes]] = None, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> None: + self.visualization_info = visualization_info + self.snapshot = snapshot + self.bitmaps = bitmaps or {} + self._xml_handler = xml_handler or XmlParserSerializer() + + @property + def guid(self) -> str: + """Return the GUID of the visualization info.""" + return self.visualization_info.guid + + @classmethod + def from_topic_viewpoints( + cls, + topic_dir: zipfile.Path, + vps: Iterable[mdl.ViewPoint], + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> dict[str, "VisualizationInfoHandler"]: + """Create VisualizationInfoHandler objects of a Topic's ViewPoints.""" + viewpoints = {} + for vpt in vps: + visinfo = cls.load(topic_dir, vpt, xml_handler) + if visinfo and vpt.viewpoint: + viewpoints[vpt.viewpoint] = visinfo + return viewpoints + + @classmethod + def load( + cls, + topic_dir: zipfile.Path, + vpt: mdl.ViewPoint, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> Optional["VisualizationInfoHandler"]: + """ + Load the VisualizationInfo and related objects from a BCF zip file. + + Args: + topic_dir: The directory in the BCF zip file to load from. + vpt: The ViewPoint to load. + xml_handler: The XML handler to use to parse the VisualizationInfo. + + Returns: + The VisualizationInfoHandler object. + """ + visinfo = cls._load_visinfo(topic_dir, vpt.viewpoint, xml_handler) + if not visinfo: + return None + snapshot = cls._load_snapshot(topic_dir, vpt.snapshot) + bitmaps = cls._load_bitmaps(topic_dir, visinfo) + return cls(visinfo, snapshot, bitmaps, xml_handler) + + @staticmethod + def _load_visinfo( + topic_dir: zipfile.Path, + vp_name: Optional[str], + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> Optional[mdl.VisualizationInfo]: + if not vp_name: + return None + vp_path = topic_dir.joinpath(vp_name) + if vp_path.exists(): + xml_handler = xml_handler or XmlParserSerializer() + return xml_handler.parse(vp_path.read_bytes(), mdl.VisualizationInfo) + return None + + @staticmethod + def _load_snapshot(topic_dir: zipfile.Path, vp_snapshot: Optional[str]) -> Optional[bytes]: + if vp_snapshot: + snapshot_path = topic_dir.joinpath(vp_snapshot) + if snapshot_path.exists(): + return snapshot_path.read_bytes() + return None + + @staticmethod + def _load_bitmaps(topic_dir: zipfile.Path, visinfo: Optional[mdl.VisualizationInfo]) -> dict[str, bytes]: + if not visinfo or not (bitmaps := visinfo.bitmaps): + return {} + bitmaps_dict = {} + for bitmap in bitmaps.bitmap: + if not bitmap.reference: + continue + bitmap_path = topic_dir.joinpath(bitmap.reference) + if bitmap_path.exists(): + bitmaps_dict[bitmap.reference] = bitmap_path.read_bytes() + return bitmaps_dict + + def save( + self, + bcf_zip: ZipFileInterface, + topic_dir: str, + vpt: mdl.ViewPoint, + ) -> None: + """ + Save the VisualizationInfo and related objects to a BCF zip file. + + Args: + bcf_zip: The BCF zip file to save to. + topic_dir: The directory in the BCF zip file to save to. + vpt: The ViewPoint to save. + """ + if not (vp_name := vpt.viewpoint): + return + self._save_visinfo(bcf_zip, topic_dir, vp_name) + self._save_snapshot(bcf_zip, topic_dir, vpt.snapshot) + self._save_bitmaps(bcf_zip, topic_dir) + + def _save_snapshot(self, bcf_zip: ZipFileInterface, topic_dir: str, filename: Optional[str]) -> None: + if self.snapshot and filename: + bcf_zip.writestr(f"{topic_dir}/{filename}", self.snapshot) + + def _save_visinfo(self, bcf_zip: ZipFileInterface, topic_dir: str, vp_name: str) -> None: + bcf_zip.writestr( + f"{topic_dir}/{vp_name}", + self._xml_handler.serialize(self.visualization_info), + ) + + def _save_bitmaps(self, bcf_zip: ZipFileInterface, topic_dir: str) -> None: + if not self.bitmaps: + return + if not (bitmaps_defs := self.visualization_info.bitmaps): + return + for bitmap_def in bitmaps_defs.bitmap: + if not (bitmap_name := bitmap_def.reference): + continue + if bitmap_name in self.bitmaps: + bcf_zip.writestr(f"{topic_dir}/{bitmap_name}", self.bitmaps[bitmap_name]) + + @classmethod + def create_new( + cls, + element: entity_instance, + xml_handler: Optional[AbstractXmlParserSerializer] = None, + ) -> "VisualizationInfoHandler": + """ + Create a new VisualizationInfoHandler object from an IFC element. + + Args: + element: The IFC element to point at. + xml_handler: The XML handler to use. + + Returns: + The VisualizationInfoHandler object. + """ + xml_handler = xml_handler or XmlParserSerializer() + return cls(visualization_info=build_viewpoint(element), xml_handler=xml_handler) + + +@lru_cache(maxsize=None) +def build_viewpoint(element: entity_instance) -> mdl.VisualizationInfo: + """ + Return a BCF viewpoint of an IFC element. + + This function is cached to speedudp the creation of multiple BCF topics regarding the same element. + + Args: + element: The IFC element to point at. + + Returns: + The BCF viewpoint definition. + """ + elem_placement = placement.get_local_placement(element.ObjectPlacement) + + return mdl.VisualizationInfo( + guid=str(uuid.uuid4()), + components=build_components(element.GlobalId), + perspective_camera=build_camera(elem_placement), + ) + + +def build_components(guid: str) -> mdl.Components: + """ + Return the BCF components from an IFC element GUID. + + Args: + guid: The IFC element GUID. + + Returns: + The BCF components definition. + """ + return mdl.Components( + selection=mdl.ComponentSelection(component=[mdl.Component(ifc_guid=guid)]), + visibility=mdl.ComponentVisibility(default_visibility=True), + ) + + +def build_camera(elem_placement: NDArray[np.float_]) -> mdl.PerspectiveCamera: + """ + Return a BCF camera for an IFC element placement matrix. + + Args: + elem_placement: The IFC element placement as a rototranslation matrix. + + Returns: + The BCF camera definition. + """ + return build_camera_from_vectors(*calc_camera_vectors(elem_placement)) + + +def build_camera_from_vectors( + camera_position: NDArray[np.float_], camera_dir: NDArray[np.float_], camera_up: NDArray[np.float_] +) -> mdl.PerspectiveCamera: + """ + Return a BCF camera for an IFC element placement matrix. + + Args: + camera_position: camera position array + camera_dir: camera direction versor + camera_up_vector: camera up versor + + Returns: + The BCF camera definition. + """ + camera_viewpoint = mdl.Point(x=camera_position[0], y=camera_position[1], z=camera_position[2]) + camera_direction = mdl.Direction(x=camera_dir[0], y=camera_dir[1], z=camera_dir[2]) + camera_up_vector = mdl.Direction(x=camera_up[0], y=camera_up[1], z=camera_up[2]) + return mdl.PerspectiveCamera( + camera_view_point=camera_viewpoint, + camera_direction=camera_direction, + camera_up_vector=camera_up_vector, + aspect_ratio=1.0, + field_of_view=60.0, + ) diff --git a/src/bcf/src/bcf/xml_parser.py b/src/bcf/src/bcf/xml_parser.py new file mode 100644 index 0000000000..9f3878f769 --- /dev/null +++ b/src/bcf/src/bcf/xml_parser.py @@ -0,0 +1,83 @@ +"""XML Parser and Serializer factories.""" +from typing import Optional, Protocol, Type, TypeVar + +from xsdata.formats.dataclass.context import XmlContext +from xsdata.formats.dataclass.parsers import XmlParser +from xsdata.formats.dataclass.serializers import XmlSerializer +from xsdata.formats.dataclass.serializers.config import SerializerConfig + + +def build_xml_parser(context: Optional[XmlContext] = None) -> XmlParser: + """Return a parser for an XML file.""" + parser = XmlParser(context=context or XmlContext()) + parser.register_namespace("xs", "http://www.w3.org/2001/XMLSchema") + return parser + + +def build_serializer(context: Optional[XmlContext] = None) -> XmlSerializer: + """Return a serializer for an XML file.""" + return XmlSerializer( + config=SerializerConfig(pretty_print=True), + context=context or XmlContext(), + ) + + +T = TypeVar("T") + + +class AbstractXmlParserSerializer(Protocol): + """XML Parser and serializer wrapper.""" + + def parse(self, xml: bytes, clazz: Type[T]) -> T: + """ + Parse an XML file to an object. + + Args: + xml: The XML file as bytes. + clazz: The class to parse to. + """ + + def serialize(self, obj: T, ns_map: Optional[dict[str, str]] = None) -> str: + """ + Serialize an object to XML. + + Args: + obj: The object to serialize. + ns_map: The namespace map to use. + + Returns: + The XML as string. + """ + + +class XmlParserSerializer: + """XML Parser and serializer wrapper.""" + + def __init__(self) -> None: + self.context = XmlContext() + self.parser = build_xml_parser(self.context) + self.serializer = build_serializer(self.context) + + def parse(self, xml: bytes, clazz: Type[T]) -> T: + """ + Parse an XML file to an object. + + Args: + xml: The XML file as bytes. + clazz: The class to parse to. + """ + return self.parser.from_bytes(xml, clazz) + + def serialize(self, obj: T, ns_map: Optional[dict[str, str]] = None) -> str: + """ + Serialize an object to XML. + + Args: + obj: The object to serialize. + ns_map: The namespace map to use. + + Returns: + The XML as string. + """ + ns_map = ns_map or {"xs": "http://www.w3.org/2001/XMLSchema"} + return self.serializer.render(obj, ns_map) diff --git a/src/bcf/tests/__init__.py b/src/bcf/tests/__init__.py new file mode 100644 index 0000000000..64bbc124fe --- /dev/null +++ b/src/bcf/tests/__init__.py @@ -0,0 +1 @@ +"""BCF tests.""" diff --git a/src/bcf/tests/conftest.py b/src/bcf/tests/conftest.py new file mode 100644 index 0000000000..ea1b2aee8b --- /dev/null +++ b/src/bcf/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from bcf.xml_parser import XmlParserSerializer + + +@pytest.fixture(scope="session") +def xml_handler() -> XmlParserSerializer: + return XmlParserSerializer() diff --git a/src/bcf/tests/v2/MaximumInformation.bcf b/src/bcf/tests/v2/MaximumInformation.bcf new file mode 100644 index 0000000000..a05d9efb19 Binary files /dev/null and b/src/bcf/tests/v2/MaximumInformation.bcf differ diff --git a/src/bcf/tests/v2/__init__.py b/src/bcf/tests/v2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bcf/tests/v2/test_bcf_xml.py b/src/bcf/tests/v2/test_bcf_xml.py new file mode 100644 index 0000000000..fa99ebda8b --- /dev/null +++ b/src/bcf/tests/v2/test_bcf_xml.py @@ -0,0 +1,122 @@ +"""BCF XML tests.""" +import uuid +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +import bcf.v2.model as mdl +from bcf.v2.bcfxml import BcfXml +from bcf.v2.topic import TopicHandler +from bcf.v2.visinfo import ( + VisualizationInfoHandler, + build_camera_from_vectors, + build_components, +) +from bcf.xml_parser import XmlParserSerializer + + +@pytest.fixture() +def build_sample(xml_handler: XmlParserSerializer) -> tuple[BcfXml, TopicHandler]: + bcf = BcfXml.create_new("Test project", xml_handler=xml_handler) + orig_th = bcf.add_topic("Test topic", "Test message", "Test author", "Test type") + return bcf, orig_th + + +def test_bcf_roundtrip(xml_handler, build_sample) -> None: + """Saving and loading a bcf xml project returns the same objects.""" + bcf, orig_th = build_sample + with TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.bcf" + bcf.save(file_path) + with BcfXml.load(file_path, xml_handler=xml_handler) as parsed: + assert parsed == bcf + parsed_th = parsed.topics[orig_th.guid] + assert parsed_th == orig_th + + +def test_bcf_edit_saveas(xml_handler, build_sample) -> None: + """Saving and loading a bcf xml project returns the same objects.""" + bcf, orig_th = build_sample + with TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.bcf" + bcf.save(file_path) + with BcfXml.load(file_path, xml_handler=xml_handler) as parsed: + for th in parsed.topics.values(): + th.topic.title = "New Topic Title" + modified_path = Path(tmp_dir) / "edited.bcf" + parsed.save(modified_path) + with BcfXml.load(modified_path, xml_handler=xml_handler) as modified_parsed: + assert modified_parsed == bcf + parsed_th = modified_parsed.topics[orig_th.guid] + assert parsed_th.markup != orig_th.markup + assert parsed_th.markup == parsed.topics[orig_th.guid].markup + assert parsed_th.viewpoints == orig_th.viewpoints + assert parsed_th.bim_snippet == orig_th.bim_snippet + + +def test_bcf_edit(xml_handler, build_sample) -> None: + """Saving and loading a bcf xml project returns the same objects.""" + bcf, orig_th = build_sample + with TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.bcf" + bcf.save(file_path) + with BcfXml.load(file_path, xml_handler=xml_handler) as parsed: + for th in parsed.topics.values(): + th.topic.title = "New Topic Title" + parsed.save() + with BcfXml.load(file_path, xml_handler=xml_handler) as modified_parsed: + assert modified_parsed == bcf + assert len(modified_parsed.topics) == 1 + parsed_th = modified_parsed.topics[orig_th.guid] + assert parsed_th.markup != orig_th.markup + assert parsed_th.markup == parsed.topics[orig_th.guid].markup + assert parsed_th.viewpoints == orig_th.viewpoints + assert parsed_th.bim_snippet == orig_th.bim_snippet + + +def test_save_no_filename(build_sample) -> None: + bcf, _ = build_sample + with pytest.raises(ValueError): + bcf.save() + + +def test_load_no_filename() -> None: + with pytest.raises(ValueError): + BcfXml.load("") + + +def test_save_keep_open(build_sample) -> None: + bcf, _ = build_sample + with TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.bcf" + bcf.save(file_path, keep_open=True) + assert bcf._zip_file is not None + bcf._zip_file.close() + + +def test_massive_bcf(xml_handler) -> None: + bcf = BcfXml.create_new("Test project", xml_handler=xml_handler) + for i in range(100): + th = bcf.add_topic(f"Topic {i:04}", f"Message {i:04}", "Test author", "Test type") + vi = mdl.VisualizationInfo( + guid=str(uuid.uuid4()), + components=build_components(str(uuid.uuid4())), + perspective_camera=build_camera_from_vectors([i, 0, 0], [0, 1, 0], [0, 0, 1]), + ) + vh = VisualizationInfoHandler(visualization_info=vi, xml_handler=xml_handler) + th.add_visinfo_handler(vh) + assert len(bcf.topics) == 100 + with TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.bcf" + bcf.save(file_path) + + +def test_equality_with_wrong_object() -> None: + with pytest.raises(TypeError): + build_sample[0] == "Wrong object" + + +def test_topic_equality_with_wrong_object() -> None: + with pytest.raises(TypeError): + build_sample[1] == "Wrong object" diff --git a/src/bcf/tests/v2/test_example_files.py b/src/bcf/tests/v2/test_example_files.py new file mode 100644 index 0000000000..09aa9e4115 --- /dev/null +++ b/src/bcf/tests/v2/test_example_files.py @@ -0,0 +1,365 @@ +import json +import os +import zipfile +from pathlib import Path + +from xsdata.models.datatype import XmlDateTime + +import bcf.v2.model as mdl +from bcf.v2.bcfxml import BcfXml +from bcf.v2.topic import TopicHandler + + +def test_maximum_information() -> None: + """ + All the info in a BCF is parsed correctly. + + Uses sample file from https://github.com/buildingSMART/BCF-XML/ + """ + bcf_path = Path(__file__).parent / "MaximumInformation.bcf" + with BcfXml.load(bcf_path) as bcf: + assert_everything_in_place(bcf) + + +def test_save_maximum_information() -> None: + base_dir = Path(__file__).parent + bcf_path = base_dir / "MaximumInformation.bcf" + target_path = base_dir / "MaximumInformationSaved.bcf" + with BcfXml.load(bcf_path) as bcf: + bcf.save(target_path) + assert target_path.exists() + with BcfXml.load(target_path) as bcf2: + assert_everything_in_place(bcf2) + + expected_files = [ + "bcf.version", + "project.bcfp", + "7ddc3ef0-0ab7-43f1-918a-45e38b42369c/markup.bcf", + "7ddc3ef0-0ab7-43f1-918a-45e38b42369c/bitmap.png", + "7ddc3ef0-0ab7-43f1-918a-45e38b42369c/tux.png", + "7ddc3ef0-0ab7-43f1-918a-45e38b42369c/JsonElement.json", + "7ddc3ef0-0ab7-43f1-918a-45e38b42369c/Viewpoint_4ab7514b-b216-4d56-98d2-45cf8500ff5a.bcfv", + "7ddc3ef0-0ab7-43f1-918a-45e38b42369c/Viewpoint_9a4a1878-ecbd-4916-83a8-dad82e560231.bcfv", + "7ddc3ef0-0ab7-43f1-918a-45e38b42369c/Viewpoint_fc4019d7-365e-47f3-b6d0-b39fc48f15fc.bcfv", + "7ddc3ef0-0ab7-43f1-918a-45e38b42369c/Snapshot_4ab7514b-b216-4d56-98d2-45cf8500ff5a.png", + "7ddc3ef0-0ab7-43f1-918a-45e38b42369c/Snapshot_9a4a1878-ecbd-4916-83a8-dad82e560231.png", + "7ddc3ef0-0ab7-43f1-918a-45e38b42369c/Snapshot_fc4019d7-365e-47f3-b6d0-b39fc48f15fc.png", + "d1068c81-af04-4546-b63c-348810f6c716/markup.bcf", + "extensions.xsd", + "IfcPile_01.ifc", + "markup.xsd", + ] + assert_files_present(target_path, expected_files) + os.unlink(target_path) + + +def assert_everything_in_place(bcf: BcfXml): + assert bcf.version.version_id == "2.1" + assert bcf.project.name == "BCF API Implementation" + assert bcf.project_info.extension_schema == "extensions.xsd" + + assert len(bcf.topics) == 2 + assert_first_topic_handler(bcf.topics["7ddc3ef0-0ab7-43f1-918a-45e38b42369c"]) + second_th = bcf.topics["d1068c81-af04-4546-b63c-348810f6c716"] + assert second_th.topic == mdl.Topic( + title="Referenced topic", + creation_date=XmlDateTime(2017, 5, 22, 7, 51, 0, 42987900), + creation_author="dangl@iabi.eu", + description="This is just an empty topic that acts as a referenced topic.", + guid="d1068c81-af04-4546-b63c-348810f6c716", + ) + + +def assert_first_topic_handler(topic_handler: TopicHandler): + assert topic_handler.guid == "7ddc3ef0-0ab7-43f1-918a-45e38b42369c" + + expected_bs1 = mdl.BimSnippet( + reference="JsonElement.json", reference_schema="http://json-schema.org", snippet_type="JSON" + ) + expected_t1 = mdl.Topic( + reference_link=["https://bim--it.net"], + title="Maximum Content", + priority="High", + index=0, + labels=["Structural", "IT Development"], + creation_date=XmlDateTime(2015, 6, 21, 12, 0, 0), + creation_author="dangl@iabi.eu", + modified_date=XmlDateTime(2015, 6, 21, 14, 22, 47), + modified_author="dangl@iabi.eu", + due_date=XmlDateTime(2016, 10, 2, 14, 22, 47), + assigned_to="linhard@iabi.eu", + stage="Construction Start", + description="This is a topic with all informations present.", + bim_snippet=expected_bs1, + document_reference=[ + mdl.TopicDocumentReference( + referenced_document="https://github.com/BuildingSMART/BCF-XML", + description="GitHub BCF Specification", + is_external=True, + ), + mdl.TopicDocumentReference( + referenced_document="../markup.xsd", + description="Markup.xsd Schema", + is_external=False, + ), + ], + related_topic=[mdl.TopicRelatedTopic(guid="d1068c81-af04-4546-b63c-348810f6c716")], + guid="7ddc3ef0-0ab7-43f1-918a-45e38b42369c", + topic_type="Structural", + topic_status="Open", + ) + assert topic_handler.topic == expected_t1 + expected_h1 = mdl.Header( + file=[ + mdl.HeaderFile( + filename="IfcPile_01.ifc", + date=XmlDateTime(2014, 10, 27, 16, 27, 27), + reference="../IfcPile_01.ifc", + ifc_project="0M6o7Znnv7hxsbWgeu7oQq", + ifc_spatial_structure_element="23B$bNeGHFQuMYJzvUX0FD", + is_external=False, + ) + ] + ) + assert topic_handler.header == expected_h1 + + expected_th1_comments = [ + mdl.Comment( + date=XmlDateTime(2015, 8, 31, 12, 40, 17), + author="dangl@iabi.eu", + comment="This is an unmodified topic at the uppermost hierarchical level.\nAll times in the XML are marked as UTC times.", + guid="07ccdba0-1736-47e1-807d-67dc6f3addaa", + ), + mdl.Comment( + date=XmlDateTime(2015, 8, 31, 14, 0, 1), + author="dangl@iabi.eu", + comment="This comment was a reply to the first comment in BCF v2.0. This is a no longer supported functionality and therefore is to be treated as a regular comment in v2.1.", + guid="a12766c2-61bc-40b4-ab19-e9f45fd0b0bf", + ), + mdl.Comment( + date=XmlDateTime(2015, 8, 31, 13, 7, 11), + author="dangl@iabi.eu", + comment="This comment again is in the highest hierarchy level.\nIt references a viewpoint.", + viewpoint=mdl.CommentViewpoint(guid="4ab7514b-b216-4d56-98d2-45cf8500ff5a"), + guid="c2bb5bb0-773d-45dd-bdaa-19a216439ed3", + ), + mdl.Comment( + date=XmlDateTime(2015, 8, 31, 15, 42, 58), + author="dangl@iabi.eu", + comment="This comment contained some spllng errs.\nHopefully, the modifier did catch them all.", + modified_date=XmlDateTime(2015, 8, 31, 16, 7, 11), + modified_author="dangl@iabi.eu", + guid="0b843a5c-c3bf-41ef-be98-52a9f7bd9790", + ), + ] + assert topic_handler.comments == expected_th1_comments + + expected_th1_viewpoints = [ + mdl.ViewPoint( + viewpoint="Viewpoint_4ab7514b-b216-4d56-98d2-45cf8500ff5a.bcfv", + snapshot="Snapshot_4ab7514b-b216-4d56-98d2-45cf8500ff5a.png", + index=2, + guid="4ab7514b-b216-4d56-98d2-45cf8500ff5a", + ), + mdl.ViewPoint( + viewpoint="Viewpoint_fc4019d7-365e-47f3-b6d0-b39fc48f15fc.bcfv", + snapshot="Snapshot_fc4019d7-365e-47f3-b6d0-b39fc48f15fc.png", + index=0, + guid="fc4019d7-365e-47f3-b6d0-b39fc48f15fc", + ), + mdl.ViewPoint( + viewpoint="Viewpoint_9a4a1878-ecbd-4916-83a8-dad82e560231.bcfv", + snapshot="Snapshot_9a4a1878-ecbd-4916-83a8-dad82e560231.png", + index=1, + guid="9a4a1878-ecbd-4916-83a8-dad82e560231", + ), + ] + + expected_m1 = mdl.Markup( + header=expected_h1, + topic=expected_t1, + comment=expected_th1_comments, + viewpoints=expected_th1_viewpoints, + ) + assert topic_handler.markup == expected_m1 + + assert json.loads(topic_handler.bim_snippet) == {"Material": "Concrete", "Temperatures": ["Cold", "Hot", "Hotter"]} + + assert topic_handler.reference_files["../IfcPile_01.ifc"] is not None + + assert_viewpoints(topic_handler.viewpoints) + + +def assert_viewpoints(viewpoints): + assert len(viewpoints) == 3 + + expected_selection = mdl.ComponentSelection( + component=[ + mdl.Component(ifc_guid="0cSRUx$EX1NRjqiKcYQ$a0"), + mdl.Component(ifc_guid="1jQQiGIAnFzxOUzrdmJYDS"), + mdl.Component(ifc_guid="0fdpeZZEX3FwJ7x0ox5kzF"), + mdl.Component(ifc_guid="23Zwlpd71EyvHlH6OZ77nK"), + mdl.Component(ifc_guid="1OpjQ1Nlv4sQuTxfUC_8zS"), + ] + ) + + expected_exception = mdl.ComponentVisibilityExceptions( + component=[ + mdl.Component(ifc_guid="0Gl71cVurFn8bxAOox6M4X"), + mdl.Component(ifc_guid="23Zwlpd71EyvHlH6OZ77nK"), + mdl.Component(ifc_guid="3DvyPxGIn8qR0KDwbL_9r1"), + mdl.Component(ifc_guid="0fdpeZZEX3FwJ7x0ox5kzF"), + mdl.Component(ifc_guid="1OpjQ1Nlv4sQuTxfUC_8zS"), + ] + ) + + expected_coloring = mdl.ComponentColoring( + color=[ + mdl.ComponentColoringColor( + component=[ + mdl.Component(ifc_guid="0fdpeZZEX3FwJ7x0ox5kzF"), + mdl.Component(ifc_guid="23Zwlpd71EyvHlH6OZ77nK"), + mdl.Component(ifc_guid="1OpjQ1Nlv4sQuTxfUC_8zS"), + mdl.Component(ifc_guid="0cSRUx$EX1NRjqiKcYQ$a0"), + ], + color="3498DB", + ) + ] + ) + + assert_first_viewpoint( + viewpoints["Viewpoint_4ab7514b-b216-4d56-98d2-45cf8500ff5a.bcfv"], + expected_selection, + expected_exception, + expected_coloring, + ) + assert_second_viewpoint( + viewpoints["Viewpoint_fc4019d7-365e-47f3-b6d0-b39fc48f15fc.bcfv"], + expected_selection, + expected_exception, + expected_coloring, + ) + assert_third_viewpoint( + viewpoints["Viewpoint_9a4a1878-ecbd-4916-83a8-dad82e560231.bcfv"], + expected_selection, + expected_exception, + expected_coloring, + ) + + +def assert_first_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None: + expected_vp = mdl.VisualizationInfo( + components=mdl.Components( + view_setup_hints=mdl.ViewSetupHints( + spaces_visible=True, + space_boundaries_visible=True, + openings_visible=True, + ), + selection=expected_selection, + visibility=mdl.ComponentVisibility( + exceptions=expected_exception, + default_visibility=True, + ), + coloring=expected_coloring, + ), + perspective_camera=mdl.PerspectiveCamera( + camera_view_point=mdl.Point(x=0.43079984188079834, y=69.52057647705078, z=10.666350364685059), + camera_direction=mdl.Direction(x=0.09159398823976517, y=-0.9375035166740417, z=-0.3357048034667969), + camera_up_vector=mdl.Direction(x=0.01938679628074169, y=-0.3353792130947113, z=0.9418837428092957), + field_of_view=60, + ), + lines=mdl.VisualizationInfoLines( + line=[ + mdl.Line(start_point=mdl.Point(x=0, y=0, z=0), end_point=mdl.Point(x=0, y=0, z=1)), + mdl.Line(start_point=mdl.Point(x=0, y=0, z=1), end_point=mdl.Point(x=0, y=1, z=1)), + mdl.Line(start_point=mdl.Point(x=0, y=1, z=1), end_point=mdl.Point(x=1, y=1, z=1)), + ] + ), + clipping_planes=mdl.VisualizationInfoClippingPlanes( + clipping_plane=[ + mdl.ClippingPlane(location=mdl.Point(x=0, y=0, z=0), direction=mdl.Direction(x=0, y=0, z=1)), + mdl.ClippingPlane(location=mdl.Point(x=0, y=0, z=0), direction=mdl.Direction(x=0, y=1, z=0)), + ] + ), + bitmap=[ + mdl.VisualizationInfoBitmap( + bitmap=mdl.BitmapFormat.PNG, + reference="bitmap.png", + location=mdl.Point(x=10, y=-10, z=7), + normal=mdl.Direction(x=0, y=1, z=0), + up=mdl.Direction(x=0, y=0, z=1), + height=5.0, + ), + mdl.VisualizationInfoBitmap( + bitmap=mdl.BitmapFormat.PNG, + reference="tux.png", + location=mdl.Point(x=20, y=-10, z=7), + normal=mdl.Direction(x=0, y=1, z=0), + up=mdl.Direction(x=0, y=0, z=1), + height=5.0, + ), + ], + guid="8dc86298-9737-40b4-a448-98a9e953293a", + ) + assert viewpoint.visualization_info == expected_vp + assert viewpoint.snapshot is not None + + +def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None: + expected_vp = mdl.VisualizationInfo( + components=mdl.Components( + view_setup_hints=mdl.ViewSetupHints( + spaces_visible=False, + space_boundaries_visible=False, + openings_visible=False, + ), + selection=expected_selection, + visibility=mdl.ComponentVisibility( + exceptions=expected_exception, + default_visibility=False, + ), + coloring=expected_coloring, + ), + perspective_camera=mdl.PerspectiveCamera( + camera_view_point=mdl.Point(x=-47.18794250488281, y=43.829200744628906, z=10.666350364685059), + camera_direction=mdl.Direction(x=0.6745243072509766, y=-0.6599355936050415, z=-0.33091068267822266), + camera_up_vector=mdl.Direction(x=0.2271970510482788, y=-0.24091780185699463, z=0.9435783624649048), + field_of_view=60, + ), + guid="21dd4807-e9af-439e-a980-04d913a6b1ce", + ) + assert viewpoint.visualization_info == expected_vp + assert viewpoint.snapshot is not None + + +def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None: + expected_vp = mdl.VisualizationInfo( + components=mdl.Components( + view_setup_hints=mdl.ViewSetupHints( + spaces_visible=False, + space_boundaries_visible=False, + openings_visible=True, + ), + selection=expected_selection, + visibility=mdl.ComponentVisibility( + exceptions=expected_exception, + default_visibility=True, + ), + coloring=expected_coloring, + ), + perspective_camera=mdl.PerspectiveCamera( + camera_view_point=mdl.Point(x=-48.974571228027344, y=-64.20051574707031, z=10.666350364685059), + camera_direction=mdl.Direction(x=0.7232745289802551, y=0.5967116951942444, z=-0.3475759029388428), + camera_up_vector=mdl.Direction(x=0.27662187814712524, y=0.21082592010498047, z=0.937567412853241), + field_of_view=60, + ), + guid="81daa431-bf01-4a49-80a2-1ab07c177717", + ) + assert viewpoint.visualization_info == expected_vp + assert viewpoint.snapshot is not None + + +def assert_files_present(saved_bcf_path, expected_files): + with zipfile.ZipFile(saved_bcf_path) as bcf_zip: + for file_path in expected_files: + assert zipfile.Path(bcf_zip, file_path).exists() diff --git a/src/bcf/tests/v3/Document reference internal.bcf b/src/bcf/tests/v3/Document reference internal.bcf new file mode 100644 index 0000000000..2f39956c9e Binary files /dev/null and b/src/bcf/tests/v3/Document reference internal.bcf differ diff --git a/src/bcf/tests/v3/__init__.py b/src/bcf/tests/v3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bcf/tests/v3/test_bcf_xml.py b/src/bcf/tests/v3/test_bcf_xml.py new file mode 100644 index 0000000000..63d0bd82b5 --- /dev/null +++ b/src/bcf/tests/v3/test_bcf_xml.py @@ -0,0 +1,129 @@ +"""BCF XML tests.""" +import uuid +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +import bcf.v3.model as mdl +from bcf.v3.bcfxml import BcfXml +from bcf.v3.topic import TopicHandler +from bcf.v3.visinfo import ( + VisualizationInfoHandler, + build_camera_from_vectors, + build_components, +) +from bcf.xml_parser import XmlParserSerializer + + +@pytest.fixture() +def build_sample(xml_handler: XmlParserSerializer) -> tuple[BcfXml, TopicHandler]: + ext = mdl.Extensions(topic_types=mdl.ExtensionsTopicTypes(topic_type=["Test type"])) + bcf = BcfXml.create_new("Test project", extensions=ext, xml_handler=xml_handler) + orig_th = bcf.add_topic("Test topic", "Test message", "Test author", "Test type") + return bcf, orig_th + + +def test_bcf_roundtrip(xml_handler, build_sample) -> None: + """Saving and loading a bcf xml project returns the same objects.""" + bcf, orig_th = build_sample + with TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.bcf" + bcf.save(file_path) + with BcfXml.load(file_path, xml_handler=xml_handler) as parsed: + assert parsed == bcf + parsed_th = parsed.topics[orig_th.guid] + assert parsed_th == orig_th + + +def test_bcf_edit_saveas(xml_handler, build_sample) -> None: + """Saving and loading a bcf xml project returns the same objects.""" + bcf, orig_th = build_sample + with TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.bcf" + bcf.save(file_path) + with BcfXml.load(file_path, xml_handler=xml_handler) as parsed: + for th in parsed.topics.values(): + th.topic.title = "New Topic Title" + modified_path = Path(tmp_dir) / "edited.bcf" + parsed.save(modified_path) + with BcfXml.load(modified_path, xml_handler=xml_handler) as modified_parsed: + assert modified_parsed == bcf + parsed_th = modified_parsed.topics[orig_th.guid] + assert parsed_th.markup != orig_th.markup + assert parsed_th.markup == parsed.topics[orig_th.guid].markup + assert parsed_th.viewpoints == orig_th.viewpoints + assert parsed_th.bim_snippet == orig_th.bim_snippet + + +def test_bcf_edit(xml_handler, build_sample) -> None: + """Saving and loading a bcf xml project returns the same objects.""" + bcf, orig_th = build_sample + with TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.bcf" + bcf.save(file_path) + with BcfXml.load(file_path, xml_handler=xml_handler) as parsed: + for th in parsed.topics.values(): + th.topic.title = "New Topic Title" + parsed.save() + with BcfXml.load(file_path, xml_handler=xml_handler) as modified_parsed: + assert modified_parsed == bcf + assert len(modified_parsed.topics) == 1 + parsed_th = modified_parsed.topics[orig_th.guid] + assert parsed_th.markup != orig_th.markup + assert parsed_th.markup == parsed.topics[orig_th.guid].markup + assert parsed_th.viewpoints == orig_th.viewpoints + assert parsed_th.bim_snippet == orig_th.bim_snippet + + +def test_save_no_filename(build_sample) -> None: + bcf, _ = build_sample + with pytest.raises(ValueError): + bcf.save() + + +def test_load_no_filename() -> None: + with pytest.raises(ValueError): + BcfXml.load("") + + +def test_save_keep_open(build_sample) -> None: + bcf, orig_th = build_sample + with TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.bcf" + bcf.save(file_path, keep_open=True) + assert bcf._zip_file is not None + bcf._zip_file.close() + + +# image = PIL.Image.new('RGB', size=(100, 100)) +# file = BinaryIO() +# image.save(file) + + +def test_massive_bcf(xml_handler) -> None: + ext = mdl.Extensions(topic_types=mdl.ExtensionsTopicTypes(topic_type=["Test type"])) + bcf = BcfXml.create_new("Test project", extensions=ext, xml_handler=xml_handler) + for i in range(100): + th = bcf.add_topic(f"Topic {i:04}", f"Message {i:04}", "Test author", "Test type") + vi = mdl.VisualizationInfo( + guid=str(uuid.uuid4()), + components=build_components(str(uuid.uuid4())), + perspective_camera=build_camera_from_vectors([i, 0, 0], [0, 1, 0], [0, 0, 1]), + ) + vh = VisualizationInfoHandler(visualization_info=vi, xml_handler=xml_handler) + th.add_visinfo_handler(vh) + assert len(bcf.topics) == 100 + with TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.bcf" + bcf.save(file_path) + + +def test_equality_with_wrong_object() -> None: + with pytest.raises(TypeError): + build_sample[0] == "Wrong object" + + +def test_topic_equality_with_wrong_object() -> None: + with pytest.raises(TypeError): + build_sample[1] == "Wrong object" diff --git a/src/bcf/tests/v3/test_example_files.py b/src/bcf/tests/v3/test_example_files.py new file mode 100644 index 0000000000..ec0081a0ea --- /dev/null +++ b/src/bcf/tests/v3/test_example_files.py @@ -0,0 +1,230 @@ +import json +import os +import zipfile +from pathlib import Path + +from xsdata.models.datatype import XmlDateTime + +import bcf.v3.model as mdl +from bcf.v3.bcfxml import BcfXml +from bcf.v3.topic import TopicHandler + + +def test_doc_ref_internal() -> None: + """ + All the info in a BCF is parsed correctly. + + Uses sample file from https://github.com/buildingSMART/BCF-XML/ + """ + bcf_path = Path(__file__).parent / "Document reference internal.bcf" + with BcfXml.load(bcf_path) as bcf: + assert_everything_in_place(bcf) + + +def test_doc_ref_internal_save() -> None: + base_dir = Path(__file__).parent + bcf_path = base_dir / "Document reference internal.bcf" + target_path = base_dir / "Document reference internal Saved.bcf" + with BcfXml.load(bcf_path) as bcf: + bcf.save(target_path) + assert target_path.exists() + with BcfXml.load(target_path) as bcf2: + assert_everything_in_place(bcf2) + assert_files_present(target_path) + os.unlink(target_path) + + +def assert_everything_in_place(bcf: BcfXml): + assert bcf.version.version_id == "3.0" + assert bcf.project.name == "BCF 3.0 test cases" + assert bcf.project.project_id == "de894a86-3a08-4ea0-b2d1-6c222b5602d1" + + assert len(bcf.topics) == 1 + topic_handler = bcf.topics["8ac9822a-761a-4deb-9f39-f61286acbf6a"] + + expected_h1 = mdl.Header( + files=mdl.HeaderFiles( + file=[ + mdl.File( + filename="BCF-ARK", + date=XmlDateTime(2021, 1, 4, 9, 37, 45), + reference="https://bimsync.com/project/f5fbb3c695274d1890036bf64f77eb71/revisions/007afab57f264d2296aae0a452486ae1", + is_external=True, + ), + mdl.File( + filename="BCF-MEP", + date=XmlDateTime(2017, 8, 7, 9, 51, 34), + reference="https://bimsync.com/project/f5fbb3c695274d1890036bf64f77eb71/revisions/a21ed391f9e046a2bb2bc879c48f1d48", + is_external=True, + ), + ] + ) + ) + assert topic_handler.header == expected_h1 + + expected_comments = [ + mdl.Comment( + date=XmlDateTime(2021, 2, 17, 9, 16, 4, 160_000_000), + author="Architect@example.com", + comment="A comment", + viewpoint=mdl.CommentViewpoint(guid="20ad2ff7-ceac-4d4b-b288-ad92a0f65182"), + guid="c045ef04-324d-4c36-9ac8-831f2a15c6d6", + ), + ] + assert topic_handler.comments == expected_comments + + expected_viewpoints = [ + mdl.ViewPoint( + viewpoint="Viewpoint_20ad2ff7-ceac-4d4b-b288-ad92a0f65182.bcfv", + snapshot="Snapshot_20ad2ff7-ceac-4d4b-b288-ad92a0f65182.png", + guid="20ad2ff7-ceac-4d4b-b288-ad92a0f65182", + ), + ] + + expected_topic = mdl.Topic( + reference_links=mdl.TopicReferenceLinks(), + title="Document Reference Internal", + labels=mdl.TopicLabels(), + creation_date=XmlDateTime(2021, 2, 17, 9, 16, 4, 160_000_000), + creation_author="Architect@example.com", + modified_date=XmlDateTime(2021, 2, 17, 9, 16, 4, 160_000_000), + assigned_to="OtherUser@doe.com", + document_references=mdl.TopicDocumentReferences( + document_reference=[ + mdl.DocumentReference( + document_guid="b1d1b7f0-60b9-457d-ad12-16e0fb997bc5", + description="ThisIsADocument.txt", + guid="048e898f-555f-47c3-a273-6c664fb2ef69", + ), + ], + ), + related_topics=mdl.TopicRelatedTopics(), + comments=mdl.TopicComments(comment=expected_comments), + viewpoints=mdl.TopicViewpoints(view_point=expected_viewpoints), + guid="8ac9822a-761a-4deb-9f39-f61286acbf6a", + server_assigned_id="6", + topic_type="ERROR", + topic_status="OPEN", + ) + assert topic_handler.topic == expected_topic + + expected_m1 = mdl.Markup( + header=expected_h1, + topic=expected_topic, + ) + assert topic_handler.markup == expected_m1 + + assert topic_handler.bim_snippet is None + + assert_viewpoints(topic_handler.viewpoints) + + +def assert_viewpoints(viewpoints): + assert len(viewpoints) == 1 + + expected_vp = mdl.VisualizationInfo( + components=mdl.Components( + selection=mdl.ComponentSelection(), + visibility=mdl.ComponentVisibility( + view_setup_hints=mdl.ViewSetupHints( + spaces_visible=False, + space_boundaries_visible=False, + openings_visible=False, + ), + exceptions=mdl.ComponentVisibilityExceptions( + component=[ + mdl.Component(ifc_guid="1m5wAJelDFdhn6qBdOGjos", originating_system="https://bimsync.com"), + mdl.Component(ifc_guid="1bbI761TbBCOoIa5Kt6PXt", originating_system="https://bimsync.com"), + ] + ), + default_visibility=True, + ), + coloring=mdl.ComponentColoring(), + ), + perspective_camera=mdl.PerspectiveCamera( + camera_view_point=mdl.Point(x=23.112083480037754, y=-25.45574897560043, z=18.52519828443092), + camera_direction=mdl.Direction(x=-0.6289237666876837, y=0.5933487270610425, z=-0.5023864884632315), + camera_up_vector=mdl.Direction(x=-0.36542566067664123, y=0.3447553774917179, z=0.8646431727652648), + field_of_view=60, + aspect_ratio=1, + ), + lines=mdl.VisualizationInfoLines(), + clipping_planes=mdl.VisualizationInfoClippingPlanes(), + bitmaps=mdl.VisualizationInfoBitmaps(), + guid="20ad2ff7-ceac-4d4b-b288-ad92a0f65182", + ) + + viewpoint = viewpoints["Viewpoint_20ad2ff7-ceac-4d4b-b288-ad92a0f65182.bcfv"] + assert viewpoint.visualization_info == expected_vp + assert viewpoint.snapshot is not None + + +def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None: + expected_vp = mdl.VisualizationInfo( + components=mdl.Components( + view_setup_hints=mdl.ViewSetupHints( + spaces_visible=False, + space_boundaries_visible=False, + openings_visible=False, + ), + selection=expected_selection, + visibility=mdl.ComponentVisibility( + exceptions=expected_exception, + default_visibility=False, + ), + coloring=expected_coloring, + ), + perspective_camera=mdl.PerspectiveCamera( + camera_view_point=mdl.Point(x=-47.18794250488281, y=43.829200744628906, z=10.666350364685059), + camera_direction=mdl.Direction(x=0.6745243072509766, y=-0.6599355936050415, z=-0.33091068267822266), + camera_up_vector=mdl.Direction(x=0.2271970510482788, y=-0.24091780185699463, z=0.9435783624649048), + field_of_view=60, + ), + guid="21dd4807-e9af-439e-a980-04d913a6b1ce", + ) + assert viewpoint.visualization_info == expected_vp + assert viewpoint.snapshot is not None + + +def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None: + expected_vp = mdl.VisualizationInfo( + components=mdl.Components( + view_setup_hints=mdl.ViewSetupHints( + spaces_visible=False, + space_boundaries_visible=False, + openings_visible=True, + ), + selection=expected_selection, + visibility=mdl.ComponentVisibility( + exceptions=expected_exception, + default_visibility=True, + ), + coloring=expected_coloring, + ), + perspective_camera=mdl.PerspectiveCamera( + camera_view_point=mdl.Point(x=-48.974571228027344, y=-64.20051574707031, z=10.666350364685059), + camera_direction=mdl.Direction(x=0.7232745289802551, y=0.5967116951942444, z=-0.3475759029388428), + camera_up_vector=mdl.Direction(x=0.27662187814712524, y=0.21082592010498047, z=0.937567412853241), + field_of_view=60, + ), + guid="81daa431-bf01-4a49-80a2-1ab07c177717", + ) + assert viewpoint.visualization_info == expected_vp + assert viewpoint.snapshot is not None + + +def assert_files_present(saved_bcf_path): + expected_files = [ + "bcf.version", + "project.bcfp", + "documents.xml", + "extensions.xml", + "documents/b1d1b7f0-60b9-457d-ad12-16e0fb997bc5", + "8ac9822a-761a-4deb-9f39-f61286acbf6a/markup.bcf", + "8ac9822a-761a-4deb-9f39-f61286acbf6a/Viewpoint_20ad2ff7-ceac-4d4b-b288-ad92a0f65182.bcfv", + "8ac9822a-761a-4deb-9f39-f61286acbf6a/Snapshot_20ad2ff7-ceac-4d4b-b288-ad92a0f65182.png", + ] + + with zipfile.ZipFile(saved_bcf_path) as bcf_zip: + for file_path in expected_files: + assert zipfile.Path(bcf_zip, file_path).exists()