diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..46ad7fe --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing + +Thank you for your interest in contributing to **Visemes Converter Tool**! + +--- + +## Project Structure + +Before contributing, make sure you are familiar with the project layout: + +``` +plugin_blender/ +├── visemes.py # Entry point: bl_info, register/unregister +├── operators/ +│ ├── __init__.py +│ ├── vrc.py # VRChat viseme operators +│ └── mmd.py # MMD conversion operators +├── panels/ +│ ├── __init__.py +│ └── main_panel.py # UI panel +└── utilities/ + ├── constants.py + ├── functions.py + └── helpers.py +``` + +--- + +## Getting Started + +1. Fork the repository +2. Clone your fork locally +3. Open the project in your editor of choice +4. Install the add-on in Blender via `Edit > Preferences > Add-ons > Install` + +--- + +## How to Contribute + +### Reporting Bugs + +- Open an issue and describe the problem clearly +- Include your Blender version and OS +- Attach steps to reproduce the bug + +### Suggesting Features + +- Open an issue with the `enhancement` label +- Describe the use case and the expected behavior + +### Submitting Code + +1. Create a new branch from `main`: + ``` + git checkout -b feature/your-feature-name + ``` +2. Make your changes following the guidelines below +3. Test the add-on inside Blender before submitting +4. Open a pull request with a clear description of the changes + +--- + +## Code Guidelines + +- **Language:** all code, variable names, and `bl_description` strings must be in **English** +- **Comments:** do not add inline comments or docstrings — refer to `DOCUMENTATION.md` instead +- **Operators:** new operators go in `operators/vrc.py` or `operators/mmd.py` depending on their scope, and must be exported from `operators/__init__.py` +- **Panels:** UI changes go in `panels/main_panel.py` +- **Data / mappings:** new constants go in `utilities/constants.py`, new mapping data in `utilities/functions.py` +- **Shared utilities:** helper functions and shared state go in `utilities/helpers.py` +- Keep `visemes.py` as a thin entry point — no logic belongs there + +--- + +## Credits + +If your contribution is inspired by or based on external work, add a credit line at the top of `visemes.py` in the existing credits block. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..cbe915a --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,170 @@ +# Visemes Converter Tool — Documentation + +Blender add-on for generating VRChat visemes from MMD blendshapes and for converting shape key names to Japanese MMD format. + +**Version:** 2.0.0 +**Minimum Blender:** 3.0.0 +**UI Location:** View3D › Sidebar (N) › Visemes Tool +**Category:** Rigging + +--- + +## Installation + +1. `Edit > Preferences > Add-ons > Install` +2. Select the `visemes.py` file (or the zipped folder) +3. Enable the add-on from the list + +--- + +## Project Structure + +``` +plugin_blender/ +├── visemes.py # Entry point: bl_info, register/unregister +├── operators/ +│ ├── __init__.py # Exports all operators +│ ├── vrc.py # VRChat viseme generation operators +│ └── mmd.py # Japanese MMD conversion operators +├── panels/ +│ ├── __init__.py # Exports the panel +│ └── main_panel.py # UI panel and preview dropdown +└── utilities/ + ├── constants.py # MMD constants and auto-detect candidates + ├── functions.py # Mapping data and build_viseme_map + └── helpers.py # Preview state and utility functions +``` + +--- + +## Modules + +### `visemes.py` + +Add-on entry point. Contains `bl_info`, the `_CLASSES` list of all classes to register, `_MMD_PROPS` with the dynamic MMD property names, and the `register()` / `unregister()` functions that manage the add-on lifecycle in Blender. + +--- + +### `utilities/constants.py` + +| Name | Type | Description | +|------|------|-------------| +| `MMD_VISEME_COUNT` | `int` | Number of primary MMD visemes (あいうえお = 5) | +| `MMD_SEP_1` | `str` | Separator shape key name for visemes (`---MMD Visemes---`) | +| `MMD_SEP_2` | `str` | Separator shape key name for other expressions (`^MMD Visemes / Other v`) | +| `MMD_CANDIDATES` | `dict` | Candidate names for auto-detection of source shape keys (keys: `a`, `o`, `ch`, `blink`) | + +--- + +### `utilities/functions.py` + +| Name | Type | Description | +|------|------|-------------| +| `MMD_JP_MAPPING` | `list[tuple]` | List of 15 entries `(jp_name, suffix, en_label, candidates)` mapping English/VRChat shape keys to Japanese MMD names | +| `VRC_ORDER` | `list[str]` | Standard VRChat order for the 16 visemes (`vrc.v_sil` … `vrc.blink`) | +| `build_viseme_map(shape_a, shape_o, shape_ch)` | `function` | Builds an `OrderedDict` mapping each VRChat name (`vrc.v_*`) to a list of `(shape_key, weight)` pairs computed from the three source shape keys | + +#### `build_viseme_map` weights + +| VRChat Viseme | Contributions | +|---------------|--------------| +| `vrc.v_aa` | A × 0.9998 | +| `vrc.v_ch` | CH × 0.9996 | +| `vrc.v_dd` | A × 0.3 + CH × 0.7 | +| `vrc.v_e` | A × 0.5 + CH × 0.2 | +| `vrc.v_ff` | A × 0.2 + CH × 0.4 | +| `vrc.v_ih` | CH × 0.7 + O × 0.3 | +| `vrc.v_kk` | A × 0.7 + CH × 0.4 | +| `vrc.v_nn` | A × 0.2 + CH × 0.7 | +| `vrc.v_oh` | A × 0.2 + O × 0.8 | +| `vrc.v_ou` | O × 0.9994 | +| `vrc.v_pp` | A × 0.0004 + O × 0.0004 | +| `vrc.v_rr` | CH × 0.5 + O × 0.3 | +| `vrc.v_sil`| A × 0.0002 + CH × 0.0002 | +| `vrc.v_ss` | CH × 0.8 | +| `vrc.v_th` | A × 0.4 + O × 0.15 | + +--- + +### `utilities/helpers.py` + +| Name | Type | Description | +|------|------|-------------| +| `_PreviewState` | `class` | Holds `active` (bool) and `saved_values` (dict) representing the current preview state | +| `_ps` | `_PreviewState` | Global instance shared across all modules | +| `guess_shape(mesh_obj, candidates)` | `function` | Iterates over `candidates` and returns the first name found among the mesh's shape keys, or `''` | +| `_save_shapekey_values(mesh_obj)` | `function` | Saves the current values of all shape keys into `_ps.saved_values` | +| `_restore_shapekey_values(mesh_obj)` | `function` | Restores shape key values from `_ps.saved_values` | +| `_reset_all_shapekeys(mesh_obj)` | `function` | Sets the value of every shape key on the mesh to `0.0` | +| `_apply_mix(mesh_obj, mix, intensity)` | `function` | Resets all shape keys then applies the `mix` list of `(name, weight)` pairs scaled by `intensity` | + +--- + +### `operators/vrc.py` + +Operators for VRChat viseme generation. + +| Class | `bl_idname` | Description | +|-------|-------------|-------------| +| `VRCVIS_OT_AutoDetect` | `vrc_viseme.auto_detect` | Searches for common MMD shape key names and fills in the A/O/CH/Blink fields | +| `VRCVIS_OT_Preview` | `vrc_viseme.preview` | Toggles a live viewport preview of the selected viseme | +| `VRCVIS_OT_UpdatePreview` | `vrc_viseme.update_preview` | Refreshes the preview when the selected viseme or intensity changes | +| `VRCVIS_OT_Generate` | `vrc_viseme.generate` | Generates the 15 `vrc.v_*` shape keys plus `vrc.blink` by mixing the three source shape keys | +| `VRCVIS_OT_RemoveAll` | `vrc_viseme.remove_all` | Deletes all shape keys whose name starts with `vrc.` | +| `VRCVIS_OT_Reorder` | `vrc_viseme.reorder` | Reorders `vrc.*` shape keys according to `VRC_ORDER`, placing them immediately after `Basis` | + +--- + +### `operators/mmd.py` + +Operators for EN/VRChat → Japanese MMD conversion. + +| Class | `bl_idname` | Description | +|-------|-------------|-------------| +| `VRCVIS_OT_MMDAutoDetect` | `vrc_viseme.mmd_auto_detect` | Automatically detects English/VRChat names and fills in the MMD fields | +| `VRCVIS_OT_MMDConvert` | `vrc_viseme.mmd_convert` | Duplicates shape keys with Japanese MMD names and inserts the separators | +| `VRCVIS_OT_MMDRemove` | `vrc_viseme.mmd_remove` | Removes all Japanese MMD shape keys and separators | + +--- + +### `panels/main_panel.py` + +| Name | Type | Description | +|------|------|-------------| +| `viseme_enum_items(self, context)` | `function` | Callback for the preview `EnumProperty`; returns all VRChat visemes plus `vrc.blink` | +| `VRCVIS_PT_Panel` | `bpy.types.Panel` | Main panel in the Blender Sidebar; `bl_idname = VIEW3D_PT_visemes_converter` | + +--- + +## Scene Properties (`bpy.types.Scene`) + +All properties are registered by `register()` in `visemes.py` with the `vrcvis_` prefix. + +| Property | Type | Description | +|----------|------|-------------| +| `vrcvis_mesh` | `StringProperty` | Name of the target mesh | +| `vrcvis_mouth_a` | `StringProperty` | Open-mouth shape key (あ / A) | +| `vrcvis_mouth_o` | `StringProperty` | Round-mouth shape key (お / O) | +| `vrcvis_mouth_ch` | `StringProperty` | Narrow-mouth shape key (い / CH) | +| `vrcvis_blink` | `StringProperty` | Eyes-closed shape key (まばたき / Blink) | +| `vrcvis_intensity` | `FloatProperty` | Global weight multiplier (0.0 – 2.0, default 1.0) | +| `vrcvis_overwrite` | `BoolProperty` | If `True`, regenerates already-existing shape keys | +| `vrcvis_preview_active` | `BoolProperty` | Internal preview state (True = preview on) | +| `vrcvis_preview_viseme` | `EnumProperty` | Viseme selected for preview | +| `vrcvis_mmd_{suffix}` | `StringProperty` | One property per entry in `MMD_JP_MAPPING` (suffix from column 2) | + +--- + +## Typical Workflow + +``` +1. Select a mesh with shape keys in the Mesh field +2. Press "Auto-detect (MMD → VRC)" to detect A / O / CH / Blink + or select them manually +3. (Optional) Use "Preview Viseme" to check the result before generating +4. Press "Generate VRChat Visemes" → creates the vrc.v_* + vrc.blink shape keys +5. (Optional) Press "Reorder VRC Keys" to apply the standard VRChat order +6. (Optional) In the MMD section: + a. "Auto-detect (EN → JP)" to map English names to Japanese + b. "Convert to MMD" to create Japanese shape keys with separators +``` diff --git a/operators/__init__.py b/operators/__init__.py new file mode 100644 index 0000000..d0884bb --- /dev/null +++ b/operators/__init__.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from .vrc import ( + VRCVIS_OT_AutoDetect, + VRCVIS_OT_Preview, + VRCVIS_OT_UpdatePreview, + VRCVIS_OT_Generate, + VRCVIS_OT_RemoveAll, + VRCVIS_OT_Reorder, +) +from .mmd import ( + VRCVIS_OT_MMDAutoDetect, + VRCVIS_OT_MMDConvert, + VRCVIS_OT_MMDRemove, +) diff --git a/operators/mmd.py b/operators/mmd.py new file mode 100644 index 0000000..3e66301 --- /dev/null +++ b/operators/mmd.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import bpy +from utilities.constants import MMD_VISEME_COUNT, MMD_SEP_1, MMD_SEP_2 +from utilities.functions import MMD_JP_MAPPING +from utilities.helpers import _ps, guess_shape, _restore_shapekey_values + + +class VRCVIS_OT_MMDAutoDetect(bpy.types.Operator): + bl_idname = 'vrc_viseme.mmd_auto_detect' + bl_label = 'Auto-rileva (EN → JP)' + bl_description = ( + 'Cerca automaticamente i nomi inglesi/VRChat comuni e precompila ' + 'i campi di destinazione MMD giapponese' + ) + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + scene = context.scene + mesh_name = scene.vrcvis_mesh + if not mesh_name or mesh_name not in bpy.data.objects: + self.report({'ERROR'}, 'Nessuna mesh selezionata') + return {'CANCELLED'} + obj = bpy.data.objects[mesh_name] + found = 0 + for _jp, suffix, _en, candidates in MMD_JP_MAPPING: + result = guess_shape(obj, candidates) + if result: + setattr(scene, f'vrcvis_mmd_{suffix}', result) + found += 1 + if found: + self.report({'INFO'}, f'Rilevate {found} shape key') + else: + self.report({'WARNING'}, 'Nessun nome riconosciuto trovato') + return {'FINISHED'} + + +class VRCVIS_OT_MMDConvert(bpy.types.Operator): + bl_idname = 'vrc_viseme.mmd_convert' + bl_label = 'Converti in MMD' + bl_description = ( + 'Duplica le shape key selezionate con i nomi MMD giapponesi, ' + 'aggiunge i separatori e posiziona tutto in fondo alla lista' + ) + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + scene = context.scene + mesh_name = scene.vrcvis_mesh + if not mesh_name or mesh_name not in bpy.data.objects: + self.report({'ERROR'}, 'Seleziona una mesh valida') + return {'CANCELLED'} + obj = bpy.data.objects[mesh_name] + if not obj.data.shape_keys: + self.report({'ERROR'}, 'La mesh non ha shape key') + return {'CANCELLED'} + + if _ps.active: + _restore_shapekey_values(obj) + _ps.active = False + scene.vrcvis_preview_active = False + + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + obj.show_only_shape_key = False + + keys = obj.data.shape_keys.key_blocks + overwrite = scene.vrcvis_overwrite + created = [] + skipped = [] + + def _add_empty(name): + if name in keys: + if overwrite: + obj.active_shape_key_index = keys.find(name) + bpy.ops.object.shape_key_remove() + else: + skipped.append(name) + return + bpy.ops.object.shape_key_clear() + obj.shape_key_add(name=name, from_mix=True) + created.append(name) + + def _add_copy(src_name, dst_name): + if not src_name or src_name not in keys: + return + if dst_name in keys: + if overwrite: + obj.active_shape_key_index = keys.find(dst_name) + bpy.ops.object.shape_key_remove() + else: + skipped.append(dst_name) + return + bpy.ops.object.shape_key_clear() + keys[src_name].slider_max = max(keys[src_name].slider_max, 1.0) + keys[src_name].value = 1.0 + obj.shape_key_add(name=dst_name, from_mix=True) + bpy.ops.object.shape_key_clear() + created.append(dst_name) + + _add_empty(MMD_SEP_1) + for jp_name, suffix, _en, _ in MMD_JP_MAPPING[:MMD_VISEME_COUNT]: + src = getattr(scene, f'vrcvis_mmd_{suffix}') + _add_copy(src, jp_name) + + _add_empty(MMD_SEP_2) + for jp_name, suffix, _en, _ in MMD_JP_MAPPING[MMD_VISEME_COUNT:]: + src = getattr(scene, f'vrcvis_mmd_{suffix}') + _add_copy(src, jp_name) + + obj.active_shape_key_index = 0 + + msg_parts = [] + if created: + msg_parts.append(f'Create: {len(created)}') + if skipped: + msg_parts.append(f'Saltate: {len(skipped)}') + self.report({'INFO'}, ' | '.join(msg_parts) if msg_parts else 'Nessuna shape key creata') + return {'FINISHED'} + + +class VRCVIS_OT_MMDRemove(bpy.types.Operator): + bl_idname = 'vrc_viseme.mmd_remove' + bl_label = 'Rimuovi MMD' + bl_description = ( + 'Rimuove le shape key MMD giapponesi e i separatori ' + '(---MMD Visemes--- e ^MMD Visemes / Other v)' + ) + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + scene = context.scene + mesh_name = scene.vrcvis_mesh + if not mesh_name or mesh_name not in bpy.data.objects: + self.report({'ERROR'}, 'Nessuna mesh selezionata') + return {'CANCELLED'} + obj = bpy.data.objects[mesh_name] + if not obj.data.shape_keys: + self.report({'INFO'}, 'Nessuna shape key presente') + return {'FINISHED'} + + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + mmd_names = {jp for jp, _, _en, _ in MMD_JP_MAPPING} | {MMD_SEP_1, MMD_SEP_2} + to_remove = [k.name for k in obj.data.shape_keys.key_blocks if k.name in mmd_names] + count = 0 + for name in to_remove: + keys = obj.data.shape_keys.key_blocks + if name in keys: + obj.active_shape_key_index = keys.find(name) + bpy.ops.object.shape_key_remove() + count += 1 + self.report({'INFO'}, f'Rimosse {count} shape key MMD') + return {'FINISHED'} diff --git a/operators/vrc.py b/operators/vrc.py new file mode 100644 index 0000000..eac6221 --- /dev/null +++ b/operators/vrc.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import bpy +from utilities.constants import MMD_CANDIDATES +from utilities.functions import build_viseme_map, VRC_ORDER +from utilities.helpers import _ps, guess_shape, _save_shapekey_values, _restore_shapekey_values, _apply_mix + + +class VRCVIS_OT_AutoDetect(bpy.types.Operator): + bl_idname = 'vrc_viseme.auto_detect' + bl_label = 'Auto-rileva (MMD → VRC)' + bl_description = ( + 'Cerca automaticamente tra i nomi MMD comuni (あ/お/い/まばたき) ' + 'e precompila i campi A/O/CH/Blink' + ) + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + scene = context.scene + mesh_name = scene.vrcvis_mesh + if not mesh_name or mesh_name not in bpy.data.objects: + self.report({'ERROR'}, 'Nessuna mesh selezionata') + return {'CANCELLED'} + obj = bpy.data.objects[mesh_name] + found_a = guess_shape(obj, MMD_CANDIDATES['a']) + found_o = guess_shape(obj, MMD_CANDIDATES['o']) + found_ch = guess_shape(obj, MMD_CANDIDATES['ch']) + found_blink = guess_shape(obj, MMD_CANDIDATES['blink']) + if found_a: scene.vrcvis_mouth_a = found_a + if found_o: scene.vrcvis_mouth_o = found_o + if found_ch: scene.vrcvis_mouth_ch = found_ch + if found_blink: scene.vrcvis_blink = found_blink + detected = ', '.join(filter(None, [found_a, found_o, found_ch, found_blink])) + if detected: + self.report({'INFO'}, f'Rilevate: {detected}') + else: + self.report({'WARNING'}, 'Nessun nome MMD riconosciuto trovato') + return {'FINISHED'} + + +class VRCVIS_OT_Preview(bpy.types.Operator): + bl_idname = 'vrc_viseme.preview' + bl_label = 'Preview Viseme' + bl_description = 'Attiva/disattiva anteprima viseme in viewport' + bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} + + def execute(self, context): + scene = context.scene + mesh_name = scene.vrcvis_mesh + if not mesh_name or mesh_name not in bpy.data.objects: + self.report({'ERROR'}, 'Mesh non valida') + return {'CANCELLED'} + obj = bpy.data.objects[mesh_name] + if not obj.data.shape_keys: + self.report({'ERROR'}, 'La mesh non ha shape key') + return {'CANCELLED'} + + if _ps.active: + _restore_shapekey_values(obj) + _ps.active = False + scene.vrcvis_preview_active = False + else: + _save_shapekey_values(obj) + _ps.active = True + scene.vrcvis_preview_active = True + sel = scene.vrcvis_preview_viseme + if sel == 'vrc.blink': + blink_src = scene.vrcvis_blink + if blink_src: + _apply_mix(obj, [(blink_src, 1.0)], scene.vrcvis_intensity) + else: + vmap = build_viseme_map( + scene.vrcvis_mouth_a, scene.vrcvis_mouth_o, scene.vrcvis_mouth_ch) + if sel in vmap: + _apply_mix(obj, vmap[sel], scene.vrcvis_intensity) + return {'FINISHED'} + + +class VRCVIS_OT_UpdatePreview(bpy.types.Operator): + bl_idname = 'vrc_viseme.update_preview' + bl_label = 'Aggiorna Preview' + bl_options = {'INTERNAL'} + + def execute(self, context): + scene = context.scene + if not _ps.active: + return {'FINISHED'} + mesh_name = scene.vrcvis_mesh + if not mesh_name or mesh_name not in bpy.data.objects: + return {'CANCELLED'} + obj = bpy.data.objects[mesh_name] + sel = scene.vrcvis_preview_viseme + if sel == 'vrc.blink': + blink_src = scene.vrcvis_blink + if blink_src: + _apply_mix(obj, [(blink_src, 1.0)], scene.vrcvis_intensity) + else: + vmap = build_viseme_map( + scene.vrcvis_mouth_a, scene.vrcvis_mouth_o, scene.vrcvis_mouth_ch) + if sel in vmap: + _apply_mix(obj, vmap[sel], scene.vrcvis_intensity) + return {'FINISHED'} + + +class VRCVIS_OT_Generate(bpy.types.Operator): + bl_idname = 'vrc_viseme.generate' + bl_label = 'Genera Visemes VRChat' + bl_description = ( + 'Crea le 15 shape key vrc.v_* mescolando A/O/CH con i pesi standard VRChat, ' + 'più vrc.blink se il sorgente blink è impostato' + ) + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + scene = context.scene + mesh_name = scene.vrcvis_mesh + if not mesh_name or mesh_name not in bpy.data.objects: + self.report({'ERROR'}, 'Seleziona una mesh valida') + return {'CANCELLED'} + obj = bpy.data.objects[mesh_name] + if not obj.data.shape_keys: + self.report({'ERROR'}, 'La mesh non ha shape key') + return {'CANCELLED'} + + shape_a = scene.vrcvis_mouth_a + shape_o = scene.vrcvis_mouth_o + shape_ch = scene.vrcvis_mouth_ch + shape_blink = scene.vrcvis_blink + intensity = scene.vrcvis_intensity + + keys = obj.data.shape_keys.key_blocks + for name, label in [(shape_a, 'A'), (shape_o, 'O'), (shape_ch, 'CH')]: + if not name or name not in keys: + self.report({'ERROR'}, f'Shape key {label} ("{name}") non trovata') + return {'CANCELLED'} + if len({shape_a, shape_o, shape_ch}) < 3: + self.report({'ERROR'}, 'Le tre shape key sorgente devono essere diverse') + return {'CANCELLED'} + + if _ps.active: + _restore_shapekey_values(obj) + _ps.active = False + scene.vrcvis_preview_active = False + + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + obj.show_only_shape_key = False + + vmap = build_viseme_map(shape_a, shape_o, shape_ch) + if shape_blink and shape_blink in keys: + vmap['vrc.blink'] = [(shape_blink, 1.0)] + + wm = bpy.context.window_manager + wm.progress_begin(0, len(vmap)) + created = [] + skipped = [] + + for idx, (vrc_name, mix) in enumerate(vmap.items()): + wm.progress_update(idx) + if vrc_name in keys: + if scene.vrcvis_overwrite: + obj.active_shape_key_index = keys.find(vrc_name) + bpy.ops.object.shape_key_remove() + else: + skipped.append(vrc_name) + continue + + bpy.ops.object.shape_key_clear() + for shape_name, weight in mix: + if shape_name in keys: + keys[shape_name].slider_max = 10.0 + keys[shape_name].value = weight * intensity + + obj.shape_key_add(name=vrc_name, from_mix=True) + + bpy.ops.object.shape_key_clear() + for shape_name, _ in mix: + if shape_name in keys: + keys[shape_name].slider_max = 1.0 + + created.append(vrc_name) + + obj.active_shape_key_index = 0 + wm.progress_end() + + msg_parts = [] + if created: + msg_parts.append(f'Create: {len(created)}') + if skipped: + msg_parts.append(f'Saltate (già esistenti): {len(skipped)}') + self.report({'INFO'}, ' | '.join(msg_parts) if msg_parts else 'Nessuna shape key generata') + return {'FINISHED'} + + +class VRCVIS_OT_RemoveAll(bpy.types.Operator): + bl_idname = 'vrc_viseme.remove_all' + bl_label = 'Rimuovi vrc.*' + bl_description = 'Cancella tutte le shape key il cui nome inizia con "vrc." (visemi + blink)' + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + scene = context.scene + mesh_name = scene.vrcvis_mesh + if not mesh_name or mesh_name not in bpy.data.objects: + self.report({'ERROR'}, 'Nessuna mesh selezionata') + return {'CANCELLED'} + obj = bpy.data.objects[mesh_name] + if not obj.data.shape_keys: + self.report({'INFO'}, 'Nessuna shape key presente') + return {'FINISHED'} + + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + to_remove = [k.name for k in obj.data.shape_keys.key_blocks if k.name.startswith('vrc.')] + count = 0 + for name in to_remove: + keys = obj.data.shape_keys.key_blocks + if name in keys: + obj.active_shape_key_index = keys.find(name) + bpy.ops.object.shape_key_remove() + count += 1 + self.report({'INFO'}, f'Rimosse {count} shape key vrc.*') + return {'FINISHED'} + + +class VRCVIS_OT_Reorder(bpy.types.Operator): + bl_idname = 'vrc_viseme.reorder' + bl_label = 'Riordina VRC Keys' + bl_description = "Riordina le shape key vrc.* nell'ordine standard VRChat subito sotto Basis" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + scene = context.scene + mesh_name = scene.vrcvis_mesh + if not mesh_name or mesh_name not in bpy.data.objects: + self.report({'ERROR'}, 'Nessuna mesh selezionata') + return {'CANCELLED'} + obj = bpy.data.objects[mesh_name] + if not obj.data.shape_keys: + self.report({'INFO'}, 'Nessuna shape key presente') + return {'FINISHED'} + + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + keys = obj.data.shape_keys.key_blocks + target_pos = 1 + moved = 0 + + for name in VRC_ORDER: + if name not in keys: + continue + current_idx = keys.find(name) + steps = current_idx - target_pos + if steps > 0: + obj.active_shape_key_index = current_idx + for _ in range(steps): + bpy.ops.object.shape_key_move(type='UP') + target_pos += 1 + moved += 1 + + obj.active_shape_key_index = 0 + self.report({'INFO'}, f'Riordinate {moved} shape key') + return {'FINISHED'} diff --git a/panels/__init__.py b/panels/__init__.py new file mode 100644 index 0000000..9a29fef --- /dev/null +++ b/panels/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from .main_panel import VRCVIS_PT_Panel, viseme_enum_items diff --git a/panels/main_panel.py b/panels/main_panel.py new file mode 100644 index 0000000..9beae14 --- /dev/null +++ b/panels/main_panel.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import bpy +from utilities.constants import MMD_VISEME_COUNT +from utilities.functions import MMD_JP_MAPPING, VRC_ORDER, build_viseme_map + + +def viseme_enum_items(self, context): + vmap = build_viseme_map('_', '_', '_') + items = [(k, k, '') for k in vmap.keys()] + items.append(('vrc.blink', 'vrc.blink', '')) + return items + + +class VRCVIS_PT_Panel(bpy.types.Panel): + bl_idname = 'VIEW3D_PT_visemes_converter' + bl_label = 'Visemes Converter' + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = 'Visemes Tool' + + def draw(self, context): + layout = self.layout + scene = context.scene + + box = layout.box() + box.label(text='Mesh', icon='MESH_DATA') + mesh_objects = [o for o in bpy.data.objects if o.type == 'MESH' and o.data.shape_keys] + if not mesh_objects: + box.label(text='Nessuna mesh con shape key', icon='ERROR') + return + box.prop_search(scene, 'vrcvis_mesh', bpy.data, 'objects', text='') + + mesh_name = scene.vrcvis_mesh + if not mesh_name or mesh_name not in bpy.data.objects: + layout.label(text='Seleziona una mesh', icon='INFO') + return + obj = bpy.data.objects[mesh_name] + if obj.type != 'MESH' or not obj.data.shape_keys: + layout.label(text='Mesh senza shape key', icon='ERROR') + return + + layout.separator(factor=0.3) + box2 = layout.box() + box2.label(text='VRChat Visemes (MMD → vrc.*)', icon='SHAPEKEY_DATA') + + row = box2.row() + row.operator('vrc_viseme.auto_detect', icon='VIEWZOOM', text='Auto-rileva (MMD → VRC)') + box2.separator(factor=0.5) + + row = box2.row(align=True) + row.label(text='A (あ):') + row.prop_search(scene, 'vrcvis_mouth_a', obj.data.shape_keys, 'key_blocks', text='') + + row = box2.row(align=True) + row.label(text='O (お):') + row.prop_search(scene, 'vrcvis_mouth_o', obj.data.shape_keys, 'key_blocks', text='') + + row = box2.row(align=True) + row.label(text='CH (い):') + row.prop_search(scene, 'vrcvis_mouth_ch', obj.data.shape_keys, 'key_blocks', text='') + + row = box2.row(align=True) + row.label(text='Blink:') + row.prop_search(scene, 'vrcvis_blink', obj.data.shape_keys, 'key_blocks', text='') + + box2.separator(factor=0.5) + box2.prop(scene, 'vrcvis_intensity', text='Intensità') + box2.prop(scene, 'vrcvis_overwrite', text='Sovrascrivi esistenti') + + box2.separator(factor=0.5) + row = box2.row(align=True) + row.scale_y = 1.2 + if scene.vrcvis_preview_active: + row.operator('vrc_viseme.preview', text='Stop Preview', icon='PAUSE') + row2 = box2.row(align=True) + row2.prop(scene, 'vrcvis_preview_viseme', text='') + row2.operator('vrc_viseme.update_preview', text='', icon='FILE_REFRESH') + else: + row.operator('vrc_viseme.preview', text='Preview Viseme', icon='PLAY') + + box2.separator(factor=0.5) + row = box2.row(align=True) + row.scale_y = 1.4 + row.operator('vrc_viseme.generate', icon='TRIA_RIGHT') + + row2 = box2.row(align=True) + row2.operator('vrc_viseme.reorder', icon='SORTSIZE') + row2.operator('vrc_viseme.remove_all', icon='X') + + if obj.data.shape_keys: + existing_vrc = [k.name for k in obj.data.shape_keys.key_blocks if k.name.startswith('vrc.')] + if existing_vrc: + total = len(VRC_ORDER) + box5 = box2.box() + box5.label(text=f'{len(existing_vrc)}/{total} vrc.* presenti', icon='CHECKMARK') + col = box5.column(align=True) + for name in VRC_ORDER: + r = col.row(align=True) + r.label(text=name, icon='CHECKBOX_HLT' if name in existing_vrc else 'CHECKBOX_DEHLT') + + layout.separator(factor=0.3) + box_mmd = layout.box() + box_mmd.label(text='Converti in MMD (EN/VRC → JP)', icon='FILE_REFRESH') + + row = box_mmd.row() + row.operator('vrc_viseme.mmd_auto_detect', icon='VIEWZOOM', text='Auto-rileva (EN → JP)') + + box_mmd.separator(factor=0.4) + box_mmd.label(text='--- MMD Visemes ---', icon='SHAPEKEY_DATA') + for _jp, suffix, en_label, _ in MMD_JP_MAPPING[:MMD_VISEME_COUNT]: + row = box_mmd.row(align=True) + row.label(text=en_label + ':') + row.prop_search(scene, f'vrcvis_mmd_{suffix}', + obj.data.shape_keys, 'key_blocks', text='') + + box_mmd.separator(factor=0.4) + box_mmd.label(text='--- Altre forme ---', icon='SHAPEKEY_DATA') + for _jp, suffix, en_label, _ in MMD_JP_MAPPING[MMD_VISEME_COUNT:]: + row = box_mmd.row(align=True) + row.label(text=en_label + ':') + row.prop_search(scene, f'vrcvis_mmd_{suffix}', + obj.data.shape_keys, 'key_blocks', text='') + + box_mmd.separator(factor=0.5) + row = box_mmd.row(align=True) + row.scale_y = 1.4 + row.operator('vrc_viseme.mmd_convert', icon='DUPLICATE') + row.operator('vrc_viseme.mmd_remove', icon='X') diff --git a/utilities/constants.py b/utilities/constants.py new file mode 100644 index 0000000..11a11a5 --- /dev/null +++ b/utilities/constants.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +MMD_VISEME_COUNT = 5 +MMD_SEP_1 = '---MMD Visemes---' +MMD_SEP_2 = '^MMD Visemes / Other v' + +MMD_CANDIDATES = { + 'a': ['あ', 'a', 'A', 'mouth_a', 'Mouth_A', 'あ口', 'v_a'], + 'o': ['お', 'o', 'O', 'mouth_o', 'Mouth_O', 'お口', 'v_o'], + 'ch': ['い', 'ch', 'CH', 'mouth_i', 'Mouth_I', 'い口', 'v_ch', 'i', 'I'], + 'blink': [ + 'まばたき', '目閉じ', '眼閉じ', 'ウィンク2', + 'Blink', 'blink', 'blink_all', + 'eye_blink', 'Eye_Blink', 'eyes_blink', 'Eyes_Blink', + 'eye_close', 'Eye_Close', 'eyes_close', 'Eyes_Close', + ], +} diff --git a/utilities/functions.py b/utilities/functions.py new file mode 100644 index 0000000..8899c17 --- /dev/null +++ b/utilities/functions.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from collections import OrderedDict + +MMD_JP_MAPPING = [ + ('あ', 'a', 'A (あ)', ['ah', 'a', 'A', 'vrc.v_aa', 'あ']), + ('い', 'i', 'I / CH (い)', ['ch', 'i', 'I', 'CH', 'vrc.v_ch', 'vrc.v_ih', 'い']), + ('う', 'u', 'U (う)', ['u', 'U', 'vrc.v_ou', 'う']), + ('え', 'e', 'E (え)', ['e', 'E', 'vrc.v_e', 'え']), + ('お', 'o', 'O (お)', ['oh', 'o', 'O', 'vrc.v_oh', 'お']), + ('笑い', 'warau', 'Blink Happy (笑い)', ['blink_happy', 'BlinkHappy', 'blink happy', 'smile', '笑い']), + ('はう', 'hau', 'Close >< (はう)', ['close><', 'hau', 'はう']), + ('ウィンク', 'wink', 'Wink (ウィンク)', ['wink', 'Wink', 'ウィンク']), + ('ウィンク右', 'wink_r', 'Wink Right (ウィンク右)', ['wink_right', 'WinkRight', 'wink right', 'ウィンク右']), + ('ウィンク2', 'wink2', 'Wink 2 (ウィンク2)', ['wink_2', 'wink2', 'Wink2', 'wink 2', 'ウィンク2']), + ('ウィンク2右','wink2_r', 'Wink 2 Right (ウィンク2右)',['wink_2_right', 'Wink2Right', 'wink 2 right', 'ウィンク2右']), + ('にこり', 'nikori', 'Cheerful (にこり)', ['cheerful', 'Cheerful', 'nikori', 'にこり']), + ('真面目', 'majime', 'Serious (真面目)', ['serious', 'Serious', 'stare', 'calm', '真面目']), + ('怒り', 'ikari', 'Anger (怒り)', ['anger', 'angry', 'Anger', 'Angry', '怒り']), + ('困る', 'komaru', 'Sadness (困る)', ['sadness', 'sad', 'Sadness', 'Sad', 'komaru', '困る']), +] + +VRC_ORDER = [ + 'vrc.v_sil', + 'vrc.v_aa', + 'vrc.v_ch', + 'vrc.v_dd', + 'vrc.v_e', + 'vrc.v_ff', + 'vrc.v_ih', + 'vrc.v_kk', + 'vrc.v_nn', + 'vrc.v_oh', + 'vrc.v_ou', + 'vrc.v_pp', + 'vrc.v_rr', + 'vrc.v_ss', + 'vrc.v_th', + 'vrc.blink', +] + + +def build_viseme_map(shape_a, shape_o, shape_ch): + m = OrderedDict() + m['vrc.v_aa'] = [(shape_a, 0.9998)] + m['vrc.v_ch'] = [(shape_ch, 0.9996)] + m['vrc.v_dd'] = [(shape_a, 0.3), (shape_ch, 0.7)] + m['vrc.v_e'] = [(shape_a, 0.5), (shape_ch, 0.2)] + m['vrc.v_ff'] = [(shape_a, 0.2), (shape_ch, 0.4)] + m['vrc.v_ih'] = [(shape_ch, 0.7), (shape_o, 0.3)] + m['vrc.v_kk'] = [(shape_a, 0.7), (shape_ch, 0.4)] + m['vrc.v_nn'] = [(shape_a, 0.2), (shape_ch, 0.7)] + m['vrc.v_oh'] = [(shape_a, 0.2), (shape_o, 0.8)] + m['vrc.v_ou'] = [(shape_o, 0.9994)] + m['vrc.v_pp'] = [(shape_a, 0.0004), (shape_o, 0.0004)] + m['vrc.v_rr'] = [(shape_ch, 0.5), (shape_o, 0.3)] + m['vrc.v_sil'] = [(shape_a, 0.0002), (shape_ch, 0.0002)] + m['vrc.v_ss'] = [(shape_ch, 0.8)] + m['vrc.v_th'] = [(shape_a, 0.4), (shape_o, 0.15)] + return m diff --git a/utilities/helpers.py b/utilities/helpers.py new file mode 100644 index 0000000..43d641d --- /dev/null +++ b/utilities/helpers.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +class _PreviewState: + active = False + saved_values: dict = {} + + +_ps = _PreviewState() + + +def guess_shape(mesh_obj, candidates): + if not mesh_obj or not mesh_obj.data.shape_keys: + return '' + keys = [k.name for k in mesh_obj.data.shape_keys.key_blocks] + for c in candidates: + if c in keys: + return c + return '' + + +def _save_shapekey_values(mesh_obj): + _ps.saved_values = {k.name: k.value for k in mesh_obj.data.shape_keys.key_blocks} + + +def _restore_shapekey_values(mesh_obj): + for k in mesh_obj.data.shape_keys.key_blocks: + k.value = _ps.saved_values.get(k.name, 0.0) + + +def _reset_all_shapekeys(mesh_obj): + for k in mesh_obj.data.shape_keys.key_blocks: + k.value = 0.0 + + +def _apply_mix(mesh_obj, mix, intensity): + _reset_all_shapekeys(mesh_obj) + keys = mesh_obj.data.shape_keys.key_blocks + for shape_name, weight in mix: + if shape_name in keys: + keys[shape_name].value = weight * intensity diff --git a/visemes.py b/visemes.py new file mode 100644 index 0000000..14843af --- /dev/null +++ b/visemes.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Credits: +# Thanks to https://github.com/ShyWolf42/Copy-to-MMD-Visemes +# Thanks to https://github.com/teamneoneko/Cats-Blender-Plugin + + +bl_info = { + "name": "Visemes Converter Tool", + "author": "Custom", + "version": (2, 0, 0), + "blender": (3, 0, 0), + "location": "View3D > Sidebar > Visemes Tool", + "description": ( + "Generates VRChat visemes (vrc.v_* + vrc.blink) from MMD blendshapes, " + "and converts blendshapes from English/VRChat to Japanese MMD format" + ), + "category": "Rigging", +} + +import bpy +from operators import ( + VRCVIS_OT_AutoDetect, + VRCVIS_OT_Preview, + VRCVIS_OT_UpdatePreview, + VRCVIS_OT_Generate, + VRCVIS_OT_RemoveAll, + VRCVIS_OT_Reorder, + VRCVIS_OT_MMDAutoDetect, + VRCVIS_OT_MMDConvert, + VRCVIS_OT_MMDRemove, +) +from panels import VRCVIS_PT_Panel, viseme_enum_items +from utilities.functions import MMD_JP_MAPPING +from utilities.helpers import _ps, _restore_shapekey_values + +_CLASSES = [ + VRCVIS_OT_AutoDetect, + VRCVIS_OT_Preview, + VRCVIS_OT_UpdatePreview, + VRCVIS_OT_Generate, + VRCVIS_OT_RemoveAll, + VRCVIS_OT_Reorder, + VRCVIS_OT_MMDAutoDetect, + VRCVIS_OT_MMDConvert, + VRCVIS_OT_MMDRemove, + VRCVIS_PT_Panel, +] + +_MMD_PROPS = [f'vrcvis_mmd_{suffix}' for _, suffix, _en, _ in MMD_JP_MAPPING] + + +def register(): + for cls in _CLASSES: + bpy.utils.register_class(cls) + + bpy.types.Scene.vrcvis_mesh = bpy.props.StringProperty( + name='Mesh', description='Mesh su cui operare', default='') + bpy.types.Scene.vrcvis_mouth_a = bpy.props.StringProperty( + name='Mouth A', description='Shape key bocca aperta (あ / A)', default='') + bpy.types.Scene.vrcvis_mouth_o = bpy.props.StringProperty( + name='Mouth O', description='Shape key bocca rotonda (お / O)', default='') + bpy.types.Scene.vrcvis_mouth_ch = bpy.props.StringProperty( + name='Mouth CH', description='Shape key bocca stretta (い / CH)', default='') + bpy.types.Scene.vrcvis_blink = bpy.props.StringProperty( + name='Blink', description='Shape key occhi chiusi (まばたき / Blink)', default='') + bpy.types.Scene.vrcvis_intensity = bpy.props.FloatProperty( + name='Intensità', description='Moltiplicatore globale dei pesi', + default=1.0, min=0.0, max=2.0, step=1) + bpy.types.Scene.vrcvis_overwrite = bpy.props.BoolProperty( + name='Sovrascrivi esistenti', + description='Rigenera le shape key già presenti', + default=True) + bpy.types.Scene.vrcvis_preview_active = bpy.props.BoolProperty( + name='Preview attiva', default=False) + bpy.types.Scene.vrcvis_preview_viseme = bpy.props.EnumProperty( + name='Viseme', description='Viseme da mostrare in anteprima', + items=viseme_enum_items) + + for jp_name, suffix, _en, _ in MMD_JP_MAPPING: + setattr(bpy.types.Scene, f'vrcvis_mmd_{suffix}', + bpy.props.StringProperty( + name=jp_name, + description=f'Shape key sorgente per {jp_name}', + default='')) + + +def unregister(): + scene = bpy.context.scene if bpy.context else None + if _ps.active and scene: + mesh_name = scene.get('vrcvis_mesh', '') + if mesh_name and mesh_name in bpy.data.objects: + _restore_shapekey_values(bpy.data.objects[mesh_name]) + _ps.active = False + + for cls in reversed(_CLASSES): + bpy.utils.unregister_class(cls) + + for prop in ( + 'vrcvis_mesh', 'vrcvis_mouth_a', 'vrcvis_mouth_o', 'vrcvis_mouth_ch', + 'vrcvis_blink', 'vrcvis_intensity', 'vrcvis_overwrite', + 'vrcvis_preview_active', 'vrcvis_preview_viseme', + *_MMD_PROPS, + ): + if hasattr(bpy.types.Scene, prop): + delattr(bpy.types.Scene, prop) + + +if __name__ == '__main__': + register()