blender tooling

This commit is contained in:
Peterino2 2026-07-10 20:51:21 -07:00
commit ca9ddb196e
15 changed files with 1570 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/dist/
/.test-profile/
.venv/
*.pyc
__pycache__/

127
README.md Normal file
View File

@ -0,0 +1,127 @@
# Blender Tools
Small tools for moving textured assets between Blender and Substance Painter.
```text
blender/
material_id_baker/ Blender 5.x extension source
scripts/ Build, install/update, and test helpers
tests/ Headless Blender smoke test
tools/
combine_substance_textures.py
requirements.txt
tests/
dist/ Built extension packages (generated)
```
Build every Blender extension in the repository at once with:
```bash
./build-all.sh
```
Set `BLENDER_BIN=/path/to/blender` when Blender is not available as `blender`.
## Material ID Baker extension
The Blender extension bakes the active mesh's material-slot assignments to a flat-color texture using its active UV map. It works on a temporary mesh copy, so the source mesh and materials are not modified.
Features include 256px8K output, configurable island margin, optional evaluated modifiers, and three palette modes: deterministic distinct colors, material viewport colors, and exact slot-index encoding. It can write a PNG plus a JSON legend used by the standalone merger.
### Install or update
Close Blender, set `BLENDER_BIN` if Blender 5.2 is not available as `blender`, and run:
```bash
BLENDER_BIN=/path/to/blender-5.2 ./blender/scripts/install.sh
```
This builds `dist/material_id_baker-<version>.zip` and installs or reinstalls the stable `material_id_baker` extension ID in Blender's `user_default` repository. For a release, bump `version` in `blender/material_id_baker/blender_manifest.toml` first.
You can instead run `./blender/scripts/build.sh` and install the resulting ZIP through **Edit > Preferences > Extensions > Install from Disk**.
### Bake an ID map
1. Select one mesh in Object Mode.
2. Ensure it has an active UV map and the intended per-face material assignments.
3. Open the 3D Viewport sidebar and choose the **Material ID** tab.
4. Select the resolution, margin, and color mode.
5. Enable **Save PNG** and **Save JSON Legend**, then click **Bake Material ID**.
Overlapping UV islands with different materials are ambiguous. Use a non-overlapping UV layout for recovery/compositing.
## Combine separate Substance Painter texture sets
Use [tools/combine_substance_textures.py](tools/combine_substance_textures.py) when an asset was imported into Substance with one texture set per Blender material and now needs one texture per channel.
Ordinary alpha compositing is unreliable here because Substance exports may have opaque backgrounds and padding. The merger uses the Material ID PNG and JSON legend as exact masks, so padding in each separate texture set cannot overwrite another material.
Install its one dependency in your preferred Python environment:
```bash
python3 -m venv .venv
.venv/bin/pip install -r tools/requirements.txt
```
Then run:
```bash
.venv/bin/python tools/combine_substance_textures.py \
--id-map recovery/Material_ID.png \
--legend recovery/Material_ID.json \
--input-dir substance-export \
--output-dir combined
```
Suppose the legend contains materials named `Body` and `Trim`, and Substance exported:
```text
Robot_Body_BaseColor.png
Robot_Trim_BaseColor.png
Robot_Body_Normal.png
Robot_Trim_Normal.png
```
The tool discovers the material token and writes:
```text
combined/Robot_BaseColor.png
combined/Robot_Normal.png
```
The ID map and exported textures must have the same dimensions. The merger first uses exact ID bytes, with an automatic one-byte fallback only when a material's exact color is absent (this handles Blender's float-to-PNG rounding). `--tolerance 0` forces strict matching; larger explicit tolerances are available for external ID maps, but the tool rejects values that make material masks overlap.
If a Substance texture-set token differs from its Blender material name, add aliases:
```bash
.venv/bin/python tools/combine_substance_textures.py \
--id-map recovery/Material_ID.png \
--legend recovery/Material_ID.json \
--input-dir substance-export \
--output-dir combined \
--material-alias 'Body Material=Body' \
--material-alias 'Trim Material=Trim'
```
Useful options:
- `--dry-run` shows discovered channel groups without writing images.
- `--recursive` searches subdirectories and can group layouts such as `Body/BaseColor.png` plus `Trim/BaseColor.png`; shared directory prefixes are preserved.
- `--overwrite` replaces existing combined textures.
- `--material-alias 'LEGEND NAME=FILENAME NAME'` may be repeated.
The merger supports common Pillow-readable image types including PNG, TGA, TIFF, and JPEG. Prefer lossless 8-bit PNG/TGA exports for data maps; JPEG compression can alter texture values.
## Tests
Run the extension bake test with Blender:
```bash
./blender/scripts/test.sh
```
Run the standalone merger tests after installing Pillow:
```bash
./tools/test.sh
```

View File

@ -0,0 +1,480 @@
from __future__ import annotations
import colorsys
import json
from pathlib import Path
from typing import Any
import bpy
from bpy.props import (
BoolProperty,
CollectionProperty,
EnumProperty,
FloatVectorProperty,
IntProperty,
PointerProperty,
StringProperty,
)
from bpy.types import Operator, Panel, PropertyGroup
_TEMP_PREFIX = "__MID_BAKER_"
def _distinct_color(index: int) -> tuple[float, float, float]:
"""Return a deterministic, vivid linear RGB color for a zero-based slot."""
hue = (0.03 + index * 0.618033988749895) % 1.0
saturation = 0.72 if index % 2 == 0 else 0.9
value = 1.0 if index % 3 else 0.82
return colorsys.hsv_to_rgb(hue, saturation, value)
def _index_color(index: int) -> tuple[float, float, float]:
"""Encode a one-based material ID into 24 bits, reserving black for empty pixels."""
value = index + 1
return (
(value & 0xFF) / 255.0,
((value >> 8) & 0xFF) / 255.0,
((value >> 16) & 0xFF) / 255.0,
)
def _slot_color(
index: int,
material: bpy.types.Material | None,
palette_mode: str,
) -> tuple[float, float, float]:
if palette_mode == "MATERIAL" and material is not None:
return tuple(material.diffuse_color[:3])
if palette_mode == "INDEX":
return _index_color(index)
return _distinct_color(index)
def _create_id_material(
slot_index: int,
color: tuple[float, float, float],
image: bpy.types.Image,
) -> bpy.types.Material:
material = bpy.data.materials.new(f"{_TEMP_PREFIX}Material_{slot_index + 1}")
material.use_nodes = True
nodes = material.node_tree.nodes
nodes.clear()
output = nodes.new("ShaderNodeOutputMaterial")
output.location = (360.0, 0.0)
emission = nodes.new("ShaderNodeEmission")
emission.location = (80.0, 0.0)
emission.inputs["Color"].default_value = (*color, 1.0)
emission.inputs["Strength"].default_value = 1.0
image_node = nodes.new("ShaderNodeTexImage")
image_node.location = (-260.0, -180.0)
image_node.image = image
image_node.select = True
nodes.active = image_node
material.node_tree.links.new(emission.outputs["Emission"], output.inputs["Surface"])
return material
def _normalise_png_path(filepath: str) -> Path:
path = Path(bpy.path.abspath(filepath)).expanduser()
if path.suffix.lower() != ".png":
path = path.with_suffix(".png")
return path
def _snapshot_bake_settings(scene: bpy.types.Scene) -> dict[str, Any]:
bake = scene.render.bake
return {
"engine": scene.render.engine,
"margin": bake.margin,
"target": bake.target,
"use_clear": bake.use_clear,
"use_selected_to_active": bake.use_selected_to_active,
}
def _restore_bake_settings(scene: bpy.types.Scene, state: dict[str, Any]) -> None:
scene.render.engine = state["engine"]
bake = scene.render.bake
bake.margin = state["margin"]
bake.target = state["target"]
bake.use_clear = state["use_clear"]
bake.use_selected_to_active = state["use_selected_to_active"]
class MIDB_PaletteEntry(PropertyGroup):
slot: IntProperty(name="Slot", default=1, min=1)
material_name: StringProperty(name="Material")
color: FloatVectorProperty(
name="ID Color",
subtype="COLOR",
size=3,
min=0.0,
max=1.0,
)
class MIDB_Settings(PropertyGroup):
resolution: EnumProperty(
name="Resolution",
items=(
("256", "256", "256 x 256"),
("512", "512", "512 x 512"),
("1024", "1K", "1024 x 1024"),
("2048", "2K", "2048 x 2048"),
("4096", "4K", "4096 x 4096"),
("8192", "8K", "8192 x 8192"),
),
default="2048",
)
margin: IntProperty(
name="Margin",
description="Extend IDs beyond UV island edges by this many pixels",
default=16,
min=0,
max=256,
subtype="PIXEL",
)
palette_mode: EnumProperty(
name="Colors",
items=(
(
"DISTINCT",
"Distinct",
"Generate deterministic, easy-to-see colors for material slots",
),
(
"MATERIAL",
"Material Viewport Colors",
"Use each material's viewport display color",
),
(
"INDEX",
"Exact Slot Index",
"Encode the one-based slot number as a 24-bit RGB integer",
),
),
default="DISTINCT",
)
apply_modifiers: BoolProperty(
name="Apply Modifiers",
description="Bake evaluated modifier geometry instead of the base mesh",
default=False,
)
image_name: StringProperty(
name="Image Name",
default="Material_ID",
)
save_to_disk: BoolProperty(
name="Save PNG",
default=True,
)
filepath: StringProperty(
name="File Path",
subtype="FILE_PATH",
default="//material_id.png",
)
write_legend: BoolProperty(
name="Save JSON Legend",
description="Write slot names and exact colors beside the PNG",
default=True,
)
last_image_name: StringProperty(name="Last Image")
last_palette: CollectionProperty(type=MIDB_PaletteEntry)
class MIDB_OT_Bake(Operator):
bl_idname = "object.bake_material_id"
bl_label = "Bake Material ID"
bl_description = "Bake the active mesh's material assignments into an ID image"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
obj = context.active_object
return bool(
obj
and obj.type == "MESH"
and context.mode == "OBJECT"
and obj.data.uv_layers
)
def execute(self, context: bpy.types.Context) -> set[str]:
settings = context.scene.material_id_baker
source = context.active_object
scene = context.scene
view_layer = context.view_layer
if source is None or source.type != "MESH":
self.report({"ERROR"}, "Select an active mesh object")
return {"CANCELLED"}
if context.mode != "OBJECT":
self.report({"ERROR"}, "Switch to Object Mode before baking")
return {"CANCELLED"}
if not source.data.uv_layers:
self.report({"ERROR"}, "The active mesh needs a UV map")
return {"CANCELLED"}
selected_before = list(context.selected_objects)
active_before = view_layer.objects.active
bake_state = _snapshot_bake_settings(scene)
temp_object: bpy.types.Object | None = None
temp_mesh: bpy.types.Mesh | None = None
temp_materials: list[bpy.types.Material] = []
image: bpy.types.Image | None = None
succeeded = False
try:
if settings.apply_modifiers:
depsgraph = context.evaluated_depsgraph_get()
evaluated = source.evaluated_get(depsgraph)
temp_mesh = bpy.data.meshes.new_from_object(
evaluated,
preserve_all_data_layers=True,
depsgraph=depsgraph,
)
else:
temp_mesh = source.data.copy()
if not temp_mesh.uv_layers:
raise RuntimeError("The evaluated mesh has no UV map")
source_uv = source.data.uv_layers.active
baked_uv = temp_mesh.uv_layers.get(source_uv.name) if source_uv else None
if baked_uv is None:
baked_uv = temp_mesh.uv_layers.active
temp_mesh.uv_layers.active = baked_uv
for uv_layer in temp_mesh.uv_layers:
uv_layer.active_render = uv_layer == baked_uv
temp_object = bpy.data.objects.new(
f"{_TEMP_PREFIX}{source.name}",
temp_mesh,
)
temp_object.matrix_world = source.matrix_world.copy()
scene.collection.objects.link(temp_object)
highest_slot = max(
(polygon.material_index for polygon in temp_mesh.polygons),
default=0,
)
slot_count = max(1, len(temp_mesh.materials), highest_slot + 1)
source_materials = []
for index in range(slot_count):
material = None
if index < len(source.material_slots):
material = source.material_slots[index].material
if material is None and index < len(temp_mesh.materials):
material = temp_mesh.materials[index]
source_materials.append(material)
colors = [
_slot_color(index, material, settings.palette_mode)
for index, material in enumerate(source_materials)
]
resolution = int(settings.resolution)
image = bpy.data.images.new(
name=settings.image_name.strip() or "Material_ID",
width=resolution,
height=resolution,
alpha=True,
float_buffer=False,
)
image.generated_color = (0.0, 0.0, 0.0, 0.0)
image.alpha_mode = "STRAIGHT"
try:
image.colorspace_settings.name = "Non-Color"
except TypeError:
# Some color configurations use a differently named raw space.
image.colorspace_settings.is_data = True
for index, color in enumerate(colors):
temp_materials.append(_create_id_material(index, color, image))
polygon_material_indices = [
polygon.material_index for polygon in temp_mesh.polygons
]
temp_mesh.materials.clear()
for material in temp_materials:
temp_mesh.materials.append(material)
for polygon, material_index in zip(
temp_mesh.polygons,
polygon_material_indices,
strict=True,
):
polygon.material_index = min(material_index, slot_count - 1)
for obj in list(context.selected_objects):
obj.select_set(False)
temp_object.hide_set(False)
temp_object.hide_render = False
temp_object.select_set(True)
view_layer.objects.active = temp_object
# Cycles owns Blender's image-bake pipeline even though this flat
# emission bake needs only one deterministic sample.
scene.render.engine = "CYCLES"
scene.render.bake.margin = settings.margin
scene.render.bake.target = "IMAGE_TEXTURES"
scene.render.bake.use_clear = True
scene.render.bake.use_selected_to_active = False
context.window_manager.progress_begin(0, 1)
try:
result = bpy.ops.object.bake(type="EMIT")
finally:
context.window_manager.progress_end()
if "FINISHED" not in result:
raise RuntimeError("Blender did not finish the bake")
palette = [
{
"slot": index + 1,
"material": material.name if material else f"Unassigned Slot {index + 1}",
"rgb": [round(component, 8) for component in colors[index]],
}
for index, material in enumerate(source_materials)
]
image["material_id_palette"] = json.dumps(palette, separators=(",", ":"))
image["source_object"] = source.name
image["uv_map"] = temp_mesh.uv_layers.active.name
saved_path: Path | None = None
if settings.save_to_disk:
saved_path = _normalise_png_path(settings.filepath)
saved_path.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(saved_path)
image.file_format = "PNG"
image.save()
if settings.write_legend:
legend_path = saved_path.with_suffix(".json")
legend = {
"image": saved_path.name,
"source_object": source.name,
"uv_map": temp_mesh.uv_layers.active.name,
"palette_mode": settings.palette_mode,
"materials": palette,
}
with legend_path.open("w", encoding="utf-8") as handle:
json.dump(legend, handle, indent=2)
handle.write("\n")
settings.last_image_name = image.name
settings.last_palette.clear()
for item in palette:
entry = settings.last_palette.add()
entry.slot = item["slot"]
entry.material_name = item["material"]
entry.color = item["rgb"]
succeeded = True
if saved_path:
self.report({"INFO"}, f"Baked material IDs to {saved_path}")
else:
self.report({"INFO"}, f"Baked material IDs to image '{image.name}'")
return {"FINISHED"}
except Exception as exc:
self.report({"ERROR"}, f"Material ID bake failed: {exc}")
return {"CANCELLED"}
finally:
if temp_object is not None:
bpy.data.objects.remove(temp_object, do_unlink=True)
if temp_mesh is not None and temp_mesh.users == 0:
bpy.data.meshes.remove(temp_mesh)
for material in temp_materials:
if material.users == 0:
bpy.data.materials.remove(material)
_restore_bake_settings(scene, bake_state)
for obj in list(context.selected_objects):
obj.select_set(False)
for obj in selected_before:
if obj.name in view_layer.objects:
obj.select_set(True)
if active_before and active_before.name in view_layer.objects:
view_layer.objects.active = active_before
if not succeeded and image is not None and image.users == 0:
bpy.data.images.remove(image)
class MIDB_PT_Panel(Panel):
bl_label = "Material ID Baker"
bl_idname = "MIDB_PT_material_id_baker"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Material ID"
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
settings = context.scene.material_id_baker
obj = context.active_object
if obj is None or obj.type != "MESH":
layout.label(text="Select a mesh object", icon="INFO")
return
mesh = obj.data
if mesh.uv_layers:
uv_layer = mesh.uv_layers.active
layout.label(text=f"UV Map: {uv_layer.name}", icon="GROUP_UVS")
else:
layout.label(text="A UV map is required", icon="ERROR")
layout.label(text=f"Material Slots: {max(1, len(obj.material_slots))}")
column = layout.column(align=True)
column.prop(settings, "resolution")
column.prop(settings, "margin")
column.prop(settings, "palette_mode")
column.prop(settings, "apply_modifiers")
output = layout.box()
output.label(text="Output")
output.prop(settings, "image_name")
output.prop(settings, "save_to_disk")
if settings.save_to_disk:
output.prop(settings, "filepath")
output.prop(settings, "write_legend")
operator_column = layout.column()
operator_column.enabled = bool(mesh.uv_layers and context.mode == "OBJECT")
operator_column.operator("object.bake_material_id", icon="RENDER_STILL")
if context.mode != "OBJECT":
layout.label(text="Switch to Object Mode to bake", icon="INFO")
if settings.last_palette:
palette_box = layout.box()
palette_box.label(text=f"Last Bake: {settings.last_image_name}")
for entry in settings.last_palette:
row = palette_box.row(align=True)
row.label(text=f"{entry.slot}: {entry.material_name}")
row.prop(entry, "color", text="")
_CLASSES = (
MIDB_PaletteEntry,
MIDB_Settings,
MIDB_OT_Bake,
MIDB_PT_Panel,
)
def register() -> None:
for cls in _CLASSES:
bpy.utils.register_class(cls)
bpy.types.Scene.material_id_baker = PointerProperty(type=MIDB_Settings)
def unregister() -> None:
del bpy.types.Scene.material_id_baker
for cls in reversed(_CLASSES):
bpy.utils.unregister_class(cls)

View File

@ -0,0 +1,17 @@
schema_version = "1.0.0"
id = "material_id_baker"
version = "1.0.0"
name = "Material ID Baker"
tagline = "Bake mesh material assignments to a color ID texture"
maintainer = "Sear"
type = "add-on"
blender_version_min = "5.0.0"
license = [
"SPDX:GPL-3.0-or-later",
]
[permissions]
files = "Save baked ID textures and JSON palette legends"

11
blender/scripts/build.sh Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
blender_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
repo_root="$(cd "$blender_root/.." && pwd)"
blender_bin="${BLENDER_BIN:-blender}"
mkdir -p "$repo_root/dist"
"$blender_bin" --factory-startup --command extension build \
--source-dir "$blender_root/material_id_baker" \
--output-dir "$repo_root/dist"

20
blender/scripts/install.sh Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
blender_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
repo_root="$(cd "$blender_root/.." && pwd)"
blender_bin="${BLENDER_BIN:-blender}"
repository="${BLENDER_EXTENSION_REPO:-user_default}"
"$blender_root/scripts/build.sh"
archive="$(find "$repo_root/dist" -maxdepth 1 -type f -name 'material_id_baker-*.zip' -printf '%T@ %p\n' | sort -n | tail -1 | cut -d' ' -f2-)"
if [[ -z "$archive" ]]; then
echo "No Material ID Baker package was produced." >&2
exit 1
fi
"$blender_bin" --factory-startup --command extension install-file \
-r "$repository" -e "$archive"
echo "Installed $archive into Blender repository '$repository'."

8
blender/scripts/test.sh Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
blender_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
blender_bin="${BLENDER_BIN:-blender}"
"$blender_bin" --factory-startup --background \
--python "$blender_root/tests/smoke_test.py"

111
blender/tests/smoke_test.py Normal file
View File

@ -0,0 +1,111 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
import bpy
BLENDER_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BLENDER_ROOT))
import material_id_baker # noqa: E402
def make_test_object() -> bpy.types.Object:
mesh = bpy.data.meshes.new("MaterialIDSmokeMesh")
mesh.from_pydata(
[
(-1.0, -1.0, 0.0),
(0.0, -1.0, 0.0),
(0.0, 1.0, 0.0),
(-1.0, 1.0, 0.0),
(0.0, -1.0, 0.0),
(1.0, -1.0, 0.0),
(1.0, 1.0, 0.0),
(0.0, 1.0, 0.0),
],
[],
[(0, 1, 2, 3), (4, 5, 6, 7)],
)
mesh.update()
left = bpy.data.materials.new("Left Material")
right = bpy.data.materials.new("Right Material")
mesh.materials.append(left)
mesh.materials.append(right)
mesh.polygons[0].material_index = 0
mesh.polygons[1].material_index = 1
uv_layer = mesh.uv_layers.new(name="ID UV")
uv_coordinates = (
(0.05, 0.05),
(0.45, 0.05),
(0.45, 0.95),
(0.05, 0.95),
(0.55, 0.05),
(0.95, 0.05),
(0.95, 0.95),
(0.55, 0.95),
)
for loop, uv in zip(mesh.loops, uv_coordinates, strict=True):
uv_layer.uv[loop.index].vector = uv
obj = bpy.data.objects.new("MaterialIDSmokeObject", mesh)
bpy.context.scene.collection.objects.link(obj)
obj.modifiers.new(name="Triangulate for evaluated-mesh test", type="TRIANGULATE")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
return obj
def pixel(image: bpy.types.Image, x: int, y: int) -> tuple[float, float, float, float]:
offset = (y * image.size[0] + x) * 4
return tuple(image.pixels[offset : offset + 4])
def close_rgb(actual: tuple[float, ...], expected: list[float], tolerance: float = 0.03) -> bool:
return all(abs(actual[index] - expected[index]) <= tolerance for index in range(3))
def main() -> None:
material_id_baker.register()
source = make_test_object()
original_engine = bpy.context.scene.render.engine
settings = bpy.context.scene.material_id_baker
settings.resolution = "256"
settings.margin = 4
settings.palette_mode = "DISTINCT"
settings.apply_modifiers = True
settings.image_name = "Material_ID_Smoke"
settings.save_to_disk = True
settings.filepath = "/tmp/material_id_baker_smoke.png"
settings.write_legend = True
result = bpy.ops.object.bake_material_id()
assert result == {"FINISHED"}, result
assert bpy.context.active_object == source
assert source.select_get()
assert bpy.context.scene.render.engine == original_engine
assert not any(item.name.startswith("__MID_BAKER_") for item in bpy.data.objects)
assert not any(item.name.startswith("__MID_BAKER_") for item in bpy.data.materials)
image = bpy.data.images[settings.last_image_name]
palette = json.loads(image["material_id_palette"])
assert len(palette) == 2
assert palette[0]["material"] == "Left Material"
assert palette[1]["material"] == "Right Material"
left_pixel = pixel(image, 64, 128)
right_pixel = pixel(image, 192, 128)
assert close_rgb(left_pixel, palette[0]["rgb"]), (left_pixel, palette[0])
assert close_rgb(right_pixel, palette[1]["rgb"]), (right_pixel, palette[1])
assert Path("/tmp/material_id_baker_smoke.png").is_file()
assert Path("/tmp/material_id_baker_smoke.json").is_file()
print("MATERIAL_ID_BAKER_SMOKE_TEST_OK")
if __name__ == "__main__":
main()

28
build-all.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
blender_bin="${BLENDER_BIN:-blender}"
dist_dir="$repo_root/dist"
mapfile -d '' manifests < <(
find "$repo_root/blender" -type f -name blender_manifest.toml -print0 | sort -z
)
if (( ${#manifests[@]} == 0 )); then
echo "No Blender extension manifests found under $repo_root/blender." >&2
exit 1
fi
mkdir -p "$dist_dir"
for manifest in "${manifests[@]}"; do
source_dir="$(dirname "$manifest")"
echo "Building Blender extension: ${source_dir#"$repo_root/"}"
"$blender_bin" --factory-startup --command extension build \
--source-dir "$source_dir" \
--output-dir "$dist_dir"
done
echo "Built ${#manifests[@]} Blender extension(s) into $dist_dir."

8
tools/README.md Normal file
View File

@ -0,0 +1,8 @@
# Substance texture-set merger
`combine_substance_textures.py` reconstructs one texture per channel from separate Substance Painter texture-set exports. It uses the PNG and JSON legend produced by the Blender Material ID Baker as masks.
See the repository [README](../README.md#combine-separate-substance-painter-texture-sets) for installation, naming examples, aliases, and command-line usage.
Run `python combine_substance_textures.py --help` for every option.

View File

@ -0,0 +1,575 @@
#!/usr/bin/env python3
"""Combine per-material Substance exports with a Blender Material ID map.
The companion Blender extension writes a PNG and JSON legend. This tool uses
those exact ID colors as masks, discovers material names in Substance Painter
filenames, and combines matching channels without trusting texture alpha.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Sequence
try:
from PIL import Image, ImageChops
except ImportError: # pragma: no cover - exercised by the friendly CLI error.
Image = None
ImageChops = None
SUPPORTED_SUFFIXES = {
".bmp",
".jpeg",
".jpg",
".png",
".tga",
".tif",
".tiff",
".webp",
}
MATERIAL_TOKEN = "{material}"
class CombineError(RuntimeError):
"""A user-correctable texture discovery or composition error."""
@dataclass(frozen=True)
class LegendMaterial:
slot: int
name: str
rgb: tuple[int, int, int]
filename_name: str
@dataclass(frozen=True)
class TextureCandidate:
path: Path
material: LegendMaterial
relative_parent: Path
stem_pattern: str
suffix: str
@dataclass
class TextureGroup:
relative_parent: Path
stem_pattern: str
suffix: str
textures: dict[str, Path]
@property
def output_name(self) -> str:
stem = self.stem_pattern.replace(MATERIAL_TOKEN, "")
stem = re.sub(r"[\s_.-]+", "_", stem).strip("_")
return f"{stem or 'Combined'}{self.suffix.lower()}"
@property
def output_parent(self) -> Path:
parts = []
for part in self.relative_parent.parts:
cleaned = part.replace(MATERIAL_TOKEN, "")
cleaned = re.sub(r"[\s_.-]+", "_", cleaned).strip("_")
if cleaned:
parts.append(cleaned)
return Path(*parts) if parts else Path(".")
@property
def description(self) -> str:
parent = "" if self.relative_parent == Path(".") else f"{self.relative_parent}/"
return f"{parent}{self.stem_pattern}{self.suffix}"
def _require_pillow() -> None:
if Image is None or ImageChops is None:
raise CombineError(
"Pillow is required. Install it with "
"'python -m pip install -r tools/requirements.txt'."
)
def _parse_aliases(values: Sequence[str]) -> dict[str, str]:
aliases: dict[str, str] = {}
for value in values:
if "=" not in value:
raise CombineError(
f"Invalid material alias {value!r}; expected 'LEGEND NAME=FILENAME NAME'."
)
legend_name, filename_name = (part.strip() for part in value.split("=", 1))
if not legend_name or not filename_name:
raise CombineError(f"Invalid material alias {value!r}; both names are required.")
aliases[legend_name.casefold()] = filename_name
return aliases
def _rgb8(value: Any, material_name: str) -> tuple[int, int, int]:
if not isinstance(value, list) or len(value) < 3:
raise CombineError(f"Legend material {material_name!r} has no valid RGB triplet.")
try:
components = tuple(float(component) for component in value[:3])
except (TypeError, ValueError) as exc:
raise CombineError(
f"Legend material {material_name!r} has a non-numeric RGB value."
) from exc
if any(component < 0.0 or component > 1.0 for component in components):
raise CombineError(
f"Legend material {material_name!r} has RGB values outside 0.01.0."
)
return tuple(round(component * 255.0) for component in components)
def load_legend(path: Path, aliases: dict[str, str] | None = None) -> list[LegendMaterial]:
aliases = aliases or {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise CombineError(f"Could not read legend {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise CombineError(f"Legend {path} is not valid JSON: {exc}") from exc
entries = payload.get("materials") if isinstance(payload, dict) else None
if not isinstance(entries, list) or not entries:
raise CombineError(f"Legend {path} does not contain a non-empty 'materials' list.")
materials: list[LegendMaterial] = []
seen_names: set[str] = set()
seen_colors: set[tuple[int, int, int]] = set()
for fallback_slot, entry in enumerate(entries, start=1):
if not isinstance(entry, dict):
raise CombineError(f"Legend entry {fallback_slot} is not an object.")
name = str(entry.get("material", "")).strip()
if not name:
raise CombineError(f"Legend entry {fallback_slot} has no material name.")
folded_name = name.casefold()
if folded_name in seen_names:
raise CombineError(f"Legend contains duplicate material name {name!r}.")
rgb = _rgb8(entry.get("rgb"), name)
if rgb in seen_colors:
raise CombineError(
f"Legend color {rgb} is shared by multiple materials; masks would be ambiguous."
)
try:
slot = int(entry.get("slot", fallback_slot))
except (TypeError, ValueError) as exc:
raise CombineError(f"Legend material {name!r} has an invalid slot.") from exc
materials.append(
LegendMaterial(
slot=slot,
name=name,
rgb=rgb,
filename_name=aliases.get(folded_name, name),
)
)
seen_names.add(folded_name)
seen_colors.add(rgb)
return materials
def _name_pattern(name: str) -> re.Pattern[str]:
tokens = re.findall(r"[A-Za-z0-9]+", name)
if not tokens:
raise CombineError(f"Material filename name {name!r} has no letters or digits.")
expression = r"[\s_.-]*".join(re.escape(token) for token in tokens)
return re.compile(
rf"(?<![A-Za-z0-9]){expression}(?![A-Za-z0-9])",
flags=re.IGNORECASE,
)
def _match_candidate(
path: Path,
input_dir: Path,
materials: Sequence[LegendMaterial],
) -> TextureCandidate | None:
relative = path.relative_to(input_dir)
searchable = [("stem", relative.stem)] + [
(f"parent:{index}", part)
for index, part in enumerate(relative.parent.parts)
]
matches: list[
tuple[int, int, LegendMaterial, re.Match[str], str]
] = []
for material in materials:
pattern = _name_pattern(material.filename_name)
for location, text in searchable:
match = pattern.search(text)
if match:
matches.append(
(
match.end() - match.start(),
len(material.filename_name),
material,
match,
location,
)
)
if not matches:
return None
_, _, material, match, location = max(
matches,
key=lambda item: (item[0], item[1], item[4] == "stem"),
)
stem_pattern = relative.stem
parent_parts = list(relative.parent.parts)
if location == "stem":
stem_pattern = (
f"{stem_pattern[:match.start()]}{MATERIAL_TOKEN}{stem_pattern[match.end():]}"
)
else:
parent_index = int(location.split(":", 1)[1])
parent_part = parent_parts[parent_index]
parent_parts[parent_index] = (
f"{parent_part[:match.start()]}{MATERIAL_TOKEN}{parent_part[match.end():]}"
)
relative_parent = Path(*parent_parts) if parent_parts else Path(".")
return TextureCandidate(
path=path,
material=material,
relative_parent=relative_parent,
stem_pattern=stem_pattern,
suffix=path.suffix,
)
def discover_groups(
input_dir: Path,
materials: Sequence[LegendMaterial],
recursive: bool = False,
excluded_paths: Iterable[Path] = (),
) -> tuple[list[TextureGroup], list[Path]]:
excluded = {path.resolve() for path in excluded_paths}
iterator = input_dir.rglob("*") if recursive else input_dir.glob("*")
files = sorted(
path
for path in iterator
if path.is_file()
and path.suffix.lower() in SUPPORTED_SUFFIXES
and path.resolve() not in excluded
)
grouped: dict[tuple[Path, str, str], TextureGroup] = {}
unmatched: list[Path] = []
for path in files:
candidate = _match_candidate(path, input_dir, materials)
if candidate is None:
unmatched.append(path)
continue
key = (
candidate.relative_parent,
candidate.stem_pattern.casefold(),
candidate.suffix.casefold(),
)
group = grouped.setdefault(
key,
TextureGroup(
relative_parent=candidate.relative_parent,
stem_pattern=candidate.stem_pattern,
suffix=candidate.suffix,
textures={},
),
)
material_key = candidate.material.name.casefold()
if material_key in group.textures:
raise CombineError(
f"Both {group.textures[material_key]} and {candidate.path} match material "
f"{candidate.material.name!r} in group {group.description!r}."
)
group.textures[material_key] = candidate.path
return sorted(grouped.values(), key=lambda group: group.description.casefold()), unmatched
def _mask_for_color(id_rgb: Any, rgb: tuple[int, int, int], tolerance: int) -> Any:
channel_masks = []
for channel, expected in zip(id_rgb.split(), rgb, strict=True):
lookup = [255 if abs(value - expected) <= tolerance else 0 for value in range(256)]
channel_masks.append(channel.point(lookup, mode="L"))
mask = ImageChops.multiply(
ImageChops.multiply(channel_masks[0], channel_masks[1]),
channel_masks[2],
)
# One bit is sufficient for exact material membership and keeps 4K/8K
# projects from retaining a full byte per material per pixel.
return mask.convert("1")
def _common_mode(images: Sequence[Any]) -> str:
modes = {image.mode for image in images}
if len(modes) == 1:
mode = next(iter(modes))
if mode in {"1", "L", "LA", "RGB", "RGBA", "I", "F"}:
return mode
if any("A" in mode or mode == "P" for mode in modes):
return "RGBA"
if modes <= {"1", "L", "I", "F"}:
return "L"
return "RGB"
def combine_group(
group: TextureGroup,
materials: Sequence[LegendMaterial],
masks: dict[str, Any],
expected_size: tuple[int, int],
output_path: Path,
overwrite: bool = False,
) -> list[str]:
if output_path.exists() and not overwrite:
raise CombineError(f"Output already exists: {output_path} (use --overwrite).")
opened: dict[str, Any] = {}
try:
for material in materials:
path = group.textures.get(material.name.casefold())
if path is None:
continue
image = Image.open(path)
image.load()
if image.size != expected_size:
raise CombineError(
f"{path} is {image.size[0]}x{image.size[1]}, but the ID map is "
f"{expected_size[0]}x{expected_size[1]}."
)
opened[material.name.casefold()] = image
if not opened:
raise CombineError(f"Texture group {group.description!r} contains no usable images.")
output_mode = _common_mode(list(opened.values()))
output = Image.new(output_mode, expected_size, 0)
try:
missing: list[str] = []
for material in materials:
key = material.name.casefold()
source = opened.get(key)
if source is None:
missing.append(material.name)
continue
converted = None
if source.mode != output_mode:
converted = source.convert(output_mode)
source = converted
try:
output.paste(source, (0, 0), masks[key])
finally:
if converted is not None:
converted.close()
output_path.parent.mkdir(parents=True, exist_ok=True)
output.save(output_path)
return missing
finally:
output.close()
finally:
for image in opened.values():
image.close()
def combine_all(
id_map_path: Path,
legend_path: Path,
input_dir: Path,
output_dir: Path,
aliases: dict[str, str] | None = None,
recursive: bool = False,
tolerance: int | None = None,
overwrite: bool = False,
dry_run: bool = False,
) -> tuple[list[Path], list[Path]]:
_require_pillow()
if not input_dir.is_dir():
raise CombineError(f"Input directory does not exist: {input_dir}")
if tolerance is not None and (tolerance < 0 or tolerance > 32):
raise CombineError("Color tolerance must be between 0 and 32.")
materials = load_legend(legend_path, aliases)
groups, unmatched = discover_groups(
input_dir,
materials,
recursive=recursive,
excluded_paths=(id_map_path,),
)
if not groups:
names = ", ".join(material.filename_name for material in materials)
raise CombineError(
f"No supported textures in {input_dir} contained a legend material name "
f"({names}). Use --material-alias when Substance texture-set names differ."
)
outputs: list[Path] = []
if dry_run:
for group in groups:
outputs.append(output_dir / group.output_parent / group.output_name)
return outputs, unmatched
try:
with Image.open(id_map_path) as loaded_id_map:
id_map = loaded_id_map.convert("RGB")
id_map.load()
except OSError as exc:
raise CombineError(f"Could not load ID map {id_map_path}: {exc}") from exc
try:
masks = {}
for material in materials:
material_tolerance = tolerance if tolerance is not None else 0
mask = _mask_for_color(id_map, material.rgb, material_tolerance)
if tolerance is None and mask.getbbox() is None:
# Blender stores byte images after float-to-byte conversion.
# Values exactly between bytes can differ from ordinary Python
# rounding by one, so only widen a mask when exact pixels do
# not exist at all.
material_tolerance = 1
mask = _mask_for_color(id_map, material.rgb, material_tolerance)
if mask.getbbox() is not None:
print(
f"notice: matched {material.name!r} with automatic ±1 byte tolerance",
file=sys.stderr,
)
masks[material.name.casefold()] = mask
occupied = Image.new("1", id_map.size, 0)
for material in materials:
mask = masks[material.name.casefold()]
if ImageChops.multiply(occupied, mask).getbbox() is not None:
raise CombineError(
f"The mask for {material.name!r} overlaps another material mask. "
"Lower --tolerance (exact matching is --tolerance 0)."
)
occupied = ImageChops.lighter(occupied, mask)
empty_masks = [
material.name
for material in materials
if masks[material.name.casefold()].getbbox() is None
]
if empty_masks:
print(
"warning: no ID-map pixels matched: " + ", ".join(empty_masks),
file=sys.stderr,
)
output_paths = [
output_dir / group.output_parent / group.output_name
for group in groups
]
if len(set(output_paths)) != len(output_paths):
duplicates = sorted(
{path for path in output_paths if output_paths.count(path) > 1},
key=str,
)
raise CombineError(
"Multiple discovered groups resolve to the same output: "
+ ", ".join(str(path) for path in duplicates)
)
for group, output_path in zip(groups, output_paths, strict=True):
missing = combine_group(
group,
materials,
masks,
id_map.size,
output_path,
overwrite=overwrite,
)
if missing:
print(
f"warning: {group.description} has no texture for: {', '.join(missing)}",
file=sys.stderr,
)
outputs.append(output_path)
finally:
id_map.close()
return outputs, unmatched
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Combine Substance Painter textures exported as separate material/texture "
"sets, using a Material ID bake and JSON legend as exact masks."
)
)
parser.add_argument("--id-map", required=True, type=Path, help="Material ID PNG")
parser.add_argument(
"--legend",
required=True,
type=Path,
help="JSON legend written beside the Material ID PNG",
)
parser.add_argument(
"--input-dir",
required=True,
type=Path,
help="Directory containing Substance Painter exports",
)
parser.add_argument(
"--output-dir",
required=True,
type=Path,
help="Directory for combined channel textures",
)
parser.add_argument(
"--material-alias",
action="append",
default=[],
metavar="LEGEND=FILENAME",
help="Map a Blender material name to a different Substance filename token",
)
parser.add_argument("--recursive", action="store_true", help="Search input subdirectories")
parser.add_argument(
"--tolerance",
type=int,
default=None,
help=(
"Fixed per-channel ID color tolerance in 8-bit values; by default the tool "
"tries exact matching and a ±1 fallback only when exact pixels are absent"
),
)
parser.add_argument("--overwrite", action="store_true", help="Replace existing outputs")
parser.add_argument(
"--dry-run",
action="store_true",
help="Print discovered groups and output paths without reading or writing images",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
aliases = _parse_aliases(args.material_alias)
outputs, unmatched = combine_all(
id_map_path=args.id_map.resolve(),
legend_path=args.legend.resolve(),
input_dir=args.input_dir.resolve(),
output_dir=args.output_dir.resolve(),
aliases=aliases,
recursive=args.recursive,
tolerance=args.tolerance,
overwrite=args.overwrite,
dry_run=args.dry_run,
)
except CombineError as exc:
parser.exit(2, f"error: {exc}\n")
action = "Would write" if args.dry_run else "Wrote"
for output in outputs:
print(f"{action}: {output}")
if unmatched:
print(f"Skipped {len(unmatched)} image(s) with no material-name match:")
for path in unmatched:
print(f" {path}")
print(f"{len(outputs)} texture channel(s) {'discovered' if args.dry_run else 'combined'}.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

2
tools/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
Pillow>=10.0,<13

11
tools/test.sh Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
tools_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
python_bin="${PYTHON_BIN:-python3}"
"$python_bin" -m unittest discover \
-s "$tools_root/tests" \
-p 'test_*.py' \
-v

View File

@ -0,0 +1,167 @@
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
TOOLS_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(TOOLS_ROOT))
import combine_substance_textures as combine # noqa: E402
class CombineSubstanceTexturesTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory(prefix="substance-combine-test-")
self.root = Path(self.temp_dir.name)
self.input_dir = self.root / "exports"
self.output_dir = self.root / "combined"
self.input_dir.mkdir()
self.id_map = self.root / "Material_ID.png"
id_image = Image.new("RGB", (4, 2), (255, 0, 0))
for y in range(2):
for x in range(2, 4):
id_image.putpixel((x, y), (0, 255, 0))
id_image.save(self.id_map)
self.legend = self.root / "Material_ID.json"
self.legend.write_text(
json.dumps(
{
"materials": [
{"slot": 1, "material": "Left Metal", "rgb": [1.0, 0.0, 0.0]},
{"slot": 2, "material": "Right Paint", "rgb": [0.0, 1.0, 0.0]},
]
}
),
encoding="utf-8",
)
def tearDown(self) -> None:
self.temp_dir.cleanup()
def write_texture(self, name: str, color: tuple[int, int, int]) -> None:
Image.new("RGB", (4, 2), color).save(self.input_dir / name)
def test_combines_multiple_channels_by_id_color(self) -> None:
self.write_texture("Robot_Left_Metal_BaseColor.png", (180, 20, 10))
self.write_texture("Robot_Right_Paint_BaseColor.png", (10, 40, 220))
self.write_texture("Robot_Left_Metal_Normal.png", (100, 120, 255))
self.write_texture("Robot_Right_Paint_Normal.png", (140, 125, 250))
outputs, unmatched = combine.combine_all(
id_map_path=self.id_map,
legend_path=self.legend,
input_dir=self.input_dir,
output_dir=self.output_dir,
)
self.assertEqual(unmatched, [])
self.assertEqual(
{path.name for path in outputs},
{"Robot_BaseColor.png", "Robot_Normal.png"},
)
with Image.open(self.output_dir / "Robot_BaseColor.png") as result:
self.assertEqual(result.getpixel((0, 0)), (180, 20, 10))
self.assertEqual(result.getpixel((3, 1)), (10, 40, 220))
def test_aliases_and_missing_materials(self) -> None:
self.write_texture("asset_left_roughness.png", (32, 32, 32))
materials = combine.load_legend(
self.legend,
{"left metal": "left", "right paint": "right"},
)
groups, unmatched = combine.discover_groups(self.input_dir, materials)
self.assertEqual(unmatched, [])
self.assertEqual(len(groups), 1)
self.assertEqual(groups[0].output_name, "asset_roughness.png")
self.assertEqual(set(groups[0].textures), {"left metal"})
def test_rejects_mismatched_resolution(self) -> None:
Image.new("RGB", (8, 8), (1, 2, 3)).save(
self.input_dir / "Left_Metal_BaseColor.png"
)
with self.assertRaisesRegex(combine.CombineError, "ID map"):
combine.combine_all(
id_map_path=self.id_map,
legend_path=self.legend,
input_dir=self.input_dir,
output_dir=self.output_dir,
)
def test_rejects_overlapping_tolerant_masks(self) -> None:
self.legend.write_text(
json.dumps(
{
"materials": [
{"slot": 1, "material": "Left", "rgb": [1 / 255, 0.0, 0.0]},
{"slot": 2, "material": "Right", "rgb": [2 / 255, 0.0, 0.0]},
]
}
),
encoding="utf-8",
)
Image.new("RGB", (4, 2), (1, 0, 0)).save(self.id_map)
self.write_texture("Left_BaseColor.png", (10, 10, 10))
self.write_texture("Right_BaseColor.png", (20, 20, 20))
with self.assertRaisesRegex(combine.CombineError, "overlaps"):
combine.combine_all(
id_map_path=self.id_map,
legend_path=self.legend,
input_dir=self.input_dir,
output_dir=self.output_dir,
tolerance=1,
)
def test_auto_tolerance_handles_float_to_png_rounding(self) -> None:
self.legend.write_text(
json.dumps(
{
"materials": [
{"slot": 1, "material": "Left", "rgb": [0.1, 0.2, 1.0]},
]
}
),
encoding="utf-8",
)
# Python rounds 0.1 * 255 to 26, while Blender may store it as 25.
Image.new("RGB", (4, 2), (25, 51, 255)).save(self.id_map)
self.write_texture("Left_BaseColor.png", (12, 34, 56))
outputs, _ = combine.combine_all(
id_map_path=self.id_map,
legend_path=self.legend,
input_dir=self.input_dir,
output_dir=self.output_dir,
)
with Image.open(outputs[0]) as result:
self.assertEqual(result.getpixel((0, 0)), (12, 34, 56))
def test_recursive_material_directories_are_grouped(self) -> None:
left_dir = self.input_dir / "Robot_Left_Metal"
right_dir = self.input_dir / "Robot_Right_Paint"
left_dir.mkdir()
right_dir.mkdir()
Image.new("L", (4, 2), 40).save(left_dir / "Roughness.png")
Image.new("L", (4, 2), 210).save(right_dir / "Roughness.png")
outputs, _ = combine.combine_all(
id_map_path=self.id_map,
legend_path=self.legend,
input_dir=self.input_dir,
output_dir=self.output_dir,
recursive=True,
)
self.assertEqual(outputs[0].relative_to(self.output_dir), Path("Robot/Roughness.png"))
with Image.open(outputs[0]) as result:
self.assertEqual(result.getpixel((0, 0)), 40)
self.assertEqual(result.getpixel((3, 1)), 210)
if __name__ == "__main__":
unittest.main()

BIN
uv_texture_preview_4096.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB