├── .github └── workflows │ ├── check-links.yaml │ ├── ruff.yml │ └── run-tests.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASE.md ├── conftest.py ├── pymvr ├── __init__.py └── value.py ├── pyproject.toml ├── ruff.toml └── tests ├── basic_fixture.mvr ├── capture_demo_show.mvr ├── scene_objects.mvr ├── test_example.py ├── test_fixture_1_5.py ├── test_fixture_scene_object_1_5.py ├── test_group_objects_1_4.py ├── test_mvr_01_write_ours.py └── test_mvr_02_read_ours.py /.github/workflows/check-links.yaml: -------------------------------------------------------------------------------- 1 | name: Check links in markdown 2 | 3 | on: push 4 | 5 | jobs: 6 | test_links_in_readme: 7 | timeout-minutes: 5 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: List files 12 | run: ls -la 13 | - uses: docker://pandoc/core:latest 14 | with: 15 | args: >- # allows you to break string into multiple lines 16 | --standalone 17 | --from markdown 18 | --output=readme.html 19 | README.md 20 | - name: Run htmltest 21 | uses: wjdp/htmltest-action@master 22 | with: 23 | path: readme.html 24 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Check if code is formatted 2 | on: [ push, pull_request ] 3 | jobs: 4 | ruff: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: astral-sh/ruff-action@v1 9 | with: 10 | args: --version 11 | - uses: astral-sh/ruff-action@v1 12 | with: 13 | args: format --check 14 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run Python tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Run tests 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 12 | 13 | steps: 14 | - uses: szenius/set-timezone@v1.1 15 | with: 16 | timezoneLinux: "Europe/Berlin" 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | allow-prereleases: true 23 | - name: Install dependencies 24 | run: pip install pytest pytest-md pytest-emoji pytest-mypy 25 | - uses: pavelzw/pytest-action@v2 26 | with: 27 | verbose: true 28 | emoji: true 29 | job-summary: true 30 | click-to-expand: true 31 | report-title: 'Test Report' 32 | - uses: pavelzw/pytest-action@v2 33 | with: 34 | verbose: true 35 | emoji: true 36 | job-summary: true 37 | click-to-expand: true 38 | custom-arguments: '--mypy -m mypy pymvr/*py' 39 | report-title: 'Typing Test Report' 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | __pycache__ 4 | .python-version 5 | .mypy_cache 6 | .pytest_cache 7 | pymvr.egg-info/ 8 | devel/ 9 | *whl 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | #### 0.5.0 4 | 5 | * Add classing to export 6 | * Improve testing of elements availability 7 | * Add python 3.14 beta to testing, add development dependencies 8 | * Add ruff to tests, adjust CI/CD runs 9 | 10 | #### Version 0.3.0 11 | 12 | - Add MVR writer 13 | - Adjust Fixture - Address 14 | - Convert setup.py to pyproject.toml 15 | 16 | #### Version 0.2.0 17 | 18 | - Handle faulty XML files with extra null byte 19 | - Handle encoded file names 20 | - Make Geometry3D comparable (Add_more_nodes) 21 | - Reformat with ruff 22 | - Add Capture MVR test file 23 | - Use ruff as a formatter, it's much faster and can configure line length 24 | - AUXData, Data, UserData 25 | - Parse GroupObject as a list 26 | - Add python 3.12 to tests 27 | 28 | #### Version 0.1.0 29 | 30 | - Initial release 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 vanous 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-mvr 2 | 3 | Python library for MVR (My Virtual Rig). MVR is part of [GDTF (General Device Type Format)](https://gdtf-share.com/) 4 | 5 | MVR specification as per https://gdtf.eu/mvr/prologue/introduction/ 6 | 7 | See source code for documentation. Naming conventions, in general, are 8 | identical to that on the GDTF, CamelCase is replaced with 9 | underscore_delimiters. 10 | 11 | [Source code](https://github.com/open-stage/python-mvr) 12 | 13 | [PyPi page](https://pypi.org/project/pymvr/) 14 | 15 | [![Pytest](https://github.com/open-stage/python-mvr/actions/workflows/run-tests.yaml/badge.svg)](https://github.com/open-stage/python-mvr/actions/workflows/run-tests.yaml) 16 | 17 | [![Check links in markdown](https://github.com/open-stage/python-mvr/actions/workflows/check-links.yaml/badge.svg)](https://github.com/open-stage/python-mvr/actions/workflows/check-links.yaml) 18 | 19 | ## Installation 20 | 21 | ```bash 22 | pip install pymvr 23 | ``` 24 | 25 | To install latest version from git via pip: 26 | 27 | ```python 28 | python -m pip install https://codeload.github.com/open-stage/python-mvr/zip/refs/heads/master 29 | ``` 30 | 31 | ## Usage 32 | 33 | ### Reading 34 | 35 | ```python 36 | import pymvr 37 | mvr_scene = pymvr.GeneralSceneDescription("mvr_file.mvr") 38 | 39 | for layer_index, layer in enumerate(mvr_scene.layers): 40 | ... #process data 41 | ``` 42 | 43 | ### Writing 44 | 45 | ```python 46 | fixtures_list = [] 47 | mvr = pymvr.GeneralSceneDescriptionWriter() 48 | pymvr.UserData().to_xml(parent=mvr.xml_root) 49 | scene = pymvr.SceneElement().to_xml(parent=mvr.xml_root) 50 | layers = pymvr.LayersElement().to_xml(parent=scene) 51 | layer = pymvr.Layer(name="Test layer").to_xml(parent=layers) 52 | child_list = pymvr.ChildList().to_xml(parent=layer) 53 | 54 | fixture = pymvr.Fixture(name="Test Fixture") # not really a valid fixture 55 | child_list.append(fixture.to_xml()) 56 | fixtures_list.append((fixture.gdtf_spec, fixture.gdtf_spec)) 57 | 58 | pymvr.AUXData().to_xml(parent=scene) 59 | 60 | mvr.files_list = list(set(fixtures_list)) 61 | mvr.write_mvr("example.mvr") 62 | ``` 63 | 64 | See [BlenderDMX](https://github.com/open-stage/blender-dmx) and 65 | [tests](https://github.com/open-stage/python-mvr/tree/master/tests) for 66 | reference implementation. 67 | 68 | ## Status 69 | 70 | - Reading: 71 | 72 | - Address 73 | - Alignment 74 | - AUXData 75 | - ChildList 76 | - Class 77 | - Connection 78 | - CustomCommand 79 | - Data 80 | - Fixture 81 | - FocusPoint 82 | - Geometries 83 | - Geometry3D 84 | - Gobo 85 | - GroupObject 86 | - Layer 87 | - Mapping 88 | - Overwrite 89 | - Position 90 | - Projector 91 | - Protocol 92 | - SceneObject 93 | - Sources 94 | - Support 95 | - Symbol 96 | - Symdef 97 | - Truss 98 | - UserData 99 | - VideoScreen 100 | 101 | - Writing: 102 | - Fixture 103 | - Focus point 104 | - creating MVR zip file 105 | 106 | ## Development 107 | 108 | PRs appreciated. You can use [uv](https://docs.astral.sh/uv/) to get the 109 | project setup by running: 110 | 111 | ```bash 112 | uv sync 113 | ``` 114 | 115 | ### Typing 116 | 117 | - We try to type the main library, at this point, the 118 | `--no-strict-optional` is needed for mypy tests to pass: 119 | 120 | ```bash 121 | mypy pymvr/*py --pretty --no-strict-optional 122 | ``` 123 | 124 | ### Format 125 | 126 | - To format, use [black](https://github.com/psf/black) or 127 | [ruff](https://docs.astral.sh/ruff/) 128 | 129 | ### Testing 130 | 131 | - to test, use pytest 132 | 133 | ```bash 134 | pytest 135 | ``` 136 | 137 | - to test typing with mypy use: 138 | 139 | ```bash 140 | pytest --mypy -m mypy pymvr/*py 141 | ``` 142 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | ## Releasing to pypi 2 | 3 | * update CHANGELOG.md 4 | * increment version in setup.py 5 | * push to master (via PR) 6 | * `git tag versionCode` 7 | * `git push origin versionCode` 8 | 9 | * generate wheel with pip wheel: 10 | 11 | ```bash 12 | python -m pip install pip wheel twine 13 | python3 -m pip wheel . 14 | ``` 15 | 16 | * generate wheel with uv: 17 | - https://docs.astral.sh/uv/ 18 | 19 | ```bash 20 | uv build 21 | ``` 22 | 23 | * test upload to TestPypi with twine: 24 | * use `__token__` for username and a token for password 25 | 26 | ```bash 27 | python -m twine upload --repository testpypi ./pymvr*whl --verbose 28 | ``` 29 | 30 | * test upload to TestPypi with uv: 31 | * use token for -t 32 | 33 | ``bash 34 | uv publish -t --publish-url https://test.pypi.org/legacy/ dist/*whl 35 | ``` 36 | 37 | * release to official pypi with twine: 38 | 39 | ```bash 40 | python -m twine upload ./pymvr*whl 41 | ``` 42 | 43 | * release to official pypi with uv: 44 | 45 | ```bash 46 | uv publish -t --publish-url https://upload.pypi.org/legacy/ dist/*whl 47 | ``` 48 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import pytest 3 | import pymvr 4 | 5 | # This file sets up a pytest fixtures for the tests 6 | # It is important that this file stays in this location 7 | # as this makes pytest to load pygdtf from the pygdtf directory 8 | 9 | 10 | @pytest.fixture(scope="function") 11 | def mvr_scene(request): 12 | file_name = request.param[0] 13 | test_mvr_scene_path = Path(Path(__file__).parents[0], "tests", file_name) # test file path is made from current directory, tests directory and a file name 14 | mvr_scene = pymvr.GeneralSceneDescription(test_mvr_scene_path) 15 | yield mvr_scene 16 | 17 | 18 | @pytest.fixture(scope="session") 19 | def pymvr_module(): 20 | yield pymvr 21 | 22 | 23 | def pytest_configure(config): 24 | plugin = config.pluginmanager.getplugin("mypy") 25 | plugin.mypy_argv.append("--no-strict-optional") 26 | -------------------------------------------------------------------------------- /pymvr/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Optional 2 | from xml.etree import ElementTree 3 | from xml.etree.ElementTree import Element 4 | import zipfile 5 | import sys 6 | import uuid as py_uuid 7 | from .value import Matrix, Color # type: ignore 8 | 9 | __version__ = "0.5.0" 10 | 11 | 12 | def _find_root(pkg: "zipfile.ZipFile") -> "ElementTree.Element": 13 | """Given a GDTF zip archive, find the GeneralSceneDescription of the 14 | corresponding GeneralSceneDescription.xml file.""" 15 | 16 | with pkg.open("GeneralSceneDescription.xml", "r") as f: 17 | description_str = f.read().decode("utf-8") 18 | if description_str[-1] == "\x00": # this should not happen, but... 19 | description_str = description_str[:-1] 20 | return ElementTree.fromstring(description_str) 21 | 22 | 23 | class GeneralSceneDescription: 24 | def __init__(self, path=None): 25 | if path is not None: 26 | self._package = zipfile.ZipFile(path, "r") 27 | if self._package is not None: 28 | self._root = _find_root(self._package) 29 | self._user_data = self._root.find("UserData") 30 | self._scene = self._root.find("Scene") 31 | if self._root is not None: 32 | self._read_xml() 33 | 34 | def _read_xml(self): 35 | self.version_major: str = self._root.get("verMajor", "") 36 | self.version_minor: str = self._root.get("verMinor", "") 37 | self.provider: str = self._root.get("provider", "") 38 | self.providerVersion: str = self._root.get("providerVersion", "") 39 | 40 | layers_collect = self._scene.find("Layers") 41 | if layers_collect is not None: 42 | self.layers: List["Layer"] = [Layer(xml_node=i) for i in layers_collect.findall("Layer")] 43 | else: 44 | self.layers = [] 45 | 46 | aux_data_collect = self._scene.find("AUXData") 47 | 48 | if aux_data_collect is not None: 49 | self.aux_data = AUXData(xml_node=aux_data_collect) 50 | else: 51 | self.aux_data = None 52 | 53 | if self._user_data is not None: 54 | self.user_data: List["Data"] = [Data(xml_node=i) for i in self._user_data.findall("Data")] 55 | 56 | 57 | class GeneralSceneDescriptionWriter: 58 | """Creates MVR zip archive with packed GeneralSceneDescription xml and other files""" 59 | 60 | # maybe we should split/rename this into xml creator and mvr creator 61 | def __init__(self): 62 | self.version_major: str = "1" 63 | self.version_minor: str = "6" 64 | self.provider: str = "pymvr" 65 | self.providerVersion: str = __version__ 66 | self.files_list: List[str] = [] 67 | self.xml_root = ElementTree.Element( 68 | "GeneralSceneDescription", verMajor=self.version_major, verMinor=self.version_minor, provider=self.provider, providerVersion=self.providerVersion 69 | ) 70 | 71 | def write_mvr(self, path=None): 72 | if path is not None: 73 | if sys.version_info >= (3, 9): 74 | ElementTree.indent(self.xml_root, space=" ", level=0) 75 | xmlstr = ElementTree.tostring(self.xml_root, encoding="unicode", xml_declaration=True) 76 | with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: 77 | z.writestr("GeneralSceneDescription.xml", xmlstr) 78 | for file_path, file_name in self.files_list: 79 | try: 80 | z.write(file_path, arcname=file_name) 81 | except Exception as e: 82 | print(f"File does not exist {file_path}") 83 | 84 | 85 | class SceneElement: 86 | def to_xml(self, parent: Element): 87 | return ElementTree.SubElement(parent, "Scene") 88 | 89 | 90 | class LayersElement: 91 | def to_xml(self, parent: Element): 92 | return ElementTree.SubElement(parent, "Layers") 93 | 94 | 95 | class UserData: 96 | def to_xml(self, parent: Element): 97 | return ElementTree.SubElement(parent, "UserData") 98 | 99 | 100 | class BaseNode: 101 | def __init__(self, xml_node: "Element" = None): 102 | if xml_node is not None: 103 | self._read_xml(xml_node) 104 | 105 | def _read_xml(self, xml_node: "Element"): 106 | pass 107 | 108 | 109 | class BaseChildNode(BaseNode): 110 | def __init__( 111 | self, 112 | name: Union[str, None] = None, 113 | uuid: Union[str, None] = None, 114 | gdtf_spec: Union[str, None] = None, 115 | gdtf_mode: Union[str, None] = None, 116 | matrix: Matrix = Matrix(0), 117 | classing: Union[str, None] = None, 118 | fixture_id: Union[str, None] = None, 119 | fixture_id_numeric: int = 0, 120 | unit_number: int = 0, 121 | fixture_type_id: int = 0, 122 | custom_id: int = 0, 123 | custom_id_type: int = 0, 124 | cast_shadow: bool = False, 125 | addresses: List["Address"] = [], 126 | alignments: List["Alignment"] = [], 127 | custom_commands: List["CustomCommand"] = [], 128 | overwrites: List["Overwrite"] = [], 129 | connections: List["Connection"] = [], 130 | child_list: Union["ChildList", None] = None, 131 | *args, 132 | **kwargs, 133 | ): 134 | self.name = name 135 | if uuid is None: 136 | uuid = str(py_uuid.uuid4()) 137 | self.uuid = uuid 138 | self.gdtf_spec = gdtf_spec 139 | self.gdtf_mode = gdtf_mode 140 | self.matrix = matrix 141 | self.classing = classing 142 | self.fixture_id = fixture_id 143 | self.fixture_id_numeric = fixture_id_numeric 144 | self.unit_number = unit_number 145 | self.fixture_type_id = fixture_type_id 146 | self.custom_id = custom_id 147 | self.custom_id_type = custom_id_type 148 | self.cast_shadow = cast_shadow 149 | self.addresses = addresses 150 | self.alignments = alignments 151 | self.custom_commands = custom_commands 152 | self.overwrites = overwrites 153 | self.connections = connections 154 | self.child_list = child_list 155 | super().__init__(*args, **kwargs) 156 | 157 | def _read_xml(self, xml_node: "Element"): 158 | self.name = xml_node.attrib.get("name") 159 | self.uuid = xml_node.attrib.get("uuid") 160 | _gdtf_spec = xml_node.find("GDTFSpec") 161 | if _gdtf_spec is not None: 162 | self.gdtf_spec = _gdtf_spec.text 163 | if self.gdtf_spec is not None: 164 | self.gdtf_spec = self.gdtf_spec.encode("utf-8").decode("cp437") # IBM PC encoding 165 | if self.gdtf_spec is not None and len(self.gdtf_spec) > 5: 166 | if self.gdtf_spec[-5:].lower() != ".gdtf": 167 | self.gdtf_spec = f"{self.gdtf_spec}.gdtf" 168 | if xml_node.find("GDTFMode") is not None: 169 | self.gdtf_mode = xml_node.find("GDTFMode").text 170 | if xml_node.find("Matrix") is not None: 171 | self.matrix = Matrix(str_repr=xml_node.find("Matrix").text) 172 | if xml_node.find("FixtureID") is not None: 173 | self.fixture_id = xml_node.find("FixtureID").text 174 | 175 | if xml_node.find("FixtureIDNumeric") is not None: 176 | self.fixture_id_numeric = int(xml_node.find("FixtureIDNumeric").text) 177 | if xml_node.find("UnitNumber") is not None: 178 | self.unit_number = int(xml_node.find("UnitNumber").text) 179 | 180 | if xml_node.find("FixtureTypeId") is not None: 181 | self.fixture_type_id = int(xml_node.find("FixtureTypeId").text or 0) 182 | 183 | if xml_node.find("CustomId") is not None: 184 | self.custom_id = int(xml_node.find("CustomId").text or 0) 185 | 186 | if xml_node.find("CustomIdType") is not None: 187 | self.custom_id_type = int(xml_node.find("CustomIdType").text or 0) 188 | 189 | if xml_node.find("CastShadow") is not None: 190 | self.cast_shadow = bool(xml_node.find("CastShadow").text) 191 | 192 | if xml_node.find("Addresses") is not None: 193 | self.addresses = [Address(xml_node=i) for i in xml_node.find("Addresses").findall("Address")] 194 | if not len(self.addresses): 195 | self.addresses = [Address(dmx_break=0, universe=0, address=0)] 196 | 197 | if xml_node.find("Alignments"): 198 | self.alignments = [Alignment(xml_node=i) for i in xml_node.find("Alignments").findall("Alignment")] 199 | if xml_node.find("Connections"): 200 | self.connections = [Connection(xml_node=i) for i in xml_node.find("Connections").findall("Connection")] 201 | if xml_node.find("CustomCommands") is not None: 202 | self.custom_commands = [CustomCommand(xml_node=i) for i in xml_node.find("CustomCommands").findall("CustomCommand")] 203 | if xml_node.find("Overwrites"): 204 | self.overwrites = [Overwrite(xml_node=i) for i in xml_node.find("Overwrites").findall("Overwrite")] 205 | if xml_node.find("Classing") is not None: 206 | self.classing = xml_node.find("Classing").text 207 | 208 | self.child_list = ChildList(xml_node=xml_node.find("ChildList")) 209 | 210 | def __str__(self): 211 | return f"{self.name}" 212 | 213 | 214 | class BaseChildNodeExtended(BaseChildNode): 215 | def __init__( 216 | self, 217 | geometries: "Geometries" = None, 218 | child_list: Union["ChildList", None] = None, 219 | *args, 220 | **kwargs, 221 | ): 222 | self.geometries = geometries 223 | self.child_list = child_list 224 | super().__init__(*args, **kwargs) 225 | 226 | def _read_xml(self, xml_node: "Element"): 227 | super()._read_xml(xml_node) 228 | if xml_node.find("Geometries") is not None: 229 | self.geometries = Geometries(xml_node=xml_node.find("Geometries")) 230 | 231 | self.child_list = ChildList(xml_node=xml_node.find("ChildList")) 232 | 233 | def __str__(self): 234 | return f"{self.name}" 235 | 236 | 237 | class Data(BaseNode): 238 | def __init__( 239 | self, 240 | provider: str = "", 241 | ver: str = "", 242 | *args, 243 | **kwargs, 244 | ): 245 | self.provider = provider 246 | self.ver = ver 247 | super().__init__(*args, **kwargs) 248 | 249 | def _read_xml(self, xml_node: "Element"): 250 | self.provider = xml_node.attrib.get("provider") 251 | self.ver = xml_node.attrib.get("ver") 252 | 253 | def __str__(self): 254 | return f"{self.provider} {self.ver}" 255 | 256 | 257 | class AUXData(BaseNode): 258 | def __init__( 259 | self, 260 | classes: List["Class"] = [], 261 | symdefs: List["Symdef"] = [], 262 | positions: List["Position"] = [], 263 | mapping_definitions: List["MappingDefinition"] = [], 264 | *args, 265 | **kwargs, 266 | ): 267 | self.classes = classes 268 | self.symdefs = symdefs 269 | self.positions = positions 270 | self.mapping_definitions = mapping_definitions 271 | super().__init__(*args, **kwargs) 272 | 273 | def _read_xml(self, xml_node: "Element"): 274 | self.classes = [Class(xml_node=i) for i in xml_node.findall("Class")] 275 | self.symdefs = [Symdef(xml_node=i) for i in xml_node.findall("Symdef")] 276 | self.positions = [Position(xml_node=i) for i in xml_node.findall("Position")] 277 | self.mapping_definitions = [MappingDefinition(xml_node=i) for i in xml_node.findall("MappingDefinition")] 278 | 279 | def to_xml(self, parent: Element): 280 | return ElementTree.SubElement(parent, type(self).__name__) 281 | 282 | 283 | class MappingDefinition(BaseNode): 284 | def __init__( 285 | self, 286 | name: Union[str, None] = None, 287 | uuid: Union[str, None] = None, 288 | size_x: int = 0, 289 | size_y: int = 0, 290 | source=None, 291 | scale_handling=None, 292 | *args, 293 | **kwargs, 294 | ): 295 | self.name = name 296 | self.uuid = uuid 297 | self.size_x = size_x 298 | self.size_y = size_y 299 | self.source = source 300 | self.scale_handling = scale_handling 301 | super().__init__(*args, **kwargs) 302 | 303 | def _read_xml(self, xml_node: "Element"): 304 | # TODO handle missing data... 305 | self.size_x = int(xml_node.find("SizeX").text) 306 | self.size_y = int(xml_node.find("SizeY").text) 307 | self.source = xml_node.find("Source") # TODO 308 | self.scale_handling = xml_node.find("ScaleHandeling").text # TODO ENUM 309 | 310 | 311 | class Fixture(BaseChildNode): 312 | def __init__( 313 | self, 314 | multipatch: Union[str, None] = None, 315 | focus: Union[str, None] = None, 316 | color: Union["Color", str, None] = Color(), 317 | dmx_invert_pan: bool = False, 318 | dmx_invert_tilt: bool = False, 319 | position: Union[str, None] = None, 320 | function_: Union[str, None] = None, 321 | child_position: Union[str, None] = None, 322 | protocols: List["Protocol"] = [], 323 | mappings: List["Mapping"] = [], 324 | gobo: Union["Gobo", None] = None, 325 | *args, 326 | **kwargs, 327 | ): 328 | self.multipatch = multipatch 329 | self.focus = focus 330 | self.color = color 331 | self.dmx_invert_pan = dmx_invert_pan 332 | self.dmx_invert_tilt = dmx_invert_tilt 333 | self.position = position 334 | self.function_ = function_ 335 | self.child_position = child_position 336 | self.protocols = protocols 337 | self.mappings = mappings 338 | self.gobo = gobo 339 | super().__init__(*args, **kwargs) 340 | 341 | def _read_xml(self, xml_node: "Element"): 342 | super()._read_xml(xml_node) 343 | 344 | if xml_node.attrib.get("multipatch") is not None: 345 | self.multipatch = xml_node.attrib.get("multipatch") 346 | 347 | if xml_node.find("Focus") is not None: 348 | self.focus = xml_node.find("Focus").text 349 | 350 | if xml_node.find("Color") is not None: 351 | self.color = Color(str_repr=xml_node.find("Color").text) 352 | 353 | if xml_node.find("DMXInvertPan") is not None: 354 | self.dmx_invert_pan = bool(xml_node.find("DMXInvertPan").text) 355 | 356 | if xml_node.find("DMXInvertTilt") is not None: 357 | self.dmx_invert_tilt = bool(xml_node.find("DMXInvertTilt").text) 358 | 359 | if xml_node.find("Position") is not None: 360 | self.position = xml_node.find("Position").text 361 | 362 | if xml_node.find("Function") is not None: 363 | self.function_ = xml_node.find("Position").text 364 | 365 | if xml_node.find("ChildPosition") is not None: 366 | self.child_position = xml_node.find("ChildPosition").text 367 | 368 | if xml_node.find("Protocols"): 369 | self.protocols = [Protocol(xml_node=i) for i in xml_node.find("Protocols").findall("Protocol")] 370 | if xml_node.find("Mappings") is not None: 371 | self.mappings = [Mapping(xml_node=i) for i in xml_node.find("Mappings").findall("Mapping")] 372 | if xml_node.find("Gobo") is not None: 373 | self.gobo = Gobo(xml_node.attrib.get("Gobo")) 374 | 375 | def to_xml(self): 376 | fixture_element = ElementTree.Element(type(self).__name__, name=self.name, uuid=self.uuid) 377 | 378 | Matrix(self.matrix.matrix).to_xml(fixture_element) 379 | ElementTree.SubElement(fixture_element, "GDTFSpec").text = self.gdtf_spec 380 | ElementTree.SubElement(fixture_element, "GDTFMode").text = self.gdtf_mode 381 | if self.focus is not None: 382 | ElementTree.SubElement(fixture_element, "Focus").text = self.focus 383 | 384 | ElementTree.SubElement(fixture_element, "FixtureID").text = self.fixture_id or "0" 385 | ElementTree.SubElement(fixture_element, "FixtureIDNumeric").text = str(self.fixture_id_numeric) 386 | ElementTree.SubElement(fixture_element, "UnitNumber").text = str(self.unit_number) 387 | ElementTree.SubElement(fixture_element, "Classing").text = str(self.classing) 388 | if self.custom_id: 389 | ElementTree.SubElement(fixture_element, "CustomId").text = str(self.custom_id) 390 | if self.custom_id_type: 391 | ElementTree.SubElement(fixture_element, "CustomIdType").text = str(self.custom_id_type) 392 | if isinstance(self.color, Color): 393 | self.color.to_xml(fixture_element) 394 | else: 395 | Color(str_repr=self.color).to_xml(fixture_element) 396 | 397 | addresses = ElementTree.SubElement(fixture_element, "Addresses") 398 | for address in self.addresses: 399 | Address(address.dmx_break, address.universe, address.address).to_xml(addresses) 400 | return fixture_element 401 | 402 | def __str__(self): 403 | return f"{self.name}" 404 | 405 | 406 | class GroupObject(BaseNode): 407 | def __init__( 408 | self, 409 | name: Union[str, None] = None, 410 | uuid: Union[str, None] = None, 411 | classing: Union[str, None] = None, 412 | child_list: Union["ChildList", None] = None, 413 | matrix: Matrix = Matrix(0), 414 | *args, 415 | **kwargs, 416 | ): 417 | self.name = name 418 | self.uuid = uuid 419 | self.classing = classing 420 | self.child_list = child_list 421 | self.matrix = matrix 422 | 423 | super().__init__(*args, **kwargs) 424 | 425 | def _read_xml(self, xml_node: "Element"): 426 | self.name = xml_node.attrib.get("name") 427 | self.uuid = xml_node.attrib.get("uuid") 428 | if xml_node.find("Classing") is not None: 429 | self.classing = xml_node.find("Classing").text 430 | self.child_list = ChildList(xml_node=xml_node.find("ChildList")) 431 | if xml_node.find("Matrix") is not None: 432 | self.matrix = Matrix(str_repr=xml_node.find("Matrix").text) 433 | 434 | def __str__(self): 435 | return f"{self.name}" 436 | 437 | 438 | class ChildList(BaseNode): 439 | def __init__( 440 | self, 441 | scene_objects: List["SceneObject"] = [], 442 | group_objects: List["GroupObject"] = [], 443 | focus_points: List["FocusPoint"] = [], 444 | fixtures: List["Fixture"] = [], 445 | supports: List["Support"] = [], 446 | trusses: List["Truss"] = [], 447 | video_screens: List["VideoScreen"] = [], 448 | projectors: List["Projector"] = [], 449 | *args, 450 | **kwargs, 451 | ): 452 | if scene_objects is not None: 453 | self.scene_objects = scene_objects 454 | else: 455 | self.scene_objects = [] 456 | 457 | if group_objects is not None: 458 | self.group_objects = group_objects 459 | else: 460 | self.group_objects = [] 461 | 462 | if focus_points is not None: 463 | self.focus_points = focus_points 464 | else: 465 | self.focus_points = [] 466 | 467 | if fixtures is not None: 468 | self.fixtures = fixtures 469 | else: 470 | self.fixtures = [] 471 | 472 | if supports is not None: 473 | self.supports = supports 474 | else: 475 | self.supports = [] 476 | 477 | if trusses is not None: 478 | self.trusses = trusses 479 | else: 480 | self.trusses = [] 481 | 482 | if video_screens is not None: 483 | self.video_screens = video_screens 484 | else: 485 | self.video_screens = [] 486 | 487 | if projectors is not None: 488 | self.projectors = projectors 489 | else: 490 | self.projectors = [] 491 | 492 | super().__init__(*args, **kwargs) 493 | 494 | def _read_xml(self, xml_node: "Element"): 495 | self.scene_objects = [SceneObject(xml_node=i) for i in xml_node.findall("SceneObject")] 496 | 497 | self.group_objects = [GroupObject(xml_node=i) for i in xml_node.findall("GroupObject")] 498 | 499 | self.focus_points = [FocusPoint(xml_node=i) for i in xml_node.findall("FocusPoint")] 500 | 501 | self.fixtures = [Fixture(xml_node=i) for i in xml_node.findall("Fixture")] 502 | 503 | self.supports = [Support(xml_node=i) for i in xml_node.findall("Support")] 504 | self.trusses = [Truss(xml_node=i) for i in xml_node.findall("Truss")] 505 | 506 | self.video_screens = [VideoScreen(xml_node=i) for i in xml_node.findall("VideoScreen")] 507 | 508 | self.projectors = [Projector(xml_node=i) for i in xml_node.findall("Projector")] 509 | 510 | def to_xml(self, parent: Element): 511 | return ElementTree.SubElement(parent, type(self).__name__) 512 | 513 | 514 | class Layer(BaseNode): 515 | def __init__( 516 | self, 517 | name: str = "", 518 | uuid: Union[str, None] = None, 519 | gdtf_spec: Union[str, None] = None, 520 | gdtf_mode: Union[str, None] = None, 521 | matrix: Matrix = Matrix(0), 522 | child_list: Union["ChildList", None] = None, 523 | *args, 524 | **kwargs, 525 | ): 526 | self.name = name 527 | if uuid is None: 528 | uuid = str(py_uuid.uuid4()) 529 | self.uuid = uuid 530 | self.gdtf_spec = gdtf_spec 531 | self.gdtf_mode = gdtf_mode 532 | self.child_list = child_list 533 | self.matrix = matrix 534 | 535 | super().__init__(*args, **kwargs) 536 | 537 | def _read_xml(self, xml_node: "Element"): 538 | self.name = xml_node.attrib.get("name", "") 539 | self.uuid = xml_node.attrib.get("uuid") 540 | _gdtf_spec = xml_node.find("GDTFSpec") 541 | if _gdtf_spec is not None: 542 | self.gdtf_spec = _gdtf_spec.text 543 | if self.gdtf_spec is not None and len(self.gdtf_spec) > 5: 544 | if self.gdtf_spec[-5:].lower() != ".gdtf": 545 | self.gdtf_spec = f"{self.gdtf_spec}.gdtf" 546 | _gdtf_mode: Optional["Element"] = xml_node.find("GDTFMode") 547 | if _gdtf_mode is not None: 548 | self.gdtf_mode = _gdtf_mode.text 549 | 550 | self.child_list = ChildList(xml_node=xml_node.find("ChildList")) 551 | if xml_node.find("Matrix") is not None: 552 | self.matrix = Matrix(str_repr=xml_node.find("Matrix").text) 553 | 554 | def to_xml(self, parent: Element): 555 | return ElementTree.SubElement(parent, type(self).__name__, name=self.name, uuid=self.uuid) 556 | 557 | def __str__(self): 558 | return f"{self.name}" 559 | 560 | 561 | class Address(BaseNode): 562 | def __init__( 563 | self, 564 | dmx_break: int = 0, 565 | universe: int = 1, 566 | address: int = 1, 567 | *args, 568 | **kwargs, 569 | ): 570 | self.dmx_break = dmx_break 571 | self.address = address 572 | self.universe = universe 573 | super().__init__(*args, **kwargs) 574 | 575 | def _read_xml(self, xml_node: "Element"): 576 | self.dmx_break = int(xml_node.attrib.get("break", 0)) 577 | raw_address = xml_node.text or "1" 578 | if raw_address == "0": 579 | raw_address = "1" 580 | if "." in raw_address: 581 | universe, address = raw_address.split(".") 582 | self.universe = int(universe) if (int(universe)) > 0 else 1 583 | self.address = int(address) if int(address) > 0 else 1 584 | return 585 | self.universe = (int(raw_address) - 1) // 512 + 1 586 | self.address = (int(raw_address) - 1) % 512 + 1 587 | 588 | def __repr__(self): 589 | return f"B: {self.dmx_break}, U: {self.universe}, A: {self.address}" 590 | 591 | def __str__(self): 592 | return f"B: {self.dmx_break}, U: {self.universe}, A: {self.address}" 593 | 594 | def to_xml(self, addresses): 595 | # universes are always from 1 in MVR 596 | if self.universe == 0: 597 | self.universe = 1 598 | 599 | universes = 512 * (self.universe - 1) 600 | 601 | raw_address = self.address + universes 602 | address = ElementTree.SubElement(addresses, "Address", attrib={"break": str(self.dmx_break)}) 603 | address.text = str(raw_address) 604 | 605 | 606 | class Class(BaseNode): 607 | def __init__( 608 | self, 609 | uuid: Union[str, None] = None, 610 | name: Union[str, None] = None, 611 | *args, 612 | **kwargs, 613 | ): 614 | self.uuid = uuid 615 | self.name = name 616 | super().__init__(*args, **kwargs) 617 | 618 | def _read_xml(self, xml_node: "Element"): 619 | self.name = xml_node.attrib.get("name") 620 | self.uuid = xml_node.attrib.get("uuid") 621 | 622 | def __str__(self): 623 | return f"{self.name}" 624 | 625 | 626 | class Position(BaseNode): 627 | def __init__( 628 | self, 629 | uuid: Union[str, None] = None, 630 | name: Union[str, None] = None, 631 | *args, 632 | **kwargs, 633 | ): 634 | self.uuid = uuid 635 | self.name = name 636 | super().__init__(*args, **kwargs) 637 | 638 | def _read_xml(self, xml_node: "Element"): 639 | self.name = xml_node.attrib.get("name") 640 | self.uuid = xml_node.attrib.get("uuid") 641 | 642 | def __str__(self): 643 | return f"{self.name}" 644 | 645 | 646 | class Symdef(BaseNode): 647 | def __init__( 648 | self, 649 | uuid: Union[str, None] = None, 650 | name: Union[str, None] = None, 651 | geometry3d: List["Geometry3D"] = [], 652 | symbol: List["Symbol"] = [], 653 | *args, 654 | **kwargs, 655 | ): 656 | self.uuid = uuid 657 | self.name = name 658 | self.geometry3d = geometry3d 659 | self.symbol = symbol 660 | super().__init__(*args, **kwargs) 661 | 662 | def _read_xml(self, xml_node: "Element"): 663 | self.name = xml_node.attrib.get("name") 664 | self.uuid = xml_node.attrib.get("uuid") 665 | 666 | self.symbol = [Symbol(xml_node=i) for i in xml_node.findall("Symbol")] 667 | _geometry3d = [Geometry3D(xml_node=i) for i in xml_node.findall("Geometry3D")] 668 | if xml_node.find("ChildList") is not None: 669 | child_list = xml_node.find("ChildList") 670 | 671 | symbols = [Symbol(xml_node=i) for i in child_list.findall("Symbol")] 672 | geometry3ds = [Geometry3D(xml_node=i) for i in child_list.findall("Geometry3D")] 673 | self.symbol += symbols 674 | _geometry3d += geometry3ds 675 | 676 | # sometimes the list of geometry3d is full of duplicates, eliminate them here 677 | self.geometry3d = list(set(_geometry3d)) 678 | 679 | 680 | class Geometry3D(BaseNode): 681 | def __init__( 682 | self, 683 | file_name: Union[str, None] = None, 684 | matrix: Matrix = Matrix(0), 685 | *args, 686 | **kwargs, 687 | ): 688 | self.file_name = file_name 689 | self.matrix = matrix 690 | super().__init__(*args, **kwargs) 691 | 692 | def _read_xml(self, xml_node: "Element"): 693 | self.file_name = xml_node.attrib.get("fileName", "").encode("utf-8").decode("cp437") 694 | if xml_node.find("Matrix") is not None: 695 | self.matrix = Matrix(str_repr=xml_node.find("Matrix").text) 696 | 697 | def __str__(self): 698 | return f"{self.file_name} {self.matrix}" 699 | 700 | def __repr__(self): 701 | return f"{self.file_name} {self.matrix}" 702 | 703 | def __eq__(self, other): 704 | return self.file_name == other.file_name and self.matrix == other.matrix 705 | 706 | def __ne__(self, other): 707 | return self.file_name != other.file_name or self.matrix != other.matrix 708 | 709 | def __hash__(self): 710 | return hash((self.file_name, str(self.matrix))) 711 | 712 | 713 | class Symbol(BaseNode): 714 | def __init__( 715 | self, 716 | uuid: Union[str, None] = None, 717 | symdef: Union[str, None] = None, 718 | matrix: Matrix = Matrix(0), 719 | *args, 720 | **kwargs, 721 | ): 722 | self.uuid = uuid 723 | self.symdef = symdef 724 | self.matrix = matrix 725 | super().__init__(*args, **kwargs) 726 | 727 | def _read_xml(self, xml_node: "Element"): 728 | self.uuid = xml_node.attrib.get("uuid") 729 | self.symdef = xml_node.attrib.get("symdef") 730 | if xml_node.find("Matrix") is not None: 731 | self.matrix = Matrix(str_repr=xml_node.find("Matrix").text) 732 | 733 | def __str__(self): 734 | return f"{self.uuid}" 735 | 736 | 737 | class Geometries(BaseNode): 738 | def __init__( 739 | self, 740 | geometry3d: List["Geometry3D"] = [], 741 | symbol: List["Symbol"] = [], 742 | *args, 743 | **kwargs, 744 | ): 745 | self.geometry3d = geometry3d 746 | self.symbol = symbol 747 | super().__init__(*args, **kwargs) 748 | 749 | def _read_xml(self, xml_node: "Element"): 750 | self.symbol = [Symbol(xml_node=i) for i in xml_node.findall("Symbol")] 751 | self.geometry3d = [Geometry3D(xml_node=i) for i in xml_node.findall("Geometry3D")] 752 | if xml_node.find("ChildList"): 753 | child_list = xml_node.find("ChildList") 754 | 755 | symbols = [Symbol(xml_node=i) for i in child_list.findall("Symbol")] 756 | geometry3ds = [Geometry3D(xml_node=i) for i in child_list.findall("Geometry3D")] 757 | self.symbol += symbols # TODO remove this over time, children should only be in child_list 758 | self.geometry3d += geometry3ds 759 | 760 | def to_xml(self, parent: Element): 761 | element = ElementTree.SubElement(parent, type(self).__name__) 762 | return element 763 | 764 | 765 | class FocusPoint(BaseNode): 766 | def __init__( 767 | self, 768 | uuid: Union[str, None] = None, 769 | name: Union[str, None] = None, 770 | matrix: Matrix = Matrix(0), 771 | classing: Union[str, None] = None, 772 | geometries: "Geometries" = None, 773 | *args, 774 | **kwargs, 775 | ): 776 | self.name = name 777 | self.uuid = uuid 778 | self.matrix = matrix 779 | self.classing = classing 780 | self.geometries = geometries 781 | 782 | super().__init__(*args, **kwargs) 783 | 784 | def _read_xml(self, xml_node: "Element"): 785 | self.uuid = xml_node.attrib.get("uuid") 786 | self.name = xml_node.attrib.get("name") 787 | if xml_node.find("Matrix") is not None: 788 | self.matrix = Matrix(str_repr=xml_node.find("Matrix").text) 789 | if xml_node.find("Classing") is not None: 790 | self.classing = xml_node.find("Classing").text 791 | if xml_node.find("Geometries") is not None: 792 | self.geometries = Geometries(xml_node=xml_node.find("Geometries")) 793 | 794 | def __str__(self): 795 | return f"{self.name}" 796 | 797 | def to_xml(self): 798 | element = ElementTree.Element(type(self).__name__, name=self.name, uuid=self.uuid) 799 | Matrix(self.matrix.matrix).to_xml(parent=element) 800 | Geometries().to_xml(parent=element) 801 | return element 802 | 803 | 804 | class SceneObject(BaseChildNodeExtended): 805 | pass 806 | 807 | 808 | class Truss(BaseChildNodeExtended): 809 | pass 810 | 811 | 812 | class Support(BaseChildNodeExtended): 813 | def __init__( 814 | self, 815 | chain_length: float = 0, 816 | *args, 817 | **kwargs, 818 | ): 819 | self.chain_length = chain_length 820 | super().__init__(*args, **kwargs) 821 | 822 | def _read_xml(self, xml_node: "Element"): 823 | if xml_node.find("ChainLength") is None: 824 | self.chain_length = float(xml_node.find("ChainLength").text or 0) 825 | 826 | 827 | class VideoScreen(BaseChildNodeExtended): 828 | def __init__( 829 | self, 830 | sources: "Sources" = None, 831 | *args, 832 | **kwargs, 833 | ): 834 | self.sources = sources 835 | super().__init__(*args, **kwargs) 836 | 837 | def _read_xml(self, xml_node: "Element"): 838 | if xml_node.find("Sources") is None: 839 | self.sources = Sources(xml_node=xml_node.find("Sources")) 840 | 841 | 842 | class Projector(BaseChildNodeExtended): 843 | def __init__( 844 | self, 845 | projections: "Projections" = None, 846 | *args, 847 | **kwargs, 848 | ): 849 | self.projections = projections 850 | super().__init__(*args, **kwargs) 851 | 852 | def _read_xml(self, xml_node: "Element"): 853 | if xml_node.find("Projections") is None: 854 | self.projections = Projections(xml_node.find("Projections")) 855 | 856 | 857 | class Protocol(BaseNode): 858 | def __init__( 859 | self, 860 | geometry: Union[str, None] = None, 861 | name: Union[str, None] = None, 862 | type_: Union[str, None] = None, 863 | version: Union[str, None] = None, 864 | transmission: Union[str, None] = None, 865 | *args, 866 | **kwargs, 867 | ): 868 | self.geometry = geometry 869 | self.name = name 870 | self.type = type_ 871 | self.version = version 872 | self.transmission = transmission 873 | super().__init__(*args, **kwargs) 874 | 875 | def _read_xml(self, xml_node: "Element"): 876 | self.geometry = xml_node.attrib.get("geometry") 877 | self.name = xml_node.attrib.get("name") 878 | self.type = xml_node.attrib.get("type") 879 | self.version = xml_node.attrib.get("version") 880 | self.transmission = xml_node.attrib.get("transmission") 881 | 882 | def __str__(self): 883 | return f"{self.name}" 884 | 885 | 886 | class Alignment(BaseNode): 887 | def __init__( 888 | self, 889 | geometry: Union[str, None] = None, 890 | up: Union[str, None] = "0,0,1", 891 | direction: Union[str, None] = "0,0,-1", 892 | *args, 893 | **kwargs, 894 | ): 895 | self.geometry = geometry 896 | self.up = up 897 | self.direction = direction 898 | super().__init__(*args, **kwargs) 899 | 900 | def _read_xml(self, xml_node: "Element"): 901 | self.geometry = xml_node.attrib.get("geometry") 902 | self.up = xml_node.attrib.get("up", "0,0,1") 903 | self.direction = xml_node.attrib.get("direction", "0,0,-1") 904 | 905 | def __str__(self): 906 | return f"{self.geometry}" 907 | 908 | 909 | class Overwrite(BaseNode): 910 | def __init__( 911 | self, 912 | universal: Union[str, None] = None, 913 | target: Union[str, None] = None, 914 | *args, 915 | **kwargs, 916 | ): 917 | self.universal = universal 918 | self.target = target 919 | super().__init__(*args, **kwargs) 920 | 921 | def _read_xml(self, xml_node: "Element"): 922 | self.universal = xml_node.attrib.get("universal") 923 | self.target = xml_node.attrib.get("target") 924 | 925 | def __str__(self): 926 | return f"{self.universal} {self.target}" 927 | 928 | 929 | class Connection(BaseNode): 930 | def __init__( 931 | self, 932 | own: Union[str, None] = None, 933 | other: Union[str, None] = None, 934 | to_object: Union[str, None] = None, 935 | *args, 936 | **kwargs, 937 | ): 938 | self.own = own 939 | self.other = other 940 | self.to_object = to_object 941 | super().__init__(*args, **kwargs) 942 | 943 | def _read_xml(self, xml_node: "Element"): 944 | self.own = xml_node.attrib.get("own") 945 | self.other = xml_node.attrib.get("other") 946 | self.to_object = xml_node.attrib.get("toObject") 947 | 948 | def __str__(self): 949 | return f"{self.own} {self.other}" 950 | 951 | 952 | class Mapping(BaseNode): 953 | def __init__( 954 | self, 955 | link_def: Union[str, None] = None, 956 | ux: Union[int, None] = None, 957 | uy: Union[int, None] = None, 958 | ox: Union[int, None] = None, 959 | oy: Union[int, None] = None, 960 | rz: Union[int, None] = None, 961 | *args, 962 | **kwargs, 963 | ): 964 | self.link_def = link_def 965 | self.ux = ux 966 | self.uy = uy 967 | self.ox = ox 968 | self.oy = oy 969 | self.rz = rz 970 | super().__init__(*args, **kwargs) 971 | 972 | def _read_xml(self, xml_node: "Element"): 973 | self.link_def = xml_node.attrib.get("linkedDef") 974 | self.ux = int(xml_node.find("ux").text) 975 | self.uy = int(xml_node.find("uy").text) 976 | self.ox = int(xml_node.find("ox").text) 977 | self.oy = int(xml_node.find("oy").text) 978 | self.rz = int(xml_node.find("rz").text) 979 | 980 | def __str__(self): 981 | return f"{self.link_def}" 982 | 983 | 984 | class Gobo(BaseNode): 985 | def __init__( 986 | self, 987 | rotation: Union[str, float, None] = None, 988 | filename: Union[str, None] = None, 989 | *args, 990 | **kwargs, 991 | ): 992 | self.rotation = rotation 993 | self.filename = filename 994 | super().__init__(*args, **kwargs) 995 | 996 | def _read_xml(self, xml_node: "Element"): 997 | self.rotation = float(xml_node.attrib.get("rotation", 0)) 998 | self.filename = xml_node.text 999 | 1000 | def __str__(self): 1001 | return f"{self.filename} {self.rotation}" 1002 | 1003 | 1004 | class CustomCommand(BaseNode): 1005 | # TODO: split more: Body_Pan,f 50 1006 | def __init__( 1007 | self, 1008 | custom_command: Union[str, None] = None, 1009 | *args, 1010 | **kwargs, 1011 | ): 1012 | self.custom_command = custom_command 1013 | super().__init__(*args, **kwargs) 1014 | 1015 | def _read_xml(self, xml_node: "Element"): 1016 | self.custom_command = xml_node.text 1017 | 1018 | def __str__(self): 1019 | return f"{self.custom_command}" 1020 | 1021 | 1022 | class Projections(BaseNode): 1023 | ... 1024 | # todo 1025 | 1026 | 1027 | class Sources(BaseNode): 1028 | def __init__( 1029 | self, 1030 | linked_geometry: Union[str, None] = None, 1031 | type_: Union[str, None] = None, 1032 | *args, 1033 | **kwargs, 1034 | ): 1035 | self.linked_geometry = linked_geometry 1036 | self.type_ = type_ 1037 | super().__init__(*args, **kwargs) 1038 | 1039 | def _read_xml(self, xml_node: "Element"): 1040 | self.linked_geometry = xml_node.attrib.get("linkedGeometry") 1041 | self.type_ = xml_node.attrib.get("type") 1042 | 1043 | def __str__(self): 1044 | return f"{self.linked_geometry} {self.type_}" 1045 | -------------------------------------------------------------------------------- /pymvr/value.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | from xml.etree import ElementTree 3 | from xml.etree.ElementTree import Element 4 | 5 | 6 | # Data type that only allows a specific set of values, if given a value 7 | # which is not permitted, the value will be set to the default 8 | class Enum: 9 | permitted: List[str] = [] 10 | _default = None 11 | 12 | def __init__(self, value): 13 | self.value = None 14 | if value not in self.permitted: 15 | self.value = self._default 16 | else: 17 | self.value = value 18 | 19 | def __str__(self): 20 | return str(self.value) 21 | 22 | def __bool__(self): 23 | return bool(self.value) 24 | 25 | 26 | class Color: 27 | def __init__( 28 | self, 29 | x: Union[float, None] = 0.3127, 30 | y: Union[float, None] = 0.3290, 31 | Y: Union[float, None] = 100.00, 32 | str_repr: Union[str, None] = None, 33 | ): 34 | self.x = x 35 | self.y = y 36 | self.Y = Y 37 | if str_repr is not None: 38 | try: 39 | self.x = float(str_repr.split(",")[0]) 40 | self.y = float(str_repr.split(",")[1]) 41 | self.Y = float(str_repr.split(",")[2]) 42 | except: 43 | # Fail gracefully with default color (White) 44 | self.x = 0.3127 45 | self.y = 0.3290 46 | self.Y = 100.00 47 | 48 | def __str__(self): 49 | return f"{self.x}, {self.y}, {self.Y}" 50 | 51 | def to_xml(self, root): 52 | element = ElementTree.SubElement(root, type(self).__name__) 53 | element.text = f"{self.x},{self.y},{self.Y}" 54 | 55 | 56 | class Rotation: 57 | def __init__(self, str_repr): 58 | str_repr = str_repr.replace("}{", ",") 59 | str_repr = str_repr.replace("{", "") 60 | str_repr = str_repr.replace("}", "") 61 | component = str_repr.split(",") 62 | component = [float(i) for i in component] 63 | self.matrix = [ 64 | [component[0], component[1], component[2]], 65 | [component[3], component[4], component[5]], 66 | [component[6], component[7], component[8]], 67 | ] 68 | 69 | 70 | class Matrix: 71 | def __init__(self, str_repr): 72 | if str_repr == "0" or str_repr == 0: 73 | self.matrix = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]] 74 | elif isinstance(str_repr, list): 75 | self.matrix = str_repr 76 | else: 77 | str_repr = str_repr.replace("}{", ",") 78 | str_repr = str_repr.replace("{", "") 79 | str_repr = str_repr.replace("}", "") 80 | component = str_repr.split(",") 81 | component = [float(i) for i in component] 82 | self.matrix = [ 83 | [component[0], component[1], component[2], 0], 84 | [component[3], component[4], component[5], 0], 85 | [component[6], component[7], component[8], 0], 86 | [component[9] * 0.001, component[10] * 0.001, component[11] * 0.001, 0], 87 | ] 88 | # TODO: the matrix down-scaling should not be done here but in the consumer, based on scaling settings and so on 89 | # same below, where we up-scale it again as mere re-processing via pymvr will cause loss of precision 90 | # another option is to have a global settings for the GeneralSceneDescription but we then must pass the GSD class 91 | # down through the hierarchy all the way to the Matrix class 92 | 93 | def __eq__(self, other): 94 | return self.matrix == other.matrix 95 | 96 | def __ne__(self, other): 97 | return self.matrix == other.matrix 98 | 99 | def __str__(self): 100 | return f"{self.matrix}" 101 | 102 | def __repr__(self): 103 | return f"{self.matrix}" 104 | 105 | def to_xml(self, parent): 106 | u, v, w, x = self.matrix 107 | matrix_str = f"{{{u[0]},{u[1]},{u[2]}}}{{{v[0]},{v[1]},{v[2]}}}{{{w[0]},{w[1]},{w[2]}}}{{{x[0] / 0.001},{x[1] / 0.001},{x[2] / 0.001}}}" 108 | matrix = ElementTree.SubElement(parent, type(self).__name__) 109 | matrix.text = matrix_str 110 | 111 | 112 | # A node link represents a link to another node in the XML tree, starting from 113 | # start_point and traversing the tree with a decimal-point notation in str_link. 114 | # There isn't yet a standard for how start_point is formatted so the only useful 115 | # feature of this type currently is the str_link. A typical use would be for 116 | # specifying linked attributes. In this case, the str_link text itself is perfectly 117 | # serviceable if all that is needed is the attribute name. For this reason, the 118 | # str representation of the NodeLink will always give the raw str_link property. 119 | class NodeLink: 120 | def __init__(self, start_point, str_link): 121 | self.start_point = start_point 122 | self.str_link = str_link 123 | 124 | def __str__(self): 125 | return str(self.str_link) 126 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name="pymvr" 7 | dynamic = ["version"] 8 | readme = "README.md" 9 | description="My Virtual Rig (MVR) library for Python" 10 | requires-python = ">=3.8" 11 | authors = [ 12 | {name = "vanous", email = "noreply@nodomain.com"}, 13 | ] 14 | maintainers = [ 15 | {name = "vanous", email = "noreply@nodomain.com"}, 16 | ] 17 | keywords = ["MVR", "GDTF"] 18 | license = {text = "MIT License"} 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "Programming Language :: Python" 22 | ] 23 | 24 | [tool.setuptools] 25 | packages = ["pymvr"] 26 | license-files = [] 27 | 28 | [tool.setuptools.dynamic] 29 | version = {attr = "pymvr.__version__"} 30 | 31 | [dependency-groups] 32 | dev = [ 33 | "pytest>=8.3.4", 34 | "pytest-mypy>=0.10.3", 35 | "ruff>=0.9.3", 36 | ] 37 | 38 | [project.urls] 39 | Repository = "https://github.com/open-stage/python-mvr.git" 40 | Changelog = "https://github.com/open-stage/python-mvr/blob/master/CHANGELOG.md" 41 | 42 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 160 2 | -------------------------------------------------------------------------------- /tests/basic_fixture.mvr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-stage/python-mvr/108a62c7e945fdc09f3c6c60637cfcbbc970cde9/tests/basic_fixture.mvr -------------------------------------------------------------------------------- /tests/capture_demo_show.mvr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-stage/python-mvr/108a62c7e945fdc09f3c6c60637cfcbbc970cde9/tests/capture_demo_show.mvr -------------------------------------------------------------------------------- /tests/scene_objects.mvr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-stage/python-mvr/108a62c7e945fdc09f3c6c60637cfcbbc970cde9/tests/scene_objects.mvr -------------------------------------------------------------------------------- /tests/test_example.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import pymvr 3 | 4 | 5 | def test_write_example_mvr_file(): 6 | fixtures_list = [] 7 | mvr = pymvr.GeneralSceneDescriptionWriter() 8 | pymvr.UserData().to_xml(parent=mvr.xml_root) 9 | scene = pymvr.SceneElement().to_xml(parent=mvr.xml_root) 10 | layers = pymvr.LayersElement().to_xml(parent=scene) 11 | layer = pymvr.Layer(name="Test layer").to_xml(parent=layers) 12 | child_list = pymvr.ChildList().to_xml(parent=layer) 13 | 14 | fixture = pymvr.Fixture(name="Test Fixture") # not really a valid fixture 15 | child_list.append(fixture.to_xml()) 16 | fixtures_list.append((fixture.gdtf_spec, fixture.gdtf_spec)) 17 | 18 | pymvr.AUXData().to_xml(parent=scene) 19 | 20 | mvr.files_list = list(set(fixtures_list)) 21 | mvr.write_mvr("example.mvr") 22 | -------------------------------------------------------------------------------- /tests/test_fixture_1_5.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize("mvr_scene", [("basic_fixture.mvr",)], indirect=True) 5 | def test_version(mvr_scene): 6 | """MVR version should be 1.5""" 7 | 8 | assert mvr_scene.version_major == "1" 9 | assert mvr_scene.version_minor == "5" 10 | 11 | 12 | def process_mvr_child_list(child_list, mvr_scene): 13 | for fixture in child_list.fixtures: 14 | process_mvr_fixture(fixture) 15 | for group in child_list.group_objects: 16 | if group.child_list is not None: 17 | process_mvr_child_list( 18 | group.child_list, 19 | mvr_scene, 20 | ) 21 | 22 | 23 | def process_mvr_fixture(fixture): 24 | assert fixture.gdtf_spec == "LED PAR 64 RGBW.gdtf" 25 | assert fixture.addresses[0].universe == 1 # even though the uni is 0 in the file, 1 is by the spec 26 | assert fixture.addresses[0].address == 1 # dtto 27 | assert fixture.gdtf_mode == "Default" 28 | assert fixture.matrix.matrix[3] == [5.0, 5.0, 5.0, 0] 29 | 30 | 31 | @pytest.mark.parametrize("mvr_scene", [("basic_fixture.mvr",)], indirect=True) 32 | def test_fixture(mvr_scene): 33 | for layer in mvr_scene.layers: 34 | process_mvr_child_list(layer.child_list, mvr_scene) 35 | -------------------------------------------------------------------------------- /tests/test_fixture_scene_object_1_5.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize("mvr_scene", [("scene_objects.mvr",)], indirect=True) 5 | def test_version(mvr_scene): 6 | """MVR version should be 1.5""" 7 | 8 | assert mvr_scene.version_major == "1" 9 | assert mvr_scene.version_minor == "5" 10 | 11 | 12 | def process_mvr_child_list(child_list, mvr_scene): 13 | for fixture in child_list.fixtures: 14 | process_mvr_fixture(fixture) 15 | 16 | for focus_point in child_list.focus_points: 17 | process_mvr_focus_point(focus_point) 18 | 19 | for scene_object in child_list.scene_objects: 20 | process_mvr_scene_object(scene_object) 21 | 22 | for group in child_list.group_objects: 23 | if group.child_list is not None: 24 | process_mvr_child_list( 25 | group.child_list, 26 | mvr_scene, 27 | ) 28 | 29 | 30 | def process_mvr_fixture(fixture): 31 | assert fixture.gdtf_spec == "Custom@Light Instr Light Source Pendant 44deg.gdtf" 32 | assert fixture.gdtf_mode == "DMX Mode" 33 | 34 | 35 | def process_mvr_scene_object(scene_object): 36 | # test getting focus points 37 | name = scene_object.name 38 | uuid = scene_object.uuid 39 | assert name is not None 40 | assert uuid is not None 41 | print("scene object", name, uuid) 42 | 43 | 44 | def process_mvr_focus_point(focus_point): 45 | # test getting focus points 46 | name = focus_point.name 47 | uuid = focus_point.uuid 48 | assert name is not None 49 | assert uuid is not None 50 | print("focus point", name, uuid) 51 | 52 | 53 | def process_classes(mvr_scene): 54 | class_ = mvr_scene.aux_data.classes[0] 55 | assert class_.name == "Site-Cieling" 56 | assert class_.uuid == "2BD0B4C7-DDE3-4CAE-AA8D-9DDDF096F43E" 57 | 58 | 59 | @pytest.mark.parametrize("mvr_scene", [("scene_objects.mvr",)], indirect=True) 60 | def test_fixture(mvr_scene): 61 | for layer in mvr_scene.layers: 62 | process_mvr_child_list(layer.child_list, mvr_scene) 63 | 64 | process_classes(mvr_scene) 65 | -------------------------------------------------------------------------------- /tests/test_group_objects_1_4.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize("mvr_scene", [("capture_demo_show.mvr",)], indirect=True) 5 | def test_version(mvr_scene): 6 | """MVR version should be 1.4""" 7 | assert mvr_scene.version_major == "1" 8 | assert mvr_scene.version_minor == "4" 9 | 10 | 11 | @pytest.mark.parametrize("mvr_scene", [("capture_demo_show.mvr",)], indirect=True) 12 | def test_auxdata(mvr_scene): 13 | """Check symdefs""" 14 | assert mvr_scene.aux_data.symdefs[0].uuid == "12fcdd5e-4194-56a0-96de-1c3c4edf1cd3" 15 | 16 | 17 | @pytest.mark.parametrize("mvr_scene", [("capture_demo_show.mvr",)], indirect=True) 18 | def test_child_list(mvr_scene): 19 | assert mvr_scene.layers[1].child_list.fixtures[0].uuid == "2e149740-6a41-bc43-bd59-8968781b11b9" 20 | assert mvr_scene.layers[2].child_list.scene_objects[1].uuid == "d1d76649-35bd-9d49-baa4-abcb481fb2c8" 21 | -------------------------------------------------------------------------------- /tests/test_mvr_01_write_ours.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pathlib import Path 3 | import pymvr 4 | 5 | 6 | def process_mvr_child_list(child_list, mvr_scene): 7 | all_fixtures = [] 8 | all_focus_points = [] 9 | 10 | all_fixtures += child_list.fixtures 11 | all_focus_points += child_list.focus_points 12 | 13 | for group in child_list.group_objects: 14 | if group.child_list is not None: 15 | new_fixtures = process_mvr_child_list( 16 | group.child_list, 17 | mvr_scene, 18 | ) 19 | all_fixtures += new_fixtures 20 | all_focus_points += all_focus_points 21 | return (all_fixtures, all_focus_points) 22 | 23 | 24 | @pytest.mark.parametrize("mvr_scene", [("basic_fixture.mvr",)], indirect=True) 25 | def test_write_mvr_file(mvr_scene): 26 | fixtures_list = [] 27 | mvr = pymvr.GeneralSceneDescriptionWriter() 28 | pymvr.UserData().to_xml(parent=mvr.xml_root) 29 | scene = pymvr.SceneElement().to_xml(parent=mvr.xml_root) 30 | layers = pymvr.LayersElement().to_xml(parent=scene) 31 | layer = pymvr.Layer(name="My layer").to_xml(parent=layers) 32 | child_list = pymvr.ChildList().to_xml(parent=layer) 33 | for layer in mvr_scene.layers: 34 | fixtures, focus_points = process_mvr_child_list(layer.child_list, mvr_scene) 35 | for fixture in fixtures: 36 | child_list.append(fixture.to_xml()) 37 | fixtures_list.append((fixture.gdtf_spec, fixture.gdtf_spec)) 38 | for point in focus_points: 39 | child_list.append(point.to_xml()) 40 | 41 | pymvr.AUXData().to_xml(parent=scene) 42 | 43 | mvr.files_list = list(set(fixtures_list)) 44 | test_file_path = Path(Path(__file__).parent, "test.mvr") 45 | mvr.write_mvr(test_file_path) 46 | -------------------------------------------------------------------------------- /tests/test_mvr_02_read_ours.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize("mvr_scene", [("test.mvr",)], indirect=True) 5 | def test_version(mvr_scene): 6 | """MVR version should be 1.6""" 7 | 8 | assert mvr_scene.version_major == "1" 9 | assert mvr_scene.version_minor == "6" 10 | 11 | 12 | def process_mvr_child_list(child_list, mvr_scene): 13 | for fixture in child_list.fixtures: 14 | process_mvr_fixture(fixture) 15 | for group in child_list.group_objects: 16 | if group.child_list is not None: 17 | process_mvr_child_list( 18 | group.child_list, 19 | mvr_scene, 20 | ) 21 | 22 | 23 | def process_mvr_fixture(fixture): 24 | assert fixture.gdtf_spec == "LED PAR 64 RGBW.gdtf" 25 | assert fixture.addresses[0].universe == 1 26 | assert fixture.addresses[0].address == 1 27 | assert fixture.gdtf_mode == "Default" 28 | assert fixture.matrix.matrix[3] == [5.0, 5.0, 5.0, 0] 29 | 30 | 31 | @pytest.mark.parametrize("mvr_scene", [("test.mvr",)], indirect=True) 32 | def test_fixture(mvr_scene): 33 | for layer in mvr_scene.layers: 34 | process_mvr_child_list(layer.child_list, mvr_scene) 35 | --------------------------------------------------------------------------------