a929f68ad4
- Truly fixes PMX Import lol, i messed up completely - Updated MMD Tools to use Cats One
567 lines
20 KiB
Python
567 lines
20 KiB
Python
# Copyright 2021 MMD Tools authors
|
|
# This file is part of MMD Tools.
|
|
|
|
import csv
|
|
import os
|
|
from typing import TYPE_CHECKING, cast
|
|
|
|
import bpy
|
|
|
|
from ..core.model import FnModel, Model
|
|
from ..core.translations import MMD_DATA_TYPE_TO_HANDLERS, FnTranslations
|
|
from ..translations import DictionaryEnum
|
|
|
|
if TYPE_CHECKING:
|
|
from ..properties.translations import (
|
|
MMDTranslation,
|
|
MMDTranslationElement,
|
|
MMDTranslationElementIndex,
|
|
)
|
|
|
|
|
|
class TranslateMMDModel(bpy.types.Operator):
|
|
bl_idname = "mmd_tools.translate_mmd_model"
|
|
bl_label = "Translate a MMD Model"
|
|
bl_description = "Translate Japanese names of a MMD model"
|
|
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
|
|
|
dictionary: bpy.props.EnumProperty(
|
|
name="Dictionary",
|
|
items=DictionaryEnum.get_dictionary_items,
|
|
description="Translate names from Japanese to English using selected dictionary",
|
|
)
|
|
types: bpy.props.EnumProperty(
|
|
name="Types",
|
|
description="Select which parts will be translated",
|
|
options={"ENUM_FLAG"},
|
|
items=[
|
|
("BONE", "Bones", "Bones", 1),
|
|
("MORPH", "Morphs", "Morphs", 2),
|
|
("MATERIAL", "Materials", "Materials", 4),
|
|
("DISPLAY", "Display", "Display frames", 8),
|
|
("PHYSICS", "Physics", "Rigidbodies and joints", 16),
|
|
("INFO", "Information", "Model name and comments", 32),
|
|
],
|
|
default={
|
|
"BONE",
|
|
"MORPH",
|
|
"MATERIAL",
|
|
"DISPLAY",
|
|
"PHYSICS",
|
|
},
|
|
)
|
|
modes: bpy.props.EnumProperty(
|
|
name="Modes",
|
|
description="Select translation mode",
|
|
options={"ENUM_FLAG"},
|
|
items=[
|
|
("MMD", "MMD Names", "Fill MMD English names", 1),
|
|
("BLENDER", "Blender Names", "Translate blender names (experimental)", 2),
|
|
],
|
|
default={"MMD"},
|
|
)
|
|
use_morph_prefix: bpy.props.BoolProperty(
|
|
name="Use Morph Prefix",
|
|
description="Add/remove prefix to English name of morph",
|
|
default=False,
|
|
)
|
|
overwrite: bpy.props.BoolProperty(
|
|
name="Overwrite",
|
|
description="Overwrite a translated English name",
|
|
default=False,
|
|
)
|
|
allow_fails: bpy.props.BoolProperty(
|
|
name="Allow Fails",
|
|
description="Allow incompletely translated names",
|
|
default=False,
|
|
)
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
obj = context.active_object
|
|
root = FnModel.find_root_object(obj)
|
|
return obj is not None and obj in context.selected_objects and root is not None
|
|
|
|
def invoke(self, context, event):
|
|
vm = context.window_manager
|
|
return vm.invoke_props_dialog(self)
|
|
|
|
def execute(self, context):
|
|
try:
|
|
self.__translator = DictionaryEnum.get_translator(self.dictionary)
|
|
except Exception as e:
|
|
self.report({"ERROR"}, f"Failed to load dictionary: {e}")
|
|
return {"CANCELLED"}
|
|
|
|
obj = context.active_object
|
|
root = FnModel.find_root_object(obj)
|
|
rig = Model(root)
|
|
|
|
if "MMD" in self.modes:
|
|
for i in self.types:
|
|
getattr(self, f"translate_{i.lower()}")(rig)
|
|
|
|
if "BLENDER" in self.modes:
|
|
self.translate_blender_names(rig)
|
|
|
|
translator = self.__translator
|
|
txt = translator.save_fails()
|
|
if translator.fails:
|
|
self.report(
|
|
{"WARNING"},
|
|
"Failed to translate %d names, see '%s' in text editor"
|
|
% (len(translator.fails), txt.name),
|
|
)
|
|
return {"FINISHED"}
|
|
|
|
def translate(self, name_j, name_e):
|
|
if not self.overwrite and name_e and self.__translator.is_translated(name_e):
|
|
return name_e
|
|
if self.allow_fails:
|
|
name_e = None
|
|
return self.__translator.translate(name_j, name_e)
|
|
|
|
def translate_blender_names(self, rig: Model):
|
|
if "BONE" in self.types:
|
|
for b in rig.armature().pose.bones:
|
|
rig.renameBone(b.name, self.translate(b.name, b.name))
|
|
|
|
if "MORPH" in self.types:
|
|
for i in (x for x in rig.meshes() if x.data.shape_keys):
|
|
for kb in i.data.shape_keys.key_blocks:
|
|
kb.name = self.translate(kb.name, kb.name)
|
|
|
|
if "MATERIAL" in self.types:
|
|
for m in (x for x in rig.materials() if x):
|
|
m.name = self.translate(m.name, m.name)
|
|
|
|
if "DISPLAY" in self.types:
|
|
g: bpy.types.BoneCollection
|
|
for g in cast("bpy.types.Armature", rig.armature().data).collections:
|
|
g.name = self.translate(g.name, g.name)
|
|
|
|
if "PHYSICS" in self.types:
|
|
for i in rig.rigidBodies():
|
|
i.name = self.translate(i.name, i.name)
|
|
|
|
for i in rig.joints():
|
|
i.name = self.translate(i.name, i.name)
|
|
|
|
if "INFO" in self.types:
|
|
objects = [rig.rootObject(), rig.armature()]
|
|
objects.extend(rig.meshes())
|
|
for i in objects:
|
|
i.name = self.translate(i.name, i.name)
|
|
|
|
def translate_info(self, rig):
|
|
mmd_root = rig.rootObject().mmd_root
|
|
mmd_root.name_e = self.translate(mmd_root.name, mmd_root.name_e)
|
|
|
|
comment_text = bpy.data.texts.get(mmd_root.comment_text, None)
|
|
comment_e_text = bpy.data.texts.get(mmd_root.comment_e_text, None)
|
|
if comment_text and comment_e_text:
|
|
comment_e = self.translate(
|
|
comment_text.as_string(), comment_e_text.as_string(),
|
|
)
|
|
comment_e_text.from_string(comment_e)
|
|
|
|
def translate_bone(self, rig):
|
|
bones = rig.armature().pose.bones
|
|
for b in bones:
|
|
if b.is_mmd_shadow_bone:
|
|
continue
|
|
b.mmd_bone.name_e = self.translate(b.mmd_bone.name_j, b.mmd_bone.name_e)
|
|
|
|
def translate_morph(self, rig):
|
|
mmd_root = rig.rootObject().mmd_root
|
|
attr_list = ("group", "vertex", "bone", "uv", "material")
|
|
prefix_list = ("G_", "", "B_", "UV_", "M_")
|
|
for attr, prefix in zip(attr_list, prefix_list, strict=False):
|
|
for m in getattr(mmd_root, attr + "_morphs", []):
|
|
m.name_e = self.translate(m.name, m.name_e)
|
|
if not prefix:
|
|
continue
|
|
if self.use_morph_prefix:
|
|
if not m.name_e.startswith(prefix):
|
|
m.name_e = prefix + m.name_e
|
|
elif m.name_e.startswith(prefix):
|
|
m.name_e = m.name_e[len(prefix) :]
|
|
|
|
def translate_material(self, rig):
|
|
for m in rig.materials():
|
|
if m is None:
|
|
continue
|
|
m.mmd_material.name_e = self.translate(
|
|
m.mmd_material.name_j, m.mmd_material.name_e,
|
|
)
|
|
|
|
def translate_display(self, rig):
|
|
mmd_root = rig.rootObject().mmd_root
|
|
for f in mmd_root.display_item_frames:
|
|
f.name_e = self.translate(f.name, f.name_e)
|
|
|
|
def translate_physics(self, rig):
|
|
for i in rig.rigidBodies():
|
|
i.mmd_rigid.name_e = self.translate(i.mmd_rigid.name_j, i.mmd_rigid.name_e)
|
|
|
|
for i in rig.joints():
|
|
i.mmd_joint.name_e = self.translate(i.mmd_joint.name_j, i.mmd_joint.name_e)
|
|
|
|
|
|
DEFAULT_SHOW_ROW_COUNT = 20
|
|
|
|
|
|
class MMD_TOOLS_LOCAL_UL_MMDTranslationElementIndex(bpy.types.UIList):
|
|
def draw_item(
|
|
self,
|
|
context,
|
|
layout: bpy.types.UILayout,
|
|
data,
|
|
mmd_translation_element_index: "MMDTranslationElementIndex",
|
|
icon,
|
|
active_data,
|
|
active_propname,
|
|
index: int,
|
|
):
|
|
mmd_translation_element: MMDTranslationElement = data.translation_elements[
|
|
mmd_translation_element_index.value
|
|
]
|
|
MMD_DATA_TYPE_TO_HANDLERS[mmd_translation_element.type].draw_item(
|
|
layout, mmd_translation_element, index,
|
|
)
|
|
|
|
|
|
class RestoreMMDDataReferenceOperator(bpy.types.Operator):
|
|
bl_idname = "mmd_tools.restore_mmd_translation_element_name"
|
|
bl_label = "Restore this Name"
|
|
bl_options = {"INTERNAL"}
|
|
|
|
index: bpy.props.IntProperty()
|
|
prop_name: bpy.props.StringProperty()
|
|
restore_value: bpy.props.StringProperty()
|
|
|
|
def execute(self, context: bpy.types.Context):
|
|
root_object = FnModel.find_root_object(context.active_object)
|
|
mmd_translation_element_index = (
|
|
root_object.mmd_root.translation.filtered_translation_element_indices[
|
|
self.index
|
|
].value
|
|
)
|
|
mmd_translation_element = root_object.mmd_root.translation.translation_elements[
|
|
mmd_translation_element_index
|
|
]
|
|
setattr(mmd_translation_element, self.prop_name, self.restore_value)
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class GlobalTranslationPopup(bpy.types.Operator):
|
|
bl_idname = "mmd_tools.global_translation_popup"
|
|
bl_label = "Global Translation Popup"
|
|
bl_options = {"INTERNAL", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
root = FnModel.find_root_object(context.active_object)
|
|
return root is not None
|
|
|
|
def draw(self, _context):
|
|
layout = self.layout
|
|
mmd_translation = self._mmd_translation
|
|
|
|
col = layout.column(align=True)
|
|
col.label(text="Filter", icon="FILTER")
|
|
row = col.row()
|
|
row.prop(mmd_translation, "filter_types")
|
|
|
|
group = row.row(align=True, heading="is Blank:")
|
|
group.alignment = "RIGHT"
|
|
group.prop(
|
|
mmd_translation, "filter_japanese_blank", toggle=True, text="Japanese",
|
|
)
|
|
group.prop(mmd_translation, "filter_english_blank", toggle=True, text="English")
|
|
|
|
group = row.row(align=True)
|
|
group.prop(
|
|
mmd_translation,
|
|
"filter_restorable",
|
|
toggle=True,
|
|
icon="FILE_REFRESH",
|
|
icon_only=True,
|
|
)
|
|
group.prop(
|
|
mmd_translation,
|
|
"filter_selected",
|
|
toggle=True,
|
|
icon="RESTRICT_SELECT_OFF",
|
|
icon_only=True,
|
|
)
|
|
group.prop(
|
|
mmd_translation,
|
|
"filter_visible",
|
|
toggle=True,
|
|
icon="HIDE_OFF",
|
|
icon_only=True,
|
|
)
|
|
|
|
col = layout.column(align=True)
|
|
box = col.box().column(align=True)
|
|
row = box.row(align=True)
|
|
row.label(text="Select the target column for Batch Operations:", icon="TRACKER")
|
|
row = box.row(align=True)
|
|
row.label(text="", icon="BLANK1")
|
|
row.prop(mmd_translation, "batch_operation_target", expand=True)
|
|
row.label(text="", icon="RESTRICT_SELECT_OFF")
|
|
row.label(text="", icon="HIDE_OFF")
|
|
|
|
if (
|
|
len(mmd_translation.filtered_translation_element_indices)
|
|
> DEFAULT_SHOW_ROW_COUNT
|
|
):
|
|
row.label(text="", icon="BLANK1")
|
|
|
|
col.template_list(
|
|
"mmd_tools_UL_MMDTranslationElementIndex",
|
|
"",
|
|
mmd_translation,
|
|
"filtered_translation_element_indices",
|
|
mmd_translation,
|
|
"filtered_translation_element_indices_active_index",
|
|
rows=DEFAULT_SHOW_ROW_COUNT,
|
|
)
|
|
|
|
box = layout.box().column(align=True)
|
|
box.label(text="Batch Operation:", icon="MODIFIER")
|
|
box.prop(mmd_translation, "batch_operation_script", text="", icon="SCRIPT")
|
|
|
|
box.separator()
|
|
row = box.row()
|
|
row.prop(
|
|
mmd_translation,
|
|
"batch_operation_script_preset",
|
|
text="Preset",
|
|
icon="CON_TRANSFORM_CACHE",
|
|
)
|
|
row.operator(ExecuteTranslationBatchOperator.bl_idname, text="Execute")
|
|
|
|
box.separator()
|
|
translation_box = box.box().column(align=True)
|
|
translation_box.label(text="Dictionaries:", icon="HELP")
|
|
row = translation_box.row()
|
|
row.prop(mmd_translation, "dictionary", text="to_english")
|
|
|
|
translation_box.separator()
|
|
row = translation_box.row()
|
|
row.prop(mmd_translation, "dictionary", text="replace")
|
|
|
|
# CSV import/export
|
|
box.separator()
|
|
translation_box = box.box().column(align=True)
|
|
translation_box.label(text="CSV:", icon="FILE_TEXT")
|
|
row = translation_box.row()
|
|
row.operator(ImportTranslationCSVOperator.bl_idname, text="Import CSV")
|
|
row.operator(ExportTranslationCSVOperator.bl_idname, text="Export CSV")
|
|
|
|
def invoke(self, context: bpy.types.Context, _event):
|
|
root_object = FnModel.find_root_object(context.active_object)
|
|
if root_object is None:
|
|
return {"CANCELLED"}
|
|
|
|
mmd_translation: MMDTranslation = root_object.mmd_root.translation
|
|
self._mmd_translation = mmd_translation
|
|
FnTranslations.clear_data(mmd_translation)
|
|
FnTranslations.collect_data(mmd_translation)
|
|
FnTranslations.update_query(mmd_translation)
|
|
|
|
return context.window_manager.invoke_props_dialog(self, width=800)
|
|
|
|
def execute(self, context):
|
|
root_object = FnModel.find_root_object(context.active_object)
|
|
if root_object is None:
|
|
return {"CANCELLED"}
|
|
|
|
FnTranslations.apply_translations(root_object)
|
|
FnTranslations.clear_data(root_object.mmd_root.translation)
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class ExecuteTranslationBatchOperator(bpy.types.Operator):
|
|
bl_idname = "mmd_tools.execute_translation_batch"
|
|
bl_label = "Execute Translation Batch"
|
|
bl_options = {"INTERNAL"}
|
|
|
|
def execute(self, context: bpy.types.Context):
|
|
root = FnModel.find_root_object(context.active_object)
|
|
if root is None:
|
|
return {"CANCELLED"}
|
|
|
|
fails, text = FnTranslations.execute_translation_batch(root)
|
|
if fails:
|
|
self.report(
|
|
{"WARNING"},
|
|
"Failed to translate %d names, see '%s' in text editor"
|
|
% (len(fails), text.name),
|
|
)
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class ExportTranslationCSVOperator(bpy.types.Operator):
|
|
bl_idname = "mmd_tools.export_translation_csv"
|
|
bl_description = "Export CSV for external translation."
|
|
bl_label = "Export Translation CSV"
|
|
|
|
filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"})
|
|
filename_ext = ".csv"
|
|
filepath: bpy.props.StringProperty(
|
|
name="File Path",
|
|
description="Path to save the translation CSV",
|
|
subtype="FILE_PATH",
|
|
default="mmd_translation.csv",
|
|
)
|
|
|
|
def _ensure_csv_extension(self):
|
|
"""Ensure the file path ends with a .csv extension (case-insensitive)."""
|
|
if not self.filepath.lower().endswith(".csv"):
|
|
self.filepath = bpy.path.ensure_ext(self.filepath, ".csv")
|
|
|
|
def invoke(self, context, event):
|
|
self._ensure_csv_extension()
|
|
context.window_manager.fileselect_add(self)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
def execute(self, context):
|
|
self._ensure_csv_extension()
|
|
root_object = FnModel.find_root_object(context.active_object)
|
|
if root_object is None:
|
|
self.report({"ERROR"}, "Root object not found")
|
|
return {"CANCELLED"}
|
|
|
|
mmd_translation = root_object.mmd_root.translation
|
|
|
|
try:
|
|
with open(self.filepath, "w", newline="", encoding="utf-8") as csvfile:
|
|
writer = csv.writer(csvfile)
|
|
writer.writerow(["type", "blender", "japanese", "english"])
|
|
for idx in mmd_translation.filtered_translation_element_indices:
|
|
element = mmd_translation.translation_elements[idx.value]
|
|
writer.writerow(
|
|
[element.type, element.name, element.name_j, element.name_e],
|
|
)
|
|
except Exception as e:
|
|
self.report({"ERROR"}, f"Failed to write CSV: {e}")
|
|
return {"CANCELLED"}
|
|
|
|
self.report({"INFO"}, f"Exported to {os.path.basename(self.filepath)}")
|
|
return {"FINISHED"}
|
|
|
|
|
|
class ImportTranslationCSVOperator(bpy.types.Operator):
|
|
bl_idname = "mmd_tools.import_translation_csv"
|
|
bl_description = "Import translated CSV."
|
|
bl_label = "Import Translation CSV"
|
|
|
|
only_update_english_name: bpy.props.BoolProperty(
|
|
name="Only Update English Name",
|
|
description="(Enabled by default) Only update English name (name_e). otherwise, update all names when different",
|
|
default=True,
|
|
)
|
|
|
|
filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"})
|
|
filepath: bpy.props.StringProperty(
|
|
name="File Path",
|
|
description="Path to import the translation CSV",
|
|
subtype="FILE_PATH",
|
|
default="*.csv",
|
|
)
|
|
|
|
def invoke(self, context, event):
|
|
context.window_manager.fileselect_add(self)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
def execute(self, context):
|
|
root_object = FnModel.find_root_object(context.active_object)
|
|
if root_object is None:
|
|
self.report({"ERROR"}, "Root object not found")
|
|
return {"CANCELLED"}
|
|
|
|
mmd_translation = root_object.mmd_root.translation
|
|
updated_count = 0
|
|
warnings = []
|
|
|
|
try:
|
|
with open(self.filepath, encoding="utf-8") as csvfile:
|
|
reader = csv.DictReader(csvfile)
|
|
required_headers = {"blender", "japanese", "english"}
|
|
if not required_headers.issubset(set(reader.fieldnames or [])):
|
|
missing = required_headers - set(reader.fieldnames or [])
|
|
self.report(
|
|
{"ERROR"},
|
|
f"Missing required headers in CSV: {', '.join(missing)}",
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
visible_indices = [
|
|
i.value
|
|
for i in mmd_translation.filtered_translation_element_indices
|
|
]
|
|
translation_elements_list = list(mmd_translation.translation_elements)
|
|
row_count = 0
|
|
|
|
for row in reader:
|
|
if row_count >= len(visible_indices):
|
|
row_count += 1
|
|
continue
|
|
|
|
element = translation_elements_list[visible_indices[row_count]]
|
|
|
|
b_name = row.get("blender", "").strip()
|
|
j_name = row.get("japanese", "").strip()
|
|
e_name = row.get("english", "").strip()
|
|
|
|
updated = False
|
|
if self.only_update_english_name:
|
|
if element.name_e != e_name:
|
|
element.name_e = e_name
|
|
updated = True
|
|
else:
|
|
if element.name != b_name:
|
|
element.name = b_name
|
|
updated = True
|
|
if element.name_j != j_name:
|
|
element.name_j = j_name
|
|
updated = True
|
|
if element.name_e != e_name:
|
|
element.name_e = e_name
|
|
updated = True
|
|
|
|
if updated:
|
|
updated_count += 1
|
|
|
|
row_count += 1
|
|
|
|
# Output warnings
|
|
if row_count > len(visible_indices):
|
|
warnings.append(
|
|
f"{row_count - len(visible_indices)} extra lines in CSV! (ignored)",
|
|
)
|
|
elif row_count < len(visible_indices):
|
|
warnings.append(
|
|
f"{len(visible_indices) - row_count} missing lines in CSV! (aborted translation)",
|
|
)
|
|
except Exception as e:
|
|
self.report({"ERROR"}, f"Failed to read CSV: {e}")
|
|
return {"CANCELLED"}
|
|
|
|
FnTranslations.update_query(mmd_translation)
|
|
|
|
msg = f"Imported {updated_count} entries from CSV"
|
|
if warnings:
|
|
for w in warnings:
|
|
self.report({"WARNING"}, w)
|
|
msg += " with warnings"
|
|
|
|
self.report({"INFO"}, msg)
|
|
return {"FINISHED"}
|