feat: Add VRM Blender addon with complete import/export functionality
- Add core VRM addon infrastructure with manifest and registration - Add common utilities module with file system, logging, and conversion helpers - Add human bone mapper with support for multiple rigging standards (Mixamo, MMD, Unreal, Rigify, etc.) - Add VRM 0.x and 1.x format support with property groups and handlers - Add editor UI panels for VRM metadata, spring bones, and MToon materials - Add exporter with glTF2 extension support for VRM format serialization - Add importer with scene reconstruction and armature generation - Add MToon shader support with auto-setup and material migration - Add spring bone physics simulation with constraint handling - Add node constraint editor for advanced rigging control - Add comprehensive validation and error handling with user dialogs - Add scene watcher for real-time property synchronization - Add workspace management and preference system - Include Python cache files and Blender manifest configuration - This is the initial commit establishing the complete VRM addon ecosystem for Blender
This commit is contained in:
559
importer/import_scene.py
Normal file
559
importer/import_scene.py
Normal file
@@ -0,0 +1,559 @@
|
||||
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
|
||||
import traceback
|
||||
from collections.abc import Set as AbstractSet
|
||||
from os import environ
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
from bpy.app.translations import pgettext
|
||||
from bpy.props import BoolProperty, CollectionProperty, StringProperty
|
||||
from bpy.types import (
|
||||
Armature,
|
||||
Context,
|
||||
Event,
|
||||
Operator,
|
||||
Panel,
|
||||
PropertyGroup,
|
||||
SpaceFileBrowser,
|
||||
)
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
|
||||
from ..common import ops, version
|
||||
from ..common.error_dialog import show_error_dialog
|
||||
from ..common.logger import get_logger
|
||||
from ..common.preferences import (
|
||||
ImportPreferencesProtocol,
|
||||
copy_import_preferences,
|
||||
create_import_preferences_dict,
|
||||
draw_import_preferences_layout,
|
||||
get_preferences,
|
||||
)
|
||||
from ..editor import search
|
||||
from ..editor.extension import get_armature_extension
|
||||
from ..editor.ops import VRM_OT_open_url_in_web_browser, layout_operator
|
||||
from ..editor.property_group import CollectionPropertyProtocol, StringPropertyGroup
|
||||
from .abstract_base_vrm_importer import AbstractBaseVrmImporter, parse_vrm_json
|
||||
from .license_validation import LicenseConfirmationRequiredError
|
||||
from .uni_vrm_vrm_animation_importer import UniVrmVrmAnimationImporter
|
||||
from .vrm0_importer import Vrm0Importer
|
||||
from .vrm1_importer import Vrm1Importer
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LicenseConfirmation(PropertyGroup):
|
||||
message: StringProperty() # type: ignore[valid-type]
|
||||
url: StringProperty() # type: ignore[valid-type]
|
||||
json_key: StringProperty() # type: ignore[valid-type]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
message: str # type: ignore[no-redef]
|
||||
url: str # type: ignore[no-redef]
|
||||
json_key: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
def import_vrm_update_addon_preferences(
|
||||
import_op: "IMPORT_SCENE_OT_vrm", context: Context
|
||||
) -> None:
|
||||
if import_op.use_addon_preferences:
|
||||
copy_import_preferences(source=import_op, destination=get_preferences(context))
|
||||
|
||||
|
||||
class IMPORT_SCENE_OT_vrm(Operator, ImportHelper):
|
||||
bl_idname = "import_scene.vrm"
|
||||
bl_label = "Open"
|
||||
bl_description = "import VRM"
|
||||
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
|
||||
|
||||
filename_ext = ".vrm"
|
||||
filter_glob: StringProperty( # type: ignore[valid-type]
|
||||
default="*.vrm",
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
use_addon_preferences: BoolProperty( # type: ignore[valid-type]
|
||||
name="Import using add-on preferences",
|
||||
description="Import using add-on preferences instead of operator arguments",
|
||||
)
|
||||
|
||||
extract_textures_into_folder: BoolProperty( # type: ignore[valid-type]
|
||||
name="Extract texture images into the folder",
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=False,
|
||||
)
|
||||
make_new_texture_folder: BoolProperty( # type: ignore[valid-type]
|
||||
name="Don't overwrite existing texture folder",
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
set_shading_type_to_material_on_import: BoolProperty( # type: ignore[valid-type]
|
||||
name='Set shading type to "Material"',
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
set_view_transform_to_standard_on_import: BoolProperty( # type: ignore[valid-type]
|
||||
name='Set view transform to "Standard"',
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
set_armature_display_to_wire: BoolProperty( # type: ignore[valid-type]
|
||||
name='Set an imported armature display to "Wire"',
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
set_armature_display_to_show_in_front: BoolProperty( # type: ignore[valid-type]
|
||||
name='Set an imported armature display to show "In-Front"',
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
set_armature_bone_shape_to_default: BoolProperty( # type: ignore[valid-type]
|
||||
name="Set an imported bone shape to default",
|
||||
update=import_vrm_update_addon_preferences,
|
||||
default=True,
|
||||
)
|
||||
enable_mtoon_outline_preview: BoolProperty( # type: ignore[valid-type]
|
||||
name="Enable MToon Outline Preview",
|
||||
default=True,
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
try:
|
||||
filepath = Path(self.filepath)
|
||||
if not filepath.is_file():
|
||||
return {"CANCELLED"}
|
||||
|
||||
if self.use_addon_preferences:
|
||||
copy_import_preferences(
|
||||
source=get_preferences(context), destination=self
|
||||
)
|
||||
|
||||
license_error = None
|
||||
try:
|
||||
return import_vrm(
|
||||
filepath,
|
||||
self,
|
||||
context,
|
||||
license_validation=True,
|
||||
)
|
||||
except LicenseConfirmationRequiredError as e:
|
||||
license_error = e # Prevent traceback dump on another exception
|
||||
except Exception:
|
||||
show_error_dialog(
|
||||
pgettext("Failed to import VRM."),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
raise
|
||||
|
||||
logger.warning(license_error.description())
|
||||
|
||||
execution_context = "INVOKE_DEFAULT"
|
||||
import_anyway = False
|
||||
if environ.get("BLENDER_VRM_AUTOMATIC_LICENSE_CONFIRMATION") == "true":
|
||||
execution_context = "EXEC_DEFAULT"
|
||||
import_anyway = True
|
||||
|
||||
return ops.wm.vrm_license_warning(
|
||||
execution_context,
|
||||
import_anyway=import_anyway,
|
||||
license_confirmations=license_error.license_confirmations(),
|
||||
filepath=str(filepath),
|
||||
**create_import_preferences_dict(self),
|
||||
)
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
self.use_addon_preferences = True
|
||||
copy_import_preferences(source=get_preferences(context), destination=self)
|
||||
|
||||
if "gltf" not in dir(bpy.ops.import_scene):
|
||||
return ops.wm.vrm_gltf2_addon_disabled_warning("INVOKE_DEFAULT")
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
def draw(self, _context: Context) -> None:
|
||||
pass # Is needed to get panels available
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
filter_glob: str # type: ignore[no-redef]
|
||||
use_addon_preferences: bool # type: ignore[no-redef]
|
||||
extract_textures_into_folder: bool # type: ignore[no-redef]
|
||||
make_new_texture_folder: bool # type: ignore[no-redef]
|
||||
set_shading_type_to_material_on_import: bool # type: ignore[no-redef]
|
||||
set_view_transform_to_standard_on_import: bool # type: ignore[no-redef]
|
||||
set_armature_display_to_wire: bool # type: ignore[no-redef]
|
||||
set_armature_display_to_show_in_front: bool # type: ignore[no-redef]
|
||||
set_armature_bone_shape_to_default: bool # type: ignore[no-redef]
|
||||
enable_mtoon_outline_preview: bool # type: ignore[no-redef]
|
||||
|
||||
|
||||
class VRM_PT_import_file_browser_tool_props(Panel):
|
||||
bl_idname = "VRM_PT_import_file_browser_tool_props"
|
||||
bl_space_type = "FILE_BROWSER"
|
||||
bl_region_type = "TOOL_PROPS"
|
||||
bl_parent_id = "FILE_PT_operator"
|
||||
bl_label = ""
|
||||
bl_options: AbstractSet[str] = {"HIDE_HEADER"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
space_data = context.space_data
|
||||
if not isinstance(space_data, SpaceFileBrowser):
|
||||
return False
|
||||
return space_data.active_operator.bl_idname == "IMPORT_SCENE_OT_vrm"
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
space_data = context.space_data
|
||||
if not isinstance(space_data, SpaceFileBrowser):
|
||||
return
|
||||
|
||||
operator = space_data.active_operator
|
||||
if not isinstance(operator, IMPORT_SCENE_OT_vrm):
|
||||
return
|
||||
|
||||
layout = self.layout
|
||||
draw_import_preferences_layout(operator, layout)
|
||||
|
||||
|
||||
class VRM_PT_import_unsupported_blender_version_warning(Panel):
|
||||
bl_idname = "VRM_PT_import_unsupported_blender_version_warning"
|
||||
bl_space_type = "FILE_BROWSER"
|
||||
bl_region_type = "TOOL_PROPS"
|
||||
bl_parent_id = "FILE_PT_operator"
|
||||
bl_label = ""
|
||||
bl_options: AbstractSet[str] = {"HIDE_HEADER"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: Context) -> bool:
|
||||
space_data = context.space_data
|
||||
if not isinstance(space_data, SpaceFileBrowser):
|
||||
return False
|
||||
if space_data.active_operator.bl_idname != "IMPORT_SCENE_OT_vrm":
|
||||
return False
|
||||
return bool(version.panel_warning_message())
|
||||
|
||||
def draw(self, _context: Context) -> None:
|
||||
warning_message = version.panel_warning_message()
|
||||
if warning_message is None:
|
||||
return
|
||||
|
||||
box = self.layout.box()
|
||||
warning_column = box.column(align=True)
|
||||
for index, warning_line in enumerate(warning_message.splitlines()):
|
||||
warning_column.label(
|
||||
text=warning_line,
|
||||
translate=False,
|
||||
icon="NONE" if index else "ERROR",
|
||||
)
|
||||
|
||||
|
||||
class WM_OT_vrm_license_confirmation(Operator):
|
||||
bl_label = "VRM License Confirmation"
|
||||
bl_idname = "wm.vrm_license_warning"
|
||||
bl_options: AbstractSet[str] = {"REGISTER"}
|
||||
|
||||
filepath: StringProperty() # type: ignore[valid-type]
|
||||
|
||||
license_confirmations: CollectionProperty(type=LicenseConfirmation) # type: ignore[valid-type]
|
||||
import_anyway: BoolProperty( # type: ignore[valid-type]
|
||||
name="Import Anyway",
|
||||
)
|
||||
|
||||
extract_textures_into_folder: BoolProperty() # type: ignore[valid-type]
|
||||
make_new_texture_folder: BoolProperty() # type: ignore[valid-type]
|
||||
set_shading_type_to_material_on_import: BoolProperty() # type: ignore[valid-type]
|
||||
set_view_transform_to_standard_on_import: BoolProperty() # type: ignore[valid-type]
|
||||
set_armature_display_to_wire: BoolProperty() # type: ignore[valid-type]
|
||||
set_armature_display_to_show_in_front: BoolProperty() # type: ignore[valid-type]
|
||||
set_armature_bone_shape_to_default: BoolProperty() # type: ignore[valid-type]
|
||||
enable_mtoon_outline_preview: BoolProperty() # type: ignore[valid-type]
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
try:
|
||||
filepath = Path(self.filepath)
|
||||
if not filepath.is_file():
|
||||
return {"CANCELLED"}
|
||||
if not self.import_anyway:
|
||||
return {"CANCELLED"}
|
||||
return import_vrm(
|
||||
filepath,
|
||||
self,
|
||||
context,
|
||||
license_validation=False,
|
||||
)
|
||||
except Exception:
|
||||
show_error_dialog(
|
||||
pgettext("Failed to import VRM."),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
raise
|
||||
|
||||
def invoke(self, context: Context, _event: Event) -> set[str]:
|
||||
return context.window_manager.invoke_props_dialog(self, width=600)
|
||||
|
||||
def draw(self, _context: Context) -> None:
|
||||
layout = self.layout
|
||||
layout.label(text=self.filepath, translate=False)
|
||||
for license_confirmation in self.license_confirmations:
|
||||
box = layout.box()
|
||||
for line in license_confirmation.message.split("\n"):
|
||||
box.label(text=line, translate=False, icon="INFO")
|
||||
if license_confirmation.json_key:
|
||||
box.label(
|
||||
text=pgettext("For more information please check following URL.")
|
||||
)
|
||||
if VRM_OT_open_url_in_web_browser.supported(license_confirmation.url):
|
||||
split = box.split(factor=0.85, align=True)
|
||||
split.prop(
|
||||
license_confirmation,
|
||||
"url",
|
||||
text=license_confirmation.json_key,
|
||||
translate=False,
|
||||
)
|
||||
op = layout_operator(split, VRM_OT_open_url_in_web_browser)
|
||||
op.url = license_confirmation.url
|
||||
else:
|
||||
box.prop(
|
||||
license_confirmation,
|
||||
"url",
|
||||
text=license_confirmation.json_key,
|
||||
translate=False,
|
||||
)
|
||||
|
||||
layout.prop(self, "import_anyway")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
filepath: str # type: ignore[no-redef]
|
||||
license_confirmations: CollectionPropertyProtocol[ # type: ignore[no-redef]
|
||||
LicenseConfirmation
|
||||
]
|
||||
import_anyway: bool # type: ignore[no-redef]
|
||||
extract_textures_into_folder: bool # type: ignore[no-redef]
|
||||
make_new_texture_folder: bool # type: ignore[no-redef]
|
||||
set_shading_type_to_material_on_import: bool # type: ignore[no-redef]
|
||||
set_view_transform_to_standard_on_import: bool # type: ignore[no-redef]
|
||||
set_armature_display_to_wire: bool # type: ignore[no-redef]
|
||||
set_armature_display_to_show_in_front: bool # type: ignore[no-redef]
|
||||
set_armature_bone_shape_to_default: bool # type: ignore[no-redef]
|
||||
enable_mtoon_outline_preview: bool # type: ignore[no-redef]
|
||||
|
||||
|
||||
def import_vrm(
|
||||
filepath: Path,
|
||||
preferences: ImportPreferencesProtocol,
|
||||
context: Context,
|
||||
*,
|
||||
license_validation: bool,
|
||||
) -> set[str]:
|
||||
parse_result = parse_vrm_json(filepath, license_validation=license_validation)
|
||||
if parse_result.spec_version_number >= (1,):
|
||||
vrm_importer: AbstractBaseVrmImporter = Vrm1Importer(
|
||||
context,
|
||||
parse_result,
|
||||
preferences,
|
||||
)
|
||||
else:
|
||||
vrm_importer = Vrm0Importer(
|
||||
context,
|
||||
parse_result,
|
||||
preferences,
|
||||
)
|
||||
|
||||
vrm_importer.import_vrm()
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def menu_import(
|
||||
menu_op: Operator, _context: Context
|
||||
) -> None: # Same as test/blender_io.py for now
|
||||
vrm_import_op = layout_operator(
|
||||
menu_op.layout, IMPORT_SCENE_OT_vrm, text="VRM (.vrm)"
|
||||
)
|
||||
vrm_import_op.use_addon_preferences = True
|
||||
|
||||
vrma_import_op = layout_operator(
|
||||
menu_op.layout, IMPORT_SCENE_OT_vrma, text="VRM Animation (.vrma)"
|
||||
)
|
||||
vrma_import_op.armature_object_name = ""
|
||||
|
||||
|
||||
class IMPORT_SCENE_OT_vrma(Operator, ImportHelper):
|
||||
bl_idname = "import_scene.vrma"
|
||||
bl_label = "Open"
|
||||
bl_description = "Import VRM Animation"
|
||||
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
|
||||
|
||||
filename_ext = ".vrma"
|
||||
filter_glob: StringProperty( # type: ignore[valid-type]
|
||||
default="*.vrma",
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
armature_object_name: StringProperty( # type: ignore[valid-type]
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
def execute(self, context: Context) -> set[str]:
|
||||
try:
|
||||
errors = WM_OT_vrma_import_prerequisite.detect_errors(
|
||||
context, self.armature_object_name
|
||||
)
|
||||
if errors:
|
||||
for error in errors:
|
||||
logger.error(error)
|
||||
return {"CANCELLED"}
|
||||
|
||||
filepath = Path(self.filepath)
|
||||
if not filepath.is_file():
|
||||
return {"CANCELLED"}
|
||||
|
||||
armature = None
|
||||
if self.armature_object_name:
|
||||
armature = context.blend_data.objects.get(self.armature_object_name)
|
||||
if not armature:
|
||||
return {"CANCELLED"}
|
||||
|
||||
if not armature:
|
||||
armature = search.current_armature(context)
|
||||
|
||||
if not armature:
|
||||
added = ops.icyp.make_basic_armature()
|
||||
if added != {"FINISHED"}:
|
||||
return added
|
||||
armature = search.current_armature(context)
|
||||
if not armature:
|
||||
return {"CANCELLED"}
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return {"CANCELLED"}
|
||||
ext = get_armature_extension(armature_data)
|
||||
ext.spec_version = ext.SPEC_VERSION_VRM1
|
||||
|
||||
return UniVrmVrmAnimationImporter.execute(context, filepath, armature)
|
||||
except Exception:
|
||||
show_error_dialog(
|
||||
pgettext("Failed to import VRM Animation."),
|
||||
traceback.format_exc(),
|
||||
)
|
||||
raise
|
||||
|
||||
def invoke(self, context: Context, event: Event) -> set[str]:
|
||||
if WM_OT_vrma_import_prerequisite.detect_errors(
|
||||
context, self.armature_object_name
|
||||
):
|
||||
return ops.wm.vrma_import_prerequisite(
|
||||
"INVOKE_DEFAULT",
|
||||
armature_object_name=self.armature_object_name,
|
||||
)
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
filter_glob: str # type: ignore[no-redef]
|
||||
armature_object_name: str # type: ignore[no-redef]
|
||||
|
||||
|
||||
class WM_OT_vrma_import_prerequisite(Operator):
|
||||
bl_label = "VRM Animation Import Prerequisite"
|
||||
bl_idname = "wm.vrma_import_prerequisite"
|
||||
bl_options: AbstractSet[str] = {"REGISTER", "UNDO"}
|
||||
|
||||
armature_object_name: StringProperty( # type: ignore[valid-type]
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
armature_object_name_candidates: CollectionProperty( # type: ignore[valid-type]
|
||||
type=StringPropertyGroup,
|
||||
options={"HIDDEN"},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def detect_errors(context: Context, armature_object_name: str) -> list[str]:
|
||||
error_messages: list[str] = []
|
||||
|
||||
if not armature_object_name:
|
||||
armature = search.current_armature(context)
|
||||
else:
|
||||
armature = context.blend_data.objects.get(armature_object_name)
|
||||
|
||||
if not armature:
|
||||
return error_messages
|
||||
|
||||
armature_data = armature.data
|
||||
if not isinstance(armature_data, Armature):
|
||||
return error_messages
|
||||
|
||||
ext = get_armature_extension(armature_data)
|
||||
if get_armature_extension(armature_data).is_vrm1():
|
||||
humanoid = ext.vrm1.humanoid
|
||||
if not humanoid.human_bones.all_required_bones_are_assigned():
|
||||
error_messages.append(pgettext("Please assign required human bones"))
|
||||
else:
|
||||
error_messages.append(pgettext("Please set the version of VRM to 1.0"))
|
||||
|
||||
return error_messages
|
||||
|
||||
def execute(self, _context: Context) -> set[str]:
|
||||
return ops.import_scene.vrma(
|
||||
"INVOKE_DEFAULT", armature_object_name=self.armature_object_name
|
||||
)
|
||||
|
||||
def invoke(self, context: Context, _event: Event) -> set[str]:
|
||||
if not self.armature_object_name:
|
||||
armature_object = search.current_armature(context)
|
||||
if armature_object:
|
||||
self.armature_object_name = armature_object.name
|
||||
self.armature_object_name_candidates.clear()
|
||||
for obj in context.blend_data.objects:
|
||||
if obj.type != "ARMATURE":
|
||||
continue
|
||||
candidate = self.armature_object_name_candidates.add()
|
||||
candidate.value = obj.name
|
||||
return context.window_manager.invoke_props_dialog(self, width=800)
|
||||
|
||||
def draw(self, context: Context) -> None:
|
||||
layout = self.layout
|
||||
|
||||
layout.label(
|
||||
text="VRM Animation import requires a VRM 1.0 armature",
|
||||
icon="INFO",
|
||||
)
|
||||
|
||||
error_messages = WM_OT_vrma_import_prerequisite.detect_errors(
|
||||
context, self.armature_object_name
|
||||
)
|
||||
|
||||
layout.prop_search(
|
||||
self,
|
||||
"armature_object_name",
|
||||
self,
|
||||
"armature_object_name_candidates",
|
||||
icon="OUTLINER_OB_ARMATURE",
|
||||
text="Armature to be animated",
|
||||
)
|
||||
|
||||
if error_messages:
|
||||
error_column = layout.box().column(align=True)
|
||||
for error_message in error_messages:
|
||||
error_column.label(text=error_message, icon="ERROR", translate=False)
|
||||
|
||||
open_op = layout_operator(
|
||||
layout,
|
||||
VRM_OT_open_url_in_web_browser,
|
||||
icon="URL",
|
||||
text="Open help in a Web Browser",
|
||||
)
|
||||
open_op.url = pgettext("https://vrm-addon-for-blender.info/en-us/animation/")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This code is auto generated.
|
||||
# To regenerate, run the `uv run tools/property_typing.py` command.
|
||||
armature_object_name: str # type: ignore[no-redef]
|
||||
armature_object_name_candidates: CollectionPropertyProtocol[ # type: ignore[no-redef]
|
||||
StringPropertyGroup
|
||||
]
|
||||
Reference in New Issue
Block a user