Add Blender material texture normalization tool#96
Conversation
📝 WalkthroughWalkthroughAdds a new Changesnormalize_material_texture_channels full-stack addition
Sequence DiagramsequenceDiagram
participant Agent as BlenderAgentV2
participant Tool as normalize_material_texture_channels (LangChain)
participant MCP as executeMcpCommand
participant Addon as BlenderMCPServer (vipermesh-addon.py)
participant Blender as Blender Node Graph
Agent->>Tool: invoke(material_names?, apply_changes?)
Tool->>MCP: executeMcpCommand("normalize_material_texture_channels", params)
MCP->>Addon: dispatch via handlers table
Addon->>Blender: iterate ShaderNodeTexImage nodes per material
Blender-->>Addon: node labels, image names, downstream links
Addon->>Addon: infer PBR role, compare colorspace_settings vs expected
alt apply_changes=true
Addon->>Blender: set image.colorspace_settings.name
end
Addon-->>MCP: per-material report, changes list, warnings
MCP-->>Tool: structured result
Tool-->>Agent: report (optionally triggers inspect_material_node_graph)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: dependency version conflict. Check your lock file or package.json. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@desktop/assets/vipermesh-addon.py`:
- Around line 6930-6937: The function returns success even when explicit
material names are requested but some are missing. After the loop where
requested_names are processed and missing materials are collected into the
missing_materials list, add a check to verify that no materials were requested
that couldn't be found. If missing_materials is not empty after processing the
requested_names loop, the function should not report success (set success to
False or raise an appropriate error) instead of returning a successful result
with materials_checked: 0. This prevents masking no-op normalization requests
when stale or typoed material names are provided.
- Around line 6842-6865: The role_aliases dictionary in the vipermesh-addon.py
file uses overly broad substring matching where the generic alias "color"
matches within more specific material names like "metallic_color" or
"normal_color", causing them to be incorrectly classified as base_color. Fix
this by either removing the overly broad "color" entry from the role_aliases
dictionary, or by modifying the matching logic that uses this dictionary to
perform token-based or word-boundary matching instead of simple substring
matching. This ensures that specific material names like "metallic_color" are
matched against their specific aliases ("metallic") rather than the generic
"color" alias.
- Around line 6907-6918: The texture role inference logic only checks immediate
destination nodes (one-hop) but needs to traverse through utility nodes like
ShaderNodeSeparateColor, ShaderNodeBump, and ColorRamp to reach the final
destination socket. Refactor the loop that iterates over node outputs and links
to recursively or iteratively follow connections through these utility nodes.
When encountering a utility node as the to_node_type, continue traversing its
outputs to find the final target node and socket instead of stopping at the
utility node. This will allow the existing checks for ShaderNodeNormalMap,
ShaderNodeDisplacement, and socket_roles to properly detect textures routed
through intermediate processing nodes.
In `@public/downloads/vipermesh-addon.py`:
- Around line 6862-6864: The AO (ambient occlusion) texture name alias mapping
dictionary is missing an entry for the common `occlusion` naming convention used
in standalone AO maps. Add a new mapping entry in the dictionary (alongside the
existing "ao", "ambientocclusion", and "ambient_occlusion" entries) that maps
"occlusion" to "ambient_occlusion". This will prevent AO maps named `occlusion`
from being classified as `unknown` and will ensure they receive the proper
`Non-Color` repair.
- Around line 6929-7018: The function returns success: True even when all
requested materials in requested_names are missing and no materials are actually
processed. Add a condition before the return statement to check if
requested_names was provided but resulted in zero materials being processed
(i.e., len(requested_names) > 0 and len(materials) == 0), and if so, set success
to False in the returned dictionary instead of True. This ensures that a no-op
due to misspelled or non-existent material names is correctly reported as a
failed operation.
- Around line 6889-6918: The infer_texture_role function checks text-based
inference (node label, image name/path) before graph link inference, which
allows broad substring matches in paths to override specific socket connections.
Reorder the logic in infer_texture_role to check the graph links first (the loop
iterating over outputs and links to find connected socket roles), and only fall
back to text-based inference on node labels and image names if no graph link
information is found. This ensures specific socket connections take precedence
over generic text pattern matching.
In `@scripts/test/test-blender-material-texture-normalization-tool.ts`:
- Around line 19-21: The test assertions are incomplete and only verify color
space mappings for base_color and normal channels, but the addon implementation
defines mappings for additional PBR channels: emission (sRGB), roughness
(Non-Color), metallic (Non-Color), ambient_occlusion (Non-Color), alpha
(Non-Color), and displacement (Non-Color). Add additional assert.match calls
after the existing ones to verify that the source contains the correct color
space mappings for each of these additional PBR channels, following the same
pattern as the existing base_color and normal assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 8359c380-01a5-494d-abaa-25bfe8c5fe12
📒 Files selected for processing (10)
data/tool-guides/materials-guide.mddesktop/assets/vipermesh-addon.pydocs/blender-mcp-capability-inventory.mdlib/ai/agents.tslib/orchestration/prompts/blender-agent-system.mdlib/orchestration/tool-filter.tslib/orchestration/tool-registry.tspublic/downloads/vipermesh-addon.pyscripts/test/test-blender-material-texture-normalization-tool.tsscripts/test/test-tool-guide-trigger-coverage.ts
| role_aliases = { | ||
| "albedo": "base_color", | ||
| "diffuse": "base_color", | ||
| "basecolor": "base_color", | ||
| "base_color": "base_color", | ||
| "color": "base_color", | ||
| "emissive": "emission", | ||
| "emission": "emission", | ||
| "rough": "roughness", | ||
| "roughness": "roughness", | ||
| "metal": "metallic", | ||
| "metalness": "metallic", | ||
| "metallic": "metallic", | ||
| "normal": "normal", | ||
| "normalmap": "normal", | ||
| "normal_map": "normal", | ||
| "height": "displacement", | ||
| "displacement": "displacement", | ||
| "alpha": "alpha", | ||
| "opacity": "alpha", | ||
| "ao": "ambient_occlusion", | ||
| "ambientocclusion": "ambient_occlusion", | ||
| "ambient_occlusion": "ambient_occlusion", | ||
| } |
There was a problem hiding this comment.
Match PBR aliases by token/specificity, not broad substrings.
Line 6892 lets "color" match before later data roles, so names like metallic_color.png or normal_color.png are classified as base_color and can be changed to sRGB.
🐛 Proposed fix
role_aliases = {
"albedo": "base_color",
"diffuse": "base_color",
"basecolor": "base_color",
"base_color": "base_color",
"color": "base_color",
+ "base_colour": "base_color",
+ "colour": "base_color",
"emissive": "emission",
"emission": "emission",
"rough": "roughness",
"roughness": "roughness",
"metal": "metallic",
"metalness": "metallic",
"metallic": "metallic",
"normal": "normal",
"normalmap": "normal",
"normal_map": "normal",
+ "nrm": "normal",
+ "nor": "normal",
"height": "displacement",
"displacement": "displacement",
+ "bump": "displacement",
"alpha": "alpha",
"opacity": "alpha",
"ao": "ambient_occlusion",
+ "occlusion": "ambient_occlusion",
"ambientocclusion": "ambient_occlusion",
"ambient_occlusion": "ambient_occlusion",
+ "arm": "ambient_occlusion",
+ "orm": "ambient_occlusion",
}
@@
def infer_from_text(*values):
joined = "_".join(normalize_token(value) for value in values if value)
- for alias, role in role_aliases.items():
- if alias in joined:
+ padded = f"_{joined}_"
+ compact = joined.replace("_", "")
+ for alias in sorted(role_aliases, key=len, reverse=True):
+ alias_compact = alias.replace("_", "")
+ if f"_{alias}_" in padded or compact == alias_compact:
+ role = role_aliases[alias]
return role
return NoneAlso applies to: 6889-6893
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@desktop/assets/vipermesh-addon.py` around lines 6842 - 6865, The role_aliases
dictionary in the vipermesh-addon.py file uses overly broad substring matching
where the generic alias "color" matches within more specific material names like
"metallic_color" or "normal_color", causing them to be incorrectly classified as
base_color. Fix this by either removing the overly broad "color" entry from the
role_aliases dictionary, or by modifying the matching logic that uses this
dictionary to perform token-based or word-boundary matching instead of simple
substring matching. This ensures that specific material names like
"metallic_color" are matched against their specific aliases ("metallic") rather
than the generic "color" alias.
Source: MCP tools
| for output in getattr(node, "outputs", []) or []: | ||
| for link in getattr(output, "links", []) or []: | ||
| to_node = getattr(link, "to_node", None) | ||
| to_socket = getattr(link, "to_socket", None) | ||
| to_socket_name = str(getattr(to_socket, "name", "")).strip().lower() | ||
| to_node_type = str(getattr(to_node, "bl_idname", "")) | ||
| if to_node_type == "ShaderNodeNormalMap": | ||
| return "normal", "normal_map_node" | ||
| if to_node_type == "ShaderNodeDisplacement": | ||
| return "displacement", "displacement_node" | ||
| if to_socket_name in socket_roles: | ||
| return socket_roles[to_socket_name], "linked_socket" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Locate current material graph patterns where texture nodes route through utility nodes.
# Expected: Any SeparateColor/Bump/Mix routes should be covered by normalize_material_texture_channels inference.
rg -n -C 5 'ShaderNodeSeparateColor|ShaderNodeSeparateRGB|ShaderNodeBump|texture_nodes\[[' desktop/assets/vipermesh-addon.py public/downloads/vipermesh-addon.pyRepository: Ker102/ViperMesh
Length of output: 275
🏁 Script executed:
#!/bin/bash
# Fixed script: search for utility nodes and material construction patterns
# First, let's check the exact code at lines 6907-6918
echo "=== Code at target lines ==="
sed -n '6900,6925p' desktop/assets/vipermesh-addon.py
# Now search for ShaderNode utility patterns (fixed regex)
echo -e "\n=== Searching for utility node usage ==="
rg -n 'ShaderNodeSeparateColor|ShaderNodeSeparateRGB|ShaderNodeBump|ShaderNodeColorRamp' desktop/assets/vipermesh-addon.py | head -20
# Check material construction context
echo -e "\n=== Searching for texture node and material construction ==="
rg -n 'normalize_material_texture_channels|def.*normalize' desktop/assets/vipermesh-addon.py | head -10Repository: Ker102/ViperMesh
Length of output: 2218
🏁 Script executed:
#!/bin/bash
# Get more context on SeparateColor usage at line 8842
echo "=== Context around SeparateColor usage (line 8842) ==="
sed -n '8835,8860p' desktop/assets/vipermesh-addon.py
# Get the function definition and first 100 lines
echo -e "\n=== normalize_material_texture_channels function start ==="
sed -n '6829,6900p' desktop/assets/vipermesh-addon.py
# Check if there's a loop/traversal pattern elsewhere for utility nodes
echo -e "\n=== Search for queue or traversal patterns ==="
rg -n 'queue|traverse|depth|while.*:' desktop/assets/vipermesh-addon.py | grep -A2 -B2 '68[0-9]{2}:\|69[0-9]{2}:' | head -20Repository: Ker102/ViperMesh
Length of output: 4572
🏁 Script executed:
#!/bin/bash
# Need to understand the flow better - check if normalize_material_texture_channels
# is run before or after material construction with SeparateColor
echo "=== Full infer_texture_role function ==="
sed -n '6900,6930p' desktop/assets/vipermesh-addon.py
# Check what calls normalize_material_texture_channels
echo -e "\n=== Calls to normalize_material_texture_channels ==="
rg -n 'normalize_material_texture_channels' desktop/assets/vipermesh-addon.py
# Check context around line 8842 - is this construction or import?
echo -e "\n=== Broader context around line 8842 ==="
sed -n '8800,8850p' desktop/assets/vipermesh-addon.py | head -40
# Get function name at line 8842
echo -e "\n=== Which function contains line 8842? ==="
awk 'NR<=8842 && /^[[:space:]]*(def |class )/ {func=$0; line=NR} NR==8842 {print "Line 8842 is in function defined at line " line ": " func}' desktop/assets/vipermesh-addon.pyRepository: Ker102/ViperMesh
Length of output: 4508
🏁 Script executed:
#!/bin/bash
# Fix awk syntax and find function containing line 8842
echo "=== Finding function at line 8842 ==="
python3 << 'PYEOF'
with open('desktop/assets/vipermesh-addon.py', 'r') as f:
lines = f.readlines()
current_func = None
for i, line in enumerate(lines[:8850], 1):
if line.lstrip().startswith(('def ', 'class ')):
current_func = (i, line.strip())
if i == 8842:
print(f"Line 8842 is in function/class at line {current_func[0]}: {current_func[1]}")
break
PYEOF
# Check if ARM material construction is in normalize_material_texture_channels or elsewhere
echo -e "\n=== Check if ARM handling is in normalize_material_texture_channels ==="
sed -n '6829,7100p' desktop/assets/vipermesh-addon.py | grep -n 'arm\|ARM' | head -5
# Let's see the full extent of normalize_material_texture_channels
echo -e "\n=== Find where normalize_material_texture_channels ends ==="
python3 << 'PYEOF'
with open('desktop/assets/vipermesh-addon.py', 'r') as f:
lines = f.readlines()
in_func = False
start_indent = 0
for i, line in enumerate(lines[6828:], 6829): # Start from line 6829
if not in_func and 'def normalize_material_texture_channels' in line:
in_func = True
start_indent = len(line) - len(line.lstrip())
continue
if in_func:
if line.strip() and not line.startswith(' ' * (start_indent + 1)) and line[0] != ' ':
print(f"normalize_material_texture_channels ends before line {i}")
break
elif line.strip() and len(line) - len(line.lstrip()) == start_indent and line.lstrip().startswith('def '):
print(f"normalize_material_texture_channels ends at line {i-1}")
print(f"Next function starts at line {i}: {line.strip()[:50]}")
break
PYEOFRepository: Ker102/ViperMesh
Length of output: 465
🏁 Script executed:
#!/bin/bash
# Now understand what normalize_material_texture_channels actually does
echo "=== Full normalize_material_texture_channels to understand its purpose ==="
sed -n '6829,7021p' desktop/assets/vipermesh-addon.py | head -120
# Check if this function imports or processes existing materials
echo -e "\n=== Key parts of the function ==="
sed -n '6930,6980p' desktop/assets/vipermesh-addon.pyRepository: Ker102/ViperMesh
Length of output: 7684
Traverse utility nodes in texture role inference to handle packed maps routed through Separate Color or Bump nodes.
The current inference only checks immediate destination nodes. Materials—whether imported or programmatically constructed—often route ARM (Ambient Occlusion, Roughness, Metallic) or height maps through ShaderNodeSeparateColor, ShaderNodeBump, or ColorRamp nodes before connecting to Principled BSDF sockets. This breaks the one-hop link inference, leaving such textures classified as unknown and missing Non-Color colorspace corrections.
Proposed direction
- for output in getattr(node, "outputs", []) or []:
- for link in getattr(output, "links", []) or []:
+ queue = [(output, 0) for output in (getattr(node, "outputs", []) or [])]
+ seen_nodes = set()
+ while queue:
+ output, depth = queue.pop(0)
+ for link in getattr(output, "links", []) or []:
to_node = getattr(link, "to_node", None)
to_socket = getattr(link, "to_socket", None)
to_socket_name = str(getattr(to_socket, "name", "")).strip().lower()
to_node_type = str(getattr(to_node, "bl_idname", ""))
if to_node_type == "ShaderNodeNormalMap":
return "normal", "normal_map_node"
+ if to_node_type == "ShaderNodeBump":
+ return "displacement", "bump_node"
if to_node_type == "ShaderNodeDisplacement":
return "displacement", "displacement_node"
if to_socket_name in socket_roles:
return socket_roles[to_socket_name], "linked_socket"
+ if to_node and depth < 4 and to_node.name not in seen_nodes:
+ seen_nodes.add(to_node.name)
+ queue.extend((candidate, depth + 1) for candidate in getattr(to_node, "outputs", []) or [])
return None, "unknown"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@desktop/assets/vipermesh-addon.py` around lines 6907 - 6918, The texture role
inference logic only checks immediate destination nodes (one-hop) but needs to
traverse through utility nodes like ShaderNodeSeparateColor, ShaderNodeBump, and
ColorRamp to reach the final destination socket. Refactor the loop that iterates
over node outputs and links to recursively or iteratively follow connections
through these utility nodes. When encountering a utility node as the
to_node_type, continue traversing its outputs to find the final target node and
socket instead of stopping at the utility node. This will allow the existing
checks for ShaderNodeNormalMap, ShaderNodeDisplacement, and socket_roles to
properly detect textures routed through intermediate processing nodes.
Source: MCP tools
| if requested_names: | ||
| materials = [] | ||
| for name in requested_names: | ||
| material = bpy.data.materials.get(name) | ||
| if material is None: | ||
| missing_materials.append(name) | ||
| else: | ||
| materials.append(material) |
There was a problem hiding this comment.
Do not report success when explicit material names are missing.
A call with only stale/typoed material_names returns success: True and materials_checked: 0, which masks a no-op normalization request.
🐛 Proposed fix
if requested_names:
materials = []
for name in requested_names:
material = bpy.data.materials.get(name)
if material is None:
missing_materials.append(name)
else:
materials.append(material)
+ if missing_materials and not materials:
+ return {
+ "success": False,
+ "error": f"Material(s) not found: {', '.join(missing_materials)}",
+ "apply_changes": apply_changes,
+ "materials_checked": 0,
+ "missing_materials": missing_materials,
+ "changes": [],
+ "warnings": [],
+ "reports": [],
+ }
@@
return {
- "success": True,
+ "success": len(missing_materials) == 0,
"apply_changes": apply_changes,
"materials_checked": len(reports),
"missing_materials": missing_materials,Also applies to: 7009-7014
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@desktop/assets/vipermesh-addon.py` around lines 6930 - 6937, The function
returns success even when explicit material names are requested but some are
missing. After the loop where requested_names are processed and missing
materials are collected into the missing_materials list, add a check to verify
that no materials were requested that couldn't be found. If missing_materials is
not empty after processing the requested_names loop, the function should not
report success (set success to False or raise an appropriate error) instead of
returning a successful result with materials_checked: 0. This prevents masking
no-op normalization requests when stale or typoed material names are provided.
| "ao": "ambient_occlusion", | ||
| "ambientocclusion": "ambient_occlusion", | ||
| "ambient_occlusion": "ambient_occlusion", |
There was a problem hiding this comment.
Add the common occlusion AO alias.
Standalone AO maps are often named occlusion; because there is no direct Principled AO socket, these maps can remain unknown and skip the intended Non-Color repair.
Suggested fix
"ao": "ambient_occlusion",
+ "occlusion": "ambient_occlusion",
"ambientocclusion": "ambient_occlusion",
"ambient_occlusion": "ambient_occlusion",📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "ao": "ambient_occlusion", | |
| "ambientocclusion": "ambient_occlusion", | |
| "ambient_occlusion": "ambient_occlusion", | |
| "ao": "ambient_occlusion", | |
| "occlusion": "ambient_occlusion", | |
| "ambientocclusion": "ambient_occlusion", | |
| "ambient_occlusion": "ambient_occlusion", |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@public/downloads/vipermesh-addon.py` around lines 6862 - 6864, The AO
(ambient occlusion) texture name alias mapping dictionary is missing an entry
for the common `occlusion` naming convention used in standalone AO maps. Add a
new mapping entry in the dictionary (alongside the existing "ao",
"ambientocclusion", and "ambient_occlusion" entries) that maps "occlusion" to
"ambient_occlusion". This will prevent AO maps named `occlusion` from being
classified as `unknown` and will ensure they receive the proper `Non-Color`
repair.
Source: MCP tools
| def infer_from_text(*values): | ||
| joined = "_".join(normalize_token(value) for value in values if value) | ||
| for alias, role in role_aliases.items(): | ||
| if alias in joined: | ||
| return role | ||
| return None | ||
|
|
||
| def infer_texture_role(node): | ||
| role = infer_from_text(getattr(node, "label", ""), getattr(node, "name", "")) | ||
| if role: | ||
| return role, "node_label_or_name" | ||
|
|
||
| image = getattr(node, "image", None) | ||
| if image: | ||
| role = infer_from_text(getattr(image, "name", ""), getattr(image, "filepath", "")) | ||
| if role: | ||
| return role, "image_name_or_path" | ||
|
|
||
| for output in getattr(node, "outputs", []) or []: | ||
| for link in getattr(output, "links", []) or []: | ||
| to_node = getattr(link, "to_node", None) | ||
| to_socket = getattr(link, "to_socket", None) | ||
| to_socket_name = str(getattr(to_socket, "name", "")).strip().lower() | ||
| to_node_type = str(getattr(to_node, "bl_idname", "")) | ||
| if to_node_type == "ShaderNodeNormalMap": | ||
| return "normal", "normal_map_node" | ||
| if to_node_type == "ShaderNodeDisplacement": | ||
| return "displacement", "displacement_node" | ||
| if to_socket_name in socket_roles: | ||
| return socket_roles[to_socket_name], "linked_socket" |
There was a problem hiding this comment.
Use graph links before generic text aliases.
infer_texture_role trusts label/image/path text before socket links, and infer_from_text matches broad substrings like color before roughness/normal. A data map in a path such as /color_corrected/Wood_Roughness.png can be inferred as base_color and changed to sRGB even when linked to the Roughness socket.
Suggested fix
+ def infer_from_links(node):
+ for output in getattr(node, "outputs", []) or []:
+ for link in getattr(output, "links", []) or []:
+ to_node = getattr(link, "to_node", None)
+ to_socket = getattr(link, "to_socket", None)
+ to_socket_name = str(getattr(to_socket, "name", "")).strip().lower()
+ to_node_type = str(getattr(to_node, "bl_idname", ""))
+ if to_node_type == "ShaderNodeNormalMap":
+ return "normal", "normal_map_node"
+ if to_node_type == "ShaderNodeDisplacement":
+ return "displacement", "displacement_node"
+ if to_socket_name in socket_roles:
+ return socket_roles[to_socket_name], "linked_socket"
+ return None, "unknown"
+
def infer_texture_role(node):
+ role, role_source = infer_from_links(node)
+ if role:
+ return role, role_source
+
role = infer_from_text(getattr(node, "label", ""), getattr(node, "name", ""))
if role:
return role, "node_label_or_name"
image = getattr(node, "image", None)
if image:
role = infer_from_text(getattr(image, "name", ""), getattr(image, "filepath", ""))
if role:
return role, "image_name_or_path"
- for output in getattr(node, "outputs", []) or []:
- for link in getattr(output, "links", []) or []:
- to_node = getattr(link, "to_node", None)
- to_socket = getattr(link, "to_socket", None)
- to_socket_name = str(getattr(to_socket, "name", "")).strip().lower()
- to_node_type = str(getattr(to_node, "bl_idname", ""))
- if to_node_type == "ShaderNodeNormalMap":
- return "normal", "normal_map_node"
- if to_node_type == "ShaderNodeDisplacement":
- return "displacement", "displacement_node"
- if to_socket_name in socket_roles:
- return socket_roles[to_socket_name], "linked_socket"
return None, "unknown"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def infer_from_text(*values): | |
| joined = "_".join(normalize_token(value) for value in values if value) | |
| for alias, role in role_aliases.items(): | |
| if alias in joined: | |
| return role | |
| return None | |
| def infer_texture_role(node): | |
| role = infer_from_text(getattr(node, "label", ""), getattr(node, "name", "")) | |
| if role: | |
| return role, "node_label_or_name" | |
| image = getattr(node, "image", None) | |
| if image: | |
| role = infer_from_text(getattr(image, "name", ""), getattr(image, "filepath", "")) | |
| if role: | |
| return role, "image_name_or_path" | |
| for output in getattr(node, "outputs", []) or []: | |
| for link in getattr(output, "links", []) or []: | |
| to_node = getattr(link, "to_node", None) | |
| to_socket = getattr(link, "to_socket", None) | |
| to_socket_name = str(getattr(to_socket, "name", "")).strip().lower() | |
| to_node_type = str(getattr(to_node, "bl_idname", "")) | |
| if to_node_type == "ShaderNodeNormalMap": | |
| return "normal", "normal_map_node" | |
| if to_node_type == "ShaderNodeDisplacement": | |
| return "displacement", "displacement_node" | |
| if to_socket_name in socket_roles: | |
| return socket_roles[to_socket_name], "linked_socket" | |
| def infer_from_text(*values): | |
| joined = "_".join(normalize_token(value) for value in values if value) | |
| for alias, role in role_aliases.items(): | |
| if alias in joined: | |
| return role | |
| return None | |
| def infer_from_links(node): | |
| for output in getattr(node, "outputs", []) or []: | |
| for link in getattr(output, "links", []) or []: | |
| to_node = getattr(link, "to_node", None) | |
| to_socket = getattr(link, "to_socket", None) | |
| to_socket_name = str(getattr(to_socket, "name", "")).strip().lower() | |
| to_node_type = str(getattr(to_node, "bl_idname", "")) | |
| if to_node_type == "ShaderNodeNormalMap": | |
| return "normal", "normal_map_node" | |
| if to_node_type == "ShaderNodeDisplacement": | |
| return "displacement", "displacement_node" | |
| if to_socket_name in socket_roles: | |
| return socket_roles[to_socket_name], "linked_socket" | |
| return None, "unknown" | |
| def infer_texture_role(node): | |
| role, role_source = infer_from_links(node) | |
| if role: | |
| return role, role_source | |
| role = infer_from_text(getattr(node, "label", ""), getattr(node, "name", "")) | |
| if role: | |
| return role, "node_label_or_name" | |
| image = getattr(node, "image", None) | |
| if image: | |
| role = infer_from_text(getattr(image, "name", ""), getattr(image, "filepath", "")) | |
| if role: | |
| return role, "image_name_or_path" | |
| return None, "unknown" |
🧰 Tools
🪛 Ruff (0.15.17)
[warning] 6889-6889: Missing return type annotation for private function infer_from_text
(ANN202)
[warning] 6889-6889: Missing type annotation for *values
(ANN002)
[warning] 6896-6896: Missing return type annotation for private function infer_texture_role
(ANN202)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@public/downloads/vipermesh-addon.py` around lines 6889 - 6918, The
infer_texture_role function checks text-based inference (node label, image
name/path) before graph link inference, which allows broad substring matches in
paths to override specific socket connections. Reorder the logic in
infer_texture_role to check the graph links first (the loop iterating over
outputs and links to find connected socket roles), and only fall back to
text-based inference on node labels and image names if no graph link information
is found. This ensures specific socket connections take precedence over generic
text pattern matching.
| missing_materials = [] | ||
| if requested_names: | ||
| materials = [] | ||
| for name in requested_names: | ||
| material = bpy.data.materials.get(name) | ||
| if material is None: | ||
| missing_materials.append(name) | ||
| else: | ||
| materials.append(material) | ||
| else: | ||
| materials = [material for material in bpy.data.materials if getattr(material, "use_nodes", False)] | ||
|
|
||
| reports = [] | ||
| changes = [] | ||
| warnings = [] | ||
| for material in materials: | ||
| material_report = { | ||
| "material": material.name, | ||
| "image_textures": [], | ||
| } | ||
| node_tree = getattr(material, "node_tree", None) | ||
| if not node_tree: | ||
| reports.append(material_report) | ||
| continue | ||
|
|
||
| for node in node_tree.nodes: | ||
| if getattr(node, "bl_idname", "") != "ShaderNodeTexImage": | ||
| continue | ||
| image = getattr(node, "image", None) | ||
| if image is None: | ||
| material_report["image_textures"].append({ | ||
| "node": node.name, | ||
| "role": None, | ||
| "issue": "missing_image", | ||
| }) | ||
| continue | ||
|
|
||
| role, role_source = infer_texture_role(node) | ||
| current_color_space = image.colorspace_settings.name | ||
| expected_color_space = expected_color_spaces.get(role) | ||
| needs_change = bool(expected_color_space and current_color_space != expected_color_space) | ||
| entry = { | ||
| "node": node.name, | ||
| "label": node.label, | ||
| "image": image.name, | ||
| "filepath": bpy.path.abspath(image.filepath) if image.filepath else "", | ||
| "role": role, | ||
| "role_source": role_source, | ||
| "current_color_space": current_color_space, | ||
| "expected_color_space": expected_color_space, | ||
| "needs_change": needs_change, | ||
| } | ||
|
|
||
| if role is None: | ||
| warnings.append(f"{material.name}/{node.name}: could not infer texture role") | ||
| elif needs_change: | ||
| change = { | ||
| "material": material.name, | ||
| "node": node.name, | ||
| "image": image.name, | ||
| "role": role, | ||
| "from": current_color_space, | ||
| "to": expected_color_space, | ||
| "applied": False, | ||
| } | ||
| if apply_changes: | ||
| try: | ||
| image.colorspace_settings.name = expected_color_space | ||
| change["applied"] = True | ||
| entry["current_color_space"] = image.colorspace_settings.name | ||
| entry["needs_change"] = image.colorspace_settings.name != expected_color_space | ||
| except Exception as exc: | ||
| change["error"] = str(exc) | ||
| warnings.append(f"{material.name}/{node.name}: failed to set color space to {expected_color_space}: {str(exc)}") | ||
| changes.append(change) | ||
|
|
||
| material_report["image_textures"].append(entry) | ||
|
|
||
| reports.append(material_report) | ||
|
|
||
| return { | ||
| "success": True, | ||
| "apply_changes": apply_changes, | ||
| "materials_checked": len(reports), | ||
| "missing_materials": missing_materials, | ||
| "changes": changes, | ||
| "warnings": warnings, | ||
| "reports": reports, | ||
| "next_safe_action": "run inspect_material_node_graph on changed materials, then validate_export_readiness if exporting", | ||
| } |
There was a problem hiding this comment.
Return unsuccessful status when requested materials are missing.
A call with only misspelled material_names returns success: True with materials_checked: 0, so the agent can treat a no-op as completed.
Suggested fix
return {
- "success": True,
+ "success": not bool(missing_materials),
"apply_changes": apply_changes,
"materials_checked": len(reports),
"missing_materials": missing_materials,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| missing_materials = [] | |
| if requested_names: | |
| materials = [] | |
| for name in requested_names: | |
| material = bpy.data.materials.get(name) | |
| if material is None: | |
| missing_materials.append(name) | |
| else: | |
| materials.append(material) | |
| else: | |
| materials = [material for material in bpy.data.materials if getattr(material, "use_nodes", False)] | |
| reports = [] | |
| changes = [] | |
| warnings = [] | |
| for material in materials: | |
| material_report = { | |
| "material": material.name, | |
| "image_textures": [], | |
| } | |
| node_tree = getattr(material, "node_tree", None) | |
| if not node_tree: | |
| reports.append(material_report) | |
| continue | |
| for node in node_tree.nodes: | |
| if getattr(node, "bl_idname", "") != "ShaderNodeTexImage": | |
| continue | |
| image = getattr(node, "image", None) | |
| if image is None: | |
| material_report["image_textures"].append({ | |
| "node": node.name, | |
| "role": None, | |
| "issue": "missing_image", | |
| }) | |
| continue | |
| role, role_source = infer_texture_role(node) | |
| current_color_space = image.colorspace_settings.name | |
| expected_color_space = expected_color_spaces.get(role) | |
| needs_change = bool(expected_color_space and current_color_space != expected_color_space) | |
| entry = { | |
| "node": node.name, | |
| "label": node.label, | |
| "image": image.name, | |
| "filepath": bpy.path.abspath(image.filepath) if image.filepath else "", | |
| "role": role, | |
| "role_source": role_source, | |
| "current_color_space": current_color_space, | |
| "expected_color_space": expected_color_space, | |
| "needs_change": needs_change, | |
| } | |
| if role is None: | |
| warnings.append(f"{material.name}/{node.name}: could not infer texture role") | |
| elif needs_change: | |
| change = { | |
| "material": material.name, | |
| "node": node.name, | |
| "image": image.name, | |
| "role": role, | |
| "from": current_color_space, | |
| "to": expected_color_space, | |
| "applied": False, | |
| } | |
| if apply_changes: | |
| try: | |
| image.colorspace_settings.name = expected_color_space | |
| change["applied"] = True | |
| entry["current_color_space"] = image.colorspace_settings.name | |
| entry["needs_change"] = image.colorspace_settings.name != expected_color_space | |
| except Exception as exc: | |
| change["error"] = str(exc) | |
| warnings.append(f"{material.name}/{node.name}: failed to set color space to {expected_color_space}: {str(exc)}") | |
| changes.append(change) | |
| material_report["image_textures"].append(entry) | |
| reports.append(material_report) | |
| return { | |
| "success": True, | |
| "apply_changes": apply_changes, | |
| "materials_checked": len(reports), | |
| "missing_materials": missing_materials, | |
| "changes": changes, | |
| "warnings": warnings, | |
| "reports": reports, | |
| "next_safe_action": "run inspect_material_node_graph on changed materials, then validate_export_readiness if exporting", | |
| } | |
| return { | |
| "success": not bool(missing_materials), | |
| "apply_changes": apply_changes, | |
| "materials_checked": len(reports), | |
| "missing_materials": missing_materials, | |
| "changes": changes, | |
| "warnings": warnings, | |
| "reports": reports, | |
| "next_safe_action": "run inspect_material_node_graph on changed materials, then validate_export_readiness if exporting", | |
| } |
🧰 Tools
🪛 Ruff (0.15.17)
[warning] 7000-7000: Do not catch blind exception: Exception
(BLE001)
[warning] 7002-7002: Use explicit conversion flag
Replace with conversion flag
(RUF010)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@public/downloads/vipermesh-addon.py` around lines 6929 - 7018, The function
returns success: True even when all requested materials in requested_names are
missing and no materials are actually processed. Add a condition before the
return statement to check if requested_names was provided but resulted in zero
materials being processed (i.e., len(requested_names) > 0 and len(materials) ==
0), and if so, set success to False in the returned dictionary instead of True.
This ensures that a no-op due to misspelled or non-existent material names is
correctly reported as a failed operation.
| assert.match(source, /expected_color_spaces = \{/, `${label} defines deterministic color-space expectations`) | ||
| assert.match(source, /"base_color": "sRGB"/, `${label} treats base color maps as sRGB`) | ||
| assert.match(source, /"normal": "Non-Color"/, `${label} treats normal maps as Non-Color`) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify the addon defines color space mappings for all standard PBR channels
rg -A 20 'expected_color_spaces\s*=' desktop/assets/vipermesh-addon.pyRepository: Ker102/ViperMesh
Length of output: 874
Test coverage for color space mappings is incomplete, but the addon implementation is more comprehensive than originally assessed.
The test validates only base_color (sRGB) and normal (Non-Color), but the addon implementation actually defines color spaces for additional PBR channels: emission (sRGB), roughness (Non-Color), metallic (Non-Color), ambient_occlusion (Non-Color), alpha (Non-Color), and displacement (Non-Color).
Note: The addon maps displacement to Non-Color rather than Linear, contradicting the external context assumption. Consider expanding test assertions to verify the complete set of expected color space mappings if full coverage is desired.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/test/test-blender-material-texture-normalization-tool.ts` around
lines 19 - 21, The test assertions are incomplete and only verify color space
mappings for base_color and normal channels, but the addon implementation
defines mappings for additional PBR channels: emission (sRGB), roughness
(Non-Color), metallic (Non-Color), ambient_occlusion (Non-Color), alpha
(Non-Color), and displacement (Non-Color). Add additional assert.match calls
after the existing ones to verify that the source contains the correct color
space mappings for each of these additional PBR channels, following the same
pattern as the existing base_color and normal assertions.
Source: MCP tools
Summary
normalize_material_texture_channelsfor report-only inspection and optional deterministic color-space repair of imported material texture maps.Validation
npx tsx scripts/test/test-blender-material-texture-normalization-tool.tsnpx tsx scripts/test/test-tool-guide-trigger-coverage.tsnpx tsx scripts/test/test-blender-capability-inventory.tspython -m py_compile desktop/assets/vipermesh-addon.py public/downloads/vipermesh-addon.pynpx tsc --noEmit --incremental falsenpm run lint(passes with existing baseline-browser-mapping age warning)git diff --check(passes with Windows line-ending warnings)Summary by CodeRabbit
New Features
Documentation