Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"msgspec>=0.21.1",
"nxt-core>=0.18.1",
]

Expand Down
24 changes: 15 additions & 9 deletions src/yrig/skin/apply.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
from pathlib import Path
from typing import Any, Callable, Sequence
from typing import Any, Callable, Iterable, Sequence

from maya import cmds

Expand All @@ -13,6 +13,13 @@
log = logging.getLogger(__name__)


def _resolve_valid_influences(influences: Iterable[str]) -> tuple[list[str], set[str]]:
influence_names = [get_short_name(influence) for influence in influences]
valid_influences = [influence for influence in influence_names if cmds.objExists(influence)]
missing_influences = set(influence_names) - set(valid_influences)
return valid_influences, missing_influences


def skin_and_apply_weights(filepath: Path, geometry: str) -> str:
"""
Skin geometry (any type) using influences from a ``.yskin`` file and apply weights.
Expand All @@ -21,8 +28,7 @@ def skin_and_apply_weights(filepath: Path, geometry: str) -> str:
"""
skin_weight_data = skin_weight_data_from_file(filepath)
influence_names = skin_weight_data.influences
valid_influences = [j for j in influence_names if cmds.objExists(j)]
missing_influences = set(influence_names) - set(valid_influences)
valid_influences, missing_influences = _resolve_valid_influences(influence_names)
if missing_influences:
log.warning(
f"[{geometry}] Missing {len(missing_influences)} influence(s) that were defined in its skin file : {sorted(missing_influences)}"
Expand All @@ -46,10 +52,7 @@ def skin_and_apply_ng_weights(filepath: Path, mesh: str) -> str:
if not filepath.exists():
raise FileNotFoundError(f"{filepath} doesn't exist")
influence_paths = get_influences_from_ng_skin_weights(filepath)
influence_names = [get_short_name(path) for path in influence_paths]
# Filter to joints that actually exist in scene
valid_influences = [j for j in influence_names if cmds.objExists(j)]
missing_influences = set(influence_names) - set(valid_influences)
valid_influences, missing_influences = _resolve_valid_influences(influence_paths)
if missing_influences:
log.warning(
f"[{mesh}] Missing {len(missing_influences)} influence(s) that were defined in its skin file : {sorted(missing_influences)}"
Expand Down Expand Up @@ -86,13 +89,16 @@ def skin_and_apply_weights_from_directory(
for i, geo in enumerate(geometry):
if skip_skinned_geometry and get_skin_clusters(geo):
continue
ng_skin_manifest_filepath: Path = directory / f"{geo}/manifest.json"
ng_skin_filepath: Path = directory / f"{geo}.json"
yskin_filepath: Path = directory / f"{geo}.yskin"
if ng_skin_filepath.exists():
if ng_skin_manifest_filepath.exists():
skin_and_apply_ng_weights(ng_skin_manifest_filepath, geo)
elif ng_skin_filepath.exists():
skin_and_apply_ng_weights(ng_skin_filepath, geo)
elif yskin_filepath.exists():
skin_and_apply_weights(yskin_filepath, geo)
else:
if fallback_skinning is not None:
fallback_skinning(geo)
progress.update_progress(i / total)
progress.update_progress((i + 1) / total)
199 changes: 155 additions & 44 deletions src/yrig/skin/ng.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import json
import logging
import tempfile
from functools import wraps
from pathlib import Path
from typing import TYPE_CHECKING, Callable, ParamSpec, TypeVar

import maya.cmds as cmds
import msgspec

from yrig.name import get_short_name, normalize_name
from yrig.util import confirm_overwrite

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -110,6 +114,46 @@ def get_or_create_ng_layer(skin_cluster: str, layer_name: str) -> ng.Layer:
return new_layer


def _load_ng_skin_data(filepath: Path) -> dict:
if filepath.name == "manifest.json":
return _combine_ng_skin_file_layers_data(filepath)
with open(filepath, "rb") as f:
return msgspec.json.decode(f.read())


def get_influences_from_ng_skin_weights(
filepath: Path,
) -> list[str]:
"""Return influence paths from an ngSkinTools2 JSON weights file.

Args:
filepath: Path to the weights file.
"""
if not filepath.exists():
raise RuntimeError(f"{filepath} doesn't exist, unable to load data.")
data = _load_ng_skin_data(filepath)
return [influence["path"] for influence in data["influences"]]


def _run_ng_import(filepath: Path, geometry: str) -> None:
config = ng.influenceMapping.InfluenceMappingConfig()
config.use_distance_matching = False
config.use_name_matching = True
ng.import_json(
target=geometry,
file=str(filepath),
vertex_transfer_mode=ng.transfer.VertexTransferMode.vertexId,
influences_mapping_config=config,
)


def _apply_ng_skin_from_manifest(manifest: Path, geometry: str) -> None:
with tempfile.NamedTemporaryFile(suffix=".json") as file:
temp_path = Path(file.name)
combine_ng_skin_file_by_layers(manifest, temp_path)
_run_ng_import(temp_path, geometry)


@require_ng_skin
def apply_ng_skin_weights(weights_file: Path, geometry: str) -> None:
"""Apply an ngSkinTools2 JSON weights file to the specified geometry.
Expand All @@ -118,7 +162,7 @@ def apply_ng_skin_weights(weights_file: Path, geometry: str) -> None:
transfer mode, so the topology of the target mesh must match the file.

Args:
weights_file: The JSON weights file to read.
weights_file: The JSON weights file to read (either a single file, or the manifest for multi-file).
geometry: The transform, shape, or skinCluster Node to apply to.
"""
config = ng.influenceMapping.InfluenceMappingConfig()
Expand All @@ -128,13 +172,10 @@ def apply_ng_skin_weights(weights_file: Path, geometry: str) -> None:
if not weights_file.exists():
raise RuntimeError(f"{weights_file} doesn't exist, unable to load weights.")

# Run the import
ng.import_json(
target=geometry,
file=str(weights_file),
vertex_transfer_mode=ng.transfer.VertexTransferMode.vertexId,
influences_mapping_config=config,
)
if weights_file.name == "manifest.json":
_apply_ng_skin_from_manifest(weights_file, geometry)
else:
_run_ng_import(weights_file, geometry)


@require_ng_skin
Expand All @@ -148,42 +189,9 @@ def write_ng_skin_weights(filepath: Path, geometry: str, force: bool = False) ->
force: If True, will automatically overwrite any existing file at the filepath specified.

"""

# If the file exists, only write it if force = True, or after asking for confirmation.
if filepath.exists():
if force:
pass
else:
confirm: str = cmds.confirmDialog(
title="File Overwrite",
message=f"{filepath} already exists and will be overwritten, are you sure you want to write the file?",
button=["Yes", "No"],
defaultButton="Yes",
cancelButton="No",
dismissString="No",
)
if confirm == "Yes":
pass
else:
return

if not confirm_overwrite(filepath):
return
ng.export_json(target=geometry, file=str(filepath))
return


def get_influences_from_ng_skin_weights(
filepath: Path,
) -> list[str]:
"""Return influence paths from an ngSkinTools2 JSON weights file.

Args:
filepath: Path to the weights file.
"""
if not filepath.exists():
raise RuntimeError(f"{filepath} doesn't exist, unable to load weights.")
with open(filepath) as file:
data: dict = json.loads(file.read())
return [influence["path"] for influence in data["influences"]]


@require_ng_skin
Expand All @@ -202,3 +210,106 @@ def cleanup_ng_data_nodes() -> None:
log.info(
f"Removed {len(ng_data_nodes)} ngst2SkinLayerData node(s) from the scene: {ng_data_nodes}"
)


def split_ng_skin_file_by_layers(
skin_file: Path, output_path: Path, layers_to_write: set[str] | None = None
) -> None:
with open(skin_file, mode="rb") as file:
data: dict = msgspec.json.decode(file.read())
mesh: dict[str, list] = data["mesh"]
influences: list[dict] = data["influences"]
layers: list[dict] = data["layers"]

manifest_data: dict = {"manifest": True, "layers": []}
layer_file_map: dict[Path, dict] = {}
layer_id_map: dict[int, dict] = {}
for layer in layers:
layer_id: int = layer["id"]
layer_id_map[layer_id] = layer
layer_name: str = layer["name"]

if layer["parentId"] is None:
layer_filepath = Path(f"{normalize_name(layer_name)}.nglayer")
manifest_data["layers"].append(
{"id": layer_id, "name": layer_name, "path": layer_filepath.as_posix()}
)
if layers_to_write is None:
layer_file_map[layer_filepath] = layer
elif layer["name"] in layers_to_write:
layer_file_map[layer_filepath] = layer

output_path.mkdir(parents=True, exist_ok=True)
with open(output_path / "manifest.json", mode="wb") as file:
encoded = msgspec.json.encode(manifest_data)
formatted = msgspec.json.format(encoded)
file.write(formatted)

output_path.mkdir(parents=True, exist_ok=True)
with open(output_path / "mesh_data.json", mode="wb") as file:
encoded = msgspec.json.encode({"mesh": mesh})
file.write(encoded)

for filepath, layer in layer_file_map.items():
file_layers = [layer]
layer_children: list[int] = layer["children"]
file_layers.extend(layer_id_map[layer_id] for layer_id in layer_children)
with open(output_path / filepath, mode="wb") as file:
encoded = msgspec.json.encode({"influences": influences, "layers": file_layers})
file.write(encoded)


def _combine_ng_skin_file_layers_data(manifest: Path) -> dict:
with open(manifest, mode="rb") as file:
manifest_data: dict = msgspec.json.decode(file.read())

with open(manifest.parent / "mesh_data.json", mode="rb") as file:
mesh_data: dict = msgspec.json.decode(file.read())

merged_influences: list[dict] = []
merged_layers: list[dict] = []

influence_name_to_index: dict[str, int] = {} # name -> new canonical ID
next_influence_id: int = 0

for layer_entry in manifest_data["layers"]:
layer_filepath = Path(layer_entry["path"])
with open(manifest.parent / layer_filepath, mode="rb") as file:
layer_data: dict = msgspec.json.decode(file.read())

local_influences: list[dict] = layer_data["influences"]
local_layers: list[dict] = layer_data["layers"]

# Build a remap from this file's influence index -> merged indices
local_index_to_merged: dict[int, int] = {}
for influence in local_influences:
name: str = get_short_name(influence["path"])
local_index: int = influence["index"]
if name not in influence_name_to_index:
influence_name_to_index[name] = next_influence_id
new_influence = influence.copy()
new_influence["index"] = next_influence_id
merged_influences.append(new_influence)
next_influence_id += 1
local_index_to_merged[local_index] = influence_name_to_index[name]

for layer in local_layers:
layer["influences"] = {
str(local_index_to_merged[int(k)]): v for k, v in layer["influences"].items()
}
merged_layers.append(layer)

combined: dict = {
"mesh": mesh_data["mesh"],
"influences": merged_influences,
"layers": merged_layers,
}

return combined


def combine_ng_skin_file_by_layers(manifest: Path, output_file: Path) -> None:
combined = _combine_ng_skin_file_layers_data(manifest)
with open(output_file, mode="wb") as file:
encoded = msgspec.json.encode(combined)
file.write(encoded)
Loading