diff --git a/cspell.json b/cspell.json index 72b5bb2..5dae8a5 100644 --- a/cspell.json +++ b/cspell.json @@ -18,6 +18,7 @@ "*.pyc", "*.pyd", "*.so", + "*.svg", ".vscode/*.json", "**/__pycache__/**", ".git", diff --git a/docs/pro-features/raw-editor.md b/docs/pro-features/raw-editor.md index e334e89..cf87a67 100644 --- a/docs/pro-features/raw-editor.md +++ b/docs/pro-features/raw-editor.md @@ -18,6 +18,7 @@ The list shows every raw control, plus a **Rest Pose** row at the top called `de - **Non-Zero** — hide-zero-value controls. (This can be useful to find which controls are activated.) - **Value Sort** — sort by value so you can see which controls have the highest values. - **Side** — a side filter (Left / Right / Center). Helpful if you only want to work on one side of the face. +- **Freeze** — the snowflake button locks the current list order and filtering in place so rows do not reshuffle while you scrub the raw control values. Select a control and click **Edit** to open an editing session for that expression's pose. diff --git a/docs/pro-features/shape-key-editor.md b/docs/pro-features/shape-key-editor.md index 14c2bbf..d190776 100644 --- a/docs/pro-features/shape-key-editor.md +++ b/docs/pro-features/shape-key-editor.md @@ -26,6 +26,8 @@ The list shows every shape key channel. Filter by: - **Non-Zero** — hide-zero-value shape keys. (This can be useful to find which shape keys are activated.) - **Value Sort** — sort by value so you can see which shape keys have the highest values. - **Side** — a side filter (Left / Right / Center). Helpful if you only want to work on one side of the face. +- **Deltas** — the ghost button is a filter to hide shape keys that do not actually contain vertex changes (a sculpted offset different from the basis). This so you can quickly spot which shape keys hold edits. This is handy when sculpting a custom character where most shape keys have been zeroed out from the beginning. +- **Freeze** — the snowflake button locks the current list order and filtering in place so rows do not reshuffle while you scrub shape key values. ## Editing with Dependencies diff --git a/docs/terminology.md b/docs/terminology.md index dafd061..ee734c5 100644 --- a/docs/terminology.md +++ b/docs/terminology.md @@ -30,9 +30,7 @@ Toggle each of these outputs on your [Rig Instance](#rig-instance) to see its in Under the hood, RigLogic runs each pose through a few stages. You don't need to know the math to use the addon, but understanding the vocabulary helps: -* **PSDs (Pose Space Deformations)** — corrective combinations. When several expressions are active at once, PSDs turn on additional "combined" expressions so overlapping poses blend correctly instead of stacking. This is why editing one shape can be affected by others (see the [Shape Key Editor](./pro-features/shape-key-editor.md)). -* **Linear outputs** — most joints and blend shapes are driven linearly by the active raw control values. -* **Conditionals** — a small share of outputs (mostly the wrinkle mask multipliers) ramp on across ranges of an input value. +* **PSDs (Pose Space Deformations)** — are expressions that represent complex poses and how they are corrected. They define how multiple different expressions should be combined together. This is why editing one shape can affect the final result of others (see the [Shape Key Editor](./pro-features/shape-key-editor.md)). ### LODs diff --git a/pyproject.toml b/pyproject.toml index 3a87bcf..dad5109 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ keywords = ["blender", "metahuman", "dna", "riglogic", "animation", "3d"] name = "character-dna-addon" readme = "README.md" requires-python = ">=3.11,<3.14" -version = "0.10.3" +version = "0.11.2" [project.optional-dependencies] dev = [ diff --git a/src/addons/character_dna/__init__.py b/src/addons/character_dna/__init__.py index a8168a1..8c69148 100644 --- a/src/addons/character_dna/__init__.py +++ b/src/addons/character_dna/__init__.py @@ -17,7 +17,7 @@ bl_info = { "name": "Character DNA", "author": "Poly Hammer", - "version": (0, 10, 3), + "version": (0, 11, 2), "blender": (4, 5, 0), "location": "File > Import > MetaHuman DNA", "description": ( diff --git a/src/addons/character_dna/blender_manifest.toml b/src/addons/character_dna/blender_manifest.toml index 2e4d790..479d9f3 100644 --- a/src/addons/character_dna/blender_manifest.toml +++ b/src/addons/character_dna/blender_manifest.toml @@ -11,7 +11,7 @@ schema_version = "1.0.0" id = "character_dna" name = "Character DNA" tagline = "Customize MetaHuman DNA files with RigLogic evaluation support" -version = "0.10.3" +version = "0.11.2" type = "add-on" # ============================================================================= diff --git a/src/addons/character_dna/constants.py b/src/addons/character_dna/constants.py index 1a1e9d3..e84b22b 100644 --- a/src/addons/character_dna/constants.py +++ b/src/addons/character_dna/constants.py @@ -33,9 +33,9 @@ class PanelOrder(IntEnum): CONVERTER = 50 MESH_EDITOR = 55 RAW_CONTROL_EDITOR = 60 - CORRECTIVES_VIEWER = 65 SHAPE_KEY_EDITOR = 70 RBF_EDITOR = 80 + BEHAVIOR_VIEWER = 85 BACKUP_MANAGER = 90 OUTPUT = 100 MIGRATE_LEGACY_DATA = 110 diff --git a/src/addons/character_dna/dna_io/misc.py b/src/addons/character_dna/dna_io/misc.py index 1081db8..a88b9e2 100644 --- a/src/addons/character_dna/dna_io/misc.py +++ b/src/addons/character_dna/dna_io/misc.py @@ -57,22 +57,33 @@ def get_dna_reader( stream = dna.FileStream.create( path=str(file_path), accessMode=dna.AccessMode_Read, openMode=mode, memRes=memory_resource ) + + # Explicitly enforce the coordinate frame our importer assumes (Maya Y-up: + # x=left, y=up, z=front) instead of trusting whatever system the incoming DNA + # was authored in. With the Transform policy the reader converts any source + # system to this frame at load time (a no-op when the data is already Maya + # Y-up), so the downstream manual +90deg X rotation and scale handling stay + # valid even for DNAs exported with a different coordinate system. Units are + # not touched here (the Configuration transform does not convert cm<->m or + # degrees<->radians); those are still adapted from getTranslationUnit / + # getRotationUnit by the importer. + coordinate_system = dna.CoordinateSystem() + coordinate_system.x = dna.Direction_left + coordinate_system.y = dna.Direction_up + coordinate_system.z = dna.Direction_front + + config = dna.Configuration() + config.layer = getattr(dna, f"DataLayer_{data_layer}") + config.unknownLayerPolicy = dna.UnknownLayerPolicy_Preserve + config.coordinateSystemTransformPolicy = dna.CoordinateSystemTransformPolicy_Transform + config.coordinateSystem = coordinate_system + if file_format.lower() == "json": - reader = dna.JSONStreamReader.create( - stream, - getattr(dna, f"DataLayer_{data_layer}"), - dna.UnknownLayerPolicy_Preserve, - 0, # Provide appropriate int value - None, # Assuming MemoryResource is None - ) + # The JSON reader has no Configuration overload, so it cannot enforce the + # coordinate system on load; JSON DNAs are expected to already be Maya Y-up. + reader = dna.JSONStreamReader.create(stream, memory_resource) elif file_format.lower() == "binary": - reader = dna.BinaryStreamReader.create( - stream, - getattr(dna, f"DataLayer_{data_layer}"), - dna.UnknownLayerPolicy_Preserve, - 0, # Provide appropriate int value - None, # Assuming MemoryResource is None - ) + reader = dna.BinaryStreamReader.create(stream, config, memory_resource) else: raise ValueError(f"Invalid file format '{file_format}'. Must be 'binary' or 'json'.") diff --git a/src/addons/character_dna/editors b/src/addons/character_dna/editors index 760cf20..26c7556 160000 --- a/src/addons/character_dna/editors +++ b/src/addons/character_dna/editors @@ -1 +1 @@ -Subproject commit 760cf2098ec90ee70ef7718d9bd2e247450b90f2 +Subproject commit 26c75565e67a85189d72dd2b9d83afe3c07ed4fd diff --git a/src/addons/character_dna/release_notes.md b/src/addons/character_dna/release_notes.md index 7c6743c..0953ec3 100644 --- a/src/addons/character_dna/release_notes.md +++ b/src/addons/character_dna/release_notes.md @@ -1,12 +1,17 @@ +## Major Changes + +* Initial implementation of the Behavior Viewer + ## Minor Changes -* Added Blender `5.2` support +* Added Freeze option to Raw Editor and Shape Key Editor list filter +* Added Ghost indicator for shape keys that do not contain deltas. Also added toggle to filter them from the view. +* Added option to turn off dependency chain isolation in Shape Key Editor ## Patch Changes -* Added experimental option to turn off batched evaluations in addon preferences -* Fixed center eye control baking bug [#308](https://github.com/poly-hammer/character-dna-addon/issues/308) -* Fixed bug with eye aim control when eyes follow head is false [#309](https://github.com/poly-hammer/character-dna-addon/issues/309) +* Fixed incomplete/opaque eye and saliva materials on Blender 4.x [#346](https://github.com/poly-hammer/character-dna-addon/issues/346) +* New Coordinate System policy is enforced on the DNA Reader to properly convert DNA's saved in other coordinate systems. ## Tests Passing On diff --git a/src/addons/character_dna/resources/poses/face/scan_reference/Show_Teeth/pose.json b/src/addons/character_dna/resources/poses/face/scan_reference/Show_Teeth/pose.json index 5ac0f8a..8dc8725 100644 --- a/src/addons/character_dna/resources/poses/face/scan_reference/Show_Teeth/pose.json +++ b/src/addons/character_dna/resources/poses/face/scan_reference/Show_Teeth/pose.json @@ -1411,23 +1411,6 @@ 1.0 ] }, - "CTRL_C_jaw": { - "location": [ - 1.8851503469363706e-08, - 0.0624239444732666, - 0.0 - ], - "rotation": [ - 0.0, - -0.0, - 0.0 - ], - "scale": [ - 1.0, - 1.0, - 1.0 - ] - }, "CTRL_C_jaw_fwdBack": { "location": [ 0.0, diff --git a/src/addons/character_dna/rig_instance.py b/src/addons/character_dna/rig_instance.py index 3843471..e8775ef 100644 --- a/src/addons/character_dna/rig_instance.py +++ b/src/addons/character_dna/rig_instance.py @@ -854,6 +854,10 @@ def sync_shape_key_list(self) -> None: self.head_shape_key_blocks # noqa: B018 shape_key_block_names = self.data.get(self.cache_key("head", "shape_key_block_names"), []) + # The has-deltas map is derived from the live block coords; drop it so it + # recomputes lazily against the freshly synced blocks. + self.data.pop(self.cache_key("head", "shape_key_has_deltas"), None) + shape_key_editor.shape_key_list.clear() for shape_key_block_name in shape_key_block_names: shape_key_item = shape_key_editor.shape_key_list.add() @@ -1359,6 +1363,7 @@ def destroy_references(self): ("head", "shape_key"), ("head", "shape_key_blocks"), ("head", "shape_key_apply_plan"), + ("head", "shape_key_has_deltas"), ("head", "body_constraints"), ("head", "rig_evaluated"), ("body", "rig_evaluated"), diff --git a/src/addons/character_dna/typing.py b/src/addons/character_dna/typing.py index c1405a5..ff33f92 100644 --- a/src/addons/character_dna/typing.py +++ b/src/addons/character_dna/typing.py @@ -17,11 +17,11 @@ BackupManagerProperties, DnaBackupEntry, ) + from .editors.behavior_viewer.properties import BehaviorViewerProperties, PsdCorrectiveListItem # noqa: TC004 from .editors.converter.properties import ( # noqa: TC004 ConverterExtraMeshItem, ConverterProperties, ) - from .editors.correctives_viewer.properties import CorrectivesViewerProperties, PsdCorrectiveListItem # noqa: TC004 from .editors.mesh_editor.properties import ( # noqa: TC004 MeshDataTransferItem, MeshEditorProperties, @@ -100,7 +100,7 @@ class RigInstance(_RigInstanceBase): backup_manager: BackupManagerProperties rbf_editor: RBFEditorProperties raw_control_editor: RawControlEditorProperties - correctives_viewer: CorrectivesViewerProperties + behavior_viewer: BehaviorViewerProperties shape_key_editor: ShapeKeyEditorProperties output: CharacterOutputProperties view_options: CharacterViewOptionsProperties @@ -181,6 +181,7 @@ class Context(bpy.types.Context): __all__ = [ "BakeAnimationBase", "BakeAnimationBase", + "BehaviorViewerProperties", "BinaryStreamReader", "BinaryStreamWriter", "CharacterAddonPreferences", @@ -198,7 +199,6 @@ class Context(bpy.types.Context): "Context", "ConverterExtraMeshItem", "ConverterProperties", - "CorrectivesViewerProperties", "DnaBackupEntry", "DuplicateRigInstance", "MeshDataTransferItem", diff --git a/src/addons/character_dna/ui/callbacks.py b/src/addons/character_dna/ui/callbacks.py index a57830e..6ced7e7 100644 --- a/src/addons/character_dna/ui/callbacks.py +++ b/src/addons/character_dna/ui/callbacks.py @@ -106,12 +106,7 @@ def get_body_texture_logic_node(material: bpy.types.Material) -> bpy.types.Shade for node in material.node_tree.nodes: # Check if this is the right group node by checking one input name # We don't check all to avoid performance issues - if ( - node.type == "GROUP" - and node.inputs.get("Color_MAIN") - and node.inputs.get("Normal_MAIN") - and node.inputs.get("Cavity_MAIN") - ): + if node.type == "GROUP" and node.inputs.get("Color_MAIN") and node.inputs.get("Normal_MAIN"): return node # type: ignore[return-value] return None @@ -697,7 +692,7 @@ def set_active_material_preview(self: "CharacterViewOptionsProperties", value: i for node_group in [head_node_group, body_node_group]: if not node_group or not node_group.node_tree: - return + continue # combined if value == 0: diff --git a/src/addons/character_dna/utilities/material.py b/src/addons/character_dna/utilities/material.py index 06561fe..79f79ec 100644 --- a/src/addons/character_dna/utilities/material.py +++ b/src/addons/character_dna/utilities/material.py @@ -73,9 +73,14 @@ def create_new_material( name: str, color: tuple[float, float, float, float] | None = None, alpha: float | None = None ) -> bpy.types.Material: material = bpy.data.materials.new(name=name) - # `Material.use_nodes` is deprecated (removed in Blender 6.0); every material - # already has a node tree by default on the addon's supported Blender range - # (4.5+), so no explicit opt-in is needed. + # Blender 4.x does not build the node tree for a freshly created material + # until node usage is enabled, so the material renders as incomplete. The + # `use_nodes` property is deprecated in Blender 5.0+ (removed in 6.0), where + # materials always use their node tree, so only set it on older versions to + # avoid emitting a deprecation warning. + if bpy.app.version < (5, 0, 0) and hasattr(material, "use_nodes"): + material.use_nodes = True + if not material.node_tree: logger.error(f"Material {name} has no node tree.") return material diff --git a/tests/behavior_viewer/test_graph.py b/tests/behavior_viewer/test_graph.py new file mode 100644 index 0000000..509a76a --- /dev/null +++ b/tests/behavior_viewer/test_graph.py @@ -0,0 +1,394 @@ +"""Tests for the Behavior Viewer live behavior-graph builder. + +Two layers: + +* A synthetic DNA reader (no Blender, no real DNA) driven by hand-supplied + ``raw_values`` / ``blend_outputs`` arrays -- exercises the Active Pose root, + the active-only filtering, the value labels, the Raw Control -> Bone Pose + + Shape Key wiring and the subset-covering shape-key layering (with arbitrary, + non-1.0 values). +* Real-Ada ``head.dna`` pins that prove the builder reads the actual DNA joint + behavior (cell-accurate bone poses) and blend-shape behavior (the channel a + raw control drives) for a controlled active state. +""" + +from __future__ import annotations + +import pytest + +from character_dna.editors.behavior_viewer.graph import ( + KIND_ACTIVE_POSE, + KIND_ANIMATED_MAP, + KIND_BONE, + KIND_RAW, + KIND_RBF_SOLVER, + KIND_SHAPE, + build_behavior_graph, +) + + +# --------------------------------------------------------------------------- +# Synthetic DNA: 3 raw controls, 5 channels, 2 PSD combinations, 1 joint group +# where only jawOpen moves a joint. +# --------------------------------------------------------------------------- +class _Reader: + _raw = ["CTRL_expressions.jawOpen", "CTRL_expressions.mouthStretchL", "CTRL_expressions.mouthStretchR"] + # channel: 0 1 2 3 4 5 + _channels = ["jaw_open", "mouth_stretch_L", "mouth_stretch_R", "Mstretch", "Jopen_Mstretch", "head_turnLeft_U"] + _joints = ["FACIAL_C_Jaw", "FACIAL_C_Neck"] + + def getRawControlCount(self) -> int: + return len(self._raw) + + def getRawControlName(self, index: int) -> str: + return self._raw[index] + + def getBlendShapeChannelCount(self) -> int: + return len(self._channels) + + def getBlendShapeChannelName(self, index: int) -> str: + return self._channels[index] + + def getBlendShapeChannelOutputIndices(self) -> list[int]: + return [0, 1, 2, 3, 4] + + def getBlendShapeChannelInputIndices(self) -> list[int]: + # channels 0..2 <- raw controls 0..2; channel 3 <- PSD 3; channel 4 <- PSD 4 + return [0, 1, 2, 3, 4] + + def getPSDRowIndices(self) -> list[int]: + return [3, 3, 4, 4, 4] + + def getPSDColumnIndices(self) -> list[int]: + return [1, 2, 0, 1, 2] + + def getPSDValues(self) -> list[float]: + return [1.0, 1.0, 0.9, 1.0, 1.0] + + def getPSDCount(self) -> int: + return 2 + + def getJointCount(self) -> int: + return len(self._joints) + + def getJointName(self, index: int) -> str: + return self._joints[index] + + def getJointGroupCount(self) -> int: + return 2 + + def getJointGroupInputIndices(self, group: int) -> list[int]: + # group 0 <- raw controls 0,1,2; group 1 <- the RBF output control 5. + return [0, 1, 2] if group == 0 else [5] + + def getJointGroupOutputIndices(self, group: int) -> list[int]: + # output 0 -> joint 0 (jaw); output 9 -> joint 1 (neck). group 1 drives neck. + return [0, 9] if group == 0 else [9] + + def getJointGroupValues(self, _group: int) -> list[float]: + # row-major (n_inputs=3): row0 = [jawOpen, mStretchL, mStretchR]; row1 zero. + return [0.5, 0.0, 0.0, 0.0, 0.0, 0.0] + + # animated maps: one wrinkle mask driven by raw control 0 (jawOpen), ramp [0,1]. + def getAnimatedMapCount(self) -> int: + return 1 + + def getAnimatedMapName(self, index: int) -> str: + return ["head_wm1_jaw_msk"][index] + + def getAnimatedMapInputIndices(self) -> list[int]: + return [0] + + def getAnimatedMapOutputIndices(self) -> list[int]: + return [0] + + def getAnimatedMapFromValues(self) -> list[float]: + return [0.0] + + def getAnimatedMapToValues(self) -> list[float]: + return [1.0] + + def getAnimatedMapSlopeValues(self) -> list[float]: + return [1.0] + + def getAnimatedMapCutValues(self) -> list[float]: + return [0.0] + + # RBF: one solver (neck raws 1,2) with one pose driving channel 5 + joint 1. + def getRBFSolverCount(self) -> int: + return 1 + + def getRBFSolverRawControlIndices(self, _solver: int) -> list[int]: + return [1, 2] + + def getRBFSolverName(self, _solver: int) -> str: + return "neck" + + def getRBFSolverPoseIndices(self, _solver: int) -> list[int]: + return [0] + + def getRBFPoseName(self, _pose: int) -> str: + return "head_turnLeft" + + def getRBFPoseBlendShapeChannelOutputIndices(self, _pose: int) -> list[int]: + return [5] + + def getRBFPoseJointOutputIndices(self, _pose: int) -> list[int]: + return [9] # joint 1 (FACIAL_C_Neck) + + def getRBFPoseAnimatedMapOutputIndices(self, _pose: int) -> list[int]: + return [] + + def getRBFPoseOutputControlIndices(self, _pose: int) -> list[int]: + return [5] # offset = raw(3) + psd(2) + ml(0) = 5 -> rbf_control_values[0] + + +def _node(graph, node_id: str) -> dict: + return next(n["data"] for n in graph.nodes if n["data"]["id"] == node_id) + + +def _has_node(graph, node_id: str) -> bool: + return any(n["data"]["id"] == node_id for n in graph.nodes) + + +def _edge_pairs(graph) -> set[tuple[str, str]]: + return {(e["data"]["source"], e["data"]["target"]) for e in graph.edges} + + +# --------------------------------------------------------------------------- +# Full active pose +# --------------------------------------------------------------------------- +def _full_graph(): + raw_values = [0.75, 0.5, 0.5] + blend_outputs = [0.75, 0.5, 0.5, 0.25, 0.34] + return build_behavior_graph(_Reader(), raw_values, blend_outputs, pose_label="mouth_lowerLipDepress") + + +def test_active_pose_root_and_activated_raw_controls() -> None: + graph = _full_graph() + root = _node(graph, "active_pose") + assert root["kind"] == KIND_ACTIVE_POSE + assert root["label"] == "mouth_lowerLipDepress" + assert root["count"] == 3 + assert root["layer"] == 0 + + assert _node(graph, "raw:0")["kind"] == KIND_RAW + assert _node(graph, "raw:0")["label"] == "jawOpen" + assert _node(graph, "raw:0")["value"] == 0.75 + assert _node(graph, "raw:1")["value"] == 0.5 + # active pose drives every activated raw control. + assert ("active_pose", "raw:0") in _edge_pairs(graph) + assert ("active_pose", "raw:1") in _edge_pairs(graph) + + +def test_raw_control_activates_bone_pose_and_shape_key() -> None: + graph = _full_graph() + # jawOpen poses a bone (cell-accurate) AND drives its primary shape key. + bone = _node(graph, "bone:raw:0") + assert bone["kind"] == KIND_BONE + assert bone["bones"] == ["FACIAL_C_Jaw"] + assert ("raw:0", "bone:raw:0") in _edge_pairs(graph) + + shape = _node(graph, "shape:0") + assert shape["kind"] == KIND_SHAPE + assert shape["label"] == "jaw_open" + assert shape["value"] == 0.75 + assert ("raw:0", "shape:0") in _edge_pairs(graph) + + # mouthStretch controls have a primary shape but pose no bone (zero cells). + assert not _has_node(graph, "bone:raw:1") + assert _has_node(graph, "shape:1") + + +def test_shape_key_layering_ordered_by_activation() -> None: + graph = _full_graph() + # Primary shapes at column 2; Mstretch (depth 1) at 3; Jopen_Mstretch (2) at 4. + assert _node(graph, "shape:0")["layer"] == 2 + assert _node(graph, "shape:3")["layer"] == 3 + assert _node(graph, "shape:4")["layer"] == 4 + + pairs = _edge_pairs(graph) + # Mstretch is used after mouth_stretch_L/R primary shapes. + assert ("shape:1", "shape:3") in pairs + assert ("shape:2", "shape:3") in pairs + # Jopen_Mstretch is used after the Mstretch corrective and the jaw_open shape. + assert ("shape:3", "shape:4") in pairs + assert ("shape:0", "shape:4") in pairs + # Its covered atoms are NOT wired directly from mouth_stretch shapes again. + assert ("shape:1", "shape:4") not in pairs + + +def test_shape_key_definition_carries_live_product() -> None: + graph = _full_graph() + definition = _node(graph, "shape:4")["definition"] + assert definition["output"] == "Jopen_Mstretch" + # Read-only op/operand rows: weight, then the raw-control product. + assert definition["operations"] == [ + {"op": "=", "operand": "0.9"}, + {"op": "\u00d7", "operand": "jawOpen"}, + {"op": "\u00d7", "operand": "mouthStretchL"}, + {"op": "\u00d7", "operand": "mouthStretchR"}, + ] + + +def test_primary_shape_and_raw_expression() -> None: + graph = _full_graph() + # Primary blend shape: a single assignment from its raw control. + primary = _node(graph, "shape:0")["definition"] + assert primary["output"] == "jaw_open" + assert primary["operations"] == [{"op": "=", "operand": "jawOpen"}] + # Raw control + joint group: base inputs -> no op rows, just a note. + raw_def = _node(graph, "raw:0")["definition"] + assert raw_def["output"] == "jawOpen" + assert raw_def["operations"] == [] + assert "Raw control" in raw_def["note"] + bone_def = _node(graph, "bone:raw:0")["definition"] + assert bone_def["operations"] == [] + assert "Joint group" in bone_def["note"] + + +def test_animated_map_node_ramps_off_its_driver() -> None: + graph = build_behavior_graph( + _Reader(), [0.75, 0.5, 0.5], [0.75, 0.5, 0.5, 0.25, 0.34], animated_map_outputs=[0.6] + ) + node = _node(graph, "anim:0") + assert node["kind"] == KIND_ANIMATED_MAP + assert node["label"] == "head_wm1_jaw_msk" + assert node["value"] == 0.6 + # Wired (ramp) from jawOpen's primary blend shape. + assert ("shape:0", "anim:0") in _edge_pairs(graph) + assert node["definition"]["operations"] == [{"op": "ramp", "operand": "jawOpen"}] + assert "slope" in node["definition"]["note"] + + +def test_animated_map_absent_when_inactive() -> None: + graph = build_behavior_graph( + _Reader(), [0.75, 0.5, 0.5], [0.75, 0.5, 0.5, 0.25, 0.34], animated_map_outputs=[0.0] + ) + assert not _has_node(graph, "anim:0") + + +def test_raw_controls_carry_rbf_flag() -> None: + graph = _full_graph() + assert _node(graph, "raw:0")["rbf"] is False # jawOpen is not an RBF input + assert _node(graph, "raw:1")["rbf"] is True # in the RBF solver's raw controls + + +def test_rbf_solver_surfaces_driven_blend_shape_and_joints() -> None: + # Head turned: channel 5 (head_turnLeft_U) is RBF-driven -- not visible through + # the blend-shape behavior, so it only appears via the RBF solver. + blend_outputs = [0.75, 0.5, 0.5, 0.25, 0.34, 0.9] + graph = build_behavior_graph(_Reader(), [0.75, 0.5, 0.5], blend_outputs, rbf_control_values=[0.66]) + + solver = _node(graph, "rbf:0") + assert solver["kind"] == KIND_RBF_SOLVER + assert solver["label"] == "neck" + assert "value" not in solver # solvers have no per-pose live weight + # Shared neck raw controls drive the solver. + assert ("raw:1", "rbf:0") in _edge_pairs(graph) + assert ("raw:2", "rbf:0") in _edge_pairs(graph) + # The RBF-driven blend shape appears under the solver with its live value. + shape = _node(graph, "shape:5") + assert shape["label"] == "head_turnLeft_U" + assert shape["value"] == 0.9 + assert ("rbf:0", "shape:5") in _edge_pairs(graph) + # And the joint group it drives (resolved via the owning joint group). + assert _node(graph, "bone:rbf:0")["bones"] == ["FACIAL_C_Neck"] + assert ("rbf:0", "bone:rbf:0") in _edge_pairs(graph) + + +def test_no_rbf_solver_when_inactive() -> None: + # No RBF-driven channel active and no RBF output-control value -> no RBF solver. + assert not _has_node(_full_graph(), "rbf:0") + + +# --------------------------------------------------------------------------- +# Arbitrary / partial poses (values need not be 1.0) +# --------------------------------------------------------------------------- +def test_arbitrary_partial_pose_shows_only_active_nodes() -> None: + # Only jawOpen active, at 0.6; correctives inactive. + graph = build_behavior_graph(_Reader(), [0.6, 0.0, 0.0], [0.6, 0.0, 0.0, 0.0, 0.0]) + ids = {n["data"]["id"] for n in graph.nodes} + assert ids == {"active_pose", "raw:0", "bone:raw:0", "shape:0"} + assert _node(graph, "raw:0")["value"] == 0.6 + assert _node(graph, "shape:0")["value"] == 0.6 + assert _node(graph, "active_pose")["label"] == "Active Pose" + + +def test_empty_when_nothing_active() -> None: + assert build_behavior_graph(_Reader(), [0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0]).nodes == [] + assert build_behavior_graph(None, [1.0], [1.0]).nodes == [] + + +def test_threshold_filters_negligible_activation() -> None: + graph = build_behavior_graph(_Reader(), [1e-6, 0.0, 0.0], [1e-6, 0.0, 0.0, 0.0, 0.0]) + assert graph.nodes == [] + + +# --------------------------------------------------------------------------- +# Real Ada head.dna pins +# --------------------------------------------------------------------------- +@pytest.fixture(scope="module") +def real_reader(): + from character_dna.dna_io import get_dna_reader + from constants import HEAD_DNA_FILE + + return get_dna_reader(file_path=HEAD_DNA_FILE, file_format="binary", data_layer="All") + + +def _raw_index(reader, short_name: str) -> int: + target = f"CTRL_expressions.{short_name}" + for i in range(int(reader.getRawControlCount())): + if str(reader.getRawControlName(i)) == target: + return i + raise AssertionError(f"raw control {target!r} not found in DNA") + + +def _channel_index(reader, name: str) -> int: + for i in range(int(reader.getBlendShapeChannelCount())): + if str(reader.getBlendShapeChannelName(i)) == name: + return i + raise AssertionError(f"channel {name!r} not found in DNA") + + +def test_real_jaw_open_active_state(real_reader) -> None: + reader = real_reader + raw_count = int(reader.getRawControlCount()) + channel_count = int(reader.getBlendShapeChannelCount()) + jaw = _raw_index(reader, "jawOpen") + jaw_open_channel = _channel_index(reader, "jaw_open") + + # Controlled active state: only jawOpen driven, its primary channel active. + raw_values = [0.0] * raw_count + raw_values[jaw] = 1.0 + blend_outputs = [0.0] * channel_count + blend_outputs[jaw_open_channel] = 1.0 + + graph = build_behavior_graph(reader, raw_values, blend_outputs) + + assert _node(graph, "active_pose")["count"] == 1 + assert _node(graph, f"raw:{jaw}")["value"] == 1.0 + # jawOpen's primary shape key resolves to the real jaw_open channel. + shape = _node(graph, f"shape:{jaw_open_channel}") + assert shape["label"] == "jaw_open" + assert (f"raw:{jaw}", f"shape:{jaw_open_channel}") in _edge_pairs(graph) + # jawOpen poses real bones (cell-accurate, non-empty joint names). + bone = _node(graph, f"bone:raw:{jaw}") + assert bone["kind"] == KIND_BONE + assert len(bone["bones"]) > 0 + assert all(isinstance(name, str) and name for name in bone["bones"]) + + +def test_real_rbf_raw_control_flagged(real_reader) -> None: + reader = real_reader + rbf_raws: set[int] = set() + for solver in range(int(reader.getRBFSolverCount())): + rbf_raws.update(int(i) for i in reader.getRBFSolverRawControlIndices(solver)) + assert rbf_raws, "Ada has RBF (neck-rotation) raw controls" + + rbf_index = min(rbf_raws) + raw_count = int(reader.getRawControlCount()) + raw_values = [0.0] * raw_count + raw_values[rbf_index] = 1.0 + graph = build_behavior_graph(reader, raw_values, [0.0] * int(reader.getBlendShapeChannelCount())) + assert _node(graph, f"raw:{rbf_index}")["rbf"] is True diff --git a/tests/correctives_viewer/test_html_report.py b/tests/behavior_viewer/test_html_report.py similarity index 72% rename from tests/correctives_viewer/test_html_report.py rename to tests/behavior_viewer/test_html_report.py index 45213d4..d337b26 100644 --- a/tests/correctives_viewer/test_html_report.py +++ b/tests/behavior_viewer/test_html_report.py @@ -12,18 +12,29 @@ import pytest -from character_dna.editors.correctives_viewer import html_report -from character_dna.editors.correctives_viewer.graph import GraphData +from character_dna.editors.behavior_viewer import html_report +from character_dna.editors.behavior_viewer.graph import GraphData @pytest.fixture def _graph() -> GraphData: return GraphData( nodes=[ - {"data": {"id": "raw:0", "label": "jawOpen", "kind": "raw"}}, - {"data": {"id": "psd:3", "label": "Mstretch_Jopen", "kind": "corrective", "layer": 2}}, + {"data": {"id": "active_pose", "label": "jawOpen", "kind": "active_pose", "layer": 0, "count": 1}}, + {"data": {"id": "raw:0", "label": "jawOpen", "kind": "raw", "layer": 1, "value": 0.75}}, + {"data": {"id": "bone:raw:0", "label": "jawOpen", "kind": "bone", "layer": 2, "bones": ["FACIAL_C_Jaw"]}}, + { + "data": { + "id": "shape:3", + "label": "jaw_open", + "kind": "shape", + "layer": 2, + "value": 0.75, + "definition": {"layer": 0, "channels": [], "net": []}, + } + }, ], - edges=[{"data": {"id": "raw:0__psd:3", "source": "raw:0", "target": "psd:3"}}], + edges=[{"data": {"id": "raw:0__shape:3", "source": "raw:0", "target": "shape:3"}}], ) @@ -58,7 +69,7 @@ def test_report_is_self_contained(tmp_path: Path, monkeypatch: pytest.MonkeyPatc ) end = document.index("", start) payload = json.loads(document[start:end].replace("<\\/", " None: diff --git a/tests/correctives_viewer/test_graph.py b/tests/correctives_viewer/test_graph.py deleted file mode 100644 index 1c21797..0000000 --- a/tests/correctives_viewer/test_graph.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Tests for the pure Correctives Viewer dependency-graph builders. - -These exercise :mod:`character_dna.editors.correctives_viewer.graph` against a -tiny synthetic DNA reader + rig definition (no Blender, no real DNA): two stacked -correctives ``Mstretch`` (layer 1) and ``Mstretch_Jopen`` (layer 2) over three -raw controls. They cover the layered node model, the subset-covering edges, the -baked ``shapeKeys`` and the focused selected-corrective sub-tree. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from character_dna.editors.correctives_viewer.graph import ( - build_dependency_graph, - build_selected_subgraph, - extract_subgraph, - find_corrective_node_id, -) - - -class _Reader: - """Minimal DNA reader exposing the raw-control table and blend-shape behavior - the graph builder + ``build_psd_corrective_rows`` use.""" - - _raw = ["CTRL_expressions.jawOpen", "CTRL_expressions.mouthStretchL", "CTRL_expressions.mouthStretchR"] - _channels = ["jaw_open", "mouth_stretch_L", "mouth_stretch_R", "Mstretch", "Mstretch_Jopen"] - - def getRawControlCount(self) -> int: - return len(self._raw) - - def getRawControlName(self, index: int) -> str: - return self._raw[index] - - def getBlendShapeChannelCount(self) -> int: - return len(self._channels) - - def getBlendShapeChannelName(self, index: int) -> str: - return self._channels[index] - - def getBlendShapeChannelOutputIndices(self) -> list[int]: - return [0, 1, 2, 3, 4] - - def getBlendShapeChannelInputIndices(self) -> list[int]: - # jaw_open<-0, mouth_stretch_L<-1, mouth_stretch_R<-2; Mstretch<-PSD 3, Mstretch_Jopen<-PSD 4 - return [0, 1, 2, 3, 4] - - -@dataclass -class _Deformer: - deformation_type: str - - -@dataclass -class _Expr: - name: str - control: str | None = None - mesh_deformers: list[_Deformer] = field(default_factory=list) - - -@dataclass -class _PsdDef: - name: str - target: str - layer: int = 1 - - -@dataclass -class _PsdInput: - expression: str - - -@dataclass -class _PsdNet: - psd_definition: str - inputs: list[_PsdInput] - - -@dataclass -class _RigDefinition: - expressions: list[_Expr] = field(default_factory=list) - psd_definitions: list[_PsdDef] = field(default_factory=list) - psd_nets: list[_PsdNet] = field(default_factory=list) - db_name: str = "test" - - -def _rig_definition() -> _RigDefinition: - blend = [_Deformer("BlendShapesOnly")] - return _RigDefinition( - expressions=[ - _Expr("jaw_open", "CTRL_expressions.jawOpen", blend), - _Expr("mouth_stretch_L", "CTRL_expressions.mouthStretchL", blend), - _Expr("mouth_stretch_R", "CTRL_expressions.mouthStretchR", blend), - _Expr("Mstretch_tgt", None, blend), - _Expr("Mstretch_Jopen_tgt", None, [_Deformer("JointsAndBlendShapes")]), - ], - psd_definitions=[ - _PsdDef("Mstretch", target="Mstretch_tgt", layer=1), - _PsdDef("Mstretch_Jopen", target="Mstretch_Jopen_tgt", layer=2), - ], - psd_nets=[ - _PsdNet("Mstretch", [_PsdInput("mouth_stretch_L"), _PsdInput("mouth_stretch_R")]), - _PsdNet( - "Mstretch_Jopen", - [_PsdInput("jaw_open"), _PsdInput("mouth_stretch_L"), _PsdInput("mouth_stretch_R")], - ), - ], - ) - - -def _node(graph, node_id: str) -> dict: - return next(n["data"] for n in graph.nodes if n["data"]["id"] == node_id) - - -def _edge_pairs(graph) -> set[tuple[str, str]]: - return {(e["data"]["source"], e["data"]["target"]) for e in graph.edges} - - -def test_layered_nodes_kinds_layers_labels() -> None: - graph = build_dependency_graph(_Reader(), _rig_definition()) - - ids = {n["data"]["id"] for n in graph.nodes} - assert ids == {"raw:0", "raw:1", "raw:2", "c:Mstretch_tgt", "c:Mstretch_Jopen_tgt"} - - assert _node(graph, "raw:0")["kind"] == "raw" - assert _node(graph, "raw:0")["layer"] == 0 - # Raw labels strip the CTRL_expressions. prefix. - assert _node(graph, "raw:0")["label"] == "jawOpen" - - top = _node(graph, "c:Mstretch_Jopen_tgt") - assert top["kind"] == "shape" # drives blend shapes - assert top["layer"] == 2 - assert top["target"] == "Mstretch_Jopen_tgt" - # Label prefers the rig-definition name over the `_tgt` target. - assert top["label"] == "Mstretch_Jopen" - assert _node(graph, "c:Mstretch_tgt")["label"] == "Mstretch" - - -def test_subset_covering_edges_point_left_to_right() -> None: - graph = build_dependency_graph(_Reader(), _rig_definition()) - # Mstretch (L1) is wired straight from its two raw controls; Mstretch_Jopen - # (L2) reaches mouth_stretch via the Mstretch corrective and jaw_open direct. - assert _edge_pairs(graph) == { - ("raw:1", "c:Mstretch_tgt"), - ("raw:2", "c:Mstretch_tgt"), - ("c:Mstretch_tgt", "c:Mstretch_Jopen_tgt"), - ("raw:0", "c:Mstretch_Jopen_tgt"), - } - - -def test_baked_shape_keys() -> None: - graph = build_dependency_graph(_Reader(), _rig_definition()) - assert _node(graph, "raw:0")["shapeKeys"] == ["jaw_open"] - assert _node(graph, "c:Mstretch_Jopen_tgt")["shapeKeys"] == ["Mstretch_Jopen"] - - -def test_selected_subtree_is_focused() -> None: - graph = build_dependency_graph(_Reader(), _rig_definition()) - node_id = find_corrective_node_id(graph, "Mstretch_tgt") - assert node_id == "c:Mstretch_tgt" - - sub = extract_subgraph(graph, node_id) - ids = {n["data"]["id"] for n in sub.nodes} - # Ancestors (its 2 raw inputs) + root + descendant (Mstretch_Jopen), but NOT - # jaw_open (raw:0), which only feeds the descendant. - assert ids == {"raw:1", "raw:2", "c:Mstretch_tgt", "c:Mstretch_Jopen_tgt"} - assert "raw:0" not in ids - - -def test_build_selected_subgraph_matches_by_target() -> None: - sub = build_selected_subgraph(_Reader(), _rig_definition(), "Mstretch_Jopen_tgt", [0, 1, 2]) - ids = {n["data"]["id"] for n in sub.nodes} - assert ids == {"raw:0", "raw:1", "raw:2", "c:Mstretch_tgt", "c:Mstretch_Jopen_tgt"} - - -def test_build_selected_subgraph_fallback_for_rbf_corrective() -> None: - # A corrective with no node in the layered graph (e.g. an RBF head-pose - # corrective) synthesizes a minimal graph from its base controls. - sub = build_selected_subgraph(_Reader(), _rig_definition(), "head_turnUp_tgt", [0, 1]) - kinds = {n["data"]["id"]: n["data"]["kind"] for n in sub.nodes} - assert kinds == {"c:head_turnUp_tgt": "corrective", "raw:0": "raw", "raw:1": "raw"} - assert _edge_pairs(sub) == {("raw:0", "c:head_turnUp_tgt"), ("raw:1", "c:head_turnUp_tgt")} - - -def test_empty_when_no_reader_or_no_correctives() -> None: - assert build_dependency_graph(None) == build_dependency_graph(None) - assert build_dependency_graph(None).nodes == [] - # No rig definition -> no correctives -> empty graph. - assert build_dependency_graph(_Reader(), rig_definition=None).nodes == [] diff --git a/tests/raw_control_editor/test_paste_raw_control.py b/tests/raw_control_editor/test_paste_raw_control.py new file mode 100644 index 0000000..fa89f5f --- /dev/null +++ b/tests/raw_control_editor/test_paste_raw_control.py @@ -0,0 +1,65 @@ +"""Unit coverage for the Raw Control Editor paste-from-raw-control helpers. + +The source enumeration and default-twin resolution are pure/lightweight (no live +Blender rig), so they are exercised directly with small fakes. The pose-bone +paste itself (``paste_raw_control_pose`` / ``compute_raw_control_pose_bases``) +needs a live rig + DNA reader and is covered by manual probes. +""" + +import pytest + +from character_dna.editors.raw_control_editor.constants import DEFAULT_RAW_CONTROL_INDEX, DEFAULT_RAW_CONTROL_NAME + + +utilities = pytest.importorskip("character_dna.editors.raw_control_editor.utilities") + + +class _Row: + def __init__(self, name: str, raw_control_index: int) -> None: + self.name = name + self.raw_control_index = raw_control_index + + +class _Editor: + def __init__(self, rows: "list[_Row]", active_index: int) -> None: + self.raw_controls = rows + self.raw_controls_active_index = active_index + + +def _sample_editor(active_index: int) -> _Editor: + rows = [ + _Row(DEFAULT_RAW_CONTROL_NAME, DEFAULT_RAW_CONTROL_INDEX), + _Row("CTRL_expressions.browDownL", 0), + _Row("CTRL_expressions.browDownR", 1), + _Row("CTRL_expressions.jawOpen", 2), + ] + return _Editor(rows, active_index) + + +def test_paste_source_items_excludes_active_and_default(): + editor = _sample_editor(active_index=1) # active = browDownL + items = utilities.raw_control_paste_source_items(None, editor) + full_names = {full for full, _short, _desc in items} + short_names = {short for _full, short, _desc in items} + assert full_names == {"CTRL_expressions.browDownR", "CTRL_expressions.jawOpen"} + assert short_names == {"browDownR", "jawOpen"} + assert DEFAULT_RAW_CONTROL_NAME not in full_names + + +def test_active_raw_control_short_name_strips_prefix(): + editor = _sample_editor(active_index=1) + assert utilities.active_raw_control_short_name(editor) == "browDownL" + + +def test_default_paste_raw_control_name_returns_twin(): + known = {"CTRL_expressions.browDownR", "CTRL_expressions.jawOpen"} + assert utilities.default_paste_raw_control_name("browDownL", known) == "CTRL_expressions.browDownR" + + +def test_default_paste_raw_control_name_none_when_twin_absent(): + assert utilities.default_paste_raw_control_name("browDownL", {"CTRL_expressions.jawOpen"}) is None + + +def test_default_paste_raw_control_name_none_for_center_control(): + known = {"CTRL_expressions.browDownR", "CTRL_expressions.jawOpen"} + assert utilities.default_paste_raw_control_name("jawOpen", known) is None diff --git a/tests/shape_key_editor/test_paste_shape_key_values.py b/tests/shape_key_editor/test_paste_shape_key_values.py new file mode 100644 index 0000000..7c34259 --- /dev/null +++ b/tests/shape_key_editor/test_paste_shape_key_values.py @@ -0,0 +1,113 @@ +"""Unit coverage for the Paste Shape Key Values operator's utilities. + +The paste helper copies a source shape key's delta (source - basis) onto the live +edited key block, is selection-aware in Edit mode, and can optionally mirror the +delta across the symmetry plane. The verbatim (non-mirror) path and the source +enumeration / default-twin helpers need no rig instance, so they are exercised +directly against a scratch Blender mesh. +""" + +import pytest + +from character_dna.constants import SHAPE_KEY_BASIS_NAME + + +# Shape-key editing lives in the Pro ``editors`` submodule; skip cleanly on a Free +# checkout where it is absent. +utilities = pytest.importorskip("character_dna.editors.shape_key_editor.utilities") + + +def _make_mesh_with_keys(name: str, key_names: "list[str]"): + """Create a scratch quad mesh with a basis plus ``key_names`` shape keys and + return ``(mesh_object, {key_name: key_block})``.""" + import bpy + + mesh = bpy.data.meshes.new(f"{name}_mesh") + mesh.from_pydata([(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (1.0, 1.0, 0.0)], [], [(0, 1, 3, 2)]) + mesh.update() + mesh_object = bpy.data.objects.new(f"{name}_object", mesh) + bpy.context.scene.collection.objects.link(mesh_object) + mesh_object.shape_key_add(name=SHAPE_KEY_BASIS_NAME, from_mix=False) + blocks = {key_name: mesh_object.shape_key_add(name=key_name, from_mix=False) for key_name in key_names} + return mesh_object, blocks + + +def test_same_mesh_paste_source_blocks_excludes_basis_and_edited(): + mesh_object, _blocks = _make_mesh_with_keys( + "paste_sources", ["head__brow_down_L", "head__brow_down_R", "head__jaw_open"] + ) + try: + sources = utilities.same_mesh_paste_source_blocks(mesh_object, "head__brow_down_L") + names = {block.name for block in sources} + assert names == {"head__brow_down_R", "head__jaw_open"} + assert SHAPE_KEY_BASIS_NAME not in names + finally: + import bpy + + bpy.data.objects.remove(mesh_object, do_unlink=True) + + +def test_default_paste_source_name_returns_mirror_twin(): + source_names = {"head__brow_down_R", "head__jaw_open"} + assert utilities.default_paste_source_name("head__brow_down_L", source_names) == "head__brow_down_R" + + +def test_default_paste_source_name_none_when_no_twin_present(): + assert utilities.default_paste_source_name("head__brow_down_L", {"head__jaw_open"}) is None + assert utilities.default_paste_source_name("head__jaw_open", {"head__brow_down_R"}) is None + + +def test_paste_verbatim_copies_source_delta_onto_edited_key(): + mesh_object, blocks = _make_mesh_with_keys("paste_verbatim", ["edited", "source"]) + try: + source = blocks["source"] + edited = blocks["edited"] + # Sculpt the source key away from the basis. + source.data[0].co.x += 0.5 + source.data[2].co.z -= 0.25 + + changed = utilities.paste_shape_key_values(None, mesh_object, edited, source, mirror=False) + + assert changed == 2 + for i in range(len(mesh_object.data.vertices)): + assert tuple(edited.data[i].co) == pytest.approx(tuple(source.data[i].co)) + finally: + import bpy + + bpy.data.objects.remove(mesh_object, do_unlink=True) + + +def test_paste_verbatim_selection_aware_in_edit_mode(): + import bmesh + import bpy + + mesh_object, blocks = _make_mesh_with_keys("paste_selection", ["edited", "source"]) + try: + source = blocks["source"] + edited = blocks["edited"] + # Sculpt the source on two vertices; only vertex 0 will be selected. + source.data[0].co.x += 0.5 + source.data[1].co.y += 0.3 + + mesh = mesh_object.data + bpy.context.view_layer.objects.active = mesh_object + bpy.ops.object.mode_set(mode="EDIT") + try: + bm = bmesh.from_edit_mesh(mesh) + bm.verts.ensure_lookup_table() + for vertex in bm.verts: + vertex.select = False + bm.verts[0].select = True + bmesh.update_edit_mesh(mesh) + + changed = utilities.paste_shape_key_values(None, mesh_object, edited, source, mirror=False) + finally: + if mesh_object.mode != "OBJECT": + bpy.ops.object.mode_set(mode="OBJECT") + + # Only the selected vertex 0 received the paste; vertex 1 stayed at basis. + assert changed == 1 + assert tuple(edited.data[0].co) == pytest.approx(tuple(source.data[0].co)) + assert edited.data[1].co.y == pytest.approx(0.0) + finally: + bpy.data.objects.remove(mesh_object, do_unlink=True) diff --git a/tests/shape_key_editor/test_twin_channel.py b/tests/shape_key_editor/test_twin_channel.py deleted file mode 100644 index 3e79ffe..0000000 --- a/tests/shape_key_editor/test_twin_channel.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Tests for the pure-DNA twin-channel resolver used by mirror-on-commit. - -``resolve_twin_channel`` maps a sided blend shape channel to its mirror twin -channel using only the DNA: the channel's single driving raw control, that -control's mirrored name, and the channel the twin control drives. Center -channels and combination correctives (multi-raw PSDs) have no twin. -""" - -from __future__ import annotations - -import pytest - -from character_dna.editors.shape_key_editor.core import resolve_twin_channel - - -_PREFIX = "CTRL_expressions." - - -class _FakeBehaviorReader: - """Minimal behavior reader surface used by the twin resolver + backsolve.""" - - def __init__( - self, - *, - raw_control_names: list[str], - channel_names: list[str], - bsc_input_indices: list[int], - bsc_output_indices: list[int], - psd_rows: list[int], - psd_columns: list[int], - ) -> None: - self._raw = raw_control_names - self._channels = channel_names - self._bsc_in = bsc_input_indices - self._bsc_out = bsc_output_indices - self._psd_rows = psd_rows - self._psd_cols = psd_columns - - def getRawControlCount(self) -> int: - return len(self._raw) - - def getRawControlName(self, index: int) -> str: - return self._raw[index] - - def getBlendShapeChannelCount(self) -> int: - return len(self._channels) - - def getBlendShapeChannelName(self, index: int) -> str: - return self._channels[index] - - def getBlendShapeChannelInputIndices(self) -> list[int]: - return self._bsc_in - - def getBlendShapeChannelOutputIndices(self) -> list[int]: - return self._bsc_out - - def getPSDRowIndices(self) -> list[int]: - return self._psd_rows - - def getPSDColumnIndices(self) -> list[int]: - return self._psd_cols - - -def _build_reader() -> _FakeBehaviorReader: - """A rig with a sided pair (brow_down_L/R), a center channel (jaw_open), and - a combination corrective (jaw_openExtreme_cor driven by a PSD). - - control space: raw [0,4) | psd [4,5) - raw 0 = browDownL, 1 = browDownR, 2 = jawOpen, 3 = jawOpenExtreme - psd 4 = jawOpen * jawOpenExtreme (columns 2, 3) - channels: - 0 brow_down_L <- raw 0 - 1 brow_down_R <- raw 1 - 2 jaw_open <- raw 2 - 3 jaw_openExtreme_cor <- psd 4 - """ - return _FakeBehaviorReader( - raw_control_names=[ - _PREFIX + "browDownL", - _PREFIX + "browDownR", - _PREFIX + "jawOpen", - _PREFIX + "jawOpenExtreme", - ], - channel_names=["brow_down_L", "brow_down_R", "jaw_open", "jaw_openExtreme_cor"], - bsc_input_indices=[0, 1, 2, 4], - bsc_output_indices=[0, 1, 2, 3], - psd_rows=[4, 4], - psd_columns=[2, 3], - ) - - -def test_sided_channel_resolves_to_twin_left() -> None: - reader = _build_reader() - assert resolve_twin_channel(reader, "brow_down_L") == ("brow_down_R", "L") - - -def test_sided_channel_resolves_to_twin_right() -> None: - reader = _build_reader() - assert resolve_twin_channel(reader, "brow_down_R") == ("brow_down_L", "R") - - -def test_center_channel_has_no_twin() -> None: - reader = _build_reader() - assert resolve_twin_channel(reader, "jaw_open") is None - - -def test_combination_corrective_has_no_twin() -> None: - reader = _build_reader() - assert resolve_twin_channel(reader, "jaw_openExtreme_cor") is None - - -def test_unknown_channel_has_no_twin() -> None: - reader = _build_reader() - assert resolve_twin_channel(reader, "does_not_exist") is None - - -# --------------------------------------------------------------------------- -# Real-rig round trip -- the shipped Ada head DNA -# --------------------------------------------------------------------------- -def test_real_ada_dna_twin_round_trips() -> None: - from character_dna.dna_io import get_dna_reader - from character_dna.editors.raw_control_editor.core import classify_raw_control_side - from character_dna.editors.shared.dependency_chain import resolve_blend_shape_raw_controls - from constants import HEAD_DNA_FILE - - reader = get_dna_reader(file_path=HEAD_DNA_FILE, file_format="binary", data_layer="All") - raw_count = int(reader.getRawControlCount()) - known = { - str(reader.getRawControlName(i)).removeprefix(_PREFIX) - for i in range(raw_count) - if str(reader.getRawControlName(i)).startswith(_PREFIX) - } - - def left_side(channel_name: str) -> bool: - raw_indices = resolve_blend_shape_raw_controls(reader, channel_name) - if len(raw_indices) != 1: - return False - short = str(reader.getRawControlName(raw_indices[0])).removeprefix(_PREFIX) - return classify_raw_control_side(short, known) == "L" - - checked = 0 - for channel_index in range(int(reader.getBlendShapeChannelCount())): - name = str(reader.getBlendShapeChannelName(channel_index)) - if not left_side(name): - continue - twin = resolve_twin_channel(reader, name) - assert twin is not None, f"{name!r} (L) should have a twin" - twin_name, side = twin - assert side == "L" - assert twin_name != name - # The twin must round-trip back to the original as the R side. - back = resolve_twin_channel(reader, twin_name) - assert back == (name, "R"), f"{twin_name!r} did not round-trip to ({name!r}, 'R'); got {back!r}" - checked += 1 - if checked >= 10: - break - - assert checked > 0, "no left-sided channels found in the Ada head DNA" - - -@pytest.mark.parametrize("channel", ["jaw_open", "jaw_openExtreme_cor"]) -def test_real_ada_dna_center_channels_have_no_twin(channel: str) -> None: - from character_dna.dna_io import get_dna_reader - from constants import HEAD_DNA_FILE - - reader = get_dna_reader(file_path=HEAD_DNA_FILE, file_format="binary", data_layer="All") - assert resolve_twin_channel(reader, channel) is None diff --git a/uv.lock b/uv.lock index fd7040a..4f7d206 100644 --- a/uv.lock +++ b/uv.lock @@ -216,7 +216,7 @@ wheels = [ [[package]] name = "character-dna-addon" -version = "0.10.3" +version = "0.11.2" source = { editable = "." } dependencies = [ { name = "sentry-sdk" },