diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index af63e87..5e03ce2 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -8,13 +8,13 @@ on: jobs: build-and-release-package: if: github.repository == 'cmlibs-python/cmlibs.argon' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 name: Release package permissions: contents: write id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - name: Release Python package - uses: hsorby/release-python-package-action@v1 + uses: hsorby/release-python-package-action@v2 with: pypi-package-name: cmlibs.argon diff --git a/.gitignore b/.gitignore index 36f924e..d507437 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ dmypy.json # Sphinx mock directory docs/mock/ +.idea/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6506623 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=61.0", "setuptools_scm>=8.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools-git-versioning] +enabled = true + +[project] +name = "cmlibs.argon" +dynamic = ["version"] +keywords = ["Argon", "ArgonDocument", "CMLibs", "Zinc"] +readme = "README.rst" +license = "Apache-2.0" +authors = [ + { name="Hugh Sorby", email="h.sorby@auckland.ac.nz" }, +] +dependencies = [ + "packaging", + "cmlibs.zinc >= 4.1.3" +] +description = "CMLibs Argon visualisation descriptions." +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering :: Medical Science Apps.", +] + +[project.urls] +Homepage = "https://cmlibs.org" +Repository = "https://github.com/CMLibs-Python/cmlibs.argon" + +[tool.setuptools_scm] diff --git a/setup.py b/setup.py deleted file mode 100644 index a04ef2e..0000000 --- a/setup.py +++ /dev/null @@ -1,54 +0,0 @@ -import io -import os -import re - -from setuptools import setup, find_packages - - -here = os.path.abspath(os.path.dirname(__file__)) - -with open(os.path.join(here, 'src', 'cmlibs', 'argon', '__init__.py')) as fd: - version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', - fd.read(), re.MULTILINE).group(1) - -if not version: - raise RuntimeError('Cannot find version information') - - -def readfile(filename, split=False): - with io.open(filename, encoding="utf-8") as stream: - if split: - return stream.read().split("\n") - return stream.read() - - -readme = readfile("README.rst", split=True) -readme.append('License') -readme.append('=======') -readme.append('') -readme.append('::') -readme.append('') -readme.append('') - -software_licence = readfile("LICENSE") - -requires = ['cmlibs.zinc', 'packaging'] - -setup( - name='cmlibs.argon', - version=version, - description='CMLibs Argon visualisation descriptions.', - long_description='\n'.join(readme) + software_licence, - long_description_content_type='text/x-rst', - classifiers=[], - author='Hugh Sorby', - author_email='h.sorby@auckland.ac.nz', - url='https://github.com/cmlibs-python/cmlibs.argon', - license='Apache Software License', - license_files=("LICENSE",), - packages=find_packages("src"), - package_dir={"": "src"}, - include_package_data=True, - zip_safe=False, - install_requires=requires, -) diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/cmlibs/argon/argondocument.py b/src/cmlibs/argon/argondocument.py index abb430f..5202ca6 100644 --- a/src/cmlibs/argon/argondocument.py +++ b/src/cmlibs/argon/argondocument.py @@ -119,7 +119,7 @@ def _regionChange(self, changedRegion, treeChange): zincRootRegion = changedRegion.getZincRegion() self._zincContext.setDefaultRegion(zincRootRegion) - def deserialize(self, state): + def deserialize(self, state, base_path=None): """ Read the JSON description to the argon document object. This will change the settings of ArgonDocument Object. @@ -145,7 +145,7 @@ def deserialize(self, state): self._materials.deserialize(d["Materials"]) if "Views" in d: self._view_manager.deserialize(d["Views"]) - self._rootRegion.deserialize(d["RootRegion"]) + self._rootRegion.deserialize(d["RootRegion"], base_path) def serialize(self, base_path=None): """ diff --git a/src/cmlibs/argon/argonmaterials.py b/src/cmlibs/argon/argonmaterials.py index 97a42e0..5bb7fef 100644 --- a/src/cmlibs/argon/argonmaterials.py +++ b/src/cmlibs/argon/argonmaterials.py @@ -13,20 +13,222 @@ See the License for the specific language governing permissions and limitations under the License. """ +import copy +import hashlib import json +from cmlibs.zinc import __version__ as zinc_version from cmlibs.zinc.status import OK as ZINC_OK from cmlibs.argon.argonerror import ArgonError -class ArgonMaterials(object): +DEFAULT_ZINC_VERSION = '4.2.1' + + +def _get_strings_hash(text): + """Calculates the SHA-256 hash for the entire text block.""" + return hashlib.sha256(text.encode('utf-8')).hexdigest() + + +def _get_item_hashes(materials_list): + """ + Calculates the canonical hash for each individual item. + Uses 'Name' as the unique key. + Returns a dictionary {material_name: hash} + """ + hashes = {} + for item in materials_list: + # Use 'Name' as the unique identifier + item_name = item.get('Name') + if not item_name: + continue + + # Create a canonical (sorted) string representation for hashing + # This ensures {'a': 1, 'b': 2} hashes the same as {'b': 2, 'a': 1} + canonical_string = json.dumps(item, sort_keys=True) + hashes[item_name] = _get_strings_hash(canonical_string) + return hashes + + +BASE_MATERIALS_OBJECT = { + 'DefaultMaterial': 'default', + 'DefaultSelectedMaterial': 'default_selected', + 'Materials': [ + {'Alpha': 1, 'Ambient': [0, 0, 0], 'Diffuse': [0, 0, 0], 'Emission': [0, 0, 0], 'Name': 'black', + 'Shininess': 0.2, 'Specular': [0.3, 0.3, 0.3]}, + {'Alpha': 1, 'Ambient': [0, 0, 1], 'Diffuse': [0, 0, 1], 'Emission': [0, 0, 0], 'Name': 'blue', + 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [0.7, 0.7, 0.6], 'Diffuse': [0.9, 0.9, 0.7], 'Emission': [0, 0, 0], + 'Name': 'bone', 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [0.5, 0.25, 0], 'Diffuse': [0.5, 0.25, 0], 'Emission': [0, 0, 0], + 'Name': 'brown', 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [0, 1, 1], 'Diffuse': [0, 1, 1], 'Emission': [0, 0, 0], 'Name': 'cyan', + 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [1, 1, 1], 'Diffuse': [1, 1, 1], 'Emission': [0, 0, 0], 'Name': 'default', + 'Shininess': 0, 'Specular': [0, 0, 0]}, + {'Alpha': 1, 'Ambient': [1, 0.2, 0], 'Diffuse': [1, 0.2, 0], 'Emission': [0, 0, 0], + 'Name': 'default_selected', 'Shininess': 0, 'Specular': [0, 0, 0]}, + {'Alpha': 1, 'Ambient': [1, 0.4, 0], 'Diffuse': [1, 0.7, 0], 'Emission': [0, 0, 0], 'Name': 'gold', + 'Shininess': 0.3, 'Specular': [0.5, 0.5, 0.5]}, + {'Alpha': 1, 'Ambient': [0, 1, 0], 'Diffuse': [0, 1, 0], 'Emission': [0, 0, 0], 'Name': 'green', + 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [0.25, 0.25, 0.25], 'Diffuse': [0.25, 0.25, 0.25], 'Emission': [0, 0, 0], + 'Name': 'grey25', 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [0.5, 0.5, 0.5], 'Diffuse': [0.5, 0.5, 0.5], 'Emission': [0, 0, 0], + 'Name': 'grey50', 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [0.75, 0.75, 0.75], 'Diffuse': [0.75, 0.75, 0.75], 'Emission': [0, 0, 0], + 'Name': 'grey75', 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [1, 0, 1], 'Diffuse': [1, 0, 1], 'Emission': [0, 0, 0], 'Name': 'magenta', + 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [0.4, 0.14, 0.11], 'Diffuse': [0.5, 0.12, 0.1], 'Emission': [0, 0, 0], + 'Name': 'muscle', 'Shininess': 0.2, 'Specular': [0.3, 0.5, 0.5]}, + {'Alpha': 1, 'Ambient': [1, 0.5, 0], 'Diffuse': [1, 0.5, 0], 'Emission': [0, 0, 0], 'Name': 'orange', + 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [1, 0, 0], 'Diffuse': [1, 0, 0], 'Emission': [0, 0, 0], 'Name': 'red', + 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]}, + {'Alpha': 1, 'Ambient': [0.4, 0.4, 0.4], 'Diffuse': [0.7, 0.7, 0.7], 'Emission': [0, 0, 0], + 'Name': 'silver', 'Shininess': 0.3, 'Specular': [0.5, 0.5, 0.5]}, + {'Alpha': 1, 'Ambient': [0.9, 0.7, 0.5], 'Diffuse': [0.9, 0.7, 0.5], 'Emission': [0, 0, 0], + 'Name': 'tissue', 'Shininess': 0.2000000029802322, 'Specular': [0.2, 0.2, 0.3]}, + {'Alpha': 1, 'Ambient': [1, 1, 1], 'Diffuse': [1, 1, 1], 'Emission': [0, 0, 0], 'Name': 'white', + 'Shininess': 0, 'Specular': [0, 0, 0]}, + {'Alpha': 1, 'Ambient': [1, 1, 0], 'Diffuse': [1, 1, 0], 'Emission': [0, 0, 0], 'Name': 'yellow', + 'Shininess': 0.2, 'Specular': [0.1, 0.1, 0.1]} + ] +} + +KNOWN_MATERIAL_DATABASE = { + '4.2.1': {'base': BASE_MATERIALS_OBJECT, 'changes': []} +} + + +def _apply_changes(base_object, change_stack): + """ + Applies a stack of change objects to a base material object. + + :param base_object: The starting state (e.g., BASE_MATERIALS_OBJECT). + :param change_stack: A list of change objects to apply in order. + :return: A new dictionary representing the final state. + """ + # 1. Start with a deep copy of the base state. + # This is crucial so we don't modify the original constant. + + final_state = copy.deepcopy(base_object) + + # 2. Convert the list of materials into a map (dictionary) + # for efficient O(1) lookups, updates, and deletions. + material_map = {mat['Name']: mat for mat in final_state['Materials']} + + # 3. Iterate through each change in the stack and apply it + for i, change in enumerate(change_stack): + # Handle Additions: Add new items to the map + for material_to_add in change.get('added', []): + name = material_to_add.get('Name') + if name: + material_map[name] = material_to_add + + # Handle Changes: Overwrite existing items in the map + for material_to_change in change.get('changed', []): + name = material_to_change.get('Name') + if name: + material_map[name] = material_to_change + + # Handle Deletions: Remove items from the map + for material_to_delete in change.get('deleted', []): + name = material_to_delete.get('Name') + if name and name in material_map: + del material_map[name] + elif name: + raise ArgonError(f"Could not delete '{name}', not found.") + + # Handle Top-Level Changes (e.g., DefaultMaterial) + # We assume if top_level_changed is True, the new values + # are also present in the change object. + if change.get('top_level_changed', False): + if 'DefaultMaterial' in change: + new_default = change['DefaultMaterial'] + final_state['DefaultMaterial'] = new_default + if 'DefaultSelectedMaterial' in change: + new_selected = change['DefaultSelectedMaterial'] + final_state['DefaultSelectedMaterial'] = new_selected + + # 4. After all changes, convert the map's values back to a list + final_state['Materials'] = list(material_map.values()) + + return final_state + +def _determine_material_changes(base, new_materials_json_string): + """ + Checks for changes using the hybrid hash method. + Returns a dictionary detailing the changes. + """ + base_full_data = copy.deepcopy(base) + + # Stores {material_name: hash} for granular checking. + base_item_hashes = _get_item_hashes(base_full_data['Materials']) + + # Granular check: Hashes don't match, so parse and investigate. + try: + new_data_dict = json.loads(new_materials_json_string) + new_materials_list = new_data_dict.get('Materials', []) + except json.JSONDecodeError as e: + return {"status": "error", "message": str(e)} + + new_item_hashes = _get_item_hashes(new_materials_list) + + # Find differences + new_names = set(new_item_hashes.keys()) + old_names = set(base_item_hashes.keys()) + + # Find items whose 'Name' exists in both but hash is different + changed = [name for name in (new_names & old_names) + if new_item_hashes[name] != base_item_hashes[name]] + + # Find items with 'Name' in new set but not old + added = list(new_names - old_names) + + # Find items with 'Name' in old set but not new + deleted = list(old_names - new_names) + + # Check for changes in top-level keys (e.g., 'DefaultMaterial') + top_level_changed = False + if base_full_data: + if (base_full_data.get('DefaultMaterial') != new_data_dict.get('DefaultMaterial') or + base_full_data.get('DefaultSelectedMaterial') != new_data_dict.get( + 'DefaultSelectedMaterial')): + top_level_changed = True + + if not changed and not added and not deleted and not top_level_changed: + # No data changes found. + # This means the *only* change was JSON formatting (e.g., whitespace, key order). + # We will report this as no data change. + return {"status": "no_change", "added": [], "changed": [], "deleted": [], + "top_level_changed": False} + + # Genuine data changes were found. + material_map = {material['Name']: material for material in new_data_dict['Materials']} + # Sorted for consistent test results. + added_materials = [material_map.get(m) for m in sorted(added)] + changed_materials = [material_map.get(m) for m in sorted(changed)] + deleted_materials = [{'Name': m} for m in sorted(deleted)] + + return { + "status": "changes_detected", + "added": added_materials, + "changed": changed_materials, + "deleted": deleted_materials, + "top_level_changed": top_level_changed + } + + +class ArgonMaterials: """ Manages and serializes Zinc Materials. """ - def __init__(self, zincContext): - self._zincContext = zincContext - self._materialsmodule = zincContext.getMaterialmodule() + def __init__(self, zinc_context): + self._zinc_context = zinc_context + self._materials_module = zinc_context.getMaterialmodule() def getZincContext(self): """ @@ -34,16 +236,29 @@ def getZincContext(self): :return: cmlibs.zinc.context.Context """ - return self._zincContext + return self._zinc_context - def deserialize(self, dictInput): + def deserialize(self, dict_input): """ Read the JSON description to the argon Material object. This will change the materials in the material module. - :param dictInput: The string containing JSON description. + :param dict_input: The string containing JSON description. """ - materialsDescription = json.dumps(dictInput) - result = self._materialsmodule.readDescription(materialsDescription) + dict_id = dict_input.get('id') + if dict_id is None: + materials_description = json.dumps(dict_input) + elif dict_id == 'nz.ac.abi.argon_document.materials': + version_materials = KNOWN_MATERIAL_DATABASE.get(dict_input['zinc_version']) + if version_materials is None: + raise ArgonError('Could not find matching materials in database for zinc version: {}'.format(dict_input['zinc_version'])) + + version_materials = _apply_changes(version_materials['base'], version_materials['changes']) + materials_dict = _apply_changes(version_materials, dict_input['changes']) + materials_description = json.dumps(materials_dict) + else: + raise ArgonError('Unknown Argon document materials ID') + + result = self._materials_module.readDescription(materials_description) if result != ZINC_OK: raise ArgonError("Failed to read materials") @@ -53,6 +268,21 @@ def serialize(self): :return: Python JSON object containing the JSON description of Argon Materials object, otherwise 0. """ - materialsDescription = self._materialsmodule.writeDescription() - dictOutput = json.loads(materialsDescription) - return dictOutput + materials_description = self._materials_module.writeDescription() + if zinc_version in KNOWN_MATERIAL_DATABASE: + db_entry = KNOWN_MATERIAL_DATABASE[zinc_version] + used_zinc_version = zinc_version + else: + db_entry = KNOWN_MATERIAL_DATABASE[DEFAULT_ZINC_VERSION] + used_zinc_version = DEFAULT_ZINC_VERSION + + effective_base = _apply_changes(db_entry['base'], db_entry['changes']) + + dict_output = { + 'id': 'nz.ac.abi.argon_document.materials', + 'version': '1.0', + 'zinc_version': used_zinc_version, + 'changes': [_determine_material_changes(effective_base, materials_description)] + } + # dict_output = json.loads(materials_description) + return dict_output diff --git a/src/cmlibs/argon/argonmodelsources.py b/src/cmlibs/argon/argonmodelsources.py index a699b14..b59a326 100644 --- a/src/cmlibs/argon/argonmodelsources.py +++ b/src/cmlibs/argon/argonmodelsources.py @@ -28,7 +28,7 @@ def _file_name_to_relative_path(file_name, base_path): class ArgonModelSourceFile(object): - def __init__(self, file_name=None, dict_input=None): + def __init__(self, file_name=None, dict_input=None, base_path=None): self._time = None self._format = None self._edit = False @@ -36,7 +36,7 @@ def __init__(self, file_name=None, dict_input=None): if file_name is not None: self._file_name = file_name else: - self._deserialize(dict_input) + self._deserialize(dict_input, base_path) def getType(self): """ @@ -145,9 +145,12 @@ def setEdit(self, edit): """ self._edit = edit - def _deserialize(self, dict_input): + def _deserialize(self, dict_input, reference_dir): # convert to absolute file path so can save Neon file to new location and get correct relative path - self._file_name = os.path.abspath(dict_input["FileName"]) + if reference_dir is not None: + self._file_name = os.path.abspath(os.path.join(reference_dir, dict_input["FileName"])) + else: + self._file_name = os.path.abspath(dict_input["FileName"]) if "Edit" in dict_input: self._edit = dict_input["Edit"] if "Format" in dict_input: @@ -175,7 +178,7 @@ def serialize(self, base_path=None): return dict_output -def deserializeArgonModelSource(dict_input): +def deserializeArgonModelSource(dict_input, base_path=None): """ Factory method for creating the appropriate neon model source type from the dict serialization """ @@ -184,7 +187,7 @@ def deserializeArgonModelSource(dict_input): typeString = dict_input["Type"] if typeString == "FILE": - modelSource = ArgonModelSourceFile(dict_input=dict_input) + modelSource = ArgonModelSourceFile(dict_input=dict_input, base_path=base_path) else: raise ArgonError("Model source has unrecognised Type " + typeString) return modelSource diff --git a/src/cmlibs/argon/argonregion.py b/src/cmlibs/argon/argonregion.py index 2fda074..61de90c 100644 --- a/src/cmlibs/argon/argonregion.py +++ b/src/cmlibs/argon/argonregion.py @@ -193,7 +193,7 @@ def _generateChildName(self): count += 1 return None - def deserialize(self, dictInput): + def deserialize(self, dictInput, base_path=None): """ Read the JSON description to the argon region object. This will change the settings of ArgonRegion Object. @@ -204,7 +204,7 @@ def deserialize(self, dictInput): if "Sources" in model: try: for dictModelSource in model["Sources"]: - modelSource = deserializeArgonModelSource(dictModelSource) + modelSource = deserializeArgonModelSource(dictModelSource, base_path=base_path) if modelSource: self._modelSources.append(modelSource) except ArgonError as neonError: @@ -260,7 +260,7 @@ def deserialize(self, dictInput): neonChild = ArgonRegion(childName, zincChild, self) neonChild._ancestorModelSourceCreated = ancestorModelSourceCreated self._children.append(neonChild) - neonChild.deserialize(dictChild) + neonChild.deserialize(dictChild, base_path=base_path) self._discoverNewZincRegions() def serialize(self, basePath=None): diff --git a/src/cmlibs/argon/argonsceneviewer.py b/src/cmlibs/argon/argonsceneviewer.py index 791d5c8..1f536c6 100644 --- a/src/cmlibs/argon/argonsceneviewer.py +++ b/src/cmlibs/argon/argonsceneviewer.py @@ -15,7 +15,6 @@ """ from cmlibs.zinc.sceneviewer import Sceneviewer - SceneviewerProjectionModeMap = { Sceneviewer.PROJECTION_MODE_PARALLEL: "PARALLEL", Sceneviewer.PROJECTION_MODE_PERSPECTIVE: "PERSPECTIVE" @@ -196,27 +195,18 @@ def serialize(self): :return: Python JSON object containing the JSON description of Argon sceneviewer object. """ - d = {} - d["AntialiasSampling"] = self._anti_alias_sampling - d["BackgroundColourRGB"] = self._background_colour_RGB - d["EyePosition"] = self._eye_position - d["FarClippingPlane"] = self._far_clipping_plane - d["LightingLocalViewer"] = self._lighting_local_viewer - d["LightingTwoSided"] = self._lighting_two_sided - d["LookatPosition"] = self._lookat_position - d["NearClippingPlane"] = self._near_clipping_plane - d["PerturbLinesFlag"] = self._perturb_lines_flag - d["ProjectionMode"] = SceneviewerProjectionModeEnumToString(self._projection_mode) - d["Scene"] = self._scene - d["Scenefilter"] = self._scene_filter - d["TranslationRate"] = self._translation_rate - d["TransparencyMode"] = SceneviewerTransparencyModeEnumToString(self._transparency_mode) - d["TransparencyLayers"] = self._transparency_layers - d["TumbleRate"] = self._tumble_rate - d["UpVector"] = self._up_vector - d["ViewAngle"] = self._view_angle - d["ZoomRate"] = self._zoom_rate - return d + return { + "AntialiasSampling": self._anti_alias_sampling, "BackgroundColourRGB": self._background_colour_RGB, + "EyePosition": self._eye_position, "FarClippingPlane": self._far_clipping_plane, + "LightingLocalViewer": self._lighting_local_viewer, "LightingTwoSided": self._lighting_two_sided, + "LookatPosition": self._lookat_position, "NearClippingPlane": self._near_clipping_plane, + "PerturbLinesFlag": self._perturb_lines_flag, + "ProjectionMode": SceneviewerProjectionModeEnumToString(self._projection_mode), "Scene": self._scene, + "Scenefilter": self._scene_filter, "TranslationRate": self._translation_rate, + "TransparencyMode": SceneviewerTransparencyModeEnumToString(self._transparency_mode), + "TransparencyLayers": self._transparency_layers, "TumbleRate": self._tumble_rate, "UpVector": self._up_vector, + "ViewAngle": self._view_angle, "ZoomRate": self._zoom_rate + } default_anti_alias_sampling = 0 diff --git a/src/cmlibs/argon/argonspectrums.py b/src/cmlibs/argon/argonspectrums.py index c648ecf..3625f5b 100644 --- a/src/cmlibs/argon/argonspectrums.py +++ b/src/cmlibs/argon/argonspectrums.py @@ -117,11 +117,11 @@ def findOrCreateSpectrumGlyphColourBar(self, spectrum): colourBar = glyphmodule.createGlyphColourBar(spectrum) tmpName = glyphName i = 1 - while (colourBar.setName(tmpName) != ZINC_OK): + while colourBar.setName(tmpName) != ZINC_OK: tmpName = glyphName + str(i) i += 1 colourBar.setManaged(True) - colourBar.setCentre([-0.9, 0.0, 0.5]) + colourBar.setCentre([0.0, 0.0, 0.0]) colourBar.setAxis([0.0, 1.6, 0.0]) # includes length colourBar.setSideAxis([0.06, 0.0, 0.0]) # includes radius colourBar.setExtendLength(0.06) diff --git a/tests/test_argon_materials.py b/tests/test_argon_materials.py new file mode 100644 index 0000000..1618ccf --- /dev/null +++ b/tests/test_argon_materials.py @@ -0,0 +1,333 @@ +import types +import unittest +import json +import copy +import sys + +import cmlibs.argon + +# Mock cmlibs.zinc classes. +class MockZincStatus: + OK = 0 + + +class MockZincModule: + def __init__(self): + # This stores the JSON string that 'deserialize' passes to Zinc + self.last_read_description = None + # This stores the JSON string that 'serialize' will get from Zinc + self.current_description = "" + + def readDescription(self, json_string): + self.last_read_description = json_string + return MockZincStatus.OK + + def writeDescription(self): + return self.current_description + + +class MockZincContext: + def __init__(self): + self._materials_module = MockZincModule() + + def getMaterialmodule(self): + return self._materials_module + + +# --- Mock cmlibs dependencies BEFORE importing the module to test --- + +# 1. Mock top-level 'cmlibs' (Namespace Package) +# This ensures that subsequent 'cmlibs.X' imports work. +mock_cmlibs = types.ModuleType('cmlibs') +mock_cmlibs.__path__ = [] # Declare it as a namespace package +sys.modules['cmlibs'] = mock_cmlibs + +# 2. Mock 'cmlibs.zinc' (Fake Package with attributes) +mock_zinc = types.ModuleType('cmlibs.zinc') +mock_zinc.__path__ = [] # Make it a package so it can have submodules +mock_zinc.__version__ = '4.2.1-mock' # Mock the version import +sys.modules['cmlibs.zinc'] = mock_zinc + +# 3. Mock 'cmlibs.zinc.status' (Fake Module) +mock_zinc_status = types.ModuleType('cmlibs.zinc.status') +mock_zinc_status.OK = 0 # Mock the OK status import +sys.modules['cmlibs.zinc.status'] = mock_zinc_status +mock_zinc.status = mock_zinc_status # Link it to the parent + +import cmlibs.argon.argonmaterials as argon_materials + +# Now we can alias the real code and mocked values +ArgonMaterials = argon_materials.ArgonMaterials +ArgonError = argon_materials.ArgonError +ZINC_OK = argon_materials.ZINC_OK +BASE_MATERIALS_OBJECT = argon_materials.BASE_MATERIALS_OBJECT +DEFAULT_ZINC_VERSION = argon_materials.DEFAULT_ZINC_VERSION + + +class TestHelperFunctions(unittest.TestCase): + """ + Tests the standalone helper functions _apply_changes + and _determine_material_changes. + """ + + def setUp(self): + self.base = copy.deepcopy(BASE_MATERIALS_OBJECT) + + def test_determine_no_change(self): + """ + Test that identical data (even with different formatting) + results in 'no_change'. + """ + # Test with compact, unsorted JSON + new_json_string = json.dumps(self.base) + changes = argon_materials._determine_material_changes(self.base, new_json_string) + self.assertEqual(changes["status"], "no_change") + self.assertEqual(len(changes["added"]), 0) + self.assertEqual(len(changes["changed"]), 0) + + def test_determine_change_one_material(self): + """Test that a change in one material is detected.""" + modified = copy.deepcopy(self.base) + modified['Materials'][1]['Alpha'] = 0.5 # Change 'blue' + new_json_string = json.dumps(modified) + + changes = argon_materials._determine_material_changes(self.base, new_json_string) + + self.assertEqual(changes["status"], "changes_detected") + self.assertEqual(len(changes["changed"]), 1) + self.assertEqual(changes["changed"][0]["Name"], "blue") + self.assertEqual(changes["changed"][0]["Alpha"], 0.5) + + def test_determine_add_one_material(self): + """Test that adding a material is detected.""" + modified = copy.deepcopy(self.base) + purple = {'Alpha': 1, 'Name': 'purple', 'Ambient': [0.5, 0, 0.5]} + modified['Materials'].append(purple) + new_json_string = json.dumps(modified) + + changes = argon_materials._determine_material_changes(self.base, new_json_string) + + self.assertEqual(changes["status"], "changes_detected") + self.assertEqual(len(changes["added"]), 1) + self.assertEqual(changes["added"][0]["Name"], "purple") + + def test_determine_delete_one_material(self): + """Test that deleting a material is detected.""" + modified = copy.deepcopy(self.base) + modified['Materials'].pop(0) # Delete 'black' + new_json_string = json.dumps(modified) + + changes = argon_materials._determine_material_changes(self.base, new_json_string) + + self.assertEqual(changes["status"], "changes_detected") + self.assertEqual(len(changes["deleted"]), 1) + self.assertEqual(changes["deleted"][0]["Name"], "black") + + def test_determine_top_level_change(self): + """Test that changing 'DefaultMaterial' is detected.""" + modified = copy.deepcopy(self.base) + modified['DefaultMaterial'] = 'blue' + new_json_string = json.dumps(modified) + + changes = argon_materials._determine_material_changes(self.base, new_json_string) + + self.assertEqual(changes["status"], "changes_detected") + self.assertTrue(changes["top_level_changed"]) + self.assertEqual(len(changes["changed"]), 0) # No list items changed + + def test_apply_changes(self): + """Test the _apply_changes logic for reconstructing a state.""" + change_1_obj = copy.deepcopy(self.base['Materials'][1]) + change_1_obj['Alpha'] = 0.5 # Change 'blue' + + change_stack = [ + { + "status": "changes_detected", + "changed": [change_1_obj], + "added": [], "deleted": [], "top_level_changed": False + }, + { + "status": "changes_detected", + "added": [{'Alpha': 1, 'Name': 'purple', 'Ambient': [1, 0, 1]}], + "changed": [], "deleted": [], "top_level_changed": False + }, + { + "status": "changes_detected", + "deleted": [{'Name': 'bone'}], + "added": [], "changed": [], "top_level_changed": True, + "DefaultMaterial": "blue" + } + ] + + final_state = argon_materials._apply_changes(self.base, change_stack) + + # Check final state + self.assertEqual(final_state['DefaultMaterial'], 'blue') + material_map = {m['Name']: m for m in final_state['Materials']} + self.assertIn('purple', material_map) + self.assertNotIn('bone', material_map) + self.assertEqual(material_map['blue']['Alpha'], 0.5) + self.assertEqual(len(material_map), 20) # 20 base + 1 add - 1 del = 20 + + +class TestArgonMaterials(unittest.TestCase): + """ + Tests the ArgonMaterials class, mocking all Zinc dependencies. + """ + + def setUp(self): + # Create fresh mocks for each test + self.mock_context = MockZincContext() + self.mock_materials_module = self.mock_context.getMaterialmodule() + + # Instantiate the class under test + self.argon_materials = ArgonMaterials(self.mock_context) + + # Save and reset the module's state for predictable tests + self.original_db = copy.deepcopy(argon_materials.KNOWN_MATERIAL_DATABASE) + self.original_zinc_version = argon_materials.zinc_version + + # Force a known state + argon_materials.zinc_version = '4.2.1' + argon_materials.KNOWN_MATERIAL_DATABASE = { + '4.2.1': {'base': BASE_MATERIALS_OBJECT, 'changes': []} + } + + def tearDown(self): + # Restore the module's original state + argon_materials.KNOWN_MATERIAL_DATABASE = self.original_db + argon_materials.zinc_version = self.original_zinc_version + + def test_serialize_no_change(self): + """ + Test serialize() when the Zinc module's state + is identical to the known '4.2.1' base. + """ + # Set what self._materials_module.writeDescription() will return + self.mock_materials_module.current_description = json.dumps(BASE_MATERIALS_OBJECT) + + result = self.argon_materials.serialize() + + self.assertEqual(result['id'], 'nz.ac.abi.argon_document.materials') + self.assertEqual(result['zinc_version'], '4.2.1') + + change_list = result['changes'] + self.assertEqual(len(change_list), 1) + self.assertEqual(change_list[0]['status'], 'no_change') + + def test_serialize_with_changes(self): + """ + Test serialize() when the Zinc module has one change + compared to the base. + """ + modified = copy.deepcopy(BASE_MATERIALS_OBJECT) + modified['Materials'][1]['Alpha'] = 0.5 # Change 'blue' + self.mock_materials_module.current_description = json.dumps(modified) + + result = self.argon_materials.serialize() + + change_list = result['changes'] + self.assertEqual(change_list[0]['status'], 'changes_detected') + self.assertEqual(len(change_list[0]['changed']), 1) + self.assertEqual(change_list[0]['changed'][0]['Name'], 'blue') + + def test_serialize_uses_fallback_version(self): + """ + Test that serialize() falls back to DEFAULT_ZINC_VERSION + if the current zinc_version is not in the database. + """ + argon_materials.zinc_version = 'unknown.version.xyz' + + # Set the mock to return the default base, so 'no_change' is expected + self.mock_materials_module.current_description = json.dumps(BASE_MATERIALS_OBJECT) + + result = self.argon_materials.serialize() + + # It should have used the DEFAULT_ZINC_VERSION + self.assertEqual(result['zinc_version'], DEFAULT_ZINC_VERSION) + self.assertEqual(result['changes'][0]['status'], 'no_change') + + def test_serialize_with_db_changes(self): + """ + Test that serialize() compares against an 'effective base' + if the database itself has changes. + """ + # Add a change to the database for version 4.2.1 + db_change_obj = copy.deepcopy(BASE_MATERIALS_OBJECT['Materials'][0]) + db_change_obj['Alpha'] = 0.1 # Change 'black' + db_change = { + 'status': 'changes_detected', + 'changed': [db_change_obj], + 'added': [], 'deleted': [], 'top_level_changed': False + } + argon_materials.KNOWN_MATERIAL_DATABASE['4.2.1']['changes'] = [db_change] + + # Now, if the current materials *have* this change, + # it should be reported as 'no_change'. + current_state = copy.deepcopy(BASE_MATERIALS_OBJECT) + current_state['Materials'][0]['Alpha'] = 0.1 # Match the DB change + self.mock_materials_module.current_description = json.dumps(current_state) + + result = self.argon_materials.serialize() + self.assertEqual(result['changes'][0]['status'], 'no_change') + + def test_deserialize_legacy_format(self): + """ + Test deserialize() with a raw dict (no 'id' field). + It should just dump and read the whole thing. + """ + legacy_dict = { + 'DefaultMaterial': 'blue', + 'Materials': [BASE_MATERIALS_OBJECT['Materials'][0]] + } + + self.argon_materials.deserialize(legacy_dict) + + # Check what was passed to Zinc's readDescription + expected_json = json.dumps(legacy_dict) + self.assertEqual(self.mock_materials_module.last_read_description, expected_json) + + def test_deserialize_new_format(self): + """ + Test deserialize() with the new 'base + changes' format. + """ + # A doc that adds 'purple' + doc_change = { + 'status': 'changes_detected', + 'added': [{'Alpha': 1, 'Name': 'purple', 'Ambient': [1, 0, 1]}], + 'changed': [], 'deleted': [], 'top_level_changed': False + } + doc = { + 'id': 'nz.ac.abi.argon_document.materials', + 'zinc_version': '4.2.1', + 'changes': [doc_change] + } + + self.argon_materials.deserialize(doc) + + # Check that the final JSON passed to Zinc has 'purple' + final_state = json.loads(self.mock_materials_module.last_read_description) + material_names = [m['Name'] for m in final_state['Materials']] + self.assertIn('purple', material_names) + self.assertEqual(len(material_names), 21) # 20 base + 1 new + self.assertEqual(final_state['DefaultMaterial'], 'default') + + def test_deserialize_unknown_id(self): + """Test that an unknown 'id' raises an ArgonError.""" + doc = {'id': 'unknown.id.string'} + with self.assertRaises(ArgonError): + self.argon_materials.deserialize(doc) + + def test_deserialize_unknown_zinc_version(self): + """Test that an unknown 'zinc_version' raises an ArgonError.""" + doc = { + 'id': 'nz.ac.abi.argon_document.materials', + 'zinc_version': 'not.a.real.version', + 'changes': [] + } + with self.assertRaises(ArgonError): + self.argon_materials.deserialize(doc) + + +if __name__ == '__main__': + unittest.main()