# Copyright (c) 2020-2021, NVIDIA CORPORATION.  All rights reserved.
#
# NVIDIA CORPORATION and its licensors retain all intellectual property
# and proprietary rights in and to this software, related documentation
# and any modifications thereto.  Any use, reproduction, disclosure or
# distribution of this software and related documentation without an express
# license agreement from NVIDIA CORPORATION is strictly prohibited.
#

from typing import List

import carb
import copy
import carb.profiler
import omni.kit.commands
import omni.kit.undo
import omni.timeline
import omni.usd
import omni.kit.notification_manager as nm

from omni.kit.usd.layers import get_layers, LayerEventType, get_layer_event_payload
from pxr import Ar, Gf, Sdf, Tf, Trace, Usd, UsdShade

from .control_state_manager import ControlStateManager
from .placeholder_attribute import PlaceholderAttribute


class UsdBase:
    def __init__(
        self,
        stage: Usd.Stage,
        object_paths: List[Sdf.Path],
        self_refresh: bool,
        metadata: dict = {},
        change_on_edit_end: bool = False,
        treat_array_entry_as_comp: bool = False,
        **kwargs,
    ):
        self._control_state_mgr = ControlStateManager.get_instance()
        self._stage = stage
        self._usd_context = None
        self._object_paths = object_paths
        self._object_paths_set = set(object_paths)
        self._metadata = metadata
        self._change_on_edit_end = change_on_edit_end
        # Whether each array entry should be treated as a comp, if this property is of an array type.
        # If set to True, each array entry will have ambiguous and different from default state checked. It may have
        # huge perf impact if the array size is big.
        # It is useful if you need to build each array entry as a single widget with mixed/non-default indicator
        # an example would SdfAssetPathAttributeModel and _sdf_asset_path_array_builder
        self._treat_array_entry_as_comp = treat_array_entry_as_comp
        self._dirty = True
        self._value = None  # The value to be displayed on widget
        self._has_default_value = False
        self._default_value = None
        self._real_values = []  # The actual values in usd, might be different from self._value if ambiguous.
        self._connections = []
        self._is_big_array = False
        self._prev_value = None
        self._prev_real_values = []
        self._editing = 0
        self._ignore_notice = False
        self._ambiguous = False
        # "comp" is the first dimension of an array (when treat_array_entry_as_comp is enabled) or vector attribute.
        # - If attribute is non-array vector type, comp indexes into the vector itself. e.g. for vec3 type, comp=1 means
        #   the 2nd channel of the vector vec3[1].
        # - If attribute is an array type:
        #   - When treat_array_entry_as_comp is enabled) is enabled:
        #       - If the attribute is an array of scalar (i.e. float[]), `comp`` is the entry index. e.g. for 
        #         SdfAssetArray type, comp=1 means the 2nd path in the path array.
        #       - If the attribute is an array of vector (i.e. vec3f[]), `comp` only indexes the array entry, it does 
        #         not support indexing into the channel of the vector within the array entry. i.e a "2D" comp is not 
        #         supported yet.
        #   - When treat_array_entry_as_comp is enabled) is disabled:
        #       - comp is 0 and the entire array is treated as one scalar value.
        #
        # This applies to all `comp` related functionality in this model base.
        self._comp_ambiguous = []
        self._might_be_time_varying = False  # Inaccurate named. It really means if timesamples > 0
        self._timeline = omni.timeline.get_timeline_interface()
        self._current_time = self._timeline.get_current_time()
        self._timeline_sub = None
        self._on_set_default_fn = None
        self._soft_range_min = None
        self._soft_range_max = None

        # get soft_range userdata settings
        attributes = self._get_attributes()
        if attributes:
            attribute = attributes[-1]
            if isinstance(attribute, Usd.Attribute):
                soft_range = attribute.GetCustomDataByKey("omni:kit:property:usd:soft_range_ui")
                if soft_range:
                    self._soft_range_min = soft_range[0]
                    self._soft_range_max = soft_range[1]

        # Hard range for the value. For vector type, range value is a float/int that compares against each component individually
        self._min = kwargs.get("min", None)
        self._max = kwargs.get("max", None)

        # Invalid range
        if self._min is not None and self._max is not None and self._min >= self._max:
            self._min = self._max = None

        # The state of the icon on the right side of the line with widgets
        self._control_state = 0
        # Callback when the control state is changing. We use it to redraw UI
        self._on_control_state_changed_fn = None
        # Callback when the value is reset. We use it to redraw UI
        self._on_set_default_fn = None
        # True if the attribute has the default value and the current value is not default
        self._different_from_default = False
        # Per component different from default
        self._comp_different_from_default = []

        # Whether the UsdModel should self register Tf.Notice or let UsdPropertiesWidget inform a property change
        if self_refresh:
            self._listener = Tf.Notice.Register(Usd.Notice.ObjectsChanged, self._on_usd_changed, self._stage)
        else:
            self._listener = None
        
        # Notification handler to throttle notifications.
        self._notification = None

        usd_context = self._get_usd_context()
        layers = get_layers(usd_context)
        self._spec_locks_subscription = layers.get_event_stream().create_subscription_to_pop(
            self._on_spec_locks_changed, name="Property USD"
        )

    @property
    def control_state(self):
        """Returns the current control state, it's the icon on the right side of the line with widgets"""
        return self._control_state

    @property
    def stage(self):
        return self._stage

    @property
    def metadata(self):
        return self._metadata

    def update_control_state(self):
        control_state, force_refresh = self._control_state_mgr.update_control_state(self)

        # Redraw control state icon when the control state is changed
        if self._control_state != control_state or force_refresh:
            self._control_state = control_state
            if self._on_control_state_changed_fn:
                self._on_control_state_changed_fn()

    def set_on_control_state_changed_fn(self, fn):
        """Callback that is called when control state is changed"""
        self._on_control_state_changed_fn = fn

    def set_on_set_default_fn(self, fn):
        """Callback that is called when value is reset"""
        self._on_set_default_fn = fn

    def clean(self):
        self._notification = None
        self._timeline_sub = None
        self._stage = None
        self._spec_locks_subscription = None
        if self._listener:
            self._listener.Revoke()
            self._listener = None

    def is_different_from_default(self) -> bool:
        """Returns True if the attribute has the default value and the current value is not default"""
        self._update_value()
        # soft_range has been overridden
        if self._soft_range_min != None and self._soft_range_max != None:
            return True
        return self._different_from_default

    def might_be_time_varying(self) -> bool:
        self._update_value()
        return self._might_be_time_varying

    def is_ambiguous(self) -> bool:
        self._update_value()
        return self._ambiguous

    def is_comp_ambiguous(self, index: int) -> bool:
        self._update_value()
        comp_len = len(self._comp_ambiguous)
        if comp_len == 0 or index < 0:
            return self.is_ambiguous()
        if index < comp_len:
            return self._comp_ambiguous[index]
        return False

    def is_array_type(self) -> bool:
        return self._is_array_type()

    def get_all_comp_ambiguous(self) -> List[bool]:
        """Empty array if attribute value is a scalar, check is_ambiguous instead"""
        self._update_value()
        return self._comp_ambiguous

    def get_attribute_paths(self) -> List[Sdf.Path]:
        return self._object_paths

    def get_property_paths(self) -> List[Sdf.Path]:
        return self.get_attribute_paths()

    def get_connections(self):
        return self._connections

    def set_default(self, comp=-1):
        """Set the UsdAttribute default value if it exists in metadata"""
        self.set_soft_range_userdata(None, None)
        if self.is_different_from_default() is False or self._has_default_value is False:
            if self._soft_range_min != None and self._soft_range_max != None:
                if self._on_set_default_fn:
                    self._on_set_default_fn()
                self.update_control_state()
            return

        current_time_code = self.get_current_time_code()

        with omni.kit.undo.group():
            # TODO clear timesample
            # However, when a value is timesampled, the "default" button is overridden by timesampled button,
            # so there's no way to invoke this function
            for attribute in self._get_attributes():
                if isinstance(attribute, Usd.Attribute):
                    current_value = attribute.Get(current_time_code)
                    if comp >= 0:
                        # OM-46294: Make a value copy to avoid changing with reference.
                        default_value = copy.copy(current_value)
                        default_value[comp] = self._default_value[comp]
                    else:
                        default_value = self._default_value

                    self._change_property(attribute.GetPath(), default_value, current_value)

        # We just set all the properties to the same value, it's no longer ambiguous
        self._ambiguous = False
        self._comp_ambiguous.clear()

        self._different_from_default = False
        self._comp_different_from_default.clear()

        if self._on_set_default_fn:
            self._on_set_default_fn()

    def _create_placeholder_attributes(self, attributes):
        # NOTE: PlaceholderAttribute.CreateAttribute cannot throw exceptions
        for index, attribute in enumerate(attributes):
            if isinstance(attribute, PlaceholderAttribute):
                self._editing += 1
                attributes[index] = attribute.CreateAttribute()
                self._editing -= 1

    def set_value(self, value, comp: int = -1):
        if self._min is not None:
            if hasattr(value, "__len__"):
                for i in range(len(value)):
                    if value[i] < self._min:
                        value[i] = self._min
            else:
                if value < self._min:
                    value = self._min

        if self._max is not None:
            if hasattr(value, "__len__"):
                for i in range(len(value)):
                    if value[i] > self._max:
                        value[i] = self._max
            else:
                if value > self._max:
                    value = self._max

        if not self._ambiguous and not any(self._comp_ambiguous) and value == self._value:
            return False

        if self.is_instance_proxy():
            self._post_notification("Cannot edit attributes of instance proxy.")
            self._update_value(True)  # reset value
            return False

        if self.is_locked():
            self._post_notification("Cannot edit locked attributes.")
            self._update_value(True)  # reset value
            return False

        if self._might_be_time_varying:
            self._post_notification("Setting time varying attribute is not supported yet")
            return False

        self._value = value if comp == -1 else UsdBase.update_value_by_comp(value, self._value, comp)
        attributes = self._get_attributes()

        if len(attributes) == 0:
            return False

        self._create_placeholder_attributes(attributes)

        if self._editing:
            for i, attribute in enumerate(attributes):
                self._ignore_notice = True
                if comp == -1:
                    self._real_values[i] = self._value
                    if not self._change_on_edit_end:
                        attribute.Set(self._value)
                else:
                    # Only update a single component of the value (for vector type)
                    value = self._real_values[i]
                    self._real_values[i] = self._update_value_by_comp(value, comp)
                    if not self._change_on_edit_end:
                        attribute.Set(value)

                self._ignore_notice = False
        else:
            with omni.kit.undo.group():
                for i, attribute in enumerate(attributes):
                    self._ignore_notice = True

                    # begin_edit is not called for certain widget (like Checkbox), issue the command directly
                    if comp == -1:
                        self._change_property(attribute.GetPath(), self._value, None)
                    else:
                        # Only update a single component of the value (for vector type)
                        value = self._real_values[i]
                        self._real_values[i] = self._update_value_by_comp(value, comp)
                        self._change_property(attribute.GetPath(), value, None)
                    self._ignore_notice = False

        if comp == -1:
            # We just set all the properties to the same value, it's no longer ambiguous
            self._ambiguous = False
            self._comp_ambiguous.clear()
        else:
            self._comp_ambiguous[comp] = False
            self._ambiguous = any(self._comp_ambiguous)

        if self._has_default_value:
            self._comp_different_from_default = [False] * self._get_comp_num()
            if comp == -1:
                self._different_from_default = value != self._default_value
                if self._different_from_default:
                    for comp in range(len(self._comp_different_from_default)):
                        self._comp_different_from_default[comp] = not self._compare_value_by_comp(
                            value, self._default_value, comp
                        )
            else:
                self._comp_different_from_default[comp] = not self._compare_value_by_comp(
                    value, self._default_value, comp
                )
                self._different_from_default = any(self._comp_different_from_default)
        else:
            self._different_from_default = False
            self._comp_different_from_default.clear()

        self.update_control_state()

        return True

    def _is_prev_same(self):
        return self._prev_real_values == self._real_values

    def begin_edit(self):
        self._editing = self._editing + 1
        self._prev_value = self._value
        self._save_real_values_as_prev()

    def end_edit(self):
        self._editing = self._editing - 1

        if self._is_prev_same():
            return

        attributes = self._get_attributes()
        self._create_placeholder_attributes(attributes)

        with omni.kit.undo.group():
            self._ignore_notice = True
            for i in range(len(attributes)):
                attribute = attributes[i]
                self._change_property(attribute.GetPath(), self._real_values[i], self._prev_real_values[i])
            self._ignore_notice = False

        # Set flags. It calls _on_control_state_changed_fn when the user finished editing
        self._update_value(True)
    
    def _post_notification(self, message):
        if not self._notification or self._notification.dismissed:
            status = nm.NotificationStatus.WARNING
            self._notification = nm.post_notification(message, status=status)
            carb.log_warn(message)

    def _change_property(self, path: Sdf.Path, new_value, old_value):
        # OM-75480: For props inside sesison layer, it will always change specs
        # in the session layer to avoid shadowing. Why it needs to be def is that
        # session layer is used for several runtime data for now as built-in cameras,
        # MDL material params, and etc. Not all of them create runtime prims inside
        # session layer. For those that are not defined inside session layer, we should
        # avoid leaving delta inside other sublayers as they are shadowed and useless after
        # stage close.
        target_layer, _ = omni.usd.find_spec_on_session_or_its_sublayers(
            self._stage, path.GetPrimPath(), lambda spec: spec.specifier == Sdf.SpecifierDef
        )
        if not target_layer:
            target_layer = self._stage.GetEditTarget().GetLayer()

        omni.kit.commands.execute(
            "ChangeProperty", prop_path=path,
            value=new_value, prev=old_value,
            target_layer=target_layer,
            usd_context_name=self._stage
        )

    def get_value_by_comp(self, comp: int):
        self._update_value()
        if comp == -1:
            return self._value

        return self._get_value_by_comp(self._value, comp)

    def _save_real_values_as_prev(self):
        # It's like copy.deepcopy but not all USD types support pickling (e.g. Gf.Quat*)
        self._prev_real_values = [type(value)(value) for value in self._real_values]

    def _get_value_by_comp(self, value, comp: int):
        if value.__class__.__module__ == "pxr.Gf":
            if value.__class__.__name__.startswith("Quat"):
                if comp == 0:
                    return value.real
                else:
                    return value.imaginary[comp - 1]
            elif value.__class__.__name__.startswith("Matrix"):
                dimension = len(value)
                row = comp // dimension
                col = comp % dimension
                return value[row, col]
            elif value.__class__.__name__.startswith("Vec"):
                return value[comp]
        else:
            if comp < len(value):
                return value[comp]

        return None

    def _update_value_by_comp(self, value, comp: int):
        """update value from self._value"""
        return UsdBase.update_value_by_comp(self._value, value, comp)

    @staticmethod
    def update_value_by_comp(from_value, to_value, comp: int):
        """update value from from_value to to_value"""
        if from_value.__class__.__module__ == "pxr.Gf":
            if from_value.__class__.__name__.startswith("Quat"):
                if comp == 0:
                    to_value.real = from_value.real
                else:
                    imaginary = from_value.imaginary
                    imaginary[comp - 1] = from_value.imaginary[comp - 1]
                    to_value.SetImaginary(imaginary)
            elif from_value.__class__.__name__.startswith("Matrix"):
                dimension = len(from_value)
                row = comp // dimension
                col = comp % dimension
                to_value[row, col] = from_value[row, col]
            elif from_value.__class__.__name__.startswith("Vec"):
                to_value[comp] = from_value[comp]
        else:
            to_value[comp] = from_value[comp]
        return to_value

    def _compare_value_by_comp(self, val1, val2, comp: int):
        return self._get_value_by_comp(val1, comp) == self._get_value_by_comp(val2, comp)

    def _get_comp_num(self):
        # TODO any better wan than this??
        # Checks if the value type is a vector type
        if self._value.__class__.__module__ == "pxr.Gf":
            if self._value.__class__.__name__.startswith("Quat"):
                return 4
            elif self._value.__class__.__name__.startswith("Matrix"):
                mat_dimension = len(self._value)
                return mat_dimension * mat_dimension
            elif hasattr(self._value, "__len__"):
                return len(self._value)
        elif self._is_array_type() and self._treat_array_entry_as_comp:
            return len(self._value)
        return 0

    @Trace.TraceFunction
    def _on_usd_changed(self, notice, stage):
        if stage != self._stage:
            return

        if self._editing > 0:
            return

        if self._ignore_notice:
            return

        for path in notice.GetResyncedPaths():
            if path in self._object_paths_set:
                self._set_dirty()
                return

        for path in notice.GetChangedInfoOnlyPaths():
            if path in self._object_paths_set:
                self._set_dirty()
                return

    def _on_dirty(self):
        pass

    def _set_dirty(self):
        if self._editing > 0:
            return

        self._dirty = True
        self._on_dirty()

    def _get_type_name(self, obj=None):
        if obj:
            if hasattr(obj, "GetTypeName"):
                return obj.GetTypeName()
            elif hasattr(obj, "typeName"):
                return obj.typeName
            else:
                return None
        else:
            type_name = self._metadata.get(Sdf.PrimSpec.TypeNameKey, "unknown type")
            return Sdf.ValueTypeNames.Find(type_name)

    def _is_array_type(self, obj=None):
        type_name = self._get_type_name(obj)

        if isinstance(type_name, Sdf.ValueTypeName):
            return type_name.isArray
        else:
            return False

    def _get_obj_type_default_value(self, obj):
        type_name = self._get_type_name(obj)

        if isinstance(type_name, Sdf.ValueTypeName):
            return type_name.defaultValue
        else:
            return None

    def _update_value(self, force=False):
        return self._update_value_objects(force, self._get_attributes())

    def _update_value_objects(self, force: bool, objects: list):
        if (self._dirty or force) and self._stage:
            with Ar.ResolverContextBinder(self._stage.GetPathResolverContext()):
                with Ar.ResolverScopedCache():
                    carb.profiler.begin(1, "UsdBase._update_value_objects")
                    current_time_code = self.get_current_time_code()
                    self._might_be_time_varying = False
                    self._value = None
                    self._has_default_value = False
                    self._default_value = None
                    self._real_values.clear()
                    self._connections.clear()
                    self._ambiguous = False
                    self._comp_ambiguous.clear()
                    self._different_from_default = False
                    self._comp_different_from_default.clear()
                    for index, object in enumerate(objects):
                        value = self._read_value(object, current_time_code)
                        self._real_values.append(value)
                        if isinstance(object, Usd.Attribute):
                            self._might_be_time_varying = self._might_be_time_varying or object.GetNumTimeSamples() > 0
                            self._connections.append(
                                [conn for conn in object.GetConnections()] if object.HasAuthoredConnections() else []
                            )
                        # only need to check the first prim. All other prims are supposedly to be the same
                        if index == 0:
                            self._value = value
                            if self._value and self._is_array_type(object) and len(self._value) > 16:
                                self._is_big_array = True
                            comp_num = self._get_comp_num()
                            self._comp_ambiguous = [False] * comp_num
                            self._comp_different_from_default = [False] * comp_num
                            # Loads the default value
                            self._has_default_value, self._default_value = self._get_default_value(object)
                        elif self._value != value:
                            self._value = value
                            self._ambiguous = True
                            comp_num = len(self._comp_ambiguous)
                            if comp_num > 0:
                                for i in range(comp_num):
                                    if not self._comp_ambiguous[i]:
                                        self._comp_ambiguous[i] = not self._compare_value_by_comp(
                                            value, self._real_values[0], i
                                        )

                        if self._has_default_value:
                            comp_num = len(self._comp_different_from_default)
                            if comp_num > 0:
                                for i in range(comp_num):
                                    if not self._comp_different_from_default[i]:
                                        self._comp_different_from_default[i] = not self._compare_value_by_comp(
                                            value, self._default_value, i
                                        )
                                self._different_from_default |= any(self._comp_different_from_default)
                            else:
                                self._different_from_default |= value != self._default_value

                    self._dirty = False
                    self._timeline_sub = self._timeline.get_timeline_event_stream().create_subscription_to_pop(
                        self._on_timeline_event
                    )

                    self.update_control_state()
                    carb.profiler.end(1)
            return True
        return False

    def _get_default_value(self, property):
        default_values = {"xformOp:scale": (1.0, 1.0, 1.0), "visibleInPrimaryRay": True, "primvars:multimatte_id": -1}

        if isinstance(property, Usd.Attribute):
            prim = property.GetPrim()
            if prim:
                custom = property.GetCustomData()
                if "default" in custom:
                    # This is not the standard USD way to get default.
                    return True, custom["default"]
                elif "customData" in self._metadata:
                    # This is to fetch default value for custom property.
                    default_value = self._metadata["customData"].get("default", None)
                    if default_value:
                        return True, default_value
                else:
                    prim_definition = prim.GetPrimDefinition()
                    prop_spec = prim_definition.GetSchemaPropertySpec(property.GetPath().name)
                    if prop_spec and prop_spec.default != None:
                        return True, prop_spec.default

                if property.GetName() in default_values:
                    return True, default_values[property.GetName()]

                # If we still don't find default value, use type's default value
                value_type = property.GetTypeName()
                default_value = value_type.defaultValue
                return True, default_value
        elif isinstance(property, PlaceholderAttribute):
            return True, property.Get()

        return False, None

    def _get_attributes(self):
        if not self._stage:
            return []
        attributes = []
        if not self._stage:
            return attributes

        for path in self._object_paths:
            prim = self._stage.GetPrimAtPath(path.GetPrimPath())
            if prim:
                attr = prim.GetAttribute(path.name)
                if attr:
                    if not attr.IsHidden():
                        attributes.append(attr)
                else:
                    attr = PlaceholderAttribute(name=path.name, prim=prim, metadata=self._metadata)
                    attributes.append(attr)
        return attributes

    def _get_objects(self):
        objects = []
        if not self._stage:
            return objects

        for path in self._object_paths:
            obj = self._stage.GetObjectAtPath(path)
            if obj and not obj.IsHidden():
                objects.append(obj)
        return objects

    def _on_timeline_event(self, e):
        if e.type == int(omni.timeline.TimelineEventType.CURRENT_TIME_TICKED) or e.type == int(
            omni.timeline.TimelineEventType.CURRENT_TIME_CHANGED
        ):
            current_time = e.payload["currentTime"]
            if current_time != self._current_time:
                self._current_time = current_time
                if self._might_be_time_varying:
                    self._set_dirty()
        elif e.type == int(omni.timeline.TimelineEventType.TENTATIVE_TIME_CHANGED):
            tentative_time = e.payload["tentativeTime"]
            if tentative_time != self._current_time:
                self._current_time = tentative_time
                if self._might_be_time_varying:
                    self._set_dirty()

    def _read_value(self, object: Usd.Object, time_code: Usd.TimeCode):
        carb.profiler.begin(1, "UsdBase._read_value")
        val = object.Get(time_code)
        if val is None:
            result, val = self._get_default_value(object)
            if not result:
                val = self._get_obj_type_default_value(object)
        carb.profiler.end(1)
        return val

    def _on_spec_locks_changed(self, event: carb.events.IEvent):
        payload = get_layer_event_payload(event)
        if payload and payload.event_type == LayerEventType.SPECS_LOCKING_CHANGED:
            self.update_control_state()
    
    def _get_usd_context(self):
        if not self._usd_context:
            self._usd_context = omni.usd.get_context_from_stage(self._stage)
        
        return self._usd_context

    def set_locked(self, locked):
        usd_context = self._get_usd_context()
        if not usd_context:
            carb.log_warn(f"Current stage is not attached to any usd context.")
            return

        if locked:
            omni.kit.usd.layers.lock_specs(usd_context, self._object_paths, False)
        else:
            omni.kit.usd.layers.unlock_specs(usd_context, self._object_paths, False)

    def is_instance_proxy(self):
        if not self._object_paths:
            return False
        
        path = Sdf.Path(self._object_paths[0]).GetPrimPath()
        prim = self._stage.GetPrimAtPath(path) 
        
        return prim and prim.IsInstanceProxy()

    def is_locked(self):
        usd_context = self._get_usd_context()
        if not usd_context:
            carb.log_warn(f"Current stage is not attached to any usd context.")
            return

        for path in self._object_paths:
            if not omni.kit.usd.layers.is_spec_locked(usd_context, path):
                return False

        return True

    def has_connections(self):
        return bool(len(self._connections[-1]) > 0)

    def get_value(self):
        self._update_value()
        return self._value

    def get_current_time_code(self):
        return Usd.TimeCode(omni.usd.get_frame_time_code(self._current_time, self._stage.GetTimeCodesPerSecond()))

    def set_soft_range_userdata(self, soft_range_min, soft_range_max):
        # set soft_range userdata settings

        for attribute in self._get_attributes():
            if isinstance(attribute, Usd.Attribute):
                if soft_range_min == None and soft_range_max == None:
                    attribute.SetCustomDataByKey("omni:kit:property:usd:soft_range_ui", None)
                else:
                    attribute.SetCustomDataByKey(
                        "omni:kit:property:usd:soft_range_ui", Gf.Vec2f(soft_range_min, soft_range_max)
                    )

        self._soft_range_min = soft_range_min
        self._soft_range_max = soft_range_max
