From ef7fea6252db0f96202f1aca3474c6bed1b235d3 Mon Sep 17 00:00:00 2001 From: Peterino2 Date: Fri, 10 Jul 2026 23:02:44 -0700 Subject: [PATCH] saving --- README.md | 27 ++++- blender/material_id_baker/__init__.py | 100 +++++++++++++++++- .../material_id_baker/blender_manifest.toml | 2 +- blender/tests/smoke_test.py | 23 +++- install-all.bat | 66 ++++++++++++ install-all.sh | 39 +++++++ 6 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 install-all.bat create mode 100755 install-all.sh diff --git a/README.md b/README.md index 1f74108..430b840 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,33 @@ Build every Blender extension in the repository at once with: Set `BLENDER_BIN=/path/to/blender` when Blender is not available as `blender`. +Build, install, and enable every extension headlessly for the current Blender user with: + +```bash +./install-all.sh +``` + +The installer targets Blender's `user_default` extension repository. Override it with `BLENDER_EXTENSION_REPO=repository_id` when needed. Close running Blender instances before installing so they do not retain an older loaded copy. + +On Windows, use the equivalent batch file from Command Prompt: + +```bat +install-all.bat +``` + +If Blender is not on `PATH`, configure it without embedding extra quotes: + +```bat +set "BLENDER_BIN=C:\Program Files\Blender Foundation\Blender 5.2\blender.exe" +set "BLENDER_EXTENSION_REPO=user_default" +install-all.bat +``` + ## 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. +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. The optional **Create Export Copy** setting leaves behind a selected duplicate with one `baked ids` material connected to the baked image, ready to export as a single-material asset. ### Install or update @@ -46,7 +68,8 @@ You can instead run `./blender/scripts/build.sh` and install the resulting ZIP t 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**. +5. Optionally enable **Create Export Copy** to create a one-material duplicate for export. +6. 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. diff --git a/blender/material_id_baker/__init__.py b/blender/material_id_baker/__init__.py index 2d9185f..6c1d444 100644 --- a/blender/material_id_baker/__init__.py +++ b/blender/material_id_baker/__init__.py @@ -78,6 +78,66 @@ def _create_id_material( return material +def _create_baked_ids_material(image: bpy.types.Image) -> bpy.types.Material: + """Create an export-friendly, single material displaying the baked ID image.""" + material = bpy.data.materials.new("baked ids") + material.use_nodes = True + nodes = material.node_tree.nodes + nodes.clear() + + output = nodes.new("ShaderNodeOutputMaterial") + output.location = (560.0, 0.0) + principled = nodes.new("ShaderNodeBsdfPrincipled") + principled.location = (260.0, 0.0) + principled.inputs["Roughness"].default_value = 1.0 + + image_node = nodes.new("ShaderNodeTexImage") + image_node.label = "Baked Material IDs" + image_node.location = (-120.0, 0.0) + image_node.image = image + image_node.interpolation = "Closest" + nodes.active = image_node + + material.node_tree.links.new(image_node.outputs["Color"], principled.inputs["Base Color"]) + material.node_tree.links.new(principled.outputs["BSDF"], output.inputs["Surface"]) + return material + + +def _create_export_copy( + source: bpy.types.Object, + baked_mesh: bpy.types.Mesh, + image: bpy.types.Image, + apply_modifiers: bool, + scene: bpy.types.Scene, +) -> tuple[bpy.types.Object, bpy.types.Mesh, bpy.types.Material]: + """Create a persistent one-material copy suitable for later export.""" + if apply_modifiers: + export_mesh = baked_mesh.copy() + export_object = bpy.data.objects.new( + f"{source.name}_Baked_IDs", + export_mesh, + ) + export_object.matrix_world = source.matrix_world.copy() + else: + export_object = source.copy() + export_mesh = source.data.copy() + export_object.data = export_mesh + export_object.name = f"{source.name}_Baked_IDs" + + target_collection = ( + source.users_collection[0] if source.users_collection else scene.collection + ) + target_collection.objects.link(export_object) + + material = _create_baked_ids_material(image) + export_mesh.materials.clear() + export_mesh.materials.append(material) + for polygon in export_mesh.polygons: + polygon.material_index = 0 + + return export_object, export_mesh, material + + def _normalise_png_path(filepath: str) -> Path: path = Path(bpy.path.abspath(filepath)).expanduser() if path.suffix.lower() != ".png": @@ -164,6 +224,13 @@ class MIDB_Settings(PropertyGroup): description="Bake evaluated modifier geometry instead of the base mesh", default=False, ) + create_export_copy: BoolProperty( + name="Create Export Copy", + description=( + "Create and select a duplicate with one 'baked ids' material using the baked image" + ), + default=False, + ) image_name: StringProperty( name="Image Name", default="Material_ID", @@ -226,6 +293,9 @@ class MIDB_OT_Bake(Operator): temp_mesh: bpy.types.Mesh | None = None temp_materials: list[bpy.types.Material] = [] image: bpy.types.Image | None = None + export_object: bpy.types.Object | None = None + export_mesh: bpy.types.Mesh | None = None + export_material: bpy.types.Material | None = None succeeded = False try: @@ -372,11 +442,23 @@ class MIDB_OT_Bake(Operator): entry.material_name = item["material"] entry.color = item["rgb"] + if settings.create_export_copy: + export_object, export_mesh, export_material = _create_export_copy( + source, + temp_mesh, + image, + settings.apply_modifiers, + scene, + ) + succeeded = True if saved_path: - self.report({"INFO"}, f"Baked material IDs to {saved_path}") + message = f"Baked material IDs to {saved_path}" else: - self.report({"INFO"}, f"Baked material IDs to image '{image.name}'") + message = f"Baked material IDs to image '{image.name}'" + if export_object is not None: + message += f" and created '{export_object.name}'" + self.report({"INFO"}, message) return {"FINISHED"} except Exception as exc: @@ -392,6 +474,13 @@ class MIDB_OT_Bake(Operator): if material.users == 0: bpy.data.materials.remove(material) + if not succeeded and export_object is not None: + bpy.data.objects.remove(export_object, do_unlink=True) + if not succeeded and export_mesh is not None and export_mesh.users == 0: + bpy.data.meshes.remove(export_mesh) + if not succeeded and export_material is not None and export_material.users == 0: + bpy.data.materials.remove(export_material) + _restore_bake_settings(scene, bake_state) for obj in list(context.selected_objects): @@ -402,6 +491,12 @@ class MIDB_OT_Bake(Operator): if active_before and active_before.name in view_layer.objects: view_layer.objects.active = active_before + if succeeded and export_object is not None: + for obj in list(context.selected_objects): + obj.select_set(False) + export_object.select_set(True) + view_layer.objects.active = export_object + if not succeeded and image is not None and image.users == 0: bpy.data.images.remove(image) @@ -436,6 +531,7 @@ class MIDB_PT_Panel(Panel): column.prop(settings, "margin") column.prop(settings, "palette_mode") column.prop(settings, "apply_modifiers") + column.prop(settings, "create_export_copy") output = layout.box() output.label(text="Output") diff --git a/blender/material_id_baker/blender_manifest.toml b/blender/material_id_baker/blender_manifest.toml index 9e87d15..5ed19b0 100644 --- a/blender/material_id_baker/blender_manifest.toml +++ b/blender/material_id_baker/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "material_id_baker" -version = "1.0.0" +version = "1.1.0" name = "Material ID Baker" tagline = "Bake mesh material assignments to a color ID texture" maintainer = "Sear" diff --git a/blender/tests/smoke_test.py b/blender/tests/smoke_test.py index ef27e31..97495f4 100644 --- a/blender/tests/smoke_test.py +++ b/blender/tests/smoke_test.py @@ -79,6 +79,7 @@ def main() -> None: settings.margin = 4 settings.palette_mode = "DISTINCT" settings.apply_modifiers = True + settings.create_export_copy = True settings.image_name = "Material_ID_Smoke" settings.save_to_disk = True settings.filepath = "/tmp/material_id_baker_smoke.png" @@ -86,13 +87,31 @@ def main() -> None: 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) + export_copy = bpy.context.active_object + assert export_copy is not None + assert export_copy.name == "MaterialIDSmokeObject_Baked_IDs" + assert export_copy != source + assert export_copy.select_get() + assert not source.select_get() + assert len(export_copy.data.materials) == 1 + baked_material = export_copy.data.materials[0] + assert baked_material.name == "baked ids" + assert all(polygon.material_index == 0 for polygon in export_copy.data.polygons) + assert len(export_copy.data.polygons) == 4 + assert len(export_copy.modifiers) == 0 + image = bpy.data.images[settings.last_image_name] + image_nodes = [ + node + for node in baked_material.node_tree.nodes + if node.bl_idname == "ShaderNodeTexImage" + ] + assert len(image_nodes) == 1 + assert image_nodes[0].image == image palette = json.loads(image["material_id_palette"]) assert len(palette) == 2 assert palette[0]["material"] == "Left Material" diff --git a/install-all.bat b/install-all.bat new file mode 100644 index 0000000..a1d062a --- /dev/null +++ b/install-all.bat @@ -0,0 +1,66 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +set "REPO_ROOT=%~dp0" +set "BLENDER_ROOT=%REPO_ROOT%blender" +set "DIST_DIR=%REPO_ROOT%dist" + +if not defined BLENDER_BIN set "BLENDER_BIN=blender" +if not defined BLENDER_EXTENSION_REPO set "BLENDER_EXTENSION_REPO=user_default" + +if not exist "%BLENDER_ROOT%" ( + echo No Blender extension directory found at "%BLENDER_ROOT%". 1>&2 + exit /b 1 +) + +if not exist "%DIST_DIR%" mkdir "%DIST_DIR%" +if errorlevel 1 exit /b 1 + +set /a EXTENSION_COUNT=0 + +for /r "%BLENDER_ROOT%" %%F in (blender_manifest.toml) do ( + set "EXTENSION_ID=" + set "EXTENSION_VERSION=" + + for /f "tokens=1,* delims==" %%A in ('findstr /b /c:"id =" "%%F"') do set "EXTENSION_ID=%%B" + for /f "tokens=1,* delims==" %%A in ('findstr /b /c:"version =" "%%F"') do set "EXTENSION_VERSION=%%B" + + set "EXTENSION_ID=!EXTENSION_ID: =!" + set "EXTENSION_ID=!EXTENSION_ID:"=!" + set "EXTENSION_VERSION=!EXTENSION_VERSION: =!" + set "EXTENSION_VERSION=!EXTENSION_VERSION:"=!" + + if not defined EXTENSION_ID ( + echo Could not read the extension id from "%%F". 1>&2 + exit /b 1 + ) + if not defined EXTENSION_VERSION ( + echo Could not read the extension version from "%%F". 1>&2 + exit /b 1 + ) + + for %%D in ("%%~dpF.") do set "SOURCE_DIR=%%~fD" + set "PACKAGE=%DIST_DIR%\!EXTENSION_ID!-!EXTENSION_VERSION!.zip" + + echo Building Blender extension: !EXTENSION_ID! !EXTENSION_VERSION! + "%BLENDER_BIN%" --factory-startup --command extension build ^ + --source-dir "!SOURCE_DIR!" ^ + --output-filepath "!PACKAGE!" + if errorlevel 1 exit /b 1 + + echo Installing Blender extension: !PACKAGE! + "%BLENDER_BIN%" --factory-startup --command extension install-file ^ + -r "%BLENDER_EXTENSION_REPO%" ^ + -e "!PACKAGE!" + if errorlevel 1 exit /b 1 + + set /a EXTENSION_COUNT+=1 +) + +if !EXTENSION_COUNT! equ 0 ( + echo No Blender extension manifests found under "%BLENDER_ROOT%". 1>&2 + exit /b 1 +) + +echo Installed and enabled !EXTENSION_COUNT! Blender extension(s) in repository "%BLENDER_EXTENSION_REPO%". +exit /b 0 diff --git a/install-all.sh b/install-all.sh new file mode 100755 index 0000000..9d143db --- /dev/null +++ b/install-all.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +blender_bin="${BLENDER_BIN:-blender}" +repository="${BLENDER_EXTENSION_REPO:-user_default}" +build_marker="$(mktemp)" + +cleanup() { + rm -f "$build_marker" +} +trap cleanup EXIT + +"$repo_root/build-all.sh" + +mapfile -d '' packages < <( + find "$repo_root/dist" \ + -maxdepth 1 \ + -type f \ + -name '*.zip' \ + -newer "$build_marker" \ + -print0 \ + | sort -z +) + +if (( ${#packages[@]} == 0 )); then + echo "No Blender extension packages were produced by build-all.sh." >&2 + exit 1 +fi + +for package in "${packages[@]}"; do + echo "Installing Blender extension: ${package#"$repo_root/"}" + "$blender_bin" --factory-startup --command extension install-file \ + -r "$repository" \ + -e "$package" +done + +echo "Installed and enabled ${#packages[@]} Blender extension(s) in repository '$repository'." +