From da663101336371f37911f0e6a640eefe5c3818c1 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Trottier Date: Thu, 6 Mar 2025 14:14:02 -0800 Subject: [PATCH] REMIX-3904: Improved mod packaging flow by adding a window to fix unresolved assets --- CHANGELOG.md | 1 + source/apps/exts.deps.generated.kit | 1 + .../lightspeed.app.trex.app.settings.toml | 4 +- .../config/extension.toml | 2 +- .../docs/CHANGELOG.md | 4 + .../mod_packaging_layers/widget/setup_ui.py | 5 +- .../lightspeed.trex.packaging.core/bin/cli.py | 16 +- .../config/extension.toml | 2 +- .../docs/CHANGELOG.md | 4 + .../lightspeed/trex/packaging/core/items.py | 5 +- .../trex/packaging/core/packaging.py | 285 ++++++++++-------- .../core/tests/e2e/test_packaging.py | 30 +- .../core/tests/unit/test_packaging.py | 54 ++-- .../config/extension.toml | 33 ++ .../data/icon.png | 3 + .../data/preview.png | 3 + .../docs/CHANGELOG.md | 7 + .../docs/README.md | 1 + .../docs/index.rst | 17 ++ .../trex/packaging/window/__init__.py | 20 ++ .../trex/packaging/window/tree/__init__.py | 28 ++ .../trex/packaging/window/tree/delegate.py | 265 ++++++++++++++++ .../trex/packaging/window/tree/item.py | 102 +++++++ .../trex/packaging/window/tree/model.py | 215 +++++++++++++ .../trex/packaging/window/window.py | 267 ++++++++++++++++ .../premake5.lua | 10 + .../config/extension.toml | 3 +- .../docs/CHANGELOG.md | 4 + .../shared/mod_packaging/widget/setup_ui.py | 54 +++- .../config/extension.toml | 2 +- .../docs/CHANGELOG.md | 8 + .../core/shared/data_models/validators.py | 4 + .../texture_replacements/core/shared/setup.py | 41 ++- .../config/extension.toml | 2 +- .../docs/CHANGELOG.md | 4 + .../ui_scene/light_manipulator/layer.py | 3 + 36 files changed, 1314 insertions(+), 195 deletions(-) create mode 100644 source/extensions/lightspeed.trex.packaging.window/config/extension.toml create mode 100644 source/extensions/lightspeed.trex.packaging.window/data/icon.png create mode 100644 source/extensions/lightspeed.trex.packaging.window/data/preview.png create mode 100644 source/extensions/lightspeed.trex.packaging.window/docs/CHANGELOG.md create mode 100644 source/extensions/lightspeed.trex.packaging.window/docs/README.md create mode 100644 source/extensions/lightspeed.trex.packaging.window/docs/index.rst create mode 100644 source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/__init__.py create mode 100644 source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/__init__.py create mode 100644 source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/delegate.py create mode 100644 source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/item.py create mode 100644 source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/model.py create mode 100644 source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/window.py create mode 100644 source/extensions/lightspeed.trex.packaging.window/premake5.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index c5030ccfc..b1b047130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - REMIX-3896: Improve the Unload Stage (Close Project) button behavior - REMIX-3832: Update material property widget to work with virtual attributes - Upgraded the AI Tools PyTorch Version +- REMIX-3904: Improved mod packaging flow by adding a window to fix unresolved assets ### Fixed - REMIX-2350: Updating capture window behavior to avoid it hanging on other tabs diff --git a/source/apps/exts.deps.generated.kit b/source/apps/exts.deps.generated.kit index 86c510c84..787cabe2c 100644 --- a/source/apps/exts.deps.generated.kit +++ b/source/apps/exts.deps.generated.kit @@ -93,6 +93,7 @@ "lightspeed.trex.mod_packaging_layers.widget" = {} "lightspeed.trex.mod_packaging_output.widget" = {} "lightspeed.trex.packaging.core" = {} +"lightspeed.trex.packaging.window" = {} "lightspeed.trex.project_wizard.core" = {} "lightspeed.trex.project_wizard.existing_mods_page.widget" = {} "lightspeed.trex.project_wizard.file_picker.widget" = {} diff --git a/source/apps/lightspeed.app.trex.app.settings.toml b/source/apps/lightspeed.app.trex.app.settings.toml index 172d3f80e..dd7d586e8 100644 --- a/source/apps/lightspeed.app.trex.app.settings.toml +++ b/source/apps/lightspeed.app.trex.app.settings.toml @@ -33,8 +33,8 @@ folders.'++' = ["${app}/../exts", "${app}/../extscache", "${app}/../apps"] grid.trackCamera = true [app.window] -width = 1700 -height = 800 +width = 1800 +height = 900 x = -1 y = -1 diff --git a/source/extensions/lightspeed.trex.mod_packaging_layers.widget/config/extension.toml b/source/extensions/lightspeed.trex.mod_packaging_layers.widget/config/extension.toml index 3087a0476..f9e6fe526 100644 --- a/source/extensions/lightspeed.trex.mod_packaging_layers.widget/config/extension.toml +++ b/source/extensions/lightspeed.trex.mod_packaging_layers.widget/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "1.1.3" +version = "1.1.4" authors =["Pierre-Olivier Trottier "] title = "Mod Packaging Layers Widget" description = "Mod Packaging Details Layers implementation" diff --git a/source/extensions/lightspeed.trex.mod_packaging_layers.widget/docs/CHANGELOG.md b/source/extensions/lightspeed.trex.mod_packaging_layers.widget/docs/CHANGELOG.md index 98a51bfd2..536afb0e2 100644 --- a/source/extensions/lightspeed.trex.mod_packaging_layers.widget/docs/CHANGELOG.md +++ b/source/extensions/lightspeed.trex.mod_packaging_layers.widget/docs/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.1.4] +## Fixed +- Fixed default state for muted layers + ## [1.1.3] ## Changed - Update to Kit 106.5 diff --git a/source/extensions/lightspeed.trex.mod_packaging_layers.widget/lightspeed/trex/mod_packaging_layers/widget/setup_ui.py b/source/extensions/lightspeed.trex.mod_packaging_layers.widget/lightspeed/trex/mod_packaging_layers/widget/setup_ui.py index c99d6b702..c1d8e7ac2 100644 --- a/source/extensions/lightspeed.trex.mod_packaging_layers.widget/lightspeed/trex/mod_packaging_layers/widget/setup_ui.py +++ b/source/extensions/lightspeed.trex.mod_packaging_layers.widget/lightspeed/trex/mod_packaging_layers/widget/setup_ui.py @@ -131,7 +131,10 @@ def _update_model_item_package_status(self, *_): item.data["exclude_package"] = not should_package if "package" in item.data and should_package: continue - item.data["package"] = should_package or is_capture_baker + # Set the default package status. Disable muted layers by default + item.data["package"] = ( + (should_package or is_capture_baker) and item.data.get("visible") and item.data.get("parent_visible") + ) self._layers_validity_changed() diff --git a/source/extensions/lightspeed.trex.packaging.core/bin/cli.py b/source/extensions/lightspeed.trex.packaging.core/bin/cli.py index 831646d89..0ac76a8a0 100644 --- a/source/extensions/lightspeed.trex.packaging.core/bin/cli.py +++ b/source/extensions/lightspeed.trex.packaging.core/bin/cli.py @@ -46,14 +46,20 @@ async def run(parsed_args): try: core = PackagingCore() + def print_completed(errors, failed_assets, cancelled): + message = "Project Packaging Finished:\n" + if errors or failed_assets: + if errors: + message += f"Errors occurred: {errors}\n" + if failed_assets: + message += f"Failed to collect assets: {failed_assets}\n" + else: + message += "Packaging was cancelled." if cancelled else "The project was successfully packaged." + _progress_sub = core.subscribe_packaging_progress( # noqa F841 lambda c, t, s: print(f"Progress: {s} ({c} / {t})") ) - _completed_sub = core.subscribe_packaging_completed( # noqa F841 - lambda e, c: print( - f"Project Packaging Finished: {f'Errors occurred: {e}' if e else 'Cancelled' if c else 'Success'}" - ) - ) + _completed_sub = core.subscribe_packaging_completed(print_completed) # noqa F841 success = await core.package(read_json_file(parsed_args.schema)) if success: diff --git a/source/extensions/lightspeed.trex.packaging.core/config/extension.toml b/source/extensions/lightspeed.trex.packaging.core/config/extension.toml index 2848234db..1ff18f2f4 100644 --- a/source/extensions/lightspeed.trex.packaging.core/config/extension.toml +++ b/source/extensions/lightspeed.trex.packaging.core/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "1.0.20" +version = "1.1.0" authors =["Pierre-Olivier Trottier "] title = "Mod Packaging Core" description = "Mod Packaging Core implementation" diff --git a/source/extensions/lightspeed.trex.packaging.core/docs/CHANGELOG.md b/source/extensions/lightspeed.trex.packaging.core/docs/CHANGELOG.md index aca2d282a..fb6127f93 100644 --- a/source/extensions/lightspeed.trex.packaging.core/docs/CHANGELOG.md +++ b/source/extensions/lightspeed.trex.packaging.core/docs/CHANGELOG.md @@ -2,6 +2,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.1.0] +## Added +- Added the ability to get unresolved assets ignore errors + ## [1.0.20] ## Changed - Update to Kit 106.5 diff --git a/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/items.py b/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/items.py index 7dd577d81..a8a8828a2 100644 --- a/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/items.py +++ b/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/items.py @@ -17,7 +17,7 @@ import re from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Tuple from lightspeed.trex.replacement.core.shared import Setup as _ReplacementCore from omni.flux.utils.common.omni_url import OmniUrl as _OmniUrl @@ -55,6 +55,9 @@ class ModPackagingSchema(BaseModel): mod_name: str = Field(..., description="The display name used for the mod in the RTX Remix Runtime.") mod_version: str = Field(..., description="The mod version. Used when building dependency lists.") mod_details: Optional[str] = Field(None, description="Optional text used to describe the mod in more details.") + ignored_errors: Optional[List[Tuple[str, str, str]]] = Field( + None, description="A list of errors to ignore when packaging the mod." + ) @validator("mod_layer_paths", allow_reuse=True) def at_least_one(cls, v): # noqa diff --git a/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/packaging.py b/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/packaging.py index 9c5fd76ca..43ece278c 100644 --- a/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/packaging.py +++ b/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/packaging.py @@ -20,7 +20,7 @@ from asyncio import ensure_future from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union import carb import omni.client @@ -35,7 +35,6 @@ from lightspeed.layer_manager.core import LSS_LAYER_MOD_NAME as _LSS_LAYER_MOD_NAME from lightspeed.layer_manager.core import LSS_LAYER_MOD_NOTES as _LSS_LAYER_MOD_NOTES from lightspeed.layer_manager.core import LSS_LAYER_MOD_VERSION as _LSS_LAYER_MOD_VERSION -from lightspeed.trex.packaging.core.items import ModPackagingSchema as _ModPackagingSchema from omni.flux.utils.common import Event as _Event from omni.flux.utils.common import EventSubscription as _EventSubscription from omni.flux.utils.common import reset_default_attrs as _reset_default_attrs @@ -43,10 +42,9 @@ from omni.flux.utils.material_converter.utils import MaterialConverterUtils as _MaterialConverterUtils from omni.kit.usd.collect.omni_client_wrapper import OmniClientWrapper as _OmniClientWrapper from omni.kit.usd.layers import LayerUtils as _LayerUtils -from pxr import Sdf, UsdUtils +from pxr import Sdf, Usd, UsdUtils -if TYPE_CHECKING: - from pxr import Usd +from .items import ModPackagingSchema as _ModPackagingSchema class PackagingCore: @@ -63,12 +61,49 @@ def __init__(self): self._cancel_token = False self._current_count = 0 self._total_count = 0 - self._status = "(0/7) Initializing" + self._status = "Initializing..." self._temp_files = {} self.__packaging_progress = _Event() self.__packaging_completed = _Event() + def cancel(self): + """ + Cancel the packaging process. + """ + self._cancel_token = True + + @property + def current_count(self) -> int: + """ + Get the current packaged items count + """ + return self._current_count + + @current_count.setter + def current_count(self, val): + self._current_count = min(val, self.total_count) + self._packaging_progress() + + @property + def total_count(self) -> int: + """ + Get the current total items count + """ + return self._total_count + + @total_count.setter + def total_count(self, val): + self._total_count = val + self._packaging_progress() + + @property + def status(self) -> str: + """ + Get the current packaging status + """ + return self._status + def package(self, schema: Dict): r""" Execute the project packaging process using the given schema. @@ -107,6 +142,7 @@ async def package_async_with_exceptions(self, schema: Dict): Asynchronous implementation of package, but async without error handling. This is meant for testing. """ errors = [] + failed_assets = [] try: model = _ModPackagingSchema(**schema) @@ -124,7 +160,7 @@ async def package_async_with_exceptions(self, schema: Dict): temp_root_mod_layer = Sdf.Layer.FindOrOpen(await self._make_temp_layer(root_mod_layer.identifier)) # Remove all deselected sublayers - self._packaging_new_stage("(1/7) Filtering the selected layers...", 1) + self._packaging_new_stage("Filtering the selected layers...", 1) temp_layers = await self._filter_sublayers( model.context_name, None, @@ -144,67 +180,36 @@ async def package_async_with_exceptions(self, schema: Dict): redirected_dependencies = set() # Don't use the omni collector because it's not flexible enough - errors.extend( - await self._collect(temp_root_mod_layer, temp_layers, model.output_directory, redirected_dependencies) + collect_errors, collected_failed_assets = await self._collect( + stage, + temp_root_mod_layer, + temp_layers, + model.output_directory, + redirected_dependencies, + model.ignored_errors, ) - - exported_mod_layer = Sdf.Layer.FindOrOpen( - str( - _OmniUrl(model.output_directory) - / _OmniUrl(self._temp_files.get(_OmniUrl(temp_root_mod_layer.identifier).path)).name + errors.extend(collect_errors) + failed_assets = collected_failed_assets + + if not failed_assets: + exported_mod_layer = Sdf.Layer.FindOrOpen( + str( + _OmniUrl(model.output_directory) + / _OmniUrl(self._temp_files.get(_OmniUrl(temp_root_mod_layer.identifier).path)).name + ) ) - ) - if exported_mod_layer: - errors.extend(self._update_layer_metadata(model, exported_mod_layer, mod_dependencies, True)) - errors.extend(self._update_layer_metadata(model, root_mod_layer, mod_dependencies, False)) - else: - errors.append("Unable to find the exported mod file.") + if exported_mod_layer: + errors.extend(self._update_layer_metadata(model, exported_mod_layer, mod_dependencies, True)) + errors.extend(self._update_layer_metadata(model, root_mod_layer, mod_dependencies, False)) + else: + errors.append("Unable to find the exported mod file.") except Exception as e: # noqa PLW0718 - if not errors: - errors = [] errors.append(str(e)) finally: # Cleanup the temp files await self._clean_temp_files() # Reset the cancel state - self._packaging_completed(errors) - - def cancel(self): - """ - Cancel the packaging process. - """ - self._cancel_token = True - - @property - def current_count(self) -> int: - """ - Get the current packaged items count - """ - return self._current_count - - @current_count.setter - def current_count(self, val): - self._current_count = min(val, self.total_count) - self._packaging_progress() - - @property - def total_count(self) -> int: - """ - Get the current total items count - """ - return self._total_count - - @total_count.setter - def total_count(self, val): - self._total_count = val - self._packaging_progress() - - @property - def status(self) -> str: - """ - Get the current total items count - """ - return self._status + self._packaging_completed(errors, failed_assets) @omni.usd.handle_exception async def _make_temp_layer(self, layer_path: str) -> str: @@ -218,13 +223,9 @@ async def _make_temp_layer(self, layer_path: str) -> str: self._temp_files[temp_path] = layer_url.name return temp_path - def _get_original_path(self, temp_layer_path: str) -> Optional[str]: - original_name = self._temp_files.get(_OmniUrl(temp_layer_path).path) - return _OmniUrl(temp_layer_path).with_name(original_name).path if original_name else None - @omni.usd.handle_exception async def _clean_temp_files(self): - self._packaging_new_stage("(7/7) Cleaning up temporary layers...", len(self._temp_files)) + self._packaging_new_stage("Cleaning up temporary layers...", len(self._temp_files)) for temp_file in self._temp_files: try: @@ -298,66 +299,38 @@ async def _filter_sublayers( return temp_layers - def _get_redirected_dependencies( - self, temp_root_layer: Sdf.Layer, external_mod_paths: List[Path] - ) -> Tuple[Set[str], Set[str]]: - mod_dependencies = set() - redirected_dependencies = set() - - all_layers, all_assets, _ = UsdUtils.ComputeAllDependencies(temp_root_layer.identifier) - all_dependencies = [*[layer.identifier for layer in all_layers], *all_assets] - - self._packaging_new_stage("(2/7) Redirecting dependencies...", len(all_dependencies)) - - # Update all the layer dependencies in this layer - for dependency in all_dependencies: - if self._cancel_token: - return mod_dependencies, redirected_dependencies - - self.current_count += 1 - - dependency_path = _OmniUrl(dependency).path - - # If dependency is in the capture directory, we should not redirect the dependency - if (_OmniUrl(_REMIX_DEPENDENCIES_FOLDER) / _REMIX_CAPTURE_FOLDER).path in dependency_path: - continue - - # Check if the dependency comes from a known mod - external_mod = None - for mod_path in external_mod_paths: - if (_OmniUrl(_REMIX_MODS_FOLDER) / mod_path.parent.name).path in dependency_path: - external_mod = mod_path.as_posix() - break - - # If the dependency points to a known mod, redirect it to the installed mod and store the mod path - if external_mod: - mod_dependencies.add(external_mod) - redirected_dependencies.add(dependency_path) - - return mod_dependencies, redirected_dependencies - @omni.usd.handle_exception async def _collect( self, + stage: "Usd.Stage", temp_root_layer: Sdf.Layer, existing_temp_layers: List[str], output_directory: Union[Path, str], redirected_dependencies: Set[str], - ) -> List[str]: + ignored_errors: Optional[List[Tuple[str, str, str]]], + ) -> Tuple[List[str], List[Tuple[str, str, str]]]: errors = [] - + failed_assets = [] if self._cancel_token: - return errors + return errors, failed_assets all_layers, all_assets, unresolved_paths = UsdUtils.ComputeAllDependencies(temp_root_layer.identifier) - self._packaging_new_stage("(3/7) Creating temporary layers...", len(all_layers)) + if unresolved_paths: + self._packaging_new_stage("Resolving invalid references...", len(list(stage.TraverseAll()))) + invalid_assets = set(await self._get_unresolved_assets_prim_paths(stage, unresolved_paths)).difference( + ignored_errors or [] + ) + if invalid_assets: + return errors, list(invalid_assets) + + self._packaging_new_stage("Creating temporary layers...", len(all_layers)) temp_layers_map = {self._get_original_path(temp_layer): temp_layer for temp_layer in existing_temp_layers} temp_layers = [] for layer in all_layers: if self._cancel_token: - return errors + return errors, failed_assets self.current_count += 1 @@ -375,23 +348,20 @@ async def _collect( else: temp_layers.append(temp_layer) - for unresolved_path in unresolved_paths: - errors.append(f"Unresolved asset found when collecting dependencies: {unresolved_path}") - if errors or self._cancel_token: - return errors + return errors, failed_assets temp_layer_paths = {_OmniUrl(temp_layer.identifier).path: temp_layer for temp_layer in temp_layers} all_dependencies = [*temp_layer_paths.keys(), *all_assets] - self._packaging_new_stage("(4/7) Listing assets to collect...", len(all_dependencies)) + self._packaging_new_stage("Listing assets to collect...", len(all_dependencies)) updated_dependencies = {} shader_subidentifiers = [url.name for url in _MaterialConverterUtils.get_material_library_shader_urls()] for dependency in all_dependencies: if self._cancel_token: - return errors + return errors, failed_assets dependency_path = _OmniUrl(dependency).path original_dependency_path = self._get_original_path(dependency) or dependency_path @@ -421,30 +391,29 @@ async def _collect( if self._get_original_path(temp_layer.identifier) not in redirected_dependencies } - self._packaging_new_stage("(5/7) Updating asset paths...", len(temp_layers)) + self._packaging_new_stage("Updating asset paths...", len(temp_layers)) for temp_layer in temp_layers: if self._cancel_token: - return errors + return errors, failed_assets self.current_count += 1 UsdUtils.ModifyAssetPaths(temp_layer, partial(self._modify_asset_paths, temp_layer, updated_dependencies)) # Wrap in a try for when Export fails to write the file try: if self._cancel_token: - return errors + return errors, failed_assets # Make sure to create a clean packaging directory if _OmniUrl(output_directory).exists: await _OmniClientWrapper.delete(str(output_directory)) - self._packaging_new_stage("(6/7) Collecting assets...", len(self._collected_dependencies)) + self._packaging_new_stage("Collecting assets...", len(self._collected_dependencies)) # Copy all collected assets to the output directory for temp_input_path, relative_output_path in self._collected_dependencies.items(): if self._cancel_token: - return errors - + return errors, failed_assets output_path = _OmniUrl(output_directory) / relative_output_path input_path = self._get_original_path(temp_input_path) if input_path: @@ -477,7 +446,75 @@ async def _collect( # Clear assets marked for collection now that they were copied self._collected_dependencies.clear() - return errors + return errors, failed_assets + + @omni.usd.handle_exception + async def _get_unresolved_assets_prim_paths( + self, stage: Usd.Stage, unresolved_paths: list[str] + ) -> List[Tuple[str, str, str]]: + result = [] + + for prim in stage.TraverseAll(): + prim_stack = prim.GetPrimStack() + for prim_spec in prim_stack: + for ref in prim_spec.referenceList.GetAddedOrExplicitItems(): + resolved_path = prim_spec.layer.ComputeAbsolutePath(ref.assetPath) + if resolved_path in unresolved_paths: + result.append((prim_spec.layer.identifier, str(prim_spec.path), resolved_path)) + for prop in prim.GetAttributes(): + if not isinstance(prop.Get(), Sdf.AssetPath): + continue + property_stack = prop.GetPropertyStack(Usd.TimeCode.Default()) + for prop_spec in property_stack: + prop_layer = prop_spec.layer + resolved_path = prop_layer.ComputeAbsolutePath(prop.Get().path) + if resolved_path in unresolved_paths: + result.append((prop_layer.identifier, str(prop.GetPath()), resolved_path)) + self.current_count += 1 + + return result + + def _get_original_path(self, temp_layer_path: str) -> Optional[str]: + original_name = self._temp_files.get(_OmniUrl(temp_layer_path).path) + return _OmniUrl(temp_layer_path).with_name(original_name).path if original_name else None + + def _get_redirected_dependencies( + self, temp_root_layer: Sdf.Layer, external_mod_paths: List[Path] + ) -> Tuple[Set[str], Set[str]]: + mod_dependencies = set() + redirected_dependencies = set() + + all_layers, all_assets, _ = UsdUtils.ComputeAllDependencies(temp_root_layer.identifier) + all_dependencies = [*[layer.identifier for layer in all_layers], *all_assets] + + self._packaging_new_stage("Redirecting dependencies...", len(all_dependencies)) + + # Update all the layer dependencies in this layer + for dependency in all_dependencies: + if self._cancel_token: + return mod_dependencies, redirected_dependencies + + self.current_count += 1 + + dependency_path = _OmniUrl(dependency).path + + # If dependency is in the capture directory, we should not redirect the dependency + if (_OmniUrl(_REMIX_DEPENDENCIES_FOLDER) / _REMIX_CAPTURE_FOLDER).path in dependency_path: + continue + + # Check if the dependency comes from a known mod + external_mod = None + for mod_path in external_mod_paths: + if (_OmniUrl(_REMIX_MODS_FOLDER) / mod_path.parent.name).path in dependency_path: + external_mod = mod_path.as_posix() + break + + # If the dependency points to a known mod, redirect it to the installed mod and store the mod path + if external_mod: + mod_dependencies.add(external_mod) + redirected_dependencies.add(dependency_path) + + return mod_dependencies, redirected_dependencies def _update_layer_metadata( self, model: _ModPackagingSchema, layer: Sdf.Layer, mod_dependencies: Set[str], update_dependencies: bool @@ -638,14 +675,14 @@ def subscribe_packaging_progress(self, function): """ return _EventSubscription(self.__packaging_progress, function) - def _packaging_completed(self, errors: List[str]): + def _packaging_completed(self, errors: List[str], failed_assets: List[Tuple[str, str, str]]): """Call the event object that has the list of functions""" for error in errors: carb.log_error(error) - self.__packaging_completed(errors, self._cancel_token) + self.__packaging_completed(errors, failed_assets, self._cancel_token) self._cancel_token = False - def subscribe_packaging_completed(self, function): + def subscribe_packaging_completed(self, function: Callable[[List[str], List[Tuple[str, str, str]], bool], Any]): """ Return the object that will automatically unsubscribe when destroyed. """ diff --git a/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/tests/e2e/test_packaging.py b/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/tests/e2e/test_packaging.py index da47bb01e..dafca0778 100644 --- a/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/tests/e2e/test_packaging.py +++ b/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/tests/e2e/test_packaging.py @@ -81,23 +81,23 @@ async def test_package_valid_arguments_should_create_expected_file_structure(sel await self.__asset_directories_equal(get_test_data_path(__name__, "package"), output_dir) self.assertEqual(79, progress_mock.call_count) - self.assertEqual(call(0, 1, "(1/7) Filtering the selected layers..."), progress_mock.call_args_list[0]) - self.assertEqual(call(3, 3, "(1/7) Filtering the selected layers..."), progress_mock.call_args_list[7]) - self.assertEqual(call(0, 13, "(2/7) Redirecting dependencies..."), progress_mock.call_args_list[8]) - self.assertEqual(call(13, 13, "(2/7) Redirecting dependencies..."), progress_mock.call_args_list[21]) - self.assertEqual(call(0, 10, "(3/7) Creating temporary layers..."), progress_mock.call_args_list[22]) - self.assertEqual(call(10, 10, "(3/7) Creating temporary layers..."), progress_mock.call_args_list[32]) - self.assertEqual(call(0, 13, "(4/7) Listing assets to collect..."), progress_mock.call_args_list[33]) - self.assertEqual(call(13, 13, "(4/7) Listing assets to collect..."), progress_mock.call_args_list[46]) - self.assertEqual(call(0, 10, "(5/7) Updating asset paths..."), progress_mock.call_args_list[47]) - self.assertEqual(call(10, 10, "(5/7) Updating asset paths..."), progress_mock.call_args_list[57]) - self.assertEqual(call(0, 9, "(6/7) Collecting assets..."), progress_mock.call_args_list[58]) - self.assertEqual(call(9, 9, "(6/7) Collecting assets..."), progress_mock.call_args_list[67]) - self.assertEqual(call(0, 10, "(7/7) Cleaning up temporary layers..."), progress_mock.call_args_list[68]) - self.assertEqual(call(10, 10, "(7/7) Cleaning up temporary layers..."), progress_mock.call_args_list[78]) + self.assertEqual(call(0, 1, "Filtering the selected layers..."), progress_mock.call_args_list[0]) + self.assertEqual(call(3, 3, "Filtering the selected layers..."), progress_mock.call_args_list[7]) + self.assertEqual(call(0, 13, "Redirecting dependencies..."), progress_mock.call_args_list[8]) + self.assertEqual(call(13, 13, "Redirecting dependencies..."), progress_mock.call_args_list[21]) + self.assertEqual(call(0, 10, "Creating temporary layers..."), progress_mock.call_args_list[22]) + self.assertEqual(call(10, 10, "Creating temporary layers..."), progress_mock.call_args_list[32]) + self.assertEqual(call(0, 13, "Listing assets to collect..."), progress_mock.call_args_list[33]) + self.assertEqual(call(13, 13, "Listing assets to collect..."), progress_mock.call_args_list[46]) + self.assertEqual(call(0, 10, "Updating asset paths..."), progress_mock.call_args_list[47]) + self.assertEqual(call(10, 10, "Updating asset paths..."), progress_mock.call_args_list[57]) + self.assertEqual(call(0, 9, "Collecting assets..."), progress_mock.call_args_list[58]) + self.assertEqual(call(9, 9, "Collecting assets..."), progress_mock.call_args_list[67]) + self.assertEqual(call(0, 10, "Cleaning up temporary layers..."), progress_mock.call_args_list[68]) + self.assertEqual(call(10, 10, "Cleaning up temporary layers..."), progress_mock.call_args_list[78]) self.assertEqual(1, completed_mock.call_count) - self.assertEqual(call([], False), completed_mock.call_args) + self.assertEqual(call([], [], False), completed_mock.call_args) async def __asset_directories_equal(self, expected: Path, actual: Path): # Make sure all the files in the expected directory are identical in the actual directory diff --git a/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/tests/unit/test_packaging.py b/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/tests/unit/test_packaging.py index ada3fed5d..21be4170f 100644 --- a/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/tests/unit/test_packaging.py +++ b/source/extensions/lightspeed.trex.packaging.core/lightspeed/trex/packaging/core/tests/unit/test_packaging.py @@ -60,7 +60,7 @@ async def test_package_unexpected_exception_should_be_caught_and_trigger_packagi # Assert self.assertEqual(0, init_usd_mock.call_count) self.assertEqual(1, completed_mock.call_count) - self.assertEqual(call([str(exception)]), completed_mock.call_args) + self.assertEqual(call([str(exception)], []), completed_mock.call_args) async def test_package_no_root_layer_should_warn_and_quick_return(self): # Arrange @@ -113,7 +113,7 @@ async def test_package_no_stage_should_raise_runtime_error(self): self.assertEqual(call(context_name_mock, str(root_mod_mock)), init_usd_mock.call_args) self.assertEqual(1, completed_mock.call_count) - self.assertEqual(call(["No stage is available in the current context."]), completed_mock.call_args) + self.assertEqual(call(["No stage is available in the current context."], []), completed_mock.call_args) async def test_package_no_root_mod_layer_should_raise_runtime_error(self): # Arrange @@ -148,7 +148,7 @@ async def test_package_no_root_mod_layer_should_raise_runtime_error(self): self.assertEqual(1, completed_mock.call_count) self.assertEqual( - call([f"Unable to open the root mod layer at path: {root_mod_mock}"]), completed_mock.call_args + call([f"Unable to open the root mod layer at path: {root_mod_mock}"], []), completed_mock.call_args ) async def test_package_no_exported_mod_layer_should_add_error(self): @@ -179,7 +179,7 @@ async def test_package_no_exported_mod_layer_should_add_error(self): init_usd_mock.return_value = Mock() make_temp_mock.return_value = temp_root_mod_mock filter_mock.return_value = [] - collect_mock.return_value = [] + collect_mock.return_value = ([], []) else: stage_future = asyncio.Future() stage_future.set_result(Mock()) @@ -207,7 +207,7 @@ async def test_package_no_exported_mod_layer_should_add_error(self): # Assert self.assertEqual(1, completed_mock.call_count) - self.assertEqual(call(["Unable to find the exported mod file."]), completed_mock.call_args) + self.assertEqual(call(["Unable to find the exported mod file."], []), completed_mock.call_args) async def test_package_should_filter_redirect_and_collect_then_trigger_packaging_complete_event(self): # Arrange @@ -245,11 +245,12 @@ async def test_package_should_filter_redirect_and_collect_then_trigger_packaging model_mock.return_value.selected_layer_paths = [root_mod_mock] model_mock.return_value.context_name = context_name_mock model_mock.return_value.output_directory = output_directory_mock + model_mock.return_value.ignored_errors = [] if sys.version_info.minor > 7: init_usd_mock.return_value = Mock() filter_mock.return_value = temp_layers_mock - collect_mock.return_value = [] + collect_mock.return_value = ([], []) else: stage_future = asyncio.Future() stage_future.set_result(Mock()) @@ -272,7 +273,7 @@ async def test_package_should_filter_redirect_and_collect_then_trigger_packaging # Assert self.assertEqual(1, completed_mock.call_count) - self.assertEqual(call([]), completed_mock.call_args) + self.assertEqual(call([], []), completed_mock.call_args) self.assertEqual(1, init_usd_mock.call_count) self.assertEqual(1, filter_mock.call_count) @@ -287,7 +288,15 @@ async def test_package_should_filter_redirect_and_collect_then_trigger_packaging ) self.assertEqual(call(temp_mod_layer_mock, []), redirect_mock.call_args) self.assertEqual( - call(temp_mod_layer_mock, temp_layers_mock, output_directory_mock, redirected_mock), collect_mock.call_args + call( + init_usd_mock.return_value, + temp_mod_layer_mock, + temp_layers_mock, + output_directory_mock, + redirected_mock, + [], + ), + collect_mock.call_args, ) self.assertEqual( call(model_mock(), exported_mod_layer_mock, dependencies_mock, True), update_metadata_mock.call_args_list[0] @@ -879,6 +888,7 @@ async def __run_collect(self, should_cancel: bool, has_unresolved_assets: bool): with ( patch.object(PackagingCore, "_get_original_path") as get_original_mock, patch.object(PackagingCore, "_make_temp_layer") as make_temp_mock, + patch.object(PackagingCore, "_get_unresolved_assets_prim_paths") as get_unresolved_mock, patch.object(Sdf.Layer, "FindOrOpen") as find_open_mock, patch.object(UsdUtils, "ComputeAllDependencies") as compute_dependencies_mock, patch.object(UsdUtils, "ModifyAssetPaths") as modify_assets_mock, @@ -912,8 +922,14 @@ async def __run_collect(self, should_cancel: bool, has_unresolved_assets: bool): find_open_mock.side_effect = [layer_0_temp_mock, layer_1_temp_mock] exists_mock.side_effect = [True, False, True, True, False] + stage_mock = Mock() + stage_mock.TraverseAll.return_value = [] + + unresolved_deps = {("layer", "prim", "asset")} if has_unresolved_assets else set() + if sys.version_info.minor > 7: make_temp_mock.side_effect = layer_1_temp_path_mock + get_unresolved_mock.return_value = unresolved_deps delete_folder_mock.return_value = None create_folder_mock.return_value = None copy_mock.return_value = None @@ -922,6 +938,10 @@ async def __run_collect(self, should_cancel: bool, has_unresolved_assets: bool): make_temp_future.set_result(layer_1_temp_path_mock) make_temp_mock.side_effect = [make_temp_future] + set_future = asyncio.Future() + set_future.set_result(unresolved_deps) + get_unresolved_mock.return_value = set_future + none_future = asyncio.Future() none_future.set_result(None) delete_folder_mock.return_value = none_future @@ -929,19 +949,17 @@ async def __run_collect(self, should_cancel: bool, has_unresolved_assets: bool): copy_mock.return_value = none_future # Act - errors = await packaging_core._collect( # noqa PLW0212 - root_layer_mock, existing_temps_mock, output_directory_mock, redirected_dependencies_mock + errors, unresolved_assets = await packaging_core._collect( # noqa PLW0212 + stage_mock, + root_layer_mock, + existing_temps_mock, + output_directory_mock, + redirected_dependencies_mock, + [], ) # Assert - self.assertListEqual( - ( - [f"Unresolved asset found when collecting dependencies: {unresolved_dependency_mock}"] - if has_unresolved_assets and not should_cancel - else [] - ), - errors, - ) + self.assertListEqual(list(unresolved_deps) if not should_cancel else [], unresolved_assets) self.assertEqual(0 if should_cancel else 1, compute_dependencies_mock.call_count) if not should_cancel: diff --git a/source/extensions/lightspeed.trex.packaging.window/config/extension.toml b/source/extensions/lightspeed.trex.packaging.window/config/extension.toml new file mode 100644 index 000000000..d17ad3295 --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/config/extension.toml @@ -0,0 +1,33 @@ +[package] +version = "1.0.0" +authors =["Pierre-Olivier Trottier "] +title = "Mod Packaging Window" +description = "Mod Packaging Window to fix unresolved references" +readme = "docs/README.md" +category = "internal" +keywords = ["kit", "lightspeed", "packaging", "window"] +changelog = "docs/CHANGELOG.md" +preview_image = "data/preview.png" +icon = "data/icon.png" +repository = "https://gitlab-master.nvidia.com/lightspeedrtx/lightspeed-kit/-/tree/main/source/extensions/lightspeed.trex.packaging.window" + +[dependencies] +"lightspeed.common" = {} +"lightspeed.trex.asset_replacements.core.shared" = {} +"lightspeed.trex.texture_replacements.core.shared" = {} +"lightspeed.trex.utils.widget" = {} +"omni.flux.info_icon.widget" = {} +"omni.flux.utils.common" = {} +"omni.flux.utils.widget" = {} +"omni.ui" = {} + +[[python.module]] +name = "lightspeed.trex.packaging.window" + +dependencies = [ + "lightspeed.trex.tests.dependencies", +] + +stdoutFailPatterns.exclude = [ + "*[omni.kit.registry.nucleus.utils.common] Skipping deletion of:*", +] diff --git a/source/extensions/lightspeed.trex.packaging.window/data/icon.png b/source/extensions/lightspeed.trex.packaging.window/data/icon.png new file mode 100644 index 000000000..7ce4a36fd --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/data/icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e3882f41b9ac9bed5887720c8feb8bd50fd05dc278856fc4252ff55af6c2e6c +size 15146 diff --git a/source/extensions/lightspeed.trex.packaging.window/data/preview.png b/source/extensions/lightspeed.trex.packaging.window/data/preview.png new file mode 100644 index 000000000..997a2305f --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/data/preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bedb35a418e28f1823d99f62928076f09b6bc52d4b81c84c665684bbd8105703 +size 57028 diff --git a/source/extensions/lightspeed.trex.packaging.window/docs/CHANGELOG.md b/source/extensions/lightspeed.trex.packaging.window/docs/CHANGELOG.md new file mode 100644 index 000000000..c81c2c006 --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/docs/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.0.0] +### Added +- Init commit. diff --git a/source/extensions/lightspeed.trex.packaging.window/docs/README.md b/source/extensions/lightspeed.trex.packaging.window/docs/README.md new file mode 100644 index 000000000..c285dfd77 --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/docs/README.md @@ -0,0 +1 @@ +# lightspeed.trex.packaging.window diff --git a/source/extensions/lightspeed.trex.packaging.window/docs/index.rst b/source/extensions/lightspeed.trex.packaging.window/docs/index.rst new file mode 100644 index 000000000..53f7b34f8 --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/docs/index.rst @@ -0,0 +1,17 @@ +lightspeed.trex.packaging.window +################################ + +.. toctree:: + :maxdepth: 1 + + README + CHANGELOG + +.. automodule:: lightspeed.trex.packaging.window + :platform: Windows-x86_64, Linux-x86_64 + :members: + :undoc-members: + :special-members: __init__ + :show-inheritance: + :imported-members: + :exclude-members: diff --git a/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/__init__.py b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/__init__.py new file mode 100644 index 000000000..dd06fd51e --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/__init__.py @@ -0,0 +1,20 @@ +""" +* SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +* SPDX-License-Identifier: Apache-2.0 +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +""" + +__all__ = ["PackagingErrorWindow"] + +from .window import PackagingErrorWindow diff --git a/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/__init__.py b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/__init__.py new file mode 100644 index 000000000..266819ca8 --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/__init__.py @@ -0,0 +1,28 @@ +""" +* SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +* SPDX-License-Identifier: Apache-2.0 +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +""" + +__all__ = [ + "PackagingErrorDelegate", + "PackagingErrorItem", + "PackagingErrorModel", + "AssetValidationError", + "PackagingActions", +] + +from .delegate import PackagingErrorDelegate +from .item import PackagingActions, PackagingErrorItem +from .model import AssetValidationError, PackagingErrorModel diff --git a/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/delegate.py b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/delegate.py new file mode 100644 index 000000000..669dddc43 --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/delegate.py @@ -0,0 +1,265 @@ +# noqa PLC0302 +""" +* SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +* SPDX-License-Identifier: Apache-2.0 +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +""" +__all__ = ["PackagingErrorDelegate"] + +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING, Callable + +from lightspeed.common.constants import READ_USD_FILE_EXTENSIONS_OPTIONS, USD_EXTENSIONS +from lightspeed.trex.utils.widget import TrexMessageDialog +from omni import ui +from omni.flux.info_icon.widget import InfoIconWidget +from omni.flux.utils.common import Event, EventSubscription, reset_default_attrs +from omni.flux.utils.common.omni_url import OmniUrl +from omni.flux.utils.widget.file_pickers import open_file_picker + +if TYPE_CHECKING: + from pxr import Sdf + +from .item import PackagingActions, PackagingErrorItem +from .model import HEADER_DICT, AssetValidationError, PackagingErrorModel + + +class PackagingErrorDelegate(ui.AbstractItemDelegate): + ROW_HEIGHT = ui.Pixel(24) + ROW_PADDING = ui.Pixel(8) + + def __init__(self): + super().__init__() + + self._default_attr = { + "_validation_error": None, + } + for attr, value in self._default_attr.items(): + setattr(self, attr, value) + + self._validation_error = AssetValidationError.NONE + + self.__on_file_picker_opened = Event() + self.__on_file_picker_closed = Event() + + def build_widget( + self, model: PackagingErrorModel, item: PackagingErrorItem, column_id: int, level: int, expanded: bool + ): + if item is None: + return + with ui.HStack(spacing=self.ROW_PADDING, height=self.ROW_HEIGHT): + ui.Spacer(width=0, height=0) + if column_id == 0: + ui.Label( + self._get_prim_path_display_name(item.prim_path), tooltip=str(item.prim_path), elided_text=True + ) + if column_id == 1: + ui.Label( + self._get_relative_path(item.asset_path, item.layer_identifier), + tooltip=self._get_asset_tooltip(item.asset_path, item.layer_identifier), + elided_text=True, + ) + if column_id == 2: + ui.Label( + self._get_relative_path(item.fixed_asset_path, item.layer_identifier), + tooltip=self._get_asset_tooltip(item.fixed_asset_path, item.layer_identifier), + elided_text=True, + ) + if column_id == 3: + with ui.HStack(spacing=self.ROW_PADDING): + action_combobox = ui.ComboBox( + list(PackagingActions).index(item.action), + *[action.value for action in PackagingActions], + ) + action_combobox.model.add_item_changed_fn(partial(self._on_action_changed, model, item)) + + with ui.VStack(width=0): + ui.Spacer(width=0) + InfoIconWidget(self._get_combobox_tooltip(item)) + ui.Spacer(width=0) + + ui.Spacer(width=0) + + def build_header(self, column_id: int): + with ui.HStack(spacing=self.ROW_PADDING, height=self.ROW_HEIGHT): + ui.Rectangle(name="ColumnSeparator", width=ui.Pixel(1)) + ui.Label(HEADER_DICT.get(column_id, "")) + + def _on_action_changed( + self, + model: PackagingErrorModel, + item: PackagingErrorItem, + combobox_model: ui.AbstractItemModel, + _: ui.AbstractItem, + ): + """ + Callback triggered when the action combobox is changed. + """ + action = list(PackagingActions)[combobox_model.get_item_value_model().get_value_as_int()] + match action: + case PackagingActions.IGNORE: + model.reset_asset_paths([item]) + case PackagingActions.REPLACE_ASSET: + self._update_reference(model, item) + case PackagingActions.REMOVE_REFERENCE: + model.remove_asset_paths([item]) + + def _get_combobox_tooltip(self, item: PackagingErrorItem) -> str: + """ + Args: + item: The packaging error item. + + Returns: + The tooltip for the packaging error item. + """ + match item.action: + case PackagingActions.IGNORE: + return "The unresolved asset will be ignored when retrying the packaging process." + case PackagingActions.REPLACE_ASSET: + return "The unresolved asset reference will be replaced with the selected asset." + case PackagingActions.REMOVE_REFERENCE: + return "The unresolved asset reference will be removed from the mod." + + return "An action must be selected to fix the unresolved asset." + + def _get_prim_path_display_name(self, path: "Sdf.Path") -> str: + """ + Args: + path: The prim path. + + Returns: + The truncated display name for the prim path. + """ + return ( + f".../{path.GetPrimPath().GetParentPath().name}/{path.GetPrimPath().name}/{path.name}" + if path.IsPropertyPath() + else f".../{path.GetParentPath().name}/{path.name}" + ) + + def _get_relative_path(self, asset_path: str | None, layer_identifier: str) -> str: + """ + Args: + asset_path: The asset path. If None, a pretty display name will be returned. + layer_identifier: The layer identifier to use for the relative path. + + Returns: + The relative path for the asset. + """ + if asset_path is None: + return "---" + + try: + relative_path = Path(asset_path).relative_to(Path(layer_identifier).parent) + except ValueError: + relative_path = Path(asset_path) + + if relative_path.is_absolute(): + return relative_path.as_posix() + return relative_path.as_posix() if str(relative_path).startswith(".") else f"./{relative_path.as_posix()}" + + def _get_asset_tooltip(self, asset_path: str | None, layer_identifier: str) -> str: + """ + Args: + asset_path: The asset path. If None, a description of the action will be returned. + layer_identifier: The layer identifier to use for the tooltip. + + Returns: + The tooltip for the asset. + """ + if asset_path is None: + return "The reference to the asset will be removed from the mod." + return f"Absolute Path: {asset_path}\nLayer Path: {layer_identifier}" + + def _update_reference(self, model: PackagingErrorModel, item: PackagingErrorItem): + """ + Open the file picker to select a new asset to replace the current asset. + """ + is_reference = OmniUrl(item.asset_path).suffix in USD_EXTENSIONS + extensions = READ_USD_FILE_EXTENSIONS_OPTIONS if is_reference else [("*.dds", "Compatible Textures")] + + self._on_file_picker_opened() + open_file_picker( + f"Select a replacement asset for: {self._get_relative_path(item.asset_path, item.layer_identifier)}", + partial(self._replace_asset_path, model, item), + lambda *_: self._cancel_file_picker(model, item), + current_file=item.asset_path, + file_extension_options=extensions, + validate_selection=partial(self._validate_selected_path, model, item, is_reference), + validation_failed_callback=self._show_error_dialog, + ) + + def _validate_selected_path( + self, model: PackagingErrorModel, item: PackagingErrorItem, is_reference: bool, directory: str, filename: str + ) -> bool: + """ + Validate the selected asset path and update the cached validation error value. + + Returns: + True if the selected path is valid, False otherwise. + """ + self._validation_error = model.validate_selected_path(item, is_reference, directory, filename) + return self._validation_error == AssetValidationError.NONE + + def _show_error_dialog(self, directory: str, filename: str): + """ + Show an error dialog for the invalid file path. + """ + TrexMessageDialog( + title="Invalid File", + message=f"[{self._validation_error}] Error: {directory}, {filename}", + ) + + def _replace_asset_path(self, model: PackagingErrorModel, item: PackagingErrorItem, file_path: str): + """ + Replace the asset path with the selected file path and trigger the __on_file_picker_closed event. + """ + model.replace_asset_paths({item: file_path}) + self._on_file_picker_closed() + + def _cancel_file_picker(self, model: PackagingErrorModel, item: PackagingErrorItem): + """ + Cancel the file picker action by resetting the asset path and triggering the __on_file_picker_closed event. + """ + model.reset_asset_paths([item]) + self._on_file_picker_closed() + + def _on_file_picker_opened(self): + """ + Trigger the __on_file_picker_opened event + """ + self.__on_file_picker_opened() + + def subscribe_file_picker_opened(self, function: Callable[[], None]): + """ + Return the object that will automatically unsubscribe when destroyed. + Called when the file picker is opened. + """ + return EventSubscription(self.__on_file_picker_opened, function) + + def _on_file_picker_closed(self): + """ + Trigger the __on_file_picker_closed event + """ + self.__on_file_picker_closed() + + def subscribe_file_picker_closed(self, function: Callable[[], None]): + """ + Return the object that will automatically unsubscribe when destroyed. + Called when the file picker is closed. + """ + return EventSubscription(self.__on_file_picker_closed, function) + + def destroy(self): + reset_default_attrs(self) diff --git a/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/item.py b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/item.py new file mode 100644 index 000000000..a00237e95 --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/item.py @@ -0,0 +1,102 @@ +# noqa PLC0302 +""" +* SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +* SPDX-License-Identifier: Apache-2.0 +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +""" +__all__ = ["PackagingErrorItem", "PackagingActions"] + +from enum import Enum + +from omni import ui +from omni.flux.utils.common import reset_default_attrs +from pxr import Sdf + + +class PackagingActions(Enum): + """ + Enum for the actions that can be taken on a packaging error. + """ + + IGNORE = "Ignore" + REPLACE_ASSET = "Replace Asset" + REMOVE_REFERENCE = "Remove Reference" + + +class PackagingErrorItem(ui.AbstractItem): + def __init__(self, layer_identifier: str, prim_path: str, asset_path: str): + super().__init__() + + self._default_attr = { + "_layer_identifier": None, + "_prim_path": None, + "_asset_path": None, + "_fixed_asset_path": None, + } + for attr, val in self._default_attr.items(): + setattr(self, attr, val) + + self._layer_identifier = layer_identifier + self._prim_path = prim_path + self._asset_path = asset_path + self._fixed_asset_path = asset_path + + @property + def layer_identifier(self) -> str: + """ + The layer identifier of the packaging error item + """ + return self._layer_identifier + + @property + def prim_path(self) -> Sdf.Path: + """ + The prim path of the packaging + """ + return Sdf.Path(self._prim_path) + + @property + def asset_path(self) -> str: + """ + The original asset path of the packaging error item + """ + return self._asset_path + + @property + def fixed_asset_path(self) -> str | None: + """ + The fixed asset path of the packaging error item + """ + return self._fixed_asset_path + + @fixed_asset_path.setter + def fixed_asset_path(self, value: str): + """ + Set the fixed asset path of the packaging error item + """ + self._fixed_asset_path = value + + @property + def action(self) -> PackagingActions: + """ + Get the action to be taken on the packaging error item + """ + if not self._fixed_asset_path: + return PackagingActions.REMOVE_REFERENCE + if self._fixed_asset_path != self._asset_path: + return PackagingActions.REPLACE_ASSET + return PackagingActions.IGNORE + + def destroy(self): + reset_default_attrs(self) diff --git a/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/model.py b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/model.py new file mode 100644 index 000000000..626251eaf --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/tree/model.py @@ -0,0 +1,215 @@ +# noqa PLC0302 +""" +* SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +* SPDX-License-Identifier: Apache-2.0 +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +""" +__all__ = ["PackagingErrorModel", "AssetValidationError", "HEADER_DICT"] + +from collections.abc import Callable +from enum import Enum +from typing import Any + +import omni.kit.undo +from lightspeed.common.constants import USD_EXTENSIONS +from lightspeed.trex.asset_replacements.core.shared import Setup as AssetReplacementsCore +from lightspeed.trex.texture_replacements.core.shared import TextureReplacementsCore +from lightspeed.trex.utils.common.file_utils import is_usd_file_path_valid_for_filepicker +from omni import ui +from omni.flux.utils.common import Event, EventSubscription, reset_default_attrs +from omni.flux.utils.common.omni_url import OmniUrl +from pxr import Sdf, Usd + +from .item import PackagingActions, PackagingErrorItem + +HEADER_DICT = {0: "Prim Path", 1: "Unresolved Path", 2: "Updated Path", 3: "Action"} + + +class AssetValidationError(Enum): + """ + Enum for the different types of asset validation errors. + """ + + NONE = 0 + INVALID_REFERENCE = 1 + INVALID_TEXTURE = 2 + NOT_INGESTED = 3 + NOT_IN_PROJECT = 4 + + +class PackagingErrorModel(ui.AbstractItemModel): + def __init__(self, context_name: str = ""): + super().__init__() + + self.default_attr = { + "_context_name": [], + "_items": [], + "_asset_core": [], + "_texture_core": [], + } + for attr, value in self.default_attr.items(): + setattr(self, attr, value) + + self._context_name = context_name + self._items = [] + + self._asset_core = AssetReplacementsCore(context_name=context_name) + self._texture_core = TextureReplacementsCore(context_name=context_name) + + self.__on_action_changed = Event() + + def refresh(self, unresolved_assets: list[tuple[str, str, str]]): + """ + Refresh the model with a new list of unresolved assets + + Args: + unresolved_assets: A list of tuples containing the layer, prim path, and asset path of the unresolved assets + """ + self._items = [PackagingErrorItem(layer, path, asset) for layer, path, asset in unresolved_assets] + self._item_changed(None) + + def replace_asset_paths(self, items: dict[PackagingErrorItem, str]): + """ + Replace the asset paths for the given items with the given new paths + + Args: + items: A dictionary of items and their new paths + """ + for item, new_path in items.items(): + item.fixed_asset_path = new_path + self.__on_action_changed() + + def remove_asset_paths(self, items: list[PackagingErrorItem]): + """ + Remove the fixed asset paths for the given items + """ + for item in items: + item.fixed_asset_path = None + self.__on_action_changed() + + def reset_asset_paths(self, items: list[PackagingErrorItem]): + """ + Reset the fixed asset paths for the given items to the original asset paths + """ + for item in items or self._items: + item.fixed_asset_path = item.asset_path + self.__on_action_changed() + + def apply_new_paths(self, items: list[PackagingErrorItem] | None = None) -> list: + """ + Apply the fixed asset paths for the given items + """ + ignored_items = [] + + stage = omni.usd.get_context(self._context_name).get_stage() + + with omni.kit.undo.group(): + for item in items or self._items: + is_reference = OmniUrl(item.asset_path).suffix in USD_EXTENSIONS + target_layer = Sdf.Layer.FindOrOpen(item.layer_identifier) + + if item.action == PackagingActions.IGNORE: + ignored_items.append((item.layer_identifier, str(item.prim_path), item.asset_path)) + continue + + with Usd.EditContext(stage, target_layer): + if item.action == PackagingActions.REPLACE_ASSET: + if is_reference: + self._asset_core.remove_reference( + stage, + item.prim_path, + Sdf.Reference(assetPath=item.asset_path, primPath=item.prim_path), + target_layer, + ) + self._asset_core.add_new_reference( + stage, + item.prim_path, + item.fixed_asset_path, + self._asset_core.get_ref_default_prim_tag(), + target_layer, + ) + else: + self._texture_core.replace_textures( + [(str(item.prim_path), item.fixed_asset_path)], use_undo_group=False + ) + else: + if is_reference: + self._asset_core.remove_reference( + stage, + item.prim_path, + Sdf.Reference(assetPath=item.asset_path, primPath=item.prim_path), + target_layer, + ) + else: + self._texture_core.replace_textures( + [(str(item.prim_path), None)], force=True, use_undo_group=False + ) + + self.__on_action_changed() + + return ignored_items + + def validate_selected_path( + self, item: PackagingErrorItem, is_reference: bool, directory: str, filename: str + ) -> AssetValidationError: + """ + Validate the selected asset path. + + Args: + item: The item to validate the path for + is_reference: Whether the asset is a reference or a texture + directory: The directory of the selected asset path + filename: The filename of the selected asset path + """ + asset_url = OmniUrl(directory) / filename + + if is_reference: + if not is_usd_file_path_valid_for_filepicker(directory, filename): + return AssetValidationError.INVALID_REFERENCE + else: + if asset_url.suffix.lower() != ".dds": + return AssetValidationError.INVALID_TEXTURE + + if not self._asset_core.was_the_asset_ingested(str(asset_url)): + return AssetValidationError.NOT_INGESTED + + if not self._asset_core.asset_is_in_project_dir( + str(asset_url), layer=Sdf.Layer.FindOrOpen(item.layer_identifier) + ): + return AssetValidationError.NOT_IN_PROJECT + + return AssetValidationError.NONE + + def get_item_children(self, item: PackagingErrorItem | None): + """ + Return the children of the given item + """ + if item is None: + return self._items + return [] + + def get_item_value_model_count(self, item: PackagingErrorItem | None): + """ + Return the number of columns in the model + """ + return len(HEADER_DICT.keys()) + + def subscribe_action_changed(self, callback: Callable[[], Any]): + """ + Return the object that will automatically unsubscribe when destroyed. + """ + return EventSubscription(self.__on_action_changed, callback) + + def destroy(self): + reset_default_attrs(self) diff --git a/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/window.py b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/window.py new file mode 100644 index 000000000..3ed679242 --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/lightspeed/trex/packaging/window/window.py @@ -0,0 +1,267 @@ +""" +* SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +* SPDX-License-Identifier: Apache-2.0 +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +""" + +__all__ = ["PackagingErrorWindow"] + +from asyncio import ensure_future +from functools import partial +from typing import Any, Callable + +import omni.kit.app +from lightspeed.common.constants import USD_EXTENSIONS +from lightspeed.trex.utils.widget import TrexMessageDialog +from omni import ui +from omni.flux.utils.common import Event, EventSubscription, reset_default_attrs +from omni.flux.utils.common.omni_url import OmniUrl +from omni.flux.utils.widget.file_pickers import open_file_picker +from omni.flux.utils.widget.tree_widget import AlternatingRowWidget + +from .tree import AssetValidationError, PackagingActions, PackagingErrorDelegate, PackagingErrorModel + + +class PackagingErrorWindow: + _PADDING = ui.Pixel(8) + + def __init__(self, assets: list[tuple[str, str, str]], context_name: str = ""): + self._default_attr = { + "_model": None, + "_delegate": None, + "_action_changed_sub": None, + "_actions_applied_sub": None, + "_file_picker_opened_dub": None, + "_file_picker_closed_dub": None, + "_window": None, + "_tree": None, + "_alternating_rows": None, + } + for attr, value in self._default_attr.items(): + setattr(self, attr, value) + + self._context_name = context_name + + self._model = PackagingErrorModel(context_name=self._context_name) + self._model.refresh(assets) + + self._delegate = PackagingErrorDelegate() + + self._action_changed_sub = self._model.subscribe_action_changed(self._refresh_delegates) + + self._file_picker_opened_dub = self._delegate.subscribe_file_picker_opened(self.hide) + self._file_picker_closed_dub = self._delegate.subscribe_file_picker_closed(self.show) + + self._window = None + self._tree = None + self._alternating_rows = None + + self.__on_actions_applied = Event() + + self._build_ui() + + def show(self): + """ + Show the window + """ + self._window.visible = True + + def hide(self): + """ + Hide the window + """ + self._window.visible = False + + def _build_ui(self): + """ + Build the UI for the window + """ + self._window = ui.Window( + "Mod Packaging Errors", + width=900, + height=600, + visible=True, + dockPreference=ui.DockPreference.DISABLED, + flags=ui.WINDOW_FLAGS_NO_COLLAPSE | ui.WINDOW_FLAGS_NO_DOCKING, + ) + + with self._window.frame: + with ui.ZStack(): + ui.Rectangle(name="WorkspaceBackground") + with ui.HStack(spacing=self._PADDING): + ui.Spacer(height=0, width=0) + with ui.VStack(spacing=self._PADDING): + ui.Spacer(height=0, width=0) + with ui.VStack(height=0, spacing=ui.Pixel(4)): + ui.Label( + "The following assets could not be resolved while packaging the mod.", + name="PropertiesWidgetLabel", + height=0, + alignment=ui.Alignment.CENTER, + ) + ui.Label( + "Specify which actions should be taken to resolve the packaging errors.", + height=0, + alignment=ui.Alignment.CENTER, + ) + ui.Spacer(height=0, width=0) + with ui.ZStack(): + self._alternating_rows = AlternatingRowWidget( + self._delegate.ROW_HEIGHT, self._delegate.ROW_HEIGHT + ) + with ui.ScrollingFrame( + name="TreePanelBackground", + scroll_y_changed_fn=self._alternating_rows.sync_scrolling_frame, + computed_content_size_changed_fn=lambda: self._alternating_rows.sync_frame_height( + self._tree.computed_height + ), + vertical_scrollbar_policy=ui.ScrollBarPolicy.SCROLLBAR_ALWAYS_ON, + horizontal_scrollbar_policy=ui.ScrollBarPolicy.SCROLLBAR_ALWAYS_OFF, + ): + self._tree = ui.TreeView( + self._model, + delegate=self._delegate, + root_visible=False, + header_visible=True, + columns_resizable=True, + column_widths=[ui.Fraction(3), ui.Fraction(2), ui.Fraction(2), ui.Pixel(200)], + ) + with ui.HStack(spacing=self._PADDING, height=0): + ui.Button( + "Ignore All", + tooltip="Set all the missing assets actions to 'Ignore'", + clicked_fn=partial(self._model.reset_asset_paths, self._model.get_item_children(None)), + ) + ui.Button( + "Remove All", + tooltip="Set all the missing assets actions to 'Remove Reference'", + clicked_fn=partial(self._model.remove_asset_paths, self._model.get_item_children(None)), + ) + ui.Button( + "Scan Directory", + tooltip="Scan a directory to attempt to resolve the missing assets automatically", + clicked_fn=self._scan_directory, + ) + ui.Rectangle(height=ui.Pixel(1), name="WizardSeparator") + with ui.HStack(spacing=self._PADDING, height=0): + ui.Button( + "Cancel", tooltip="Close the window without applying any actions", clicked_fn=self.hide + ) + ui.Button( + "Retry Packaging", + tooltip=( + "Apply the actions to the unresolved assets and retry packaging the mod\n\n" + "NOTE: Make sure to save the stage before retrying packaging or replaced/removed " + "references will not be applied. " + ), + clicked_fn=partial(self._apply_actions), + ) + ui.Spacer(height=0, width=0) + ui.Spacer(height=0, width=0) + + async def _display_confirmation_dialog(self, *args, **kwargs): + """ + Display a confirmation dialog after 1 frame to ensure the window is centered. + + Args: + args: The arguments to pass to the TrexMessageDialog + kwargs: The keyword arguments to pass to the TrexMessageDialog + """ + await omni.kit.app.get_app().next_update_async() + TrexMessageDialog(*args, **kwargs) + + def _scan_directory(self): + """ + Open the file picker to select a directory to scan. + """ + + def scan_directory(path): + unresolved_paths = {OmniUrl(item.asset_path).name: item for item in self._model.get_item_children(None)} + asset_path = {} + + for file in OmniUrl(path).iterdir(): + if not file.is_file: + continue + # Check if the file is in the unresolved paths + if file.name not in unresolved_paths: + continue + item = unresolved_paths[file.name] + # If the item was already replaced, skip it + if item.fixed_asset_path and item.fixed_asset_path != item.asset_path: + continue + # If the file path is invalid, skip it + if ( + self._model.validate_selected_path( + item, file.suffix in USD_EXTENSIONS, str(file.parent_url), file.name + ) + != AssetValidationError.NONE + ): + continue + asset_path[item] = str(file) + + self._model.replace_asset_paths(asset_path) + self.show() + + self.hide() + open_file_picker( + "Select a directory to scan", + scan_directory, + lambda *_: self.show(), + select_directory=True, + ) + + def _apply_actions(self): + """ + Apply the actions to the unresolved assets + """ + + def apply_action(*_): + ignored_items = self._model.apply_new_paths() + omni.kit.window.file.save(on_save_done=lambda *_: self.__on_actions_applied(ignored_items)) + + self.hide() + + if any(item for item in self._model.get_item_children(None) if item.action != PackagingActions.IGNORE): + title = "Retry Packaging the Mod" + message = ( + "The stage must be saved before retrying packaging or replaced/removed references will not be applied." + "\n\n" + "Do you want to save the stage and retry packaging the mod?" + ) + confirm_text = "Save and Retry" + + ensure_future( + self._display_confirmation_dialog( + message, title=title, ok_label=confirm_text, ok_handler=apply_action, cancel_handler=self.show + ) + ) + else: + apply_action() + + def _refresh_delegates(self): + """ + Refresh the tree's delegates + """ + if not self._tree: + return + self._tree.dirty_widgets() + + def subscribe_actions_applied(self, callback: Callable[[list], Any]): + """ + Return the object that will automatically unsubscribe when destroyed. + """ + return EventSubscription(self.__on_actions_applied, callback) + + def destroy(self): + reset_default_attrs(self) diff --git a/source/extensions/lightspeed.trex.packaging.window/premake5.lua b/source/extensions/lightspeed.trex.packaging.window/premake5.lua new file mode 100644 index 000000000..41fa76cf5 --- /dev/null +++ b/source/extensions/lightspeed.trex.packaging.window/premake5.lua @@ -0,0 +1,10 @@ +-- Use folder name to build extension name and tag. Version is specified explicitly. +local ext = get_current_extension_info() + +-- Link the current "target" folders into the extension target folder: +project_ext (ext) +repo_build.prebuild_link { + { "data", ext.target_dir.."/data" }, + { "docs", ext.target_dir.."/docs" }, + { "lightspeed", ext.target_dir.."/lightspeed" }, +} diff --git a/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/config/extension.toml b/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/config/extension.toml index 37698a586..1ed19672e 100644 --- a/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/config/extension.toml +++ b/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/config/extension.toml @@ -2,7 +2,7 @@ authors =["Pierre-Olivier Trottier "] title = "NVIDIA RTX Remix Properties Pane Mod Packaging widget for the StageCraft" description = "Mod Packaging Properties Pane widget for NVIDIA RTX Remix StageCraft App" -version = "1.0.4" +version = "1.1.0" readme = "docs/README.md" repository = "https://gitlab-master.nvidia.com/lightspeedrtx/lightspeed-kit/-/tree/main/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget" category = "internal" @@ -19,6 +19,7 @@ icon = "data/icon.png" "lightspeed.trex.mod_packaging_layers.widget" = {} "lightspeed.trex.mod_packaging_output.widget" = {} "lightspeed.trex.packaging.core" = {} +"lightspeed.trex.packaging.window" = {} "lightspeed.trex.utils.widget" = {} "omni.appwindow" = {} "omni.flux.utils.common" = {} diff --git a/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/docs/CHANGELOG.md b/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/docs/CHANGELOG.md index 33b03275e..099ad533d 100644 --- a/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/docs/CHANGELOG.md +++ b/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/docs/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.1.0] +### Added +- Added a window to fix unresolved references + ## [1.0.4] ### Changed - Changed repo link diff --git a/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/lightspeed/trex/properties_pane/shared/mod_packaging/widget/setup_ui.py b/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/lightspeed/trex/properties_pane/shared/mod_packaging/widget/setup_ui.py index 9ad4d1136..0b37dc797 100644 --- a/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/lightspeed/trex/properties_pane/shared/mod_packaging/widget/setup_ui.py +++ b/source/extensions/lightspeed.trex.properties_pane.shared.mod_packaging.widget/lightspeed/trex/properties_pane/shared/mod_packaging/widget/setup_ui.py @@ -17,7 +17,7 @@ from asyncio import ensure_future from functools import partial -from typing import List +from typing import List, Tuple import omni.appwindow import omni.kit.window.file @@ -31,6 +31,7 @@ from lightspeed.trex.mod_packaging_layers.widget import ModPackagingLayersWidget as _ModPackagingLayersWidget from lightspeed.trex.mod_packaging_output.widget import ModPackagingOutputWidget as _ModPackagingOutputWidget from lightspeed.trex.packaging.core import PackagingCore as _PackagingCore +from lightspeed.trex.packaging.window import PackagingErrorWindow as _PackagingErrorWindow from lightspeed.trex.utils.widget import TrexMessageDialog as _TrexMessageDialog from omni.flux.utils.common import reset_default_attrs as _reset_default_attrs from omni.flux.utils.common.omni_url import OmniUrl as _OmniUrl @@ -51,9 +52,11 @@ def __init__(self, context_name: str = ""): "_packaging_core": None, "_packaging_progress_sub": None, "_packaging_completed_sub": None, + "_packaging_errors_resolved_sub": None, "_output_valid": None, "_layers_valid": None, "_progress_popup": None, + "_packaging_window": None, "_root_frame": None, "_package_details_collapsable_frame": None, "_package_details_widget": None, @@ -75,12 +78,17 @@ def __init__(self, context_name: str = ""): self._layer_manager = _LayerManagerCore(self._context_name) self._packaging_progress_sub = self._packaging_core.subscribe_packaging_progress(self._on_packaging_progress) - self._packaging_completed_sub = self._packaging_core.subscribe_packaging_completed(self._on_packaging_completed) + self._packaging_completed_sub = self._packaging_core.subscribe_packaging_completed( + lambda e, f, c: ensure_future(self._on_packaging_completed(e, f, c)) + ) + + self._packaging_errors_resolved_sub = None self._output_valid = False self._layers_valid = False self._progress_popup = None + self._packaging_window = None self.__info_hovered_task = None self.__exit_task = False @@ -204,7 +212,8 @@ def _update_export_button_state(self): if not self._layers_valid: self._export_button.tooltip += "The current layer selection is invalid.\n" - def _on_package_pressed(self): + def _on_package_pressed(self, silent: bool = False, ignored_errors: List[Tuple[str, str, str]] = None): + @omni.usd.handle_exception async def package(success, error): await omni.kit.app.get_app().next_update_async() if success: @@ -220,10 +229,11 @@ async def package(success, error): "mod_name": self._package_details_widget.mod_name, "mod_version": self._package_details_widget.mod_version, "mod_details": self._package_details_widget.mod_details, + "ignored_errors": ignored_errors, } ) else: - self._on_packaging_completed([error], False) + ensure_future(self._on_packaging_completed([error], [], False)) def start_packaging(should_save: bool): if should_save: @@ -231,8 +241,12 @@ def start_packaging(should_save: bool): else: ensure_future(package(True, "")) - def validate_pending_edits(): - if self._context and self._context.has_pending_edit(): + @omni.usd.handle_exception + async def validate_pending_edits(): + # Wait for the previous popup to be hidden to center the following message dialog + await omni.kit.app.get_app().next_update_async() + + if not silent and self._context and self._context.has_pending_edit(): _TrexMessageDialog( message="There are some pending edits in your current stage. " "All unsaved changes will be lost after packaging is started.\n\n" @@ -248,15 +262,15 @@ def validate_pending_edits(): start_packaging(False) output_url = _OmniUrl(self._package_output_widget.output_path) - if output_url.exists and list(output_url.iterdir()): + if not silent and output_url.exists and list(output_url.iterdir()): _TrexMessageDialog( message="The output directory is not empty.\n\n" "Would you like to delete the directory content or cancel the packaging process?", - ok_handler=validate_pending_edits, + ok_handler=partial(ensure_future, validate_pending_edits()), ok_label="Delete", ) else: - validate_pending_edits() + ensure_future(validate_pending_edits()) def _on_packaging_progress(self, current: int, total: int, status: str): if not self._progress_popup: @@ -271,11 +285,19 @@ def _on_packaging_progress(self, current: int, total: int, status: str): self._progress_popup.set_progress(current / total if total > 0 else 0) - def _on_packaging_completed(self, errors: List[str], was_cancelled: bool): + @omni.usd.handle_exception + async def _on_packaging_completed( + self, errors: List[str], failed_assets: List[Tuple[str, str, str]], was_cancelled: bool + ): if self._progress_popup: self._progress_popup.hide() self._progress_popup = None + # Wait for the progress popup to be hidden to center the following message dialog + await omni.kit.app.get_app().next_update_async() + + self._packaging_errors_resolved_sub = None + if errors: error_popup = _ErrorPopup( "Mod Packaging Error", @@ -284,6 +306,11 @@ def _on_packaging_completed(self, errors: List[str], was_cancelled: bool): window_size=(800, 400), ) error_popup.show() + elif failed_assets: + self._packaging_window = _PackagingErrorWindow(failed_assets, context_name=self._context_name) + self._packaging_errors_resolved_sub = self._packaging_window.subscribe_actions_applied( + lambda ignored_errors: ensure_future(self._retry_packaging(ignored_errors)) + ) else: message = ( "The mod packaging process was cancelled." if was_cancelled else "The mod was successfully packaged." @@ -291,6 +318,13 @@ def _on_packaging_completed(self, errors: List[str], was_cancelled: bool): title = "Mod Packaging Cancelled" if was_cancelled else "Mod Packaging Successful" _TrexMessageDialog(message, title, disable_cancel_button=True) + @omni.usd.handle_exception + async def _retry_packaging(self, ignored_errors: List[Tuple[str, str, str]]): + # Wait 1 frame to make sure the dialogs are centered + await omni.kit.app.get_app().next_update_async() + + self._on_package_pressed(silent=True, ignored_errors=ignored_errors) + def __on_info_hovered(self, icon_widget, tooltip, hovered): self._tooltip_window = None if hovered: diff --git a/source/extensions/lightspeed.trex.texture_replacements.core.shared/config/extension.toml b/source/extensions/lightspeed.trex.texture_replacements.core.shared/config/extension.toml index 9c39a9604..cdb042a02 100644 --- a/source/extensions/lightspeed.trex.texture_replacements.core.shared/config/extension.toml +++ b/source/extensions/lightspeed.trex.texture_replacements.core.shared/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "1.1.4" +version = "1.2.0" authors =["Pierre-Olivier Trottier "] title = "NVIDIA RTX Remix Texture Replacements extension for the StageCraft" description = "Extension that works on texture replacement data for NVIDIA RTX Remix StageCraft App" diff --git a/source/extensions/lightspeed.trex.texture_replacements.core.shared/docs/CHANGELOG.md b/source/extensions/lightspeed.trex.texture_replacements.core.shared/docs/CHANGELOG.md index cf8e90694..be53cecb8 100644 --- a/source/extensions/lightspeed.trex.texture_replacements.core.shared/docs/CHANGELOG.md +++ b/source/extensions/lightspeed.trex.texture_replacements.core.shared/docs/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.2.0] +## Added +- Added the ability to remove properties when replacing textures +- Added the ability to use or not an undo group + +## Changed +- Update texture validator to pass when None (remove replacement) + ## [1.1.4] ## Changed - Update to Kit 106.5 diff --git a/source/extensions/lightspeed.trex.texture_replacements.core.shared/lightspeed/trex/texture_replacements/core/shared/data_models/validators.py b/source/extensions/lightspeed.trex.texture_replacements.core.shared/lightspeed/trex/texture_replacements/core/shared/data_models/validators.py index 243d1dd57..aae399497 100644 --- a/source/extensions/lightspeed.trex.texture_replacements.core.shared/lightspeed/trex/texture_replacements/core/shared/data_models/validators.py +++ b/source/extensions/lightspeed.trex.texture_replacements.core.shared/lightspeed/trex/texture_replacements/core/shared/data_models/validators.py @@ -55,6 +55,10 @@ def is_valid_texture_prim(cls, texture_tuple: tuple[str, Path], context_name: st @classmethod def is_valid_texture_asset(cls, texture_tuple: tuple[str, Path], force: bool): _, asset_path = texture_tuple + + if asset_path is None: + return texture_tuple + asset_url = OmniUrl(asset_path) if asset_url.suffix.lower() not in SUPPORTED_TEXTURE_EXTENSIONS: diff --git a/source/extensions/lightspeed.trex.texture_replacements.core.shared/lightspeed/trex/texture_replacements/core/shared/setup.py b/source/extensions/lightspeed.trex.texture_replacements.core.shared/lightspeed/trex/texture_replacements/core/shared/setup.py index 8278861ac..5313b9569 100644 --- a/source/extensions/lightspeed.trex.texture_replacements.core.shared/lightspeed/trex/texture_replacements/core/shared/setup.py +++ b/source/extensions/lightspeed.trex.texture_replacements.core.shared/lightspeed/trex/texture_replacements/core/shared/setup.py @@ -17,6 +17,9 @@ __all__ = ["TextureReplacementsCore"] +from contextlib import nullcontext +from pathlib import Path + import omni.usd from lightspeed.trex.utils.common.asset_utils import TEXTURE_TYPE_INPUT_MAP as _TEXTURE_TYPE_INPUT_MAP from lightspeed.trex.utils.common.asset_utils import get_ingested_texture_type as _get_ingested_texture_type @@ -170,7 +173,9 @@ def get_texture_prims_assets( return textures - def replace_textures(self, textures: list[tuple[str, str]], force: bool = False): + def replace_textures( + self, textures: list[tuple[str, str | Path | None]], force: bool = False, use_undo_group: bool = True + ): """ Replace a list of textures @@ -178,8 +183,9 @@ def replace_textures(self, textures: list[tuple[str, str]], force: bool = False) textures: A list of tuples in the format (texture property, asset path) where the texture property should be a shader input and the asset path should be the absolute path to the texture asset force: Whether to force replace the texture or validate it was ingested correctly + use_undo_group: Whether to use an undo group for the texture replacements """ - with undo.group(): + with undo.group() if use_undo_group else nullcontext(): for texture_attr_path, texture_asset_path in textures: try: TextureReplacementsValidators.is_valid_texture_prim( @@ -189,18 +195,25 @@ def replace_textures(self, textures: list[tuple[str, str]], force: bool = False) except ValueError: continue - commands.execute( - "ChangeProperty", - prop_path=texture_attr_path, - value=Sdf.AssetPath( - omni.usd.make_path_relative_to_current_edit_target( - str(texture_asset_path), stage=self._context.get_stage() - ) - ), - prev=None, - usd_context_name=self._context_name, - target_layer=self._context.get_stage().GetEditTarget().GetLayer(), - ) + if texture_asset_path: + commands.execute( + "ChangeProperty", + prop_path=texture_attr_path, + value=Sdf.AssetPath( + omni.usd.make_path_relative_to_current_edit_target( + str(texture_asset_path), stage=self._context.get_stage() + ) + ), + prev=None, + usd_context_name=self._context_name, + target_layer=self._context.get_stage().GetEditTarget().GetLayer(), + ) + else: + commands.execute( + "RemoveProperty", + prop_path=texture_attr_path, + usd_context_name=self._context_name, + ) def get_texture_material(self, texture_prim_path: str) -> str | None: """ diff --git a/source/extensions/lightspeed.ui_scene.light_manipulator/config/extension.toml b/source/extensions/lightspeed.ui_scene.light_manipulator/config/extension.toml index 7fb246edc..5592be15c 100644 --- a/source/extensions/lightspeed.ui_scene.light_manipulator/config/extension.toml +++ b/source/extensions/lightspeed.ui_scene.light_manipulator/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "1.0.6" +version = "1.0.7" authors = ["Nicolas Kendall-Bar "] title = "Omni.UI Scene Sample For Manipulating Select Light" description = "This example show an 3D manipulator for a selected light" diff --git a/source/extensions/lightspeed.ui_scene.light_manipulator/docs/CHANGELOG.md b/source/extensions/lightspeed.ui_scene.light_manipulator/docs/CHANGELOG.md index 0b971868d..f29951a7e 100644 --- a/source/extensions/lightspeed.ui_scene.light_manipulator/docs/CHANGELOG.md +++ b/source/extensions/lightspeed.ui_scene.light_manipulator/docs/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.0.7] +## Fixed +- Fixed crash when stage is not available + ## [1.0.6] ## Changed - Update to Kit 106.5 diff --git a/source/extensions/lightspeed.ui_scene.light_manipulator/lightspeed/ui_scene/light_manipulator/layer.py b/source/extensions/lightspeed.ui_scene.light_manipulator/lightspeed/ui_scene/light_manipulator/layer.py index e0c490856..40c378cbb 100644 --- a/source/extensions/lightspeed.ui_scene.light_manipulator/lightspeed/ui_scene/light_manipulator/layer.py +++ b/source/extensions/lightspeed.ui_scene.light_manipulator/lightspeed/ui_scene/light_manipulator/layer.py @@ -150,6 +150,9 @@ def _get_global_manipulator_scale(self): return 1.0 def _create_manipulators(self, stage): + if not stage: + return + # Release stale manipulators self._destroy_manipulators()