blender tooling
This commit is contained in:
commit
ca9ddb196e
|
|
@ -0,0 +1,5 @@
|
|||
/dist/
|
||||
/.test-profile/
|
||||
.venv/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
|
|
@ -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 256px–8K 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
|
||||
```
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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'."
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
|
|
@ -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."
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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.0–1.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())
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
Pillow>=10.0,<13
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Loading…
Reference in New Issue